finish refactor pre merge
This commit is contained in:
114
app/editor/featureCommands.ts
Normal file
114
app/editor/featureCommands.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
48
app/page.tsx
48
app/page.tsx
@@ -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
1317
app/submitted/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user