replay in route /

This commit is contained in:
taDuc
2026-05-28 11:32:31 +07:00
parent b3d2f56797
commit 0dbe26fd4e
15 changed files with 1929 additions and 880 deletions
+78
View File
@@ -0,0 +1,78 @@
# Data Fetching Optimization: In-flight Promise Caching
## 1. Vấn đề (The Problem)
Trong quá trình tương tác với bản đồ (ví dụ: kéo thả nhanh từ khu vực A sang B rồi sang C), các tính năng lấy dữ liệu quan hệ (entities, wikis) thường phải tải hàng chục đến hàng trăm item thông qua mảng ID (`geometryIds`).
Nếu chỉ sử dụng cơ chế Cache Data tĩnh (lưu kết quả sau khi API trả về), ta sẽ gặp phải bài toán **Race Condition** với các request đang bay (In-flight requests):
- **A -> B**: Hệ thống gọi API xin 5 ID mới. Request mất 500ms để hoàn thành.
- **B -> C** (xảy ra ở mốc 200ms): Lúc này request của B chưa xong, Cache tĩnh chưa có dữ liệu của 5 ID đó.
- Hệ thống gửi tiếp API xin 10 ID mới (bao gồm 5 ID của C và **5 ID của B**).
=> Hậu quả: Lãng phí băng thông, tải lại dữ liệu dư thừa.
## 2. Giải pháp (The Solution: DataLoader Pattern)
Để khắc phục triệt để, hệ thống sử dụng **In-flight Promise Caching** tại tầng API (`src/uhm/api/relations.ts`). Thay vì chỉ lưu trữ Data, hệ thống lưu trữ **Tiến trình (Promise)**.
### Cơ chế hoạt động:
1. **Kiểm tra Cache:** Khi nhận mảng `ids` cần tải, hệ thống kiểm tra xem ID nào đã có Promise tương ứng trong Cache (nghĩa là đang được tải hoặc đã tải xong).
2. **Lọc Missing IDs:** Chỉ những ID chưa có Promise trong Cache mới được đưa vào mảng `missingIds` để gọi API.
3. **Tạo Batch Promise:** Một HTTP Request duy nhất được gửi đi để tải `missingIds`. (Trả về `batchPromise`).
4. **Chia tách Promise (Demultiplexing):** Với mỗi ID trong `missingIds`, hệ thống gán cho nó một Promise con (tách ra từ `batchPromise` cha) có nhiệm vụ chỉ extract dữ liệu của riêng ID đó. Các Promise con này lập tức được lưu vào Cache.
5. **Đợi kết quả:** Hàm gọi `await Promise.all()` để chờ tất cả các Promise của `ids` yêu cầu hoàn thành và trả về.
## 3. Các "Hố Tử Thần" (Edge Cases) & Cách Xử Lý (Production-Ready)
Khi triển khai Promise Caching, có 3 rủi ro cực kỳ lớn cần phải xử lý để hệ thống không bị crash hoặc dính lỗi logic:
### 3.1. Hiệu ứng Domino của `Promise.all` (Sập cả Viewport)
**Rủi ro:** Nếu một ID trong mảng bị lỗi mạng (`throw err`), `Promise.all` sẽ ngắt mạch (short-circuit) và vứt bỏ toàn bộ kết quả của các ID khác, khiến bản đồ trắng xóa.
**Cách xử lý:** Trong khối `.catch()` của từng Promise con, tuyệt đối không được `throw err`. Thay vào đó, **phải trả về một giá trị an toàn (ví dụ mảng rỗng `[]`)** để cứu các Promise còn lại.
```typescript
.catch(err => {
delete promiseCache[id]; // Xóa khỏi cache để lần sau thử lại
return []; // Trả về fallback thay vì ném lỗi
})
```
### 3.2. Nhiễm độc Cache vĩnh viễn (Zombie Cache) & Negative Cache
**Rủi ro:** Nếu API trả về HTTP 200, nhưng một `geometryId` không hề có dữ liệu (thực tế rất nhiều vùng biển không có thực thể), biến `res[id]` sẽ là `undefined`. Nếu ta xóa Cache đi vì tưởng là lỗi, lần sau kéo lại, hệ thống sẽ tiếp tục gọi API xin dữ liệu của vùng biển đó => Spam API vô tận.
**Cách xử lý (Negative Cache):** Ép giá trị `undefined` thành `[]`**VẪN LƯU VÀO CACHE**. Bằng cách này, hệ thống "nhớ" rằng vị trí này trống rỗng và sẽ không bao giờ tốn công gọi API lên Domain nữa.
```typescript
.then(res => res[id] || []) // Lưu hẳn mảng rỗng vào Cache
```
### 3.3. Vấn đề Gom cụm (Scope Batching)
**Rủi ro:** Nếu hệ thống kích hoạt API lẻ tẻ cách nhau vài mili-giây, code sẽ không gom (batch) được request lại với nhau.
**Đặc thù dự án:** May mắn là UI Hook (`usePublicPreviewData`) đã thu thập đủ toàn bộ các Geometries trên bản đồ thành 1 mảng tĩnh duy nhất trước khi gọi xuống hàm API. Do đó, mảng `ids` truyền vào bản thân nó đã là một Batch hoàn chỉnh, không cần phải dùng đến Event Loop Tick (`setTimeout(0)`) để gom cụm như thư viện DataLoader nguyên gốc.
## 4. Mã nguồn chuẩn Production (Tham khảo)
```typescript
const entitiesPromiseCache: Record<string, Promise<Entity[]>> = {};
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
if (missingIds.length > 0) {
// 1. Tạo request cha
const batchPromise = fetchFromServer(missingIds);
// 2. Chia nhỏ thành request con và lưu cache
for (const id of missingIds) {
entitiesPromiseCache[id] = batchPromise
.then(res => res[id] || []) // Negative Cache: Ép mảng rỗng
.catch(err => {
delete entitiesPromiseCache[id]; // Xóa cache lỗi
return []; // Chặn Domino Effect của Promise.all
});
}
}
const result: Record<string, Entity[]> = {};
// 3. Đợi toàn bộ hoàn thành an toàn
await Promise.all(uniqueIds.map(async id => {
result[id] = await entitiesPromiseCache[id];
}));
return result;
}
```
+204 -800
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { BattleReplay } from "@/uhm/types/projects";
const BATCH_SIZE = 20;
const BATCH_CONCURRENCY = 6;
export async function fetchBattleReplaysByGeometryIds(geometryIds: string[]): Promise<Record<string, BattleReplay[]>> {
const uniqueIds = Array.from(new Set(
(geometryIds || [])
.map((id) => String(id || "").trim())
.filter((id) => id.length > 0)
));
if (!uniqueIds.length) {
return {};
}
const chunks: string[][] = [];
for (let index = 0; index < uniqueIds.length; index += BATCH_SIZE) {
chunks.push(uniqueIds.slice(index, index + BATCH_SIZE));
}
const results: Array<Record<string, BattleReplay[]>> = new Array(chunks.length);
const runnerCount = Math.max(1, Math.min(BATCH_CONCURRENCY, chunks.length));
let nextIndex = 0;
await Promise.all(
Array.from({ length: runnerCount }, async () => {
while (true) {
const current = nextIndex++;
if (current >= chunks.length) return;
const batch = chunks[current];
const params = new URLSearchParams();
for (const id of batch) {
params.append("geometry_ids", id);
}
try {
results[current] = await requestJson<Record<string, BattleReplay[]>>(
`${API_ENDPOINTS.battleReplays}/geometries?${params.toString()}`
);
} catch (err) {
console.error("Failed to fetch battle replays batch", err);
results[current] = {};
}
}
})
);
const merged: Record<string, BattleReplay[]> = {};
for (const res of results) {
if (!res) continue;
for (const [key, list] of Object.entries(res)) {
merged[key] = list || [];
}
}
return merged;
}
+2 -1
View File
@@ -3,7 +3,6 @@ import { API_URL_ROOT } from "../../../api";
const GOONG_TILES_BASE_URL = "https://tiles.goong.io"; const GOONG_TILES_BASE_URL = "https://tiles.goong.io";
export const API_BASE_URL = normalizeApiBaseUrl(API_URL_ROOT); export const API_BASE_URL = normalizeApiBaseUrl(API_URL_ROOT);
const GOONG_PROXY_BASE_PATH = `${API_BASE_URL}/proxy`;
export const GOONG_SATELLITE_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_satellite.json`; export const GOONG_SATELLITE_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_satellite.json`;
export const GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_map_web.json`; export const GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_map_web.json`;
@@ -45,6 +44,7 @@ export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`, geometries: `${API_BASE_URL}/geometries`,
entities: `${API_BASE_URL}/entities`, entities: `${API_BASE_URL}/entities`,
wikis: `${API_BASE_URL}/wikis`, wikis: `${API_BASE_URL}/wikis`,
relations: `${API_BASE_URL}/relations`,
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`, wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
// New API uses projects + commits + submissions (JWT-protected). // New API uses projects + commits + submissions (JWT-protected).
authSignin: `${API_BASE_URL}/auth/signin`, authSignin: `${API_BASE_URL}/auth/signin`,
@@ -54,4 +54,5 @@ export const API_ENDPOINTS = {
currentUserProjects: `${API_BASE_URL}/users/current/project`, currentUserProjects: `${API_BASE_URL}/users/current/project`,
projects: `${API_BASE_URL}/projects`, projects: `${API_BASE_URL}/projects`,
submissions: `${API_BASE_URL}/submissions`, submissions: `${API_BASE_URL}/submissions`,
battleReplays: `${API_BASE_URL}/battle-replays`,
} as const; } as const;
+142 -12
View File
@@ -1,7 +1,7 @@
import { API_ENDPOINTS } from "@/uhm/api/config"; import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http"; import { requestJson } from "@/uhm/api/http";
import type { GeometriesBBoxQuery } from "@/uhm/types/api"; import type { GeometriesBBoxQuery } from "@/uhm/types/api";
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, FeatureEntityPreview, FeatureProperties, FeatureWikiPreview, Geometry } from "@/uhm/types/geo";
import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap";
export type { GeometriesBBoxQuery } from "@/uhm/types/api"; export type { GeometriesBBoxQuery } from "@/uhm/types/api";
@@ -90,17 +90,28 @@ export async function searchGeometriesByEntityName(
return { return {
...response, ...response,
items: (response.items || []).map((item) => ({ items: normalizeEntityGeometryItems(response.items),
...item, };
geometries: (item.geometries || []).map((geometry) => ({ }
id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null, export async function fetchEntityGeometryIndexPage(options?: {
draw_geometry: geometry.draw_geometry, cursor?: string;
bound_with: normalizeBoundWith(geometry.bound_with), limit?: number;
time_start: geometry.time_start ?? null, }): Promise<SearchGeometriesByEntityNameResponse> {
time_end: geometry.time_end ?? null, const params = new URLSearchParams();
})), if (options?.cursor) params.set("cursor", options.cursor);
})), if (options?.limit && Number.isFinite(options.limit)) {
params.set("limit", String(Math.trunc(options.limit)));
}
const suffix = params.toString();
const response = await requestJson<SearchGeometriesByEntityNameApiResponse>(
`${API_ENDPOINTS.geometries}/entity${suffix ? `?${suffix}` : ""}`
);
return {
...response,
items: normalizeEntityGeometryItems(response.items),
}; };
} }
@@ -118,6 +129,32 @@ type GeometryRow = {
max_lng: number; max_lng: number;
max_lat: number; max_lat: number;
} | null; } | null;
entity_id?: string | null;
entity_name?: string | null;
entity_description?: string | null;
entities?: GeometryRowEntity[];
};
type GeometryRowEntity = {
id?: string | null;
entity_id?: string | null;
name?: string | null;
entity_name?: string | null;
description?: string | null;
entity_description?: string | null;
time_start?: number | null;
time_end?: number | null;
wikis?: GeometryRowWiki[];
};
type GeometryRowWiki = {
id?: string | null;
wiki_id?: string | null;
title?: string | null;
slug?: string | null;
preview_quote?: string | null;
blockquote_preview?: string | null;
content?: string | null;
}; };
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
@@ -129,6 +166,9 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
const boundWith = normalizeBoundWith(row.bound_with); const boundWith = normalizeBoundWith(row.bound_with);
const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null; const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null;
const entityPreviews = normalizeGeometryRowEntities(row);
const entityIds = entityPreviews.map((entity) => entity.id);
const entityNames = entityPreviews.map((entity) => entity.name);
const properties: FeatureProperties = { const properties: FeatureProperties = {
id: row.id, id: row.id,
@@ -136,6 +176,21 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
time_start: row.time_start ?? null, time_start: row.time_start ?? null,
time_end: row.time_end ?? null, time_end: row.time_end ?? null,
bound_with: boundWith, bound_with: boundWith,
...(entityPreviews.length
? {
entity_id: entityIds[0] || null,
entity_ids: entityIds,
entity_name: entityNames[0] || null,
entity_names: entityNames,
entity_label_candidates: entityPreviews.map((entity) => ({
id: entity.id,
name: entity.name,
time_start: entity.time_start ?? null,
time_end: entity.time_end ?? null,
})),
public_entity_previews: entityPreviews,
}
: {}),
}; };
features.push({ features.push({
@@ -148,6 +203,67 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
return { type: "FeatureCollection", features }; return { type: "FeatureCollection", features };
} }
function normalizeGeometryRowEntities(row: GeometryRow): FeatureEntityPreview[] {
const candidates: GeometryRowEntity[] = Array.isArray(row.entities) ? row.entities : [];
if (!candidates.length && (row.entity_id || row.entity_name)) {
candidates.push({
entity_id: row.entity_id,
entity_name: row.entity_name,
entity_description: row.entity_description,
});
}
const byId = new Map<string, FeatureEntityPreview>();
for (const candidate of candidates) {
const id = normalizeString(candidate.id ?? candidate.entity_id);
if (!id) continue;
const name = normalizeString(candidate.name ?? candidate.entity_name) || id;
byId.set(id, {
id,
name,
description: normalizeNullableString(candidate.description ?? candidate.entity_description),
time_start: normalizeNumber(candidate.time_start),
time_end: normalizeNumber(candidate.time_end),
wikis: normalizeGeometryRowWikis(candidate.wikis),
});
}
return Array.from(byId.values());
}
function normalizeGeometryRowWikis(wikis: GeometryRowWiki[] | undefined): FeatureWikiPreview[] {
if (!Array.isArray(wikis)) return [];
const byId = new Map<string, FeatureWikiPreview>();
for (const wiki of wikis) {
const id = normalizeString(wiki.id ?? wiki.wiki_id);
if (!id) continue;
byId.set(id, {
id,
title: normalizeNullableString(wiki.title) ?? undefined,
slug: normalizeNullableString(wiki.slug),
preview_quote: normalizeNullableString(wiki.preview_quote ?? wiki.blockquote_preview),
content: normalizeNullableString(wiki.content),
});
}
return Array.from(byId.values());
}
function normalizeString(value: unknown): string {
if (typeof value !== "string" && typeof value !== "number") return "";
return String(value).trim();
}
function normalizeNullableString(value: unknown): string | null {
const normalized = normalizeString(value);
return normalized.length ? normalized : null;
}
function normalizeNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeGeometry(value: unknown): Geometry | null { function normalizeGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const g = value as Record<string, unknown>; const g = value as Record<string, unknown>;
@@ -162,3 +278,17 @@ function normalizeBoundWith(value: unknown): string | null {
const id = String(value).trim(); const id = String(value).trim();
return id.length ? id : null; return id.length ? id : null;
} }
function normalizeEntityGeometryItems(items: EntityGeometriesSearchItemRow[] | undefined): EntityGeometriesSearchItem[] {
return (items || []).map((item) => ({
...item,
geometries: (item.geometries || []).map((geometry) => ({
id: geometry.id,
type: geoTypeCodeToTypeKey(geometry.geo_type) || null,
draw_geometry: geometry.draw_geometry,
bound_with: normalizeBoundWith(geometry.bound_with),
time_start: geometry.time_start ?? null,
time_end: geometry.time_end ?? null,
})),
}));
}
+196
View File
@@ -0,0 +1,196 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
const RELATION_BATCH_SIZE = 20;
const RELATION_BATCH_CONCURRENCY = 10;
export type WikiContentPreview = {
id: string;
preview?: string | null;
created_at?: string | null;
};
const entitiesPromiseCache: Record<string, Promise<Entity[]>> = {};
export async function fetchEntitiesByGeometryIds(ids: string[]): Promise<Record<string, Entity[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]);
if (missingIds.length > 0) {
const batchPromise = (async () => {
const result: Record<string, Entity[]> = {};
const pages = await mapWithConcurrency(
chunkIds(missingIds),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<Record<string, Entity[]>>(
`${API_ENDPOINTS.relations}/entities-by-geometries?${buildArrayQuery("geometry_ids", batch)}`
)
);
for (const rows of pages) {
mergeRelationRecord(result, rows);
}
return result;
})();
batchPromise.catch(() => {});
for (const id of missingIds) {
entitiesPromiseCache[id] = batchPromise
.then(res => res[id] || [])
.catch(err => {
// Xóa khỏi cache để lần sau thử lại
delete entitiesPromiseCache[id];
// Trả về [] để không làm sập Promise.all của UI
return [];
});
}
}
const result: Record<string, Entity[]> = {};
await Promise.all(uniqueIds.map(async id => {
result[id] = await entitiesPromiseCache[id];
}));
return result;
}
export async function fetchWikisByEntityIds(ids: string[]): Promise<Record<string, Wiki[]>> {
const result: Record<string, Wiki[]> = {};
const pages = await mapWithConcurrency(
chunkIds(ids),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<Record<string, Wiki[]>>(
`${API_ENDPOINTS.relations}/wikis-by-entities?${buildArrayQuery("entity_ids", batch)}`
)
);
for (const rows of pages) {
mergeRelationRecord(result, rows);
}
return result;
}
export async function fetchWikiContentPreviewsByIds(ids: string[]): Promise<WikiContentPreview[]> {
const result: WikiContentPreview[] = [];
const seen = new Set<string>();
const pages = await mapWithConcurrency(
chunkIds(ids),
RELATION_BATCH_CONCURRENCY,
(batch) => requestJson<WikiContentPreview[]>(
`${API_ENDPOINTS.relations}/wiki-contents/preview?${buildArrayQuery("ids", batch)}`
)
);
for (const rows of pages) {
for (const row of rows || []) {
const id = String(row?.id || "").trim();
if (!id || seen.has(id)) continue;
seen.add(id);
result.push(row);
}
}
return result;
}
const wikisWithPreviewPromiseCache: Record<string, Promise<Wiki[]>> = {};
export async function fetchWikisByEntityIdsWithPreviews(ids: string[]): Promise<Record<string, Wiki[]>> {
const uniqueIds = uniqueStrings(ids);
const missingIds = uniqueIds.filter(id => !wikisWithPreviewPromiseCache[id]);
if (missingIds.length > 0) {
const batchPromise = (async () => {
const wikisByEntityId = await fetchWikisByEntityIds(missingIds);
const previewContentIds = uniqueStrings(
Object.values(wikisByEntityId || {})
.flat()
.map((wiki) => wiki.content_sample?.[0]?.id)
);
if (!previewContentIds.length) return wikisByEntityId;
const previews = await fetchWikiContentPreviewsByIds(previewContentIds);
const previewById = new Map(
previews.map((item) => [String(item.id), String(item.preview || "").trim()])
);
const result: Record<string, Wiki[]> = {};
for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) {
result[entityId] = (wikis || []).map((wiki) => {
const previewId = wiki.content_sample?.[0]?.id;
const preview = previewId ? previewById.get(String(previewId)) || "" : "";
return preview ? { ...wiki, preview_quote: preview } : wiki;
});
}
return result;
})();
batchPromise.catch(() => {});
for (const id of missingIds) {
wikisWithPreviewPromiseCache[id] = batchPromise
.then(res => res[id] || [])
.catch(err => {
// Xóa khỏi cache để lần sau thử lại
delete wikisWithPreviewPromiseCache[id];
// Trả về [] để không làm sập Promise.all của UI
return [];
});
}
}
const result: Record<string, Wiki[]> = {};
await Promise.all(uniqueIds.map(async id => {
result[id] = await wikisWithPreviewPromiseCache[id];
}));
return result;
}
function buildArrayQuery(key: string, values: string[]): string {
const query = new URLSearchParams();
for (const value of uniqueStrings(values)) {
query.append(key, value);
}
return query.toString();
}
function chunkIds(ids: string[]): string[][] {
const values = uniqueStrings(ids);
const chunks: string[][] = [];
for (let index = 0; index < values.length; index += RELATION_BATCH_SIZE) {
chunks.push(values.slice(index, index + RELATION_BATCH_SIZE));
}
return chunks;
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => value.length > 0)
));
}
function mergeRelationRecord<T>(target: Record<string, T[]>, source: Record<string, T[]> | undefined) {
for (const [key, rows] of Object.entries(source || {})) {
target[key] = rows || [];
}
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
const runnerCount = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
let nextIndex = 0;
await Promise.all(
Array.from({ length: runnerCount }, async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await worker(items[current]);
}
})
);
return results;
}
+2 -1
View File
@@ -8,6 +8,7 @@ export type Wiki = {
title?: string; title?: string;
slug?: string | null; slug?: string | null;
content?: string; content?: string;
preview_quote?: string | null;
is_deleted?: boolean; is_deleted?: boolean;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
@@ -71,4 +72,4 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
export const getContentByVersionWikiId = async (id: string) => { export const getContentByVersionWikiId = async (id: string) => {
const response = await api.get(API_ENDPOINTS.wikiContent(id)); const response = await api.get(API_ENDPOINTS.wikiContent(id));
return response?.data; return response?.data;
}; };
+129 -56
View File
@@ -336,15 +336,115 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "10px", gap: "10px",
background: "rgba(15, 23, 42, 0.88)", background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
border: "1px solid rgba(148, 163, 184, 0.38)", border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "999px", borderRadius: "50px",
padding: "8px 12px", padding: "8px 16px",
color: "#e2e8f0", color: "#f8fafc",
backdropFilter: "blur(3px)", boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
pointerEvents: "auto", pointerEvents: "auto",
}} }}
> >
<style dangerouslySetInnerHTML={{ __html: `
.premium-zoom-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: grid;
place-items: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.premium-zoom-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.15);
}
.premium-zoom-btn:active {
background: rgba(16, 185, 129, 0.25);
border-color: #10b981;
}
.premium-zoom-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
min-width: 80px;
height: 24px;
background: transparent;
cursor: pointer;
outline: none;
}
.premium-zoom-slider::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.premium-zoom-slider:hover::-webkit-slider-runnable-track {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.premium-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
margin-top: -6px;
height: 18px;
width: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
border: 1.5px solid #ffffff;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4), 0 3px 6px rgba(0, 0, 0, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
}
.premium-zoom-slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
box-shadow: 0 0 15px rgba(16, 185, 129, 0.6), 0 5px 10px rgba(0, 0, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.5);
}
.premium-toggle-track {
width: 38px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
position: relative;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex: 0 0 auto;
}
.premium-toggle-track.active {
background: rgba(52, 211, 153, 0.35);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.premium-toggle-thumb {
position: absolute;
top: 1.5px;
left: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.premium-toggle-track.active .premium-toggle-thumb {
left: 19px;
background: #34d399;
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.25);
}
`}} />
<label <label
title={ title={
isGlobeProjection isGlobeProjection
@@ -355,7 +455,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: "8px", gap: "8px",
padding: "0 6px", padding: "0 2px",
userSelect: "none", userSelect: "none",
cursor: "pointer", cursor: "pointer",
flexShrink: 0, flexShrink: 0,
@@ -368,41 +468,16 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
aria-label="Toggle globe projection" aria-label="Toggle globe projection"
style={{ display: "none" }} style={{ display: "none" }}
/> />
<span <div className={`premium-toggle-track ${isGlobeProjection ? "active" : ""}`}>
aria-hidden="true" <span className="premium-toggle-thumb" />
style={{ </div>
position: "relative",
width: "42px",
height: "22px",
borderRadius: "999px",
border: "1px solid rgba(148, 163, 184, 0.45)",
background: isGlobeProjection
? "rgba(56, 189, 248, 0.30)"
: "rgba(148, 163, 184, 0.18)",
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
transition: "background 160ms ease",
}}
>
<span
style={{
position: "absolute",
top: "2px",
left: isGlobeProjection ? "22px" : "2px",
width: "18px",
height: "18px",
borderRadius: "999px",
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
transition: "left 160ms ease, background 160ms ease",
}}
/>
</span>
<span <span
style={{ style={{
fontSize: "12px", fontSize: "12px",
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1", color: isGlobeProjection ? "#ffffff" : "#94a3b8",
fontWeight: 700, fontWeight: 700,
minWidth: "40px", minWidth: "40px",
transition: "color 0.25s ease",
}} }}
> >
{isGlobeProjection ? "Globe" : "Flat"} {isGlobeProjection ? "Globe" : "Flat"}
@@ -410,7 +485,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
</label> </label>
{onViewModeChange ? ( {onViewModeChange ? (
<div style={{ display: "flex", background: "rgba(15, 23, 42, 0.6)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(148, 163, 184, 0.2)", gap: "2px", flexShrink: 0 }}> <div style={{ display: "flex", background: "rgba(255, 255, 255, 0.08)", borderRadius: "999px", padding: "2px", border: "1px solid rgba(255, 255, 255, 0.15)", gap: "2px", flexShrink: 0 }}>
<button <button
type="button" type="button"
onClick={() => onViewModeChange("local")} onClick={() => onViewModeChange("local")}
@@ -452,13 +527,14 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
<button <button
type="button" type="button"
onClick={isPreviewMode ? onExitPreview : onEnterPreview} onClick={isPreviewMode ? onExitPreview : onEnterPreview}
className="premium-zoom-btn"
style={{ style={{
...zoomButtonStyle,
width: "auto", width: "auto",
minWidth: "76px", minWidth: "76px",
padding: "0 12px", padding: "0 12px",
background: isPreviewMode ? "#334155" : "#166534", background: isPreviewMode ? "rgba(51, 65, 85, 0.6)" : "rgba(16, 185, 129, 0.25)",
fontWeight: 800, borderColor: isPreviewMode ? "rgba(255,255,255,0.15)" : "#10b981",
color: isPreviewMode ? "#ffffff" : "#34d399",
flexShrink: 0, flexShrink: 0,
}} }}
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"} aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
@@ -472,18 +548,17 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
<button <button
type="button" type="button"
onClick={onPlayPreviewReplay} onClick={onPlayPreviewReplay}
className="premium-zoom-btn"
style={{ style={{
...zoomButtonStyle,
width: "auto", width: "auto",
minWidth: "64px", minWidth: "64px",
padding: "0 12px", padding: "0 12px",
display: "inline-flex", display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "7px", gap: "7px",
background: "#2563eb", background: "rgba(56, 189, 248, 0.15)",
borderColor: "rgba(56, 189, 248, 0.4)",
color: "#38bdf8",
fontSize: "13px", fontSize: "13px",
fontWeight: 800,
flexShrink: 0, flexShrink: 0,
}} }}
aria-label="Play selected replay" aria-label="Play selected replay"
@@ -506,7 +581,8 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
<button <button
type="button" type="button"
onClick={() => handleZoomByStep(-0.8)} onClick={() => handleZoomByStep(-0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }} className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Zoom out" aria-label="Zoom out"
> >
- -
@@ -518,6 +594,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
max={zoomBounds.max} max={zoomBounds.max}
step={0.1} step={0.1}
value={zoomLevel} value={zoomLevel}
className="premium-zoom-slider"
onPointerDown={(event) => { onPointerDown={(event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
@@ -539,19 +616,14 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
onPointerCancel={endZoomSliderDrag} onPointerCancel={endZoomSliderDrag}
onBlur={endZoomSliderDrag} onBlur={endZoomSliderDrag}
onChange={(event) => handleZoomSliderChange(Number(event.target.value))} onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
style={{
flex: 1,
minWidth: "60px",
accentColor: "#38bdf8",
cursor: "pointer",
}}
aria-label="Map zoom" aria-label="Map zoom"
/> />
<button <button
type="button" type="button"
onClick={() => handleZoomByStep(0.8)} onClick={() => handleZoomByStep(0.8)}
style={{ ...zoomButtonStyle, flexShrink: 0 }} className="premium-zoom-btn"
style={{ flexShrink: 0 }}
aria-label="Zoom in" aria-label="Zoom in"
> >
+ +
@@ -559,10 +631,11 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
<div <div
style={{ style={{
minWidth: "56px", minWidth: "48px",
textAlign: "right", textAlign: "right",
fontSize: "12px", fontSize: "12px",
color: "#cbd5e1", fontWeight: 700,
color: "#94a3b8",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
flexShrink: 0, flexShrink: 0,
}} }}
+35 -5
View File
@@ -4,35 +4,65 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
if (mode === "add-line" || mode === "add-path") { if (mode === "add-line" || mode === "add-path") {
return ( return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy. <div style={{ marginBottom: 4 }}>Click trên bản đ đ thêm đnh.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div> </div>
); );
} }
if (mode === "add-circle") { if (mode === "add-circle") {
return ( return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất. <div style={{ marginBottom: 4 }}>Kéo chuột đ vẽ hình tròn.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Nhấn giữ chuột trái</b>: Chọn tâm & kéo đ tạo bán kính</li>
<li><b>Nhả chuột trái</b>: Hoàn tất chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác đang kéo vẽ dở</li>
</ul>
</div> </div>
); );
} }
if (mode === "add-point") { if (mode === "add-point") {
return ( return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 điểm trên bản đ đ đt đa điểm. <div style={{ marginBottom: 4 }}>Click trên bản đ đ tạo một Điểm.</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) chính xác vào hình khác</li>
</ul>
</div> </div>
) )
} }
if (mode === "select") { if (mode === "select") {
return ( return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn 1 hình, đưng, điểm trên bản đ đ xem chi tiết. <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>(Khi đã chọn) Nhấp biểu tượng <b>Cây Bút</b> đ sửa đnh.</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>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>
</li>
</ul>
</div> </div>
) )
} }
if (mode === "draw") { if (mode === "draw") {
return ( return (
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
Chọn các điểm trên bản đ đ vẽ hình, ENTER đ kết thúc, ESC đ hủy. <div style={{ marginBottom: 4 }}>Click trên bản đ đ vẽ Đa giác (Polygon).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li><b>Enter</b>: Hoàn tất & Chốt hình</li>
<li><b>Esc</b>: Hủy bỏ thao tác vẽ</li>
<li><b>Backspace</b>: Xóa đnh vừa vẽ cuối cùng</li>
<li><b>Giữ Shift / Alt</b>: Bắt dính (Snap) vào hình khác</li>
</ul>
</div> </div>
) )
} }
+32 -5
View File
@@ -4,9 +4,11 @@ import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEdito
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants"; import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
export type MapHoverPopupContent = { export type MapHoverPopupContent = {
key?: string;
rows: Array<{ rows: Array<{
title: string; title: string;
quote?: string | null; quote?: string | null;
onClick?: () => void;
}>; }>;
}; };
@@ -40,12 +42,12 @@ export function useMapHoverPopup({
className: "uhm-map-hover-popup", className: "uhm-map-hover-popup",
}); });
let hoveredId: string | null = null; let hoveredKey: string | null = null;
let frameId: number | null = null; let frameId: number | null = null;
let pendingEvent: maplibregl.MapMouseEvent | null = null; let pendingEvent: maplibregl.MapMouseEvent | null = null;
const removePopup = () => { const removePopup = () => {
hoveredId = null; hoveredKey = null;
popup.remove(); popup.remove();
}; };
@@ -91,8 +93,9 @@ export function useMapHoverPopup({
return; return;
} }
if (id !== hoveredId) { const contentKey = `${id}:${content.key || content.rows.map((row) => `${row.title}:${row.quote || ""}`).join("|")}`;
hoveredId = id; if (contentKey !== hoveredKey) {
hoveredKey = contentKey;
popup.setDOMContent(buildPopupNode(content)); popup.setDOMContent(buildPopupNode(content));
} }
@@ -185,13 +188,37 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
const titleText = row.title.trim(); const titleText = row.title.trim();
if (!titleText) continue; if (!titleText) continue;
const card = document.createElement("div"); const card: HTMLButtonElement | HTMLDivElement = row.onClick
? document.createElement("button")
: document.createElement("div");
if (row.onClick) {
(card as HTMLButtonElement).type = "button";
card.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
row.onClick?.();
};
}
card.style.width = "100%"; card.style.width = "100%";
card.style.border = "1px solid rgba(255, 255, 255, 0.10)"; card.style.border = "1px solid rgba(255, 255, 255, 0.10)";
card.style.borderRadius = "8px"; card.style.borderRadius = "8px";
card.style.background = "rgba(255, 255, 255, 0.03)"; card.style.background = "rgba(255, 255, 255, 0.03)";
card.style.padding = "12px"; card.style.padding = "12px";
card.style.textAlign = "left"; card.style.textAlign = "left";
card.style.font = "inherit";
card.style.cursor = row.onClick ? "pointer" : "default";
card.style.display = "block";
card.style.transition = "border-color 140ms ease, background 140ms ease";
if (row.onClick) {
card.onmouseenter = () => {
card.style.borderColor = "rgba(56, 189, 248, 0.40)";
card.style.background = "rgba(14, 165, 233, 0.10)";
};
card.onmouseleave = () => {
card.style.borderColor = "rgba(255, 255, 255, 0.10)";
card.style.background = "rgba(255, 255, 255, 0.03)";
};
}
const title = document.createElement("div"); const title = document.createElement("div");
title.textContent = titleText; title.textContent = titleText;
@@ -0,0 +1,167 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import TimelineBar from "@/uhm/components/ui/TimelineBar";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
import type { BackgroundLayerId, BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { Feature } from "@/uhm/lib/editor/state/useEditorState";
type Props = {
renderDraft: FeatureCollection;
labelContextDraft: FeatureCollection;
labelTimelineYear?: number | null;
selectedFeatureIds: (string | number)[];
onSelectFeatureIds: (ids: (string | number)[]) => void;
backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
timelineYear: number;
onTimelineYearChange: (year: number) => void;
timelineTimeRange?: number;
onTimelineTimeRangeChange?: (range: number) => void;
timelineFilterEnabled?: boolean;
onTimelineFilterEnabledChange?: (enabled: boolean) => void;
isTimelineLoading: boolean;
timelineDisabled?: boolean;
timelineStatusText?: string | null;
timelineStyle?: CSSProperties;
onFeatureClick?: (payload: MapFeaturePayload | null) => void;
hoverPopupEnabled?: boolean;
getHoverPopupContent?: (feature: Feature) => MapHoverPopupContent | null;
activeEntity?: Entity | null;
activeWiki?: Wiki | null;
isWikiLoading?: boolean;
wikiError?: string | null;
onCloseWikiSidebar?: () => void;
onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void;
sidebarWidth?: number;
onSidebarWidthChange?: (width: number) => void;
maxSidebarDragWidth?: number;
onPlayPreviewReplay?: () => void;
mapHandleRef?: React.RefObject<import("@/uhm/components/Map").MapHandle | null>;
overlay?: ReactNode;
children?: ReactNode;
};
export default function PreviewMapShell({
renderDraft,
labelContextDraft,
labelTimelineYear,
selectedFeatureIds,
onSelectFeatureIds,
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
timelineYear,
onTimelineYearChange,
timelineTimeRange,
onTimelineTimeRangeChange,
timelineFilterEnabled,
onTimelineFilterEnabledChange,
isTimelineLoading,
timelineDisabled = false,
timelineStatusText = null,
timelineStyle,
onFeatureClick,
hoverPopupEnabled = false,
getHoverPopupContent,
activeEntity = null,
activeWiki = null,
isWikiLoading = false,
wikiError = null,
onCloseWikiSidebar,
onWikiLinkRequest,
sidebarWidth,
onSidebarWidthChange,
maxSidebarDragWidth,
onPlayPreviewReplay,
mapHandleRef,
overlay,
children,
}: Props) {
return (
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
<div className="relative min-h-screen">
<Map
ref={mapHandleRef}
mode="preview"
renderDraft={renderDraft}
labelContextDraft={labelContextDraft}
labelTimelineYear={labelTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={onSelectFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false}
allowFeatureSelection
applyGeometryBindingFilter
isPreviewMode
onFeatureClick={onFeatureClick}
hoverPopupEnabled={hoverPopupEnabled}
getHoverPopupContent={getHoverPopupContent}
onPlayPreviewReplay={onPlayPreviewReplay}
/>
<TimelineBar
year={timelineYear}
onYearChange={onTimelineYearChange}
timeRange={timelineTimeRange}
onTimeRangeChange={onTimelineTimeRangeChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
filterEnabled={timelineFilterEnabled}
onFilterEnabledChange={onTimelineFilterEnabledChange}
style={timelineStyle}
/>
<aside
style={{
position: "absolute",
top: "50%",
left: 18,
transform: "translateY(-50%)",
zIndex: 16,
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={onToggleBackground}
onToggleGeometry={onToggleGeometry}
/>
</aside>
{overlay}
{activeEntity ? (
<aside className="absolute bottom-4 right-4 top-4 z-20 max-w-[calc(100vw-2rem)]">
<PublicWikiSidebar
entity={activeEntity}
wiki={activeWiki}
isLoading={isWikiLoading}
error={wikiError}
onClose={onCloseWikiSidebar || (() => {})}
onWikiLinkRequest={onWikiLinkRequest || (() => {})}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={onSidebarWidthChange}
maxDragWidth={maxSidebarDragWidth}
compactHeader
/>
</aside>
) : null}
{children}
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { fetchGeometriesByBBox } from "@/uhm/api/geometries";
import { ApiError } from "@/uhm/api/http";
import {
fetchEntitiesByGeometryIds,
fetchWikisByEntityIdsWithPreviews,
} from "@/uhm/api/relations";
import type { Wiki } from "@/uhm/api/wikis";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
import {
buildEntityLabelContextDraft,
buildPublicPreviewRelationIndex,
} from "@/uhm/lib/preview/relationIndex";
import {
EMPTY_PREVIEW_RELATIONS,
type PreviewRelationIndex,
} from "@/uhm/lib/preview/types";
import type { Entity } from "@/uhm/types/entities";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
import { fetchBattleReplaysByGeometryIds } from "@/uhm/api/battleReplays";
export function usePublicPreviewData(options: {
timelineYear: number;
timeRange: number;
}) {
const { timelineYear, timeRange } = options;
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [relations, setRelations] = useState<PreviewRelationIndex>(EMPTY_PREVIEW_RELATIONS);
const [replays, setReplays] = useState<BattleReplay[]>([]);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
const [relationsStatus, setRelationsStatus] = useState<string | null>(null);
const timelineFetchRequestRef = useRef(0);
useEffect(() => {
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
let next: FeatureCollection;
try {
next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setRelations(EMPTY_PREVIEW_RELATIONS);
setData(next);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometries failed", err.body);
} else {
console.error("Load public map geometries failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setTimelineStatus("Không tải được dữ liệu bản đồ tại mốc thời gian đã chọn.");
}
return;
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const geometryIds = next.features
.map((feature) => String(feature.properties.id))
.filter((id) => Boolean(id) && UUID_REGEX.test(id));
if (!geometryIds.length) {
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
return;
}
setIsRelationsLoading(true);
setRelationsStatus("Đang nạp liên kết entity/wiki.");
let entitiesByGeometryId: Record<string, Entity[]>;
let fetchedReplays: BattleReplay[] = [];
try {
const [entities, replaysRes] = await Promise.all([
fetchEntitiesByGeometryIds(geometryIds),
fetchBattleReplaysByGeometryIds(geometryIds).catch((err) => {
console.error("Failed to load replays:", err);
return {};
}),
]);
entitiesByGeometryId = entities;
const uniqueReplaysMap = new Map<string, BattleReplay>();
for (const list of Object.values(replaysRes)) {
for (const item of list || []) {
if (item && item.id) {
uniqueReplaysMap.set(item.id, item);
}
}
}
fetchedReplays = Array.from(uniqueReplaysMap.values());
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setReplays(fetchedReplays);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map geometry-entity relations failed", err.body);
} else {
console.error("Load public map geometry-entity relations failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setRelations(EMPTY_PREVIEW_RELATIONS);
setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ.");
setIsRelationsLoading(false);
}
return;
}
const entityIds = uniqueStrings(
Object.values(entitiesByGeometryId)
.flat()
.map((entity) => entity.id)
);
const entityOnlyRelations = buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {})
);
setRelations(entityOnlyRelations);
if (!entityIds.length) {
setRelationsStatus(null);
setIsRelationsLoading(false);
return;
}
try {
const wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setRelations(buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
));
setRelationsStatus(null);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map entity-wiki previews failed", err.body);
} else {
console.error("Load public map entity-wiki previews failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setRelationsStatus("Không tải được tóm tắt wiki cho bản đồ.");
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsRelationsLoading(false);
}
}
}
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear, timeRange]);
const labelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations),
[data, relations]
);
return {
data,
renderDraft: labelContextDraft,
labelContextDraft,
relations,
setRelations,
isTimelineLoading,
timelineStatus,
isRelationsLoading,
relationsStatus,
replays,
};
}
function buildRelationInputFromGeometryRelations(
draft: FeatureCollection,
entitiesByGeometryId: Record<string, Entity[]>,
wikisByEntityId: Record<string, Wiki[]>
): {
entities: Entity[];
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
} {
const entitiesById: Record<string, Entity> = {};
const entityGeometriesById: Record<string, FeatureCollection> = {};
for (const feature of draft.features) {
const geometryId = String(feature.properties.id);
for (const entity of entitiesByGeometryId[geometryId] || []) {
const id = String(entity?.id || "").trim();
if (!id) continue;
entitiesById[id] = entity;
pushFeature(entityGeometriesById, id, feature);
}
}
return {
entities: Object.values(entitiesById),
entityGeometriesById,
entityWikisById: wikisByEntityId,
};
}
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))) {
target[entityId].features.push(feature);
}
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => value.length > 0)
));
}
@@ -0,0 +1,578 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Entity } from "@/uhm/api/entities";
import { fetchWikisByEntityIdsWithPreviews } from "@/uhm/api/relations";
import {
fetchWikiBySlug,
getContentByVersionWikiId,
type Wiki,
} from "@/uhm/api/wikis";
import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup";
import type { PreviewRelationIndex } from "@/uhm/lib/preview/types";
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay } from "@/uhm/types/projects";
type CachedWiki = Wiki & { __fetched?: boolean };
type HoverWikiPreview = {
rows: Array<{
wiki: Wiki;
quote: string;
}>;
isLoaded: boolean;
};
export type LinkEntityPopupState = {
slug: string;
entities: Entity[];
top: number;
left: number;
};
export function usePublicPreviewInteraction(options: {
data: FeatureCollection;
relations: PreviewRelationIndex;
setRelations: React.Dispatch<React.SetStateAction<PreviewRelationIndex>>;
selectedFeatureIds: (string | number)[];
setSelectedFeatureIds: React.Dispatch<React.SetStateAction<(string | number)[]>>;
replayActiveWikiId?: string | null;
replayMode?: "idle" | "playing";
}) {
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, replayActiveWikiId, replayMode } = options;
const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
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(() => {
if (replayMode === "playing" && replayActiveWikiId) {
const activeWikiEntityIds = relations.wikiEntityIdsById[String(replayActiveWikiId)] || [];
const entityId = activeWikiEntityIds[0] || null;
const wikiSlug = relations.wikiById[String(replayActiveWikiId)]?.slug || null;
if (entityId) {
setActiveEntityId(entityId);
}
if (wikiSlug) {
setActiveWikiSlug(wikiSlug);
}
}
}, [replayMode, replayActiveWikiId, relations.wikiEntityIdsById, relations.wikiById]);
useEffect(() => {
if (!selectedFeatureIds.length) return;
const stillExistIds = selectedFeatureIds.filter((id) =>
data.features.some((feature) => String(feature.properties.id) === String(id))
);
if (stillExistIds.length !== selectedFeatureIds.length) {
setSelectedFeatureIds(stillExistIds);
}
}, [data.features, selectedFeatureIds, setSelectedFeatureIds]);
const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null;
const activeWiki = useMemo(() => {
if (!activeWikiSlug) return null;
return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null;
}, [activeWikiSlug, relations.wikiBySlug, wikiCache]);
const selectEntity = useCallback((
entityId: string,
selectOptions?: {
sourceFeatureId?: string | number | null;
preferredWikiSlug?: string | null;
selectGeometry?: boolean;
}
) => {
const entity = relations.entitiesById[entityId] || null;
if (!entity) return;
const linkedWikis = relations.entityWikisById[entityId] || [];
const preferredWikiSlug = String(selectOptions?.preferredWikiSlug || "").trim();
const nextWikiSlug =
(preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug)
? preferredWikiSlug
: "") ||
firstWikiSlug(linkedWikis);
setActiveEntityId(entityId);
setActiveWikiSlug(nextWikiSlug);
setActiveWikiError(null);
setLinkEntityPopup(null);
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
setSelectedFeatureIds([selectOptions.sourceFeatureId]);
}
}, [relations.entitiesById, relations.entityWikisById, setSelectedFeatureIds]);
useEffect(() => {
if (!selectedFeatureIds.length) return;
const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || [];
if (linkedEntityIds.length !== 1) return;
const onlyEntityId = linkedEntityIds[0];
if (activeEntityId === onlyEntityId) return;
selectEntity(onlyEntityId, {
sourceFeatureId: selectedFeatureIds[0],
selectGeometry: false,
});
}, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]);
const loadHoverWikiPreviewForEntity = useCallback(async (entityId: string) => {
try {
const relationWikis = relations.entityWikisById[entityId] || [];
const wikis = relationWikis.length ? relationWikis : await fetchRelationWikisForEntity(entityId);
if (!relationWikis.length && wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, entityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
}
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 };
})
);
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: {
rows: rows.filter((row) => row.quote.trim().length > 0),
isLoaded: true,
},
}));
} catch (err) {
console.error("Load hover wiki preview failed", err);
hoverWikiPreviewRequestsRef.current.delete(entityId);
setHoverWikiPreviewByEntityId((prev) => ({
...prev,
[entityId]: { rows: [], isLoaded: true },
}));
}
}, [relations.entityWikisById, setRelations]);
const getHoverPopupContent = useCallback((feature: Feature): MapHoverPopupContent | null => {
const featureId = feature.properties.id;
const entityIds = relations.geometryEntityIds[String(featureId)] || [];
const entities = entityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (!entities.length) return null;
return {
key: entities
.map((entity) => {
const preview = hoverWikiPreviewByEntityId[entity.id] ||
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,
}];
}),
};
}, [
hoverWikiPreviewByEntityId,
loadHoverWikiPreviewForEntity,
relations.entitiesById,
relations.entityWikisById,
relations.geometryEntityIds,
selectEntity,
]);
useEffect(() => {
if (!linkEntityPopup) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setLinkEntityPopup(null);
};
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && linkEntityPopupRef.current?.contains(target)) return;
setLinkEntityPopup(null);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("pointerdown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("pointerdown", handlePointerDown);
};
}, [linkEntityPopup]);
useEffect(() => {
if (!activeEntityId || activeWikiSlug) return;
const existingWikis = relations.entityWikisById[activeEntityId] || [];
const existingSlug = firstWikiSlug(existingWikis);
if (existingSlug) {
setActiveWikiSlug(existingSlug);
return;
}
if (loadedWikiEntityIdsRef.current.has(activeEntityId)) return;
loadedWikiEntityIdsRef.current.add(activeEntityId);
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const wikis = await fetchRelationWikisForEntity(activeEntityId);
if (disposed) return;
if (wikis.length) {
setRelations((prev) => mergeEntityWikisIntoRelations(prev, activeEntityId, wikis));
setWikiCache((prev) => ({
...wikisBySlug(wikis),
...prev,
}));
const nextSlug = firstWikiSlug(wikis);
if (nextSlug) {
setActiveWikiSlug(nextSlug);
} else {
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} else {
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.");
}
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeEntityId, activeWikiSlug, relations.entityWikisById, setRelations]);
const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined;
useEffect(() => {
if (!activeWikiSlug) {
setIsActiveWikiLoading(false);
setActiveWikiError(null);
return;
}
if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) {
setIsActiveWikiLoading(false);
setActiveWikiError(cachedWiki.id === "__not_found__" ? "Không tìm thấy wiki cho entity đã chọn." : null);
return;
}
let disposed = false;
(async () => {
setIsActiveWikiLoading(true);
setActiveWikiError(null);
try {
const row = await fetchWikiBySlug(activeWikiSlug);
if (disposed) return;
if (row) {
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 version content:", err);
}
if (disposed) return;
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { ...row, content: versionContent, __fetched: true },
}));
} else {
setWikiCache((prev) => ({
...prev,
[activeWikiSlug]: { id: "__not_found__", project_id: "" },
}));
setActiveWikiError("Không tìm thấy wiki cho entity đã chọn.");
}
} catch (err) {
if (disposed) return;
setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki.");
} finally {
if (!disposed) setIsActiveWikiLoading(false);
}
})();
return () => {
disposed = true;
};
}, [activeWikiSlug, cachedWiki]);
const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => {
const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || [];
const linkedEntities = linkedEntityIds
.map((entityId) => relations.entitiesById[entityId] || null)
.filter((entity): entity is Entity => Boolean(entity));
if (linkedEntities.length === 1) {
selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug });
return;
}
if (!wikiCache[slug] && !relations.wikiBySlug[slug]) {
try {
const row = await fetchWikiBySlug(slug);
if (row) setWikiCache((prev) => ({ ...prev, [slug]: row }));
} catch (err) {
console.error("Load wiki by slug failed", err);
}
}
if (!linkedEntities.length) return;
const popupWidth = 240;
const popupHeight = Math.min(240, linkedEntities.length * 44 + 20);
const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight);
setLinkEntityPopup({
slug,
entities: linkedEntities,
top,
left,
});
}, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]);
const closeWikiSidebar = useCallback(() => {
setActiveEntityId(null);
setActiveWikiSlug(null);
setActiveWikiError(null);
setLinkEntityPopup(null);
setSelectedFeatureIds([]);
}, [setSelectedFeatureIds]);
return {
activeEntity,
activeWiki,
isActiveWikiLoading,
activeWikiError,
linkEntityPopup,
linkEntityPopupRef,
getHoverPopupContent,
selectEntity,
handleWikiLinkRequest,
closeWikiSidebar,
setLinkEntityPopup,
};
}
async function fetchRelationWikisForEntity(entityId: string): Promise<Wiki[]> {
const rows = await fetchWikisByEntityIdsWithPreviews([entityId]);
return rows[entityId] || [];
}
function cleanPreviewQuoteText(content: string | null | undefined): string {
if (!content) return "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
let rawText = blockquoteMatch ? (blockquoteMatch[1]?.trim() || "") : content.trim();
rawText = rawText.replace(/<\/?blockquote[^>]*>/gi, "");
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function buildPresetHoverPreview(wikis: Wiki[]): HoverWikiPreview | undefined {
const rows = (wikis || [])
.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 "";
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
const rawText = blockquoteMatch?.[1]?.trim() || "";
if (!rawText) return "";
return rawText
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/\u00a0/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function mergeEntityWikisIntoRelations(
prev: PreviewRelationIndex,
entityId: string,
wikis: Wiki[]
): PreviewRelationIndex {
const wikiById = { ...prev.wikiById };
const wikiBySlug = { ...prev.wikiBySlug };
const entityWikis = [...(prev.entityWikisById[entityId] || [])];
const wikiEntityIdsById = cloneStringArrayRecord(prev.wikiEntityIdsById);
const wikiEntityIdsBySlug = cloneStringArrayRecord(prev.wikiEntityIdsBySlug);
for (const wiki of wikis) {
if (!wiki?.id) continue;
wikiById[wiki.id] = wiki;
if (!entityWikis.some((item) => item.id === wiki.id)) entityWikis.push(wiki);
appendUnique(wikiEntityIdsById, wiki.id, entityId);
const slug = String(wiki.slug || "").trim();
if (slug) {
wikiBySlug[slug] = wiki;
appendUnique(wikiEntityIdsBySlug, slug, entityId);
}
}
return {
...prev,
entityWikisById: {
...prev.entityWikisById,
[entityId]: entityWikis,
},
wikiEntityIdsById,
wikiEntityIdsBySlug,
wikiById,
wikiBySlug,
};
}
function wikisBySlug(wikis: Wiki[]): Record<string, Wiki> {
const result: Record<string, Wiki> = {};
for (const wiki of wikis) {
const slug = String(wiki?.slug || "").trim();
if (slug) result[slug] = wiki;
}
return result;
}
function firstWikiSlug(wikis: Wiki[]): string | null {
return wikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) || null;
}
function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [key, value] of Object.entries(source)) {
result[key] = [...value];
}
return result;
}
function appendUnique(target: Record<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) target[key].push(value);
}
function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
const margin = 12;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
const preferredLeft = rect.right + margin;
const maxLeft = Math.max(margin, viewportWidth - width - margin);
const left = Math.min(preferredLeft, maxLeft);
const preferredTop = rect.top;
const maxTop = Math.max(margin, viewportHeight - height - margin);
const top = Math.max(margin, Math.min(preferredTop, maxTop));
return { top, left };
}
+53
View File
@@ -0,0 +1,53 @@
# Data Fetching Optimization: In-flight Promise Caching
## 1. Vấn đề (The Problem)
Trong quá trình tương tác với bản đồ (ví dụ: kéo thả nhanh từ khu vực A sang B rồi sang C), các tính năng lấy dữ liệu quan hệ (entities, wikis) thường phải tải hàng chục đến hàng trăm item thông qua mảng ID (`geometryIds`).
Nếu chỉ sử dụng cơ chế Cache Data tĩnh (lưu kết quả sau khi API trả về), ta sẽ gặp phải bài toán **Race Condition** với các request đang bay (In-flight requests):
- **A -> B**: Hệ thống gọi API xin 5 ID mới. Request mất 500ms để hoàn thành.
- **B -> C** (xảy ra ở mốc 200ms): Lúc này request của B chưa xong, Cache tĩnh chưa có dữ liệu của 5 ID đó.
- Hệ thống gửi tiếp API xin 10 ID mới (bao gồm 5 ID của C và **5 ID của B**).
=> Hậu quả: Lãng phí băng thông, tải lại dữ liệu dư thừa.
## 2. Giải pháp (The Solution: DataLoader Pattern)
Để khắc phục triệt để, hệ thống sử dụng **In-flight Promise Caching** tại tầng API (`src/uhm/api/relations.ts`). Thay vì chỉ lưu trữ Data, hệ thống lưu trữ **Tiến trình (Promise)**.
### Cơ chế hoạt động:
1. **Kiểm tra Cache:** Khi nhận mảng `ids` cần tải, hệ thống kiểm tra xem ID nào đã có Promise tương ứng trong Cache (nghĩa là đang được tải hoặc đã tải xong).
2. **Lọc Missing IDs:** Chỉ những ID chưa có Promise trong Cache mới được đưa vào mảng `missingIds` để gọi API.
3. **Tạo Batch Promise:** Một HTTP Request duy nhất được gửi đi để tải `missingIds`. (Trả về `batchPromise`).
4. **Chia tách Promise (Demultiplexing):** Với mỗi ID trong `missingIds`, hệ thống gán cho nó một Promise con (tách ra từ `batchPromise` cha) có nhiệm vụ chỉ extract dữ liệu của riêng ID đó. Các Promise con này lập tức được lưu vào Cache.
5. **Đợi kết quả:** Hàm gọi `await Promise.all()` để chờ tất cả các Promise của `ids` yêu cầu hoàn thành và trả về.
## 3. Xử lý rủi ro (Trade-offs & Error Handling)
- **Error Recovery:** Nếu một Promise bị reject (do đứt mạng, server lỗi), đoạn code tạo Promise con bắt buộc phải có khối `.catch()`. Trong khối này, ID lỗi **phải bị xóa khỏi Cache**. Nếu không xóa, UI sẽ vĩnh viễn tin rằng ID đó đã được xử lý xong và không bao giờ gọi lại (Deadlock).
- **Memory Footprint:** Cache được lưu ở biến Global (`Map` hoặc `Record`). Nó sẽ tồn tại suốt phiên người dùng. Kích thước JSON là rất nhỏ, nên dung lượng RAM tăng lên không đáng kể.
## 4. Mã giả (Pseudocode)
```typescript
const promiseCache: Record<string, Promise<any>> = {};
async function fetchCached(ids: string[]) {
const missingIds = ids.filter(id => !promiseCache[id]);
if (missingIds.length > 0) {
// 1. Tạo request cha
const batchPromise = fetchFromServer(missingIds);
// 2. Chia nhỏ thành request con và lưu cache
for (const id of missingIds) {
promiseCache[id] = batchPromise
.then(res => res[id])
.catch(err => {
delete promiseCache[id]; // QUAN TRỌNG: Xóa cache nếu lỗi
throw err;
});
}
}
// 3. Chờ tất cả Promise hoàn thành (kể cả cũ lẫn mới)
const results = await Promise.all(ids.map(id => promiseCache[id]));
return mergeResults(results);
}
```
+18
View File
@@ -28,6 +28,7 @@ export type FeatureProperties = {
entity_name?: string | null; entity_name?: string | null;
entity_names?: string[]; entity_names?: string[];
entity_label_candidates?: EntityLabelCandidate[]; entity_label_candidates?: EntityLabelCandidate[];
public_entity_previews?: FeatureEntityPreview[];
entity_type_id?: string | null; entity_type_id?: string | null;
point_label?: string | null; point_label?: string | null;
line_label?: string | null; line_label?: string | null;
@@ -41,6 +42,23 @@ export type EntityLabelCandidate = {
time_end?: number | null; time_end?: number | null;
}; };
export type FeatureWikiPreview = {
id: string;
title?: string;
slug?: string | null;
preview_quote?: string | null;
content?: string | null;
};
export type FeatureEntityPreview = {
id: string;
name: string;
description?: string | null;
time_start?: number | null;
time_end?: number | null;
wikis?: FeatureWikiPreview[];
};
export type Feature = { export type Feature = {
type: "Feature"; type: "Feature";
properties: FeatureProperties; properties: FeatureProperties;