replay in route /
This commit is contained in:
@@ -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à **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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { API_URL_ROOT } from "../../../api";
|
||||
const GOONG_TILES_BASE_URL = "https://tiles.goong.io";
|
||||
|
||||
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_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`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
wikis: `${API_BASE_URL}/wikis`,
|
||||
relations: `${API_BASE_URL}/relations`,
|
||||
wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`,
|
||||
// New API uses projects + commits + submissions (JWT-protected).
|
||||
authSignin: `${API_BASE_URL}/auth/signin`,
|
||||
@@ -54,4 +54,5 @@ export const API_ENDPOINTS = {
|
||||
currentUserProjects: `${API_BASE_URL}/users/current/project`,
|
||||
projects: `${API_BASE_URL}/projects`,
|
||||
submissions: `${API_BASE_URL}/submissions`,
|
||||
battleReplays: `${API_BASE_URL}/battle-replays`,
|
||||
} as const;
|
||||
|
||||
+142
-12
@@ -1,7 +1,7 @@
|
||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||
import { requestJson } from "@/uhm/api/http";
|
||||
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";
|
||||
|
||||
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
|
||||
@@ -90,17 +90,28 @@ export async function searchGeometriesByEntityName(
|
||||
|
||||
return {
|
||||
...response,
|
||||
items: (response.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,
|
||||
})),
|
||||
})),
|
||||
items: normalizeEntityGeometryItems(response.items),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchEntityGeometryIndexPage(options?: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}): Promise<SearchGeometriesByEntityNameResponse> {
|
||||
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_lat: number;
|
||||
} | 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 {
|
||||
@@ -129,6 +166,9 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||
|
||||
const boundWith = normalizeBoundWith(row.bound_with);
|
||||
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 = {
|
||||
id: row.id,
|
||||
@@ -136,6 +176,21 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||
time_start: row.time_start ?? null,
|
||||
time_end: row.time_end ?? null,
|
||||
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({
|
||||
@@ -148,6 +203,67 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
|
||||
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 {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const g = value as Record<string, unknown>;
|
||||
@@ -162,3 +278,17 @@ function normalizeBoundWith(value: unknown): string | null {
|
||||
const id = String(value).trim();
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type Wiki = {
|
||||
title?: string;
|
||||
slug?: string | null;
|
||||
content?: string;
|
||||
preview_quote?: string | null;
|
||||
is_deleted?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -71,4 +72,4 @@ export async function checkWikiSlugExists(slug: string): Promise<boolean> {
|
||||
export const getContentByVersionWikiId = async (id: string) => {
|
||||
const response = await api.get(API_ENDPOINTS.wikiContent(id));
|
||||
return response?.data;
|
||||
};
|
||||
};
|
||||
|
||||
+129
-56
@@ -336,15 +336,115 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
background: "rgba(15, 23, 42, 0.88)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.38)",
|
||||
borderRadius: "999px",
|
||||
padding: "8px 12px",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(3px)",
|
||||
background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "50px",
|
||||
padding: "8px 16px",
|
||||
color: "#f8fafc",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
title={
|
||||
isGlobeProjection
|
||||
@@ -355,7 +455,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "0 6px",
|
||||
padding: "0 2px",
|
||||
userSelect: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
@@ -368,41 +468,16 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
aria-label="Toggle globe projection"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
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>
|
||||
<div className={`premium-toggle-track ${isGlobeProjection ? "active" : ""}`}>
|
||||
<span className="premium-toggle-thumb" />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
||||
color: isGlobeProjection ? "#ffffff" : "#94a3b8",
|
||||
fontWeight: 700,
|
||||
minWidth: "40px",
|
||||
transition: "color 0.25s ease",
|
||||
}}
|
||||
>
|
||||
{isGlobeProjection ? "Globe" : "Flat"}
|
||||
@@ -410,7 +485,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
</label>
|
||||
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => onViewModeChange("local")}
|
||||
@@ -452,13 +527,14 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
<button
|
||||
type="button"
|
||||
onClick={isPreviewMode ? onExitPreview : onEnterPreview}
|
||||
className="premium-zoom-btn"
|
||||
style={{
|
||||
...zoomButtonStyle,
|
||||
width: "auto",
|
||||
minWidth: "76px",
|
||||
padding: "0 12px",
|
||||
background: isPreviewMode ? "#334155" : "#166534",
|
||||
fontWeight: 800,
|
||||
background: isPreviewMode ? "rgba(51, 65, 85, 0.6)" : "rgba(16, 185, 129, 0.25)",
|
||||
borderColor: isPreviewMode ? "rgba(255,255,255,0.15)" : "#10b981",
|
||||
color: isPreviewMode ? "#ffffff" : "#34d399",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label={isPreviewMode ? "Exit preview" : "Enter preview"}
|
||||
@@ -472,18 +548,17 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayPreviewReplay}
|
||||
className="premium-zoom-btn"
|
||||
style={{
|
||||
...zoomButtonStyle,
|
||||
width: "auto",
|
||||
minWidth: "64px",
|
||||
padding: "0 12px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "7px",
|
||||
background: "#2563eb",
|
||||
background: "rgba(56, 189, 248, 0.15)",
|
||||
borderColor: "rgba(56, 189, 248, 0.4)",
|
||||
color: "#38bdf8",
|
||||
fontSize: "13px",
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="Play selected replay"
|
||||
@@ -506,7 +581,8 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(-0.8)}
|
||||
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||
className="premium-zoom-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
-
|
||||
@@ -518,6 +594,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
max={zoomBounds.max}
|
||||
step={0.1}
|
||||
value={zoomLevel}
|
||||
className="premium-zoom-slider"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
@@ -539,19 +616,14 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
onPointerCancel={endZoomSliderDrag}
|
||||
onBlur={endZoomSliderDrag}
|
||||
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: "60px",
|
||||
accentColor: "#38bdf8",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-label="Map zoom"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleZoomByStep(0.8)}
|
||||
style={{ ...zoomButtonStyle, flexShrink: 0 }}
|
||||
className="premium-zoom-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
@@ -559,10 +631,11 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
|
||||
|
||||
<div
|
||||
style={{
|
||||
minWidth: "56px",
|
||||
minWidth: "48px",
|
||||
textAlign: "right",
|
||||
fontSize: "12px",
|
||||
color: "#cbd5e1",
|
||||
fontWeight: 700,
|
||||
color: "#94a3b8",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
|
||||
@@ -4,35 +4,65 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||
if (mode === "add-line" || mode === "add-path") {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
if (mode === "add-circle") {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
if (mode === "add-point") {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
if (mode === "select") {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
if (mode === "draw") {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { Feature, FeatureCollection } from "@/uhm/lib/editor/state/useEdito
|
||||
import { FEATURE_STATE_SOURCE_IDS } from "@/uhm/lib/map/constants";
|
||||
|
||||
export type MapHoverPopupContent = {
|
||||
key?: string;
|
||||
rows: Array<{
|
||||
title: string;
|
||||
quote?: string | null;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -40,12 +42,12 @@ export function useMapHoverPopup({
|
||||
className: "uhm-map-hover-popup",
|
||||
});
|
||||
|
||||
let hoveredId: string | null = null;
|
||||
let hoveredKey: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let pendingEvent: maplibregl.MapMouseEvent | null = null;
|
||||
|
||||
const removePopup = () => {
|
||||
hoveredId = null;
|
||||
hoveredKey = null;
|
||||
popup.remove();
|
||||
};
|
||||
|
||||
@@ -91,8 +93,9 @@ export function useMapHoverPopup({
|
||||
return;
|
||||
}
|
||||
|
||||
if (id !== hoveredId) {
|
||||
hoveredId = id;
|
||||
const contentKey = `${id}:${content.key || content.rows.map((row) => `${row.title}:${row.quote || ""}`).join("|")}`;
|
||||
if (contentKey !== hoveredKey) {
|
||||
hoveredKey = contentKey;
|
||||
popup.setDOMContent(buildPopupNode(content));
|
||||
}
|
||||
|
||||
@@ -185,13 +188,37 @@ function buildPopupNode(content: MapHoverPopupContent): HTMLElement {
|
||||
const titleText = row.title.trim();
|
||||
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.border = "1px solid rgba(255, 255, 255, 0.10)";
|
||||
card.style.borderRadius = "8px";
|
||||
card.style.background = "rgba(255, 255, 255, 0.03)";
|
||||
card.style.padding = "12px";
|
||||
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");
|
||||
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(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
@@ -28,6 +28,7 @@ export type FeatureProperties = {
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_label_candidates?: EntityLabelCandidate[];
|
||||
public_entity_previews?: FeatureEntityPreview[];
|
||||
entity_type_id?: string | null;
|
||||
point_label?: string | null;
|
||||
line_label?: string | null;
|
||||
@@ -41,6 +42,23 @@ export type EntityLabelCandidate = {
|
||||
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 = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
|
||||
Reference in New Issue
Block a user