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

View File

@@ -3,12 +3,11 @@
import { useState } from "react";
import CommitTreePopup from "@/components/CommitTreePopup";
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
type Props = {
mode: Mode;
setMode: (mode: Mode) => void;
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
@@ -103,12 +102,13 @@ export default function Editor({
createdEntities,
createdGeometries,
}: Props) {
// Bật/tắt popup cây commit.
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
const formatCommitTitle = (commit: Props["commits"][number]) =>
commit.title?.trim() || `Commit #${commit.commit_no}`;
const toggleMode = (newMode: Mode) => {
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle"); // bấm lại → tắt
} else {
@@ -129,7 +129,7 @@ export default function Editor({
return labels.reverse();
})();
const getButtonStyle = (btnMode: Mode) => ({
const getButtonStyle = (btnMode: EditorMode) => ({
width: "100%",
padding: "8px",
marginBottom: "6px",

View File

@@ -14,9 +14,31 @@ import { initCircle } from "@/lib/engine/circleEngine";
import { createEditingEngine } from "@/lib/engine/editingEngine";
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
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 = {
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
mode: EditorMode;
draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
selectedFeatureId: string | number | null;
@@ -37,93 +59,6 @@ type EngineBinding = {
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({
mode,
draft,
@@ -139,24 +74,57 @@ export default function Map({
fitToDraftBounds = false,
fitBoundsKey = null,
}: 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);
// 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);
// Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks.
const draftRef = useRef<FeatureCollection>(draft);
// Mirror của backgroundVisibility để sync visibility khi map đã load.
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);
// Mirror của callback onSelectFeatureId.
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
// Mirror của callback onCreateFeature.
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
// Mirror của callback onDeleteFeature.
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
// Mirror của callback onUpdateFeature.
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
// Zoom hiện tại để render UI zoom control.
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 });
// 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);
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
const fitBoundsAppliedRef = useRef(false);
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
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>>>({});
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
const previousModeRef = useRef<MapProps["mode"]>(mode);
useEffect(() => {
fitToDraftBoundsRef.current = fitToDraftBounds;
}, [fitToDraftBounds]);
useEffect(() => {
respectBindingFilterRef.current = respectBindingFilter;
}, [respectBindingFilter]);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
@@ -266,7 +234,7 @@ export default function Map({
}
}
const visibleDraft = respectBindingFilter
const visibleDraft = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc;
const { polygons, points } = splitDraftFeatures(visibleDraft);
@@ -283,14 +251,17 @@ export default function Map({
if (mapRef.current !== map) return;
setSelectedFeatureState(map, selectedId, true);
});
if (fitToDraftBounds && !fitBoundsAppliedRef.current) {
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
}
}, [fitToDraftBounds, respectBindingFilter]);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const map = new maplibregl.Map({
container: "map",
container,
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
@@ -434,6 +405,7 @@ export default function Map({
mapRef.current = map;
map.on("load", async () => {
try {
const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom()));
};
@@ -821,7 +793,7 @@ export default function Map({
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -860,7 +832,7 @@ export default function Map({
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -882,7 +854,7 @@ export default function Map({
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -904,7 +876,7 @@ export default function Map({
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -926,7 +898,7 @@ export default function Map({
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -968,6 +940,10 @@ export default function Map({
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
} catch (err) {
console.error("Map initialization failed", err);
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
}
});
return () => {
@@ -1011,7 +987,39 @@ export default function Map({
return (
<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
style={{
@@ -1203,12 +1211,16 @@ function normalizeBindingIds(rawBinding: unknown): string[] {
function splitDraftFeatures(fc: FeatureCollection) {
const polygons = {
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;
const points = {
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;
return { polygons, points };
@@ -1540,6 +1552,7 @@ function createPathArrowImageData(): ImageData | null {
function addPointSymbolLayer(map: maplibregl.Map) {
void ensurePointAssetIcon(map).then((hasPointIcon) => {
try {
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
map.addLayer({
@@ -1557,6 +1570,10 @@ function addPointSymbolLayer(map: maplibregl.Map) {
if (map.getLayer("places-circle")) {
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;
}
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 {
if (value < min) return min;
if (value > max) return max;

View File

@@ -10,18 +10,7 @@ import {
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/lib/entityTypeOptions";
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
import type { EntityFormState, GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
type Props = {
selectedFeature: Feature | null;

View File

@@ -1,5 +1,7 @@
"use client";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline";
type Props = {
year: number;
onYearChange: (year: number) => void;
@@ -8,9 +10,6 @@ type Props = {
statusText?: string | null;
};
const FIXED_TIMELINE_START_YEAR = -2000;
const FIXED_TIMELINE_END_YEAR = 2000;
export default function TimelineBar({
year,
onYearChange,
@@ -21,14 +20,14 @@ export default function TimelineBar({
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYear(year, lower, upper);
const safeYear = clampYearValue(year, lower, upper);
const helperText = isLoading
? "Đ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.";
const handleYearChange = (nextYear: number) => {
onYearChange(clampYear(Math.trunc(nextYear), lower, upper));
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
};
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 {
if (year < 0) {
return `${Math.abs(year)} TCN`;

View File

@@ -3,7 +3,9 @@ import type { FeatureCollection } from "@/types/geo";
import { deepClone } from "@/lib/editor/draft/draftDiff";
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));
// 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 commitDraft = useCallback((nextDraft: FeatureCollection) => {

View File

@@ -8,6 +8,7 @@ type Options = {
export function useUndoStack(options: Options) {
const { applyUndoAction } = options;
// Stack thao tác undo (append-only, pop khi undo).
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const pushUndo = useCallback((action: UndoAction) => {

View File

@@ -5,9 +5,11 @@ import {
} from "@/lib/backgroundLayers";
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>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
// Đảm bảo đã load visibility trước khi render map thật.
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
return {
@@ -17,4 +19,3 @@ export function useBackgroundSessionState() {
setIsBackgroundVisibilityReady,
};
}

View File

@@ -10,27 +10,41 @@ import type {
} from "@/lib/editor/session/sessionTypes";
export function useEntitySessionState() {
// Entities đã persisted từ backend (dùng cho search/binding).
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[]>([]);
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
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);
// Feature đang được chọn để thao tác bind entities/metadata.
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
// Form tạo entity mới (độc lập).
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
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[]>([]);
// Form metadata geometry (time range + binding ids).
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
// Cờ loading khi apply entity/metadata (local submit).
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);
// Keyword search entity theo name.
const [entitySearchQuery, setEntitySearchQuery] = useState("");
// Kết quả search entity để user chọn.
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
// Entity ID đang được chọn trong dropdown kết quả search.
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
// Cờ loading khi search entity.
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
return {

View File

@@ -9,6 +9,7 @@ type Options = {
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
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 setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
setSectionTask((prev) => {
@@ -32,15 +33,25 @@ export function useSectionSessionState(options: Options) {
setTaskFlag("opening-section", next);
}, [setTaskFlag]);
// Danh sách sections để user chọn mở.
const [availableSections, setAvailableSections] = useState<Section[]>([]);
// Section ID đang được chọn trong dropdown.
const [selectedSectionId, setSelectedSectionId] = useState("");
// Title section mới (để create).
const [newSectionTitle, setNewSectionTitle] = useState("");
// Input title cho commit.
const [commitTitle, setCommitTitle] = useState("");
// Input note cho commit.
const [commitNote, setCommitNote] = useState("");
// User ID dùng để gắn vào commit/submit/lock.
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
// Section đang mở để edit (null nếu chưa mở).
const [activeSection, setActiveSection] = useState<Section | null>(null);
// Trạng thái section (version/head/status/lock).
const [sectionState, setSectionState] = useState<SectionState | null>(null);
// Danh sách commits của section đang mở.
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
return {

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
import { clampYearValue } from "@/lib/timeline";
type Options = {
currentYear: number;
@@ -7,6 +8,7 @@ type Options = {
};
export function useTimelineState(options: Options) {
// Năm timeline "đã chốt" để fetch dữ liệu.
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
@@ -14,6 +16,7 @@ export function useTimelineState(options: Options) {
options.fallbackTimelineRange.max
)
);
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
@@ -21,7 +24,9 @@ export function useTimelineState(options: Options) {
options.fallbackTimelineRange.max
)
);
// Cờ loading khi fetch theo timeline.
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);
return {
@@ -35,11 +40,3 @@ export function useTimelineState(options: Options) {
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;
}

View File

@@ -1,7 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
const EARTH_RADIUS_METERS = 6371008.8;
const CIRCLE_SEGMENTS = 72;

View File

@@ -1,7 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
export function initDrawing(

View File

@@ -0,0 +1,4 @@
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
export type ModeGetter = () => EditorMode;

View File

@@ -1,7 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",

View File

@@ -1,7 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",

View File

@@ -1,7 +1,6 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
// Khởi tạo engine thêm point bằng click đơn.
export function initPoint(

View File

@@ -1,6 +1,5 @@
import maplibregl from "maplibre-gl";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
import type { ModeGetter } from "@/lib/engine/engineTypes";
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect(

14
lib/geo/constants.ts Normal file
View 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
View 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
View 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
View 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);
}

View File

@@ -23,7 +23,9 @@ type Options = {
};
export function useEditorSessionState(options: Options) {
// Mode thao tác map/editor hiện tại.
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 section = useSectionSessionState({

View File

@@ -20,9 +20,11 @@ export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
export function useEditorState(initialData: FeatureCollection) {
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>>(
buildInitialMap(initialData)
);
// Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0);
const applyUndoAction = useCallback((action: UndoAction): boolean => {