feat: update replay preview logic, refactor narrative editor state management, and improve overlay UI styling
Build and Release / release (push) Successful in 40s
Build and Release / release (push) Successful in 40s
This commit is contained in:
@@ -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<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;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1108,6 +1108,14 @@ function EditorPageContent() {
|
|||||||
timelineFilterEnabled,
|
timelineFilterEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleEnterPreviewClick = useCallback(() => {
|
||||||
|
if (mode === "replay") {
|
||||||
|
openReplayPreview("start");
|
||||||
|
} else {
|
||||||
|
openViewerPreview();
|
||||||
|
}
|
||||||
|
}, [mode, openReplayPreview, openViewerPreview]);
|
||||||
|
|
||||||
const viewerPreviewSelectedReplay = useMemo(() => {
|
const viewerPreviewSelectedReplay = useMemo(() => {
|
||||||
if (!isViewerPreviewMode || !selectedFeatureIds.length) return null;
|
if (!isViewerPreviewMode || !selectedFeatureIds.length) return null;
|
||||||
const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim();
|
const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim();
|
||||||
@@ -1212,9 +1220,9 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (m === "replay" && featureId) {
|
if (m === "replay" && featureId) {
|
||||||
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
// Sử dụng chính geo được click chuột phải làm main replay geometry
|
||||||
const finalSelectedIds = Array.from(new Set([...selectedFeatureIds, featureId]));
|
const triggerId = featureId;
|
||||||
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
const finalSelectedIds = Array.from(new Set([featureId, ...selectedFeatureIds]));
|
||||||
|
|
||||||
setReplayFeatureId(triggerId);
|
setReplayFeatureId(triggerId);
|
||||||
setReplaySelection({ stageId: null, stepIndex: null });
|
setReplaySelection({ stageId: null, stepIndex: null });
|
||||||
@@ -2682,7 +2690,7 @@ function EditorPageContent() {
|
|||||||
localFeatureIds={localFeatureIds}
|
localFeatureIds={localFeatureIds}
|
||||||
showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible}
|
showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible}
|
||||||
isPreviewMode={isAnyPreviewMode}
|
isPreviewMode={isAnyPreviewMode}
|
||||||
onEnterPreview={openViewerPreview}
|
onEnterPreview={handleEnterPreviewClick}
|
||||||
onExitPreview={isReplayPreviewMode ? exitReplayPreview : exitViewerPreview}
|
onExitPreview={isReplayPreviewMode ? exitReplayPreview : exitViewerPreview}
|
||||||
onPlayPreviewReplay={viewerPreviewSelectedReplay ? handleMapPlayPreviewReplay : undefined}
|
onPlayPreviewReplay={viewerPreviewSelectedReplay ? handleMapPlayPreviewReplay : undefined}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export default function ReplayEffectsSidebar({
|
|||||||
{selectedStage && selectedStep && selectedStepIndex != null ? (
|
{selectedStage && selectedStep && selectedStepIndex != null ? (
|
||||||
<>
|
<>
|
||||||
<ActionGroupEditor
|
<ActionGroupEditor
|
||||||
|
resetKey={`narrative-${selectedStage.id}-${selectedStepIndex}`}
|
||||||
title="Narrative"
|
title="Narrative"
|
||||||
groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`}
|
groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`}
|
||||||
actions={selectedStep.use_narrow_function}
|
actions={selectedStep.use_narrow_function}
|
||||||
@@ -1144,6 +1145,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
emptyOptionLabel,
|
emptyOptionLabel,
|
||||||
onUpdateActions,
|
onUpdateActions,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
|
resetKey,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
groupLabel: string;
|
groupLabel: string;
|
||||||
@@ -1155,6 +1157,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
emptyOptionLabel?: string;
|
emptyOptionLabel?: string;
|
||||||
onUpdateActions: (nextActions: ReplayAction<T>[], label: string) => void;
|
onUpdateActions: (nextActions: ReplayAction<T>[], label: string) => void;
|
||||||
onLinkClick?: (quill: any) => void;
|
onLinkClick?: (quill: any) => void;
|
||||||
|
resetKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
|
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
|
||||||
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
|
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
|
||||||
@@ -1167,12 +1170,15 @@ function ActionGroupEditor<T extends string>({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lastResetKeyRef = useRef<string | undefined>(undefined);
|
||||||
const lastLoadedActionsRef = useRef<any>(null);
|
const lastLoadedActionsRef = useRef<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) {
|
const resetKeyChanged = resetKey !== lastResetKeyRef.current;
|
||||||
|
if (!resetKeyChanged && JSON.stringify(actions) === JSON.stringify(lastLoadedActionsRef.current)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastResetKeyRef.current = resetKey;
|
||||||
lastLoadedActionsRef.current = actions;
|
lastLoadedActionsRef.current = actions;
|
||||||
|
|
||||||
if (actions.length > 0) {
|
if (actions.length > 0) {
|
||||||
@@ -1187,7 +1193,7 @@ function ActionGroupEditor<T extends string>({
|
|||||||
setComposerFunctionName(defaultFun);
|
setComposerFunctionName(defaultFun);
|
||||||
setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun));
|
setComposerDraftValues(buildActionComposerDraft(definitions, defaultFun));
|
||||||
}
|
}
|
||||||
}, [actions, definitions, createOnSelect, functionNames]);
|
}, [actions, definitions, createOnSelect, functionNames, resetKey]);
|
||||||
|
|
||||||
const composerDefinition = composerFunctionName
|
const composerDefinition = composerFunctionName
|
||||||
? definitions[composerFunctionName]
|
? definitions[composerFunctionName]
|
||||||
@@ -1343,6 +1349,29 @@ function FieldInput({
|
|||||||
onChange: (nextValue: ActionValue) => void;
|
onChange: (nextValue: ActionValue) => void;
|
||||||
onLinkClick?: (quill: any) => 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 = (
|
const baseLabel = (
|
||||||
<div style={{ fontSize: 12, color: "#cbd5e1", fontWeight: 700 }}>
|
<div style={{ fontSize: 12, color: "#cbd5e1", fontWeight: 700 }}>
|
||||||
{field.label}
|
{field.label}
|
||||||
@@ -1358,21 +1387,7 @@ function FieldInput({
|
|||||||
theme="snow"
|
theme="snow"
|
||||||
value={asString(value)}
|
value={asString(value)}
|
||||||
onChange={(content: string) => onChange(content)}
|
onChange={(content: string) => onChange(content)}
|
||||||
modules={{
|
modules={quillModules}
|
||||||
toolbar: {
|
|
||||||
container: [
|
|
||||||
["bold", "italic", "underline", "strike"],
|
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
|
||||||
["link"],
|
|
||||||
["clean"],
|
|
||||||
],
|
|
||||||
handlers: {
|
|
||||||
link: function (this: { quill?: any }) {
|
|
||||||
onLinkClick?.(this?.quill);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -93,53 +93,69 @@ export default function ReplayPreviewOverlay({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 18,
|
left: 88,
|
||||||
right: rightOffset,
|
right: rightOffset,
|
||||||
bottom: 96,
|
bottom: 96,
|
||||||
borderRadius: 20,
|
pointerEvents: "none",
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
||||||
background: "rgba(11, 18, 32, 0.85)",
|
|
||||||
backdropFilter: "blur(12px)",
|
|
||||||
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
justifyContent: "flex-start",
|
||||||
maxHeight: "calc(100vh - 180px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dialog.image_url?.trim() ? (
|
<div
|
||||||
<img
|
style={{
|
||||||
src={dialog.image_url}
|
width: "min(640px, 100%)",
|
||||||
alt="Historical"
|
borderRadius: 20,
|
||||||
style={{
|
overflow: "hidden",
|
||||||
width: "100%",
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
display: "block",
|
background: "rgba(11, 18, 32, 0.85)",
|
||||||
maxHeight: 140,
|
backdropFilter: "blur(12px)",
|
||||||
objectFit: "cover",
|
boxShadow: "0 16px 44px rgba(2, 6, 23, 0.42)",
|
||||||
background: "#020617",
|
pointerEvents: "auto",
|
||||||
}}
|
display: "flex",
|
||||||
/>
|
flexDirection: "column",
|
||||||
) : null}
|
maxHeight: "calc(100vh - 180px)",
|
||||||
{dialog.text?.trim() ? (
|
}}
|
||||||
<div
|
>
|
||||||
className="uhm-replay-dialog-content"
|
{dialog.image_url?.trim() ? (
|
||||||
style={{
|
<img
|
||||||
padding: "16px",
|
src={dialog.image_url}
|
||||||
color: "#f8fafc",
|
alt="Historical"
|
||||||
fontSize: "14px",
|
style={{
|
||||||
lineHeight: "1.6",
|
width: "100%",
|
||||||
overflowY: "auto",
|
display: "block",
|
||||||
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
|
maxHeight: 140,
|
||||||
minHeight: 0,
|
objectFit: "cover",
|
||||||
background: "transparent",
|
background: "#020617",
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: dialog.text }}
|
/>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
{dialog.text?.trim() ? (
|
||||||
|
<div
|
||||||
|
className="uhm-replay-dialog-content"
|
||||||
|
style={{
|
||||||
|
padding: "16px",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
overflowY: "auto",
|
||||||
|
maxHeight: dialog.image_url?.trim() ? "180px" : "140px",
|
||||||
|
minHeight: 0,
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{ __html: dialog.text }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
|
.uhm-replay-dialog-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.uhm-replay-dialog-content {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
.uhm-replay-dialog-content :global(p) {
|
.uhm-replay-dialog-content :global(p) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export type UIOptionName =
|
|||||||
| "layer_panel"
|
| "layer_panel"
|
||||||
| "zoom_panel"
|
| "zoom_panel"
|
||||||
| "wiki"
|
| "wiki"
|
||||||
| "toast";
|
|
||||||
|
|
||||||
export type MapFunctionName =
|
export type MapFunctionName =
|
||||||
| "set_camera_view"
|
| "set_camera_view"
|
||||||
@@ -226,7 +225,6 @@ export type ReplayUiParamTupleDocs = {
|
|||||||
layer_panel: [visible: boolean];
|
layer_panel: [visible: boolean];
|
||||||
zoom_panel: [visible: boolean];
|
zoom_panel: [visible: boolean];
|
||||||
wiki: [wiki_id: string | null];
|
wiki: [wiki_id: string | null];
|
||||||
toast: [message: string];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user