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";
|
} from "@/api/sections";
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
FeatureCollection,
|
|
||||||
useEditorState,
|
useEditorState,
|
||||||
} from "@/lib/useEditorState";
|
} from "@/lib/useEditorState";
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
EntityFormState,
|
EntityFormState,
|
||||||
PendingEntityCreate,
|
PendingEntityCreate,
|
||||||
TimelineRange,
|
|
||||||
useEditorSessionState,
|
useEditorSessionState,
|
||||||
} from "@/lib/useEditorSessionState";
|
} from "@/lib/useEditorSessionState";
|
||||||
import {
|
import {
|
||||||
@@ -44,13 +42,11 @@ import {
|
|||||||
} from "@/lib/editor/snapshot/editorSnapshot";
|
} from "@/lib/editor/snapshot/editorSnapshot";
|
||||||
import {
|
import {
|
||||||
buildClientEntityId,
|
buildClientEntityId,
|
||||||
buildFeatureEntityPatch,
|
|
||||||
formatEntityNamesForDisplay,
|
formatEntityNamesForDisplay,
|
||||||
mergeEntitiesWithPending,
|
mergeEntitiesWithPending,
|
||||||
mergeEntitySearchResults,
|
mergeEntitySearchResults,
|
||||||
} from "@/lib/editor/entity/entityBinding";
|
} from "@/lib/editor/entity/entityBinding";
|
||||||
import {
|
import {
|
||||||
buildGeometryMetadataPatch,
|
|
||||||
formatBindingIdsForDisplay,
|
formatBindingIdsForDisplay,
|
||||||
} from "@/lib/editor/geometry/geometryMetadata";
|
} from "@/lib/editor/geometry/geometryMetadata";
|
||||||
import {
|
import {
|
||||||
@@ -58,20 +54,11 @@ import {
|
|||||||
persistBackgroundLayerVisibility,
|
persistBackgroundLayerVisibility,
|
||||||
} from "@/lib/editor/background/backgroundVisibilityStorage";
|
} from "@/lib/editor/background/backgroundVisibilityStorage";
|
||||||
import { useSectionCommands } from "@/lib/editor/section/useSectionCommands";
|
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 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";
|
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@@ -147,12 +134,14 @@ export default function Page() {
|
|||||||
isBackgroundVisibilityReady,
|
isBackgroundVisibilityReady,
|
||||||
setIsBackgroundVisibilityReady,
|
setIsBackgroundVisibilityReady,
|
||||||
} = useEditorSessionState({
|
} = useEditorSessionState({
|
||||||
emptyFeatureCollection: EMPTY_FC,
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
||||||
fallbackTimelineRange: FALLBACK_TIMELINE_RANGE,
|
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
|
||||||
currentYear: CURRENT_YEAR,
|
currentYear: CURRENT_YEAR,
|
||||||
});
|
});
|
||||||
|
// Counter để bỏ qua response cũ khi user đổi timeline/section liên tục.
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
|
||||||
const entitySearchRequestRef = useRef(0);
|
const entitySearchRequestRef = useRef(0);
|
||||||
|
|
||||||
const editor = useEditorState(initialData);
|
const editor = useEditorState(initialData);
|
||||||
@@ -196,7 +185,7 @@ export default function Page() {
|
|||||||
const sectionCommands = useSectionCommands({
|
const sectionCommands = useSectionCommands({
|
||||||
editor,
|
editor,
|
||||||
editorUserId,
|
editorUserId,
|
||||||
emptyFeatureCollection: EMPTY_FC,
|
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
|
||||||
activeSection,
|
activeSection,
|
||||||
sectionState,
|
sectionState,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
@@ -534,7 +523,7 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineYearChange = (nextYear: number) => {
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||||
@@ -562,57 +551,17 @@ export default function Page() {
|
|||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyGeometryMetadata = async () => {
|
const featureCommands = useFeatureCommands({
|
||||||
if (!selectedFeature) {
|
editor,
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
selectedFeature,
|
||||||
return;
|
geometryMetaForm,
|
||||||
}
|
setGeometryMetaForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
let metadata;
|
setSelectedGeometryEntityIds,
|
||||||
try {
|
entities,
|
||||||
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
setIsEntitySubmitting,
|
||||||
} catch (err) {
|
setEntityFormStatus,
|
||||||
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 handleCreateEntityOnly = async () => {
|
const handleCreateEntityOnly = async () => {
|
||||||
const name = entityForm.name.trim();
|
const name = entityForm.name.trim();
|
||||||
@@ -797,8 +746,8 @@ export default function Page() {
|
|||||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
onCreateEntityOnly={handleCreateEntityOnly}
|
onCreateEntityOnly={handleCreateEntityOnly}
|
||||||
onApplyGeometryMetadata={handleApplyGeometryMetadata}
|
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
|
||||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry}
|
||||||
changeCount={editor.changeCount}
|
changeCount={editor.changeCount}
|
||||||
entityFormStatus={entityFormStatus}
|
entityFormStatus={entityFormStatus}
|
||||||
/>
|
/>
|
||||||
@@ -813,18 +762,6 @@ function normalizeEditorUserId(value: string): string {
|
|||||||
return normalized || DEFAULT_EDITOR_USER_ID;
|
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 {
|
function formatCommitTitle(commit: SectionCommit): string {
|
||||||
return commit.title?.trim() || `Commit #${commit.commit_no}`;
|
return commit.title?.trim() || `Commit #${commit.commit_no}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Ultimate History Map",
|
||||||
description: "Generated by create next app",
|
description: "Map editor and viewer for historical entities, geometries, and timelines.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,7 +23,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="vi">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
48
app/page.tsx
48
app/page.tsx
@@ -19,41 +19,35 @@ import {
|
|||||||
loadBackgroundLayerVisibilityFromStorage,
|
loadBackgroundLayerVisibilityFromStorage,
|
||||||
persistBackgroundLayerVisibility,
|
persistBackgroundLayerVisibility,
|
||||||
} from "@/lib/editor/background/backgroundVisibilityStorage";
|
} 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 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() {
|
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);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||||
|
// Năm timeline "đã chốt" để trigger fetch dữ liệu.
|
||||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
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>(() =>
|
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);
|
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);
|
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>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
);
|
||||||
|
// Đảm bảo đã load backgroundVisibility từ storage trước khi render map.
|
||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
// Counter để bỏ qua response cũ khi user đổi timeline liên tục.
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
|
||||||
const selectedFeature =
|
const selectedFeature =
|
||||||
@@ -154,7 +148,7 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineYearChange = (nextYear: number) => {
|
const handleTimelineYearChange = (nextYear: number) => {
|
||||||
setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
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 {
|
function isYearNumber(value: number | null | undefined): value is number {
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
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
@@ -3,12 +3,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import CommitTreePopup from "@/components/CommitTreePopup";
|
import CommitTreePopup from "@/components/CommitTreePopup";
|
||||||
import { UndoAction } from "@/lib/useEditorState";
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: Mode;
|
mode: EditorMode;
|
||||||
setMode: (mode: Mode) => void;
|
setMode: (mode: EditorMode) => void;
|
||||||
entityStatus?: string | null;
|
entityStatus?: string | null;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
@@ -103,12 +102,13 @@ export default function Editor({
|
|||||||
createdEntities,
|
createdEntities,
|
||||||
createdGeometries,
|
createdGeometries,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// Bật/tắt popup cây commit.
|
||||||
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
|
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
|
||||||
|
|
||||||
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||||
commit.title?.trim() || `Commit #${commit.commit_no}`;
|
commit.title?.trim() || `Commit #${commit.commit_no}`;
|
||||||
|
|
||||||
const toggleMode = (newMode: Mode) => {
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
if (mode === newMode) {
|
if (mode === newMode) {
|
||||||
setMode("idle"); // bấm lại → tắt
|
setMode("idle"); // bấm lại → tắt
|
||||||
} else {
|
} else {
|
||||||
@@ -129,7 +129,7 @@ export default function Editor({
|
|||||||
return labels.reverse();
|
return labels.reverse();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const getButtonStyle = (btnMode: Mode) => ({
|
const getButtonStyle = (btnMode: EditorMode) => ({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
marginBottom: "6px",
|
marginBottom: "6px",
|
||||||
|
|||||||
@@ -14,9 +14,31 @@ import { initCircle } from "@/lib/engine/circleEngine";
|
|||||||
import { createEditingEngine } from "@/lib/engine/editingEngine";
|
import { createEditingEngine } from "@/lib/engine/editingEngine";
|
||||||
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/lib/geo/constants";
|
||||||
|
import {
|
||||||
|
DEFAULT_POINT_ICON_ID,
|
||||||
|
FEATURE_STATE_SOURCE_IDS,
|
||||||
|
MAP_MAX_ZOOM,
|
||||||
|
MAP_MIN_ZOOM,
|
||||||
|
PATH_ARROW_ICON_ID,
|
||||||
|
PATH_ARROW_SOURCE_ID,
|
||||||
|
POINT_ICON_URL,
|
||||||
|
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||||
|
RASTER_BASE_LAYER_ID,
|
||||||
|
RASTER_BASE_SOURCE_ID,
|
||||||
|
} from "@/lib/map/constants";
|
||||||
|
import {
|
||||||
|
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||||
|
LINE_COLOR_BY_TYPE,
|
||||||
|
PATH_RENDER_BY_TYPE,
|
||||||
|
POLYGON_FILL_BY_TYPE,
|
||||||
|
POLYGON_OPACITY_BY_TYPE,
|
||||||
|
POLYGON_STROKE_BY_TYPE,
|
||||||
|
} from "@/lib/map/style";
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
mode: EditorMode;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
backgroundVisibility: BackgroundLayerVisibility;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
selectedFeatureId: string | number | null;
|
selectedFeatureId: string | number | null;
|
||||||
@@ -37,93 +59,6 @@ type EngineBinding = {
|
|||||||
clearSelection?: () => void;
|
clearSelection?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
|
||||||
const POINT_ICON_URL = "/point.png";
|
|
||||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
|
||||||
const MAP_MIN_ZOOM = 1;
|
|
||||||
const MAP_MAX_ZOOM = 10;
|
|
||||||
const RASTER_BASE_SOURCE_ID = "rasterBase";
|
|
||||||
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
|
||||||
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
|
||||||
const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
|
||||||
const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
|
||||||
const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [],
|
|
||||||
};
|
|
||||||
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
|
||||||
"coalesce",
|
|
||||||
["get", "MAPCOLOR7"],
|
|
||||||
["get", "MAPCOLOR9"],
|
|
||||||
["get", "scalerank"],
|
|
||||||
0,
|
|
||||||
];
|
|
||||||
const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
|
||||||
"match",
|
|
||||||
COUNTRY_COLOR_KEY_EXPRESSION,
|
|
||||||
1, "#ef4444",
|
|
||||||
2, "#f97316",
|
|
||||||
3, "#f59e0b",
|
|
||||||
4, "#22c55e",
|
|
||||||
5, "#06b6d4",
|
|
||||||
6, "#3b82f6",
|
|
||||||
7, "#8b5cf6",
|
|
||||||
8, "#a855f7",
|
|
||||||
9, "#d946ef",
|
|
||||||
10, "#14b8a6",
|
|
||||||
"#64748b",
|
|
||||||
];
|
|
||||||
|
|
||||||
const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
|
||||||
country: "#2563eb",
|
|
||||||
state: "#0ea5e9",
|
|
||||||
empire: "#f59e0b",
|
|
||||||
kingdom: "#d97706",
|
|
||||||
war: "#dc2626",
|
|
||||||
battle: "#f43f5e",
|
|
||||||
civilization: "#14b8a6",
|
|
||||||
rebellion_zone: "#7c3aed",
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
|
||||||
country: "#1e3a8a",
|
|
||||||
state: "#0c4a6e",
|
|
||||||
empire: "#7c2d12",
|
|
||||||
kingdom: "#9a3412",
|
|
||||||
war: "#7f1d1d",
|
|
||||||
battle: "#9f1239",
|
|
||||||
civilization: "#134e4a",
|
|
||||||
rebellion_zone: "#4c1d95",
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
|
||||||
war: 0.3,
|
|
||||||
battle: 0.34,
|
|
||||||
civilization: 0.38,
|
|
||||||
rebellion_zone: 0.32,
|
|
||||||
};
|
|
||||||
|
|
||||||
const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
|
||||||
defense_line: "#f97316",
|
|
||||||
attack_route: "#ef4444",
|
|
||||||
retreat_route: "#94a3b8",
|
|
||||||
invasion_route: "#b91c1c",
|
|
||||||
migration_route: "#0ea5e9",
|
|
||||||
refugee_route: "#06b6d4",
|
|
||||||
trade_route: "#eab308",
|
|
||||||
shipping_route: "#2563eb",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
|
||||||
attack_route: true,
|
|
||||||
retreat_route: true,
|
|
||||||
invasion_route: true,
|
|
||||||
migration_route: true,
|
|
||||||
refugee_route: true,
|
|
||||||
trade_route: true,
|
|
||||||
shipping_route: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Map({
|
export default function Map({
|
||||||
mode,
|
mode,
|
||||||
draft,
|
draft,
|
||||||
@@ -139,24 +74,57 @@ export default function Map({
|
|||||||
fitToDraftBounds = false,
|
fitToDraftBounds = false,
|
||||||
fitBoundsKey = null,
|
fitBoundsKey = null,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
|
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Nếu init map fail (throw trong onLoad), show overlay thay vì crash âm thầm.
|
||||||
|
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Mirror các flags props để tránh phải re-create map khi props thay đổi.
|
||||||
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
|
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||||
|
|
||||||
|
// Instance maplibre (được tạo 1 lần khi component mount).
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
// Mirror của props mode để event handlers/engines đọc giá trị mới nhất (tránh stale closure).
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
|
// Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks.
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
|
// Mirror của backgroundVisibility để sync visibility khi map đã load.
|
||||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
|
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
||||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||||
|
// Mirror của callback onSelectFeatureId.
|
||||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||||
|
// Mirror của callback onCreateFeature.
|
||||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||||
|
// Mirror của callback onDeleteFeature.
|
||||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||||
|
// Mirror của callback onUpdateFeature.
|
||||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||||
|
// Zoom hiện tại để render UI zoom control.
|
||||||
const [zoomLevel, setZoomLevel] = useState(2);
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
|
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
||||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
|
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
|
||||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||||
|
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
|
||||||
const engineBindingsRef = useRef<Partial<Record<MapProps["mode"], EngineBinding>>>({});
|
const engineBindingsRef = useRef<Partial<Record<MapProps["mode"], EngineBinding>>>({});
|
||||||
|
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
|
||||||
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||||
|
}, [fitToDraftBounds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
respectBindingFilterRef.current = respectBindingFilter;
|
||||||
|
}, [respectBindingFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modeRef.current = mode;
|
modeRef.current = mode;
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
@@ -266,7 +234,7 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleDraft = respectBindingFilter
|
const visibleDraft = respectBindingFilterRef.current
|
||||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||||
: fc;
|
: fc;
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
@@ -283,14 +251,17 @@ export default function Map({
|
|||||||
if (mapRef.current !== map) return;
|
if (mapRef.current !== map) return;
|
||||||
setSelectedFeatureState(map, selectedId, true);
|
setSelectedFeatureState(map, selectedId, true);
|
||||||
});
|
});
|
||||||
if (fitToDraftBounds && !fitBoundsAppliedRef.current) {
|
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||||
}
|
}
|
||||||
}, [fitToDraftBounds, respectBindingFilter]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: "map",
|
container,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
minZoom: MAP_MIN_ZOOM,
|
minZoom: MAP_MIN_ZOOM,
|
||||||
maxZoom: MAP_MAX_ZOOM,
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
@@ -434,6 +405,7 @@ export default function Map({
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
|
try {
|
||||||
const syncZoomLevel = () => {
|
const syncZoomLevel = () => {
|
||||||
setZoomLevel(roundZoom(map.getZoom()));
|
setZoomLevel(roundZoom(map.getZoom()));
|
||||||
};
|
};
|
||||||
@@ -821,7 +793,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -860,7 +832,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -882,7 +854,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -904,7 +876,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -926,7 +898,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -968,6 +940,10 @@ export default function Map({
|
|||||||
if (allowGeometryEditing) {
|
if (allowGeometryEditing) {
|
||||||
editingEngineRef.current?.bindEditEvents(map);
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Map initialization failed", err);
|
||||||
|
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1011,7 +987,39 @@ export default function Map({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height, position: "relative" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
<div id="map" style={{ width: "100%", height: "100%" }} />
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
|
|
||||||
|
{fatalInitError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
padding: "24px",
|
||||||
|
background: "rgba(2, 6, 23, 0.78)",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "680px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "rgba(15, 23, 42, 0.92)",
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
|
||||||
|
Map khong khoi tao duoc
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
|
{fatalInitError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1203,12 +1211,16 @@ function normalizeBindingIds(rawBinding: unknown): string[] {
|
|||||||
function splitDraftFeatures(fc: FeatureCollection) {
|
function splitDraftFeatures(fc: FeatureCollection) {
|
||||||
const polygons = {
|
const polygons = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: fc.features.filter((f) => f.geometry.type !== "Point"),
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||||
|
),
|
||||||
} as FeatureCollection;
|
} as FeatureCollection;
|
||||||
|
|
||||||
const points = {
|
const points = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: fc.features.filter((f) => f.geometry.type === "Point"),
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||||
|
),
|
||||||
} as FeatureCollection;
|
} as FeatureCollection;
|
||||||
|
|
||||||
return { polygons, points };
|
return { polygons, points };
|
||||||
@@ -1540,6 +1552,7 @@ function createPathArrowImageData(): ImageData | null {
|
|||||||
|
|
||||||
function addPointSymbolLayer(map: maplibregl.Map) {
|
function addPointSymbolLayer(map: maplibregl.Map) {
|
||||||
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
||||||
|
try {
|
||||||
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||||
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -1557,6 +1570,10 @@ function addPointSymbolLayer(map: maplibregl.Map) {
|
|||||||
if (map.getLayer("places-circle")) {
|
if (map.getLayer("places-circle")) {
|
||||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Map might have been removed while icon was loading.
|
||||||
|
console.warn("Add point symbol layer skipped", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,6 +1619,14 @@ function roundZoom(value: number): number {
|
|||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildClientFeatureId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms.
|
||||||
|
return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number): number {
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
if (value < min) return min;
|
if (value < min) return min;
|
||||||
if (value > max) return max;
|
if (value > max) return max;
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ import {
|
|||||||
findEntityTypeOption,
|
findEntityTypeOption,
|
||||||
groupEntityTypeOptions,
|
groupEntityTypeOptions,
|
||||||
} from "@/lib/entityTypeOptions";
|
} from "@/lib/entityTypeOptions";
|
||||||
|
import type { EntityFormState, GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
|
||||||
type EntityFormState = {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
type_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeometryMetaFormState = {
|
|
||||||
time_start: string;
|
|
||||||
time_end: string;
|
|
||||||
binding: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedFeature: Feature | null;
|
selectedFeature: Feature | null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
year: number;
|
year: number;
|
||||||
onYearChange: (year: number) => void;
|
onYearChange: (year: number) => void;
|
||||||
@@ -8,9 +10,6 @@ type Props = {
|
|||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIXED_TIMELINE_START_YEAR = -2000;
|
|
||||||
const FIXED_TIMELINE_END_YEAR = 2000;
|
|
||||||
|
|
||||||
export default function TimelineBar({
|
export default function TimelineBar({
|
||||||
year,
|
year,
|
||||||
onYearChange,
|
onYearChange,
|
||||||
@@ -21,14 +20,14 @@ export default function TimelineBar({
|
|||||||
const lower = FIXED_TIMELINE_START_YEAR;
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
const upper = FIXED_TIMELINE_END_YEAR;
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
const effectiveDisabled = disabled;
|
const effectiveDisabled = disabled;
|
||||||
const safeYear = clampYear(year, lower, upper);
|
const safeYear = clampYearValue(year, lower, upper);
|
||||||
|
|
||||||
const helperText = isLoading
|
const helperText = isLoading
|
||||||
? "Đang tải geometry theo mốc thời gian..."
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
|
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
|
||||||
|
|
||||||
const handleYearChange = (nextYear: number) => {
|
const handleYearChange = (nextYear: number) => {
|
||||||
onYearChange(clampYear(Math.trunc(nextYear), lower, upper));
|
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,12 +132,6 @@ export default function TimelineBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampYear(year: number, minYear: number, maxYear: number): number {
|
|
||||||
if (year < minYear) return minYear;
|
|
||||||
if (year > maxYear) return maxYear;
|
|
||||||
return year;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatYear(year: number): string {
|
function formatYear(year: number): string {
|
||||||
if (year < 0) {
|
if (year < 0) {
|
||||||
return `${Math.abs(year)} TCN`;
|
return `${Math.abs(year)} TCN`;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import type { FeatureCollection } from "@/types/geo";
|
|||||||
import { deepClone } from "@/lib/editor/draft/draftDiff";
|
import { deepClone } from "@/lib/editor/draft/draftDiff";
|
||||||
|
|
||||||
export function useDraftState(initialData: FeatureCollection) {
|
export function useDraftState(initialData: FeatureCollection) {
|
||||||
|
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
|
||||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||||
|
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
|
||||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||||
|
|
||||||
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Options = {
|
|||||||
|
|
||||||
export function useUndoStack(options: Options) {
|
export function useUndoStack(options: Options) {
|
||||||
const { applyUndoAction } = options;
|
const { applyUndoAction } = options;
|
||||||
|
// Stack thao tác undo (append-only, pop khi undo).
|
||||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||||
|
|
||||||
const pushUndo = useCallback((action: UndoAction) => {
|
const pushUndo = useCallback((action: UndoAction) => {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
} from "@/lib/backgroundLayers";
|
} from "@/lib/backgroundLayers";
|
||||||
|
|
||||||
export function useBackgroundSessionState() {
|
export function useBackgroundSessionState() {
|
||||||
|
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
|
||||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
);
|
||||||
|
// Đảm bảo đã load visibility trước khi render map thật.
|
||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -17,4 +19,3 @@ export function useBackgroundSessionState() {
|
|||||||
setIsBackgroundVisibilityReady,
|
setIsBackgroundVisibilityReady,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,27 +10,41 @@ import type {
|
|||||||
} from "@/lib/editor/session/sessionTypes";
|
} from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
export function useEntitySessionState() {
|
export function useEntitySessionState() {
|
||||||
|
// Entities đã persisted từ backend (dùng cho search/binding).
|
||||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||||
|
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
|
||||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||||
|
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
|
||||||
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
||||||
|
// Thông báo trạng thái/lỗi liên quan entity/session.
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
|
// Feature đang được chọn để thao tác bind entities/metadata.
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||||
|
// Form tạo entity mới (độc lập).
|
||||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
});
|
});
|
||||||
|
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||||
|
// Form metadata geometry (time range + binding ids).
|
||||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||||
time_start: "",
|
time_start: "",
|
||||||
time_end: "",
|
time_end: "",
|
||||||
binding: "",
|
binding: "",
|
||||||
});
|
});
|
||||||
|
// Cờ loading khi apply entity/metadata (local submit).
|
||||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi cho form entity/metadata.
|
||||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||||
|
// Keyword search entity theo name.
|
||||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||||
|
// Kết quả search entity để user chọn.
|
||||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||||
|
// Entity ID đang được chọn trong dropdown kết quả search.
|
||||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||||
|
// Cờ loading khi search entity.
|
||||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Options = {
|
|||||||
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
|
||||||
|
|
||||||
export function useSectionSessionState(options: Options) {
|
export function useSectionSessionState(options: Options) {
|
||||||
|
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
|
||||||
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
|
||||||
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
|
||||||
setSectionTask((prev) => {
|
setSectionTask((prev) => {
|
||||||
@@ -32,15 +33,25 @@ export function useSectionSessionState(options: Options) {
|
|||||||
setTaskFlag("opening-section", next);
|
setTaskFlag("opening-section", next);
|
||||||
}, [setTaskFlag]);
|
}, [setTaskFlag]);
|
||||||
|
|
||||||
|
// Danh sách sections để user chọn mở.
|
||||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||||
|
// Section ID đang được chọn trong dropdown.
|
||||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||||
|
// Title section mới (để create).
|
||||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||||
|
// Input title cho commit.
|
||||||
const [commitTitle, setCommitTitle] = useState("");
|
const [commitTitle, setCommitTitle] = useState("");
|
||||||
|
// Input note cho commit.
|
||||||
const [commitNote, setCommitNote] = useState("");
|
const [commitNote, setCommitNote] = useState("");
|
||||||
|
// User ID dùng để gắn vào commit/submit/lock.
|
||||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||||
|
// Section đang mở để edit (null nếu chưa mở).
|
||||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||||
|
// Trạng thái section (version/head/status/lock).
|
||||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||||
|
// Danh sách commits của section đang mở.
|
||||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||||
|
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
|
||||||
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||||
|
import { clampYearValue } from "@/lib/timeline";
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
@@ -7,6 +8,7 @@ type Options = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useTimelineState(options: Options) {
|
export function useTimelineState(options: Options) {
|
||||||
|
// Năm timeline "đã chốt" để fetch dữ liệu.
|
||||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||||
clampYearValue(
|
clampYearValue(
|
||||||
options.currentYear,
|
options.currentYear,
|
||||||
@@ -14,6 +16,7 @@ export function useTimelineState(options: Options) {
|
|||||||
options.fallbackTimelineRange.max
|
options.fallbackTimelineRange.max
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
|
||||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||||
clampYearValue(
|
clampYearValue(
|
||||||
options.currentYear,
|
options.currentYear,
|
||||||
@@ -21,7 +24,9 @@ export function useTimelineState(options: Options) {
|
|||||||
options.fallbackTimelineRange.max
|
options.fallbackTimelineRange.max
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// Cờ loading khi fetch theo timeline.
|
||||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi khi fetch theo timeline.
|
||||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,11 +40,3 @@ export function useTimelineState(options: Options) {
|
|||||||
setTimelineStatus,
|
setTimelineStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EARTH_RADIUS_METERS = 6371008.8;
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
const CIRCLE_SEGMENTS = 72;
|
const CIRCLE_SEGMENTS = 72;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
export function initDrawing(
|
export function initDrawing(
|
||||||
|
|||||||
4
lib/engine/engineTypes.ts
Normal file
4
lib/engine/engineTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type ModeGetter = () => EditorMode;
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine thêm point bằng click đơn.
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
export function initPoint(
|
export function initPoint(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
export function initSelect(
|
export function initSelect(
|
||||||
|
|||||||
14
lib/geo/constants.ts
Normal file
14
lib/geo/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { FeatureCollection } from "@/types/geo";
|
||||||
|
|
||||||
|
export const WORLD_BBOX = {
|
||||||
|
minLng: -180,
|
||||||
|
minLat: -90,
|
||||||
|
maxLng: 180,
|
||||||
|
maxLat: 90,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
13
lib/map/constants.ts
Normal file
13
lib/map/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||||
|
export const POINT_ICON_URL = "/point.png";
|
||||||
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
|
export const MAP_MIN_ZOOM = 1;
|
||||||
|
export const MAP_MAX_ZOOM = 10;
|
||||||
|
|
||||||
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
76
lib/map/style.ts
Normal file
76
lib/map/style.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"coalesce",
|
||||||
|
["get", "MAPCOLOR7"],
|
||||||
|
["get", "MAPCOLOR9"],
|
||||||
|
["get", "scalerank"],
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"match",
|
||||||
|
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||||
|
1, "#ef4444",
|
||||||
|
2, "#f97316",
|
||||||
|
3, "#f59e0b",
|
||||||
|
4, "#22c55e",
|
||||||
|
5, "#06b6d4",
|
||||||
|
6, "#3b82f6",
|
||||||
|
7, "#8b5cf6",
|
||||||
|
8, "#a855f7",
|
||||||
|
9, "#d946ef",
|
||||||
|
10, "#14b8a6",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#2563eb",
|
||||||
|
state: "#0ea5e9",
|
||||||
|
empire: "#f59e0b",
|
||||||
|
kingdom: "#d97706",
|
||||||
|
war: "#dc2626",
|
||||||
|
battle: "#f43f5e",
|
||||||
|
civilization: "#14b8a6",
|
||||||
|
rebellion_zone: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#1e3a8a",
|
||||||
|
state: "#0c4a6e",
|
||||||
|
empire: "#7c2d12",
|
||||||
|
kingdom: "#9a3412",
|
||||||
|
war: "#7f1d1d",
|
||||||
|
battle: "#9f1239",
|
||||||
|
civilization: "#134e4a",
|
||||||
|
rebellion_zone: "#4c1d95",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||||
|
war: 0.3,
|
||||||
|
battle: 0.34,
|
||||||
|
civilization: 0.38,
|
||||||
|
rebellion_zone: 0.32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
defense_line: "#f97316",
|
||||||
|
attack_route: "#ef4444",
|
||||||
|
retreat_route: "#94a3b8",
|
||||||
|
invasion_route: "#b91c1c",
|
||||||
|
migration_route: "#0ea5e9",
|
||||||
|
refugee_route: "#06b6d4",
|
||||||
|
trade_route: "#eab308",
|
||||||
|
shipping_route: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||||
|
attack_route: true,
|
||||||
|
retreat_route: true,
|
||||||
|
invasion_route: true,
|
||||||
|
migration_route: true,
|
||||||
|
refugee_route: true,
|
||||||
|
trade_route: true,
|
||||||
|
shipping_route: true,
|
||||||
|
};
|
||||||
|
|
||||||
25
lib/timeline.ts
Normal file
25
lib/timeline.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
// Single source of truth for the app-wide timeline range.
|
||||||
|
export const FIXED_TIMELINE_START_YEAR = -2000;
|
||||||
|
export const FIXED_TIMELINE_END_YEAR = 2000;
|
||||||
|
|
||||||
|
export const FIXED_TIMELINE_RANGE: TimelineRange = {
|
||||||
|
min: FIXED_TIMELINE_START_YEAR,
|
||||||
|
max: FIXED_TIMELINE_END_YEAR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI debounce when user drags timeline before triggering data fetch.
|
||||||
|
export const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampYearToFixedRange(year: number): number {
|
||||||
|
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ type Options = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useEditorSessionState(options: Options) {
|
export function useEditorSessionState(options: Options) {
|
||||||
|
// Mode thao tác map/editor hiện tại.
|
||||||
const [mode, setMode] = useState<EditorMode>("idle");
|
const [mode, setMode] = useState<EditorMode>("idle");
|
||||||
|
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
|
||||||
|
|
||||||
const section = useSectionSessionState({
|
const section = useSectionSessionState({
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
|||||||
export function useEditorState(initialData: FeatureCollection) {
|
export function useEditorState(initialData: FeatureCollection) {
|
||||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||||
|
|
||||||
|
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
|
||||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
buildInitialMap(initialData)
|
buildInitialMap(initialData)
|
||||||
);
|
);
|
||||||
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
|
|
||||||
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||||
|
|||||||
Reference in New Issue
Block a user