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"; } 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}`;
} }

View File

@@ -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;
} }

View File

@@ -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`}
> >

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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,24 +405,25 @@ export default function Map({
mapRef.current = map; mapRef.current = map;
map.on("load", async () => { map.on("load", async () => {
const syncZoomLevel = () => { try {
setZoomLevel(roundZoom(map.getZoom())); const syncZoomLevel = () => {
}; setZoomLevel(roundZoom(map.getZoom()));
};
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current); applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
const hasPathArrowIcon = ensurePathArrowIcon(map); const hasPathArrowIcon = ensurePathArrowIcon(map);
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
syncZoomLevel(); syncZoomLevel();
map.on("zoom", syncZoomLevel); map.on("zoom", syncZoomLevel);
// preview (drawing) // preview (drawing)
map.addSource("draw-preview", { map.addSource("draw-preview", {
type: "geojson", type: "geojson",
data: { data: {
type: "FeatureCollection", type: "FeatureCollection",
features: [], features: [],
}, },
}); });
map.addLayer({ map.addLayer({
id: "draw-preview-fill", id: "draw-preview-fill",
@@ -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,22 +1552,27 @@ function createPathArrowImageData(): ImageData | null {
function addPointSymbolLayer(map: maplibregl.Map) { function addPointSymbolLayer(map: maplibregl.Map) {
void ensurePointAssetIcon(map).then((hasPointIcon) => { void ensurePointAssetIcon(map).then((hasPointIcon) => {
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; try {
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
map.addLayer({ map.addLayer({
id: "places-symbol", id: "places-symbol",
type: "symbol", type: "symbol",
source: "places", source: "places",
layout: { layout: {
"icon-image": DEFAULT_POINT_ICON_ID, "icon-image": DEFAULT_POINT_ICON_ID,
"icon-size": 0.06, "icon-size": 0.06,
"icon-anchor": "center", "icon-anchor": "center",
"icon-allow-overlap": true, "icon-allow-overlap": true,
}, },
}); });
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;

View File

@@ -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;

View File

@@ -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 (
@@ -75,22 +74,22 @@ export default function TimelineBar({
gap: "10px", gap: "10px",
}} }}
> >
<input <input
type="range" type="range"
min={lower} min={lower}
max={upper} max={upper}
step={1} step={1}
value={safeYear} value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))} onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled} disabled={effectiveDisabled}
aria-label="Timeline year" aria-label="Timeline year"
style={{ style={{
width: "100%", width: "100%",
accentColor: "#22c55e", accentColor: "#22c55e",
cursor: effectiveDisabled ? "not-allowed" : "pointer", cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1, opacity: effectiveDisabled ? 0.6 : 1,
}} }}
/> />
<input <input
type="number" type="number"
min={lower} min={lower}
@@ -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`;

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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,
}; };
} }

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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(

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 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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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
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) { 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({

View File

@@ -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 => {