diff --git a/doc/in_flight_promise_caching.md b/doc/in_flight_promise_caching.md deleted file mode 100644 index 61a1df2..0000000 --- a/doc/in_flight_promise_caching.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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> = {}; - -export async function fetchEntitiesByGeometryIds(ids: string[]): Promise> { - 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 = {}; - // 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; -} -``` diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 24c2c23..7f281ed 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1108,6 +1108,14 @@ function EditorPageContent() { timelineFilterEnabled, ]); + const handleEnterPreviewClick = useCallback(() => { + if (mode === "replay") { + openReplayPreview("start"); + } else { + openViewerPreview(); + } + }, [mode, openReplayPreview, openViewerPreview]); + const viewerPreviewSelectedReplay = useMemo(() => { if (!isViewerPreviewMode || !selectedFeatureIds.length) return null; const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim(); @@ -1212,9 +1220,9 @@ function EditorPageContent() { } if (m === "replay" && featureId) { - // QUY TẮC: Geo chọn đầu tiên là geo main. - const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId])); - const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId; + // Sử dụng chính geo được click chuột phải làm main replay geometry + const triggerId = featureId; + const finalSelectedIds = Array.from(new Set([featureId, ...selectedFeatureIds])); setReplayFeatureId(triggerId); setReplaySelection({ stageId: null, stepIndex: null }); @@ -2682,7 +2690,7 @@ function EditorPageContent() { localFeatureIds={localFeatureIds} showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible} isPreviewMode={isAnyPreviewMode} - onEnterPreview={openViewerPreview} + onEnterPreview={handleEnterPreviewClick} onExitPreview={isReplayPreviewMode ? exitReplayPreview : exitViewerPreview} onPlayPreviewReplay={viewerPreviewSelectedReplay ? handleMapPlayPreviewReplay : undefined} viewMode={viewMode} diff --git a/src/uhm/components/editor/ReplayEffectsSidebar.tsx b/src/uhm/components/editor/ReplayEffectsSidebar.tsx index 50d921b..6bc7356 100644 --- a/src/uhm/components/editor/ReplayEffectsSidebar.tsx +++ b/src/uhm/components/editor/ReplayEffectsSidebar.tsx @@ -520,6 +520,7 @@ export default function ReplayEffectsSidebar({ {selectedStage && selectedStep && selectedStepIndex != null ? ( <> ({ emptyOptionLabel, onUpdateActions, onLinkClick, + resetKey, }: { title: string; groupLabel: string; @@ -1155,6 +1157,7 @@ function ActionGroupEditor({ emptyOptionLabel?: string; onUpdateActions: (nextActions: ReplayAction[], label: string) => void; onLinkClick?: (quill: any) => void; + resetKey?: string; }) { const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]); const [composerFunctionName, setComposerFunctionName] = useState( @@ -1167,12 +1170,15 @@ function ActionGroupEditor({ ) ); + const lastResetKeyRef = useRef(undefined); const lastLoadedActionsRef = useRef(null); useEffect(() => { - if (JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) { + const resetKeyChanged = resetKey !== lastResetKeyRef.current; + if (!resetKeyChanged && JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) { return; } + lastResetKeyRef.current = resetKey; lastLoadedActionsRef.current = actions; if (actions.length > 0) { @@ -1187,7 +1193,7 @@ function ActionGroupEditor({ setComposerFunctionName(defaultFun); setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun)); } - }, [actions, definitions, createOnSelect, functionNames]); + }, [actions, definitions, createOnSelect, functionNames, resetKey]); const composerDefinition = composerFunctionName ? definitions[composerFunctionName] @@ -1343,6 +1349,29 @@ function FieldInput({ onChange: (nextValue: ActionValue) => void; onLinkClick?: (quill: any) => void; }) { + const onLinkClickRef = useRef(onLinkClick); + useEffect(() => { + onLinkClickRef.current = onLinkClick; + }, [onLinkClick]); + + const quillModules = useMemo(() => { + return { + toolbar: { + container: [ + ["bold", "italic", "underline", "strike"], + [{ list: "ordered" }, { list: "bullet" }], + ["link"], + ["clean"], + ], + handlers: { + link: function (this: { quill?: any }) { + onLinkClickRef.current?.(this?.quill); + }, + }, + }, + }; + }, []); + const baseLabel = (
{field.label} @@ -1358,21 +1387,7 @@ function FieldInput({ theme="snow" value={asString(value)} onChange={(content: string) => onChange(content)} - modules={{ - toolbar: { - container: [ - ["bold", "italic", "underline", "strike"], - [{ list: "ordered" }, { list: "bullet" }], - ["link"], - ["clean"], - ], - handlers: { - link: function (this: { quill?: any }) { - onLinkClick?.(this?.quill); - }, - }, - }, - }} + modules={quillModules} />
diff --git a/src/uhm/components/editor/ReplayPreviewOverlay.tsx b/src/uhm/components/editor/ReplayPreviewOverlay.tsx index 5a06e9b..74eb073 100644 --- a/src/uhm/components/editor/ReplayPreviewOverlay.tsx +++ b/src/uhm/components/editor/ReplayPreviewOverlay.tsx @@ -93,53 +93,69 @@ export default function ReplayPreviewOverlay({
- {dialog.image_url?.trim() ? ( - Historical - ) : null} - {dialog.text?.trim() ? ( -
- ) : null} +
+ {dialog.image_url?.trim() ? ( + Historical + ) : null} + {dialog.text?.trim() ? ( +
+ ) : null} +
) : null}