finish refactor pre merge

This commit is contained in:
taDuc
2026-04-21 16:07:21 +07:00
parent 3ca7098831
commit 845bfd41a1
30 changed files with 1832 additions and 1631 deletions

View File

@@ -0,0 +1,114 @@
"use client";
import { useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { Entity } from "@/types/entities";
import type { Feature, FeatureProperties } from "@/types/geo";
import { ApiError } from "@/api/http";
import { buildFeatureEntityPatch } from "@/lib/editor/entity/entityBinding";
import { buildGeometryMetadataPatch } from "@/lib/editor/geometry/geometryMetadata";
import { uniqueEntityIds } from "@/lib/editor/snapshot/editorSnapshot";
import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
type EditorDraftApi = {
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
};
type Options = {
editor: EditorDraftApi;
selectedFeature: Feature | null;
geometryMetaForm: GeometryMetaFormState;
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
selectedGeometryEntityIds: string[];
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
entities: Entity[];
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
};
export function useFeatureCommands(options: Options) {
const {
editor,
selectedFeature,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
entities,
setIsEntitySubmitting,
setEntityFormStatus,
} = options;
const applyGeometryMetadata = useCallback(async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
let metadata;
try {
metadata = buildGeometryMetadataPatch(geometryMetaForm);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
} finally {
setIsEntitySubmitting(false);
}
}, [
editor,
geometryMetaForm,
selectedFeature,
setEntityFormStatus,
setGeometryMetaForm,
setIsEntitySubmitting,
]);
const applyEntitiesToSelectedGeometry = useCallback(async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(
selectedFeature.properties.id,
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
);
setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
}, [
editor,
entities,
selectedFeature,
selectedGeometryEntityIds,
setEntityFormStatus,
setIsEntitySubmitting,
setSelectedGeometryEntityIds,
]);
return {
applyGeometryMetadata,
applyEntitiesToSelectedGeometry,
};
}

View File

@@ -15,7 +15,6 @@ import {
} from "@/api/sections";
import {
Feature,
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
import {
@@ -33,7 +32,6 @@ import {
import {
EntityFormState,
PendingEntityCreate,
TimelineRange,
useEditorSessionState,
} from "@/lib/useEditorSessionState";
import {
@@ -44,13 +42,11 @@ import {
} from "@/lib/editor/snapshot/editorSnapshot";
import {
buildClientEntityId,
buildFeatureEntityPatch,
formatEntityNamesForDisplay,
mergeEntitiesWithPending,
mergeEntitySearchResults,
} from "@/lib/editor/entity/entityBinding";
import {
buildGeometryMetadataPatch,
formatBindingIdsForDisplay,
} from "@/lib/editor/geometry/geometryMetadata";
import {
@@ -58,20 +54,11 @@ import {
persistBackgroundLayerVisibility,
} from "@/lib/editor/background/backgroundVisibilityStorage";
import { useSectionCommands } from "@/lib/editor/section/useSectionCommands";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants";
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline";
import { useFeatureCommands } from "./featureCommands";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = {
minLng: -180,
minLat: -90,
maxLng: 180,
maxLat: 90,
} as const;
const CURRENT_YEAR = new Date().getUTCFullYear();
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
min: -2000,
max: 2000,
};
const TIMELINE_DEBOUNCE_MS = 180;
const DEFAULT_EDITOR_USER_ID = "local-editor";
export default function Page() {
@@ -147,12 +134,14 @@ export default function Page() {
isBackgroundVisibilityReady,
setIsBackgroundVisibilityReady,
} = useEditorSessionState({
emptyFeatureCollection: EMPTY_FC,
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
fallbackTimelineRange: FALLBACK_TIMELINE_RANGE,
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
currentYear: CURRENT_YEAR,
});
// Counter để bỏ qua response cũ khi user đổi timeline/section liên tục.
const timelineFetchRequestRef = useRef(0);
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
const entitySearchRequestRef = useRef(0);
const editor = useEditorState(initialData);
@@ -196,7 +185,7 @@ export default function Page() {
const sectionCommands = useSectionCommands({
editor,
editorUserId,
emptyFeatureCollection: EMPTY_FC,
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
activeSection,
sectionState,
selectedSectionId,
@@ -534,7 +523,7 @@ export default function Page() {
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
@@ -562,57 +551,17 @@ export default function Page() {
setEntityFormStatus(null);
};
const handleApplyGeometryMetadata = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
let metadata;
try {
metadata = buildGeometryMetadataPatch(geometryMetaForm);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
setGeometryMetaForm(metadata.formState);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
} finally {
setIsEntitySubmitting(false);
}
};
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
editor.patchFeatureProperties(
selectedFeature.properties.id,
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
);
setSelectedGeometryEntityIds(entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const featureCommands = useFeatureCommands({
editor,
selectedFeature,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
entities,
setIsEntitySubmitting,
setEntityFormStatus,
});
const handleCreateEntityOnly = async () => {
const name = entityForm.name.trim();
@@ -797,8 +746,8 @@ export default function Page() {
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onApplyGeometryMetadata={handleApplyGeometryMetadata}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
@@ -813,18 +762,6 @@ function normalizeEditorUserId(value: string): string {
return normalized || DEFAULT_EDITOR_USER_ID;
}
function clampYear(year: number, range: TimelineRange): number {
return clampYearValue(year, range.min, range.max);
}
function clampYearValue(year: number, minYear: number, maxYear: number): number {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
if (year < lower) return lower;
if (year > upper) return upper;
return year;
}
function formatCommitTitle(commit: SectionCommit): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}

View File

@@ -22,5 +22,5 @@
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Ultimate History Map",
description: "Map editor and viewer for historical entities, geometries, and timelines.",
};
export default function RootLayout({
@@ -23,7 +23,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="vi">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -19,41 +19,35 @@ import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/lib/editor/background/backgroundVisibilityStorage";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = {
minLng: -180,
minLat: -90,
maxLng: 180,
maxLat: 90,
} as const;
const CURRENT_YEAR = new Date().getUTCFullYear();
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
min: -2000,
max: 2000,
};
const TIMELINE_DEBOUNCE_MS = 180;
type TimelineRange = {
min: number;
max: number;
};
export default function Page() {
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
// Dữ liệu GeoJSON hiện đang hiển thị trên map (theo timelineYear).
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
// ID geometry đang được chọn trên map (null nếu chưa chọn).
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
// Năm timeline "đã chốt" để trigger fetch dữ liệu.
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
clampYearToFixedRange(CURRENT_YEAR)
);
// Năm timeline đang kéo/nhập (debounce rồi mới đẩy sang timelineYear).
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
clampYearToFixedRange(CURRENT_YEAR)
);
// Cờ loading khi fetch geometries theo timeline.
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
// Thông báo trạng thái/lỗi khi load timeline.
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
// Trạng thái bật/tắt các layer nền.
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
// Đảm bảo đã load backgroundVisibility từ storage trước khi render map.
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
// Counter để bỏ qua response cũ khi user đổi timeline liên tục.
const timelineFetchRequestRef = useRef(0);
const selectedFeature =
@@ -154,7 +148,7 @@ export default function Page() {
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const selectedType = resolveSelectedTypeSummary(selectedFeature);
@@ -250,18 +244,6 @@ export default function Page() {
);
}
function clampYear(year: number, range: TimelineRange): number {
return clampYearValue(year, range.min, range.max);
}
function clampYearValue(year: number, minYear: number, maxYear: number): number {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
if (year < lower) return lower;
if (year > upper) return upper;
return year;
}
function isYearNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}

File diff suppressed because it is too large Load Diff

1317
app/submitted/page.tsx Normal file

File diff suppressed because it is too large Load Diff