Merge branch 'main' of https://github.com/Pregnant-Guild/FE-ultimate-history-map
All checks were successful
Build and Release / release (push) Successful in 26s

This commit is contained in:
2026-04-25 14:39:27 +07:00
46 changed files with 2408 additions and 1968 deletions

View File

@@ -1,17 +1,8 @@
import { API_ENDPOINTS } from "@/api/config";
import { requestJson } from "@/api/http";
import type { Entity } from "@/types/entities";
export type Entity = {
id: string;
name: string;
slug?: string | null;
description?: string | null;
type_id?: string | null;
status?: number | null;
geometry_count?: number;
created_at?: string;
updated_at?: string;
};
export type { Entity } from "@/types/entities";
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
const params = new URLSearchParams();

View File

@@ -1,15 +1,9 @@
import { API_ENDPOINTS } from "@/api/config";
import { requestJson } from "@/api/http";
import { FeatureCollection } from "@/lib/useEditorState";
import type { GeometriesBBoxQuery } from "@/types/api";
import type { FeatureCollection } from "@/types/geo";
export type GeometriesBBoxQuery = {
minLng: number;
minLat: number;
maxLng: number;
maxLat: number;
time?: number;
entity_id?: string;
};
export type { GeometriesBBoxQuery } from "@/types/api";
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({

View File

@@ -1,3 +1,5 @@
import type { ApiEnvelope } from "@/types/api";
export class ApiError extends Error {
status: number;
body: string;
@@ -12,13 +14,6 @@ export class ApiError extends Error {
}
}
type ApiEnvelope<T> = {
status: "success" | "error" | string;
data: T;
message: string;
errors: unknown[];
};
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const res = await fetch(input, init);
const payload = await parseJsonResponse(res);

View File

@@ -1,81 +1,26 @@
import { API_ENDPOINTS } from "@/api/config";
import { jsonRequestInit, requestJson } from "@/api/http";
import type {
CreateCommitInput,
CreateSectionInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/types/sections";
export type SectionState = {
section_id?: string;
status: "editing" | "submitted" | "approved" | "rejected";
head_commit_id: string | null;
version: number;
locked_by: string | null;
locked_at: string | null;
lock_expires_at: string | null;
updated_at?: string;
};
export type Section = {
id: string;
title: string;
description: string | null;
user_id: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
state: Omit<SectionState, "section_id" | "updated_at">;
};
export type SectionCommit = {
id: string;
section_id: string;
parent_commit_id: string | null;
commit_no: number;
kind: "manual" | "restore";
restored_from_commit_id: string | null;
created_by: string;
created_at: string;
title: string | null;
note: string | null;
snapshot_hash: string | null;
snapshot?: unknown;
};
export type SectionSubmission = {
id: string;
section_id: string;
commit_id: string;
submitted_by: string;
submitted_at: string;
status: "pending" | "approved" | "rejected" | "conflicted";
reviewed_by: string | null;
reviewed_at: string | null;
review_note: string | null;
snapshot_hash: string | null;
snapshot?: unknown;
};
export type EditorLoadResponse = {
section: Section;
state: SectionState;
commit: SectionCommit | null;
snapshot: unknown;
};
export type CreateSectionInput = {
id?: string;
title: string;
description?: string | null;
user_id?: string;
created_by?: string;
};
export type CreateCommitInput = {
snapshot: unknown;
created_by?: string;
user_id?: string;
expected_version?: number;
expected_head_commit_id?: string | null;
title?: string | null;
note?: string | null;
};
export type {
CreateCommitInput,
CreateSectionInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/types/sections";
export async function fetchSections(): Promise<Section[]> {
return requestJson<Section[]>(API_ENDPOINTS.sections);
@@ -126,15 +71,7 @@ export async function fetchSectionCommits(
export async function restoreSectionCommit(
sectionId: string,
input: {
commit_id: string;
created_by?: string;
user_id?: string;
expected_version?: number;
expected_head_commit_id?: string | null;
title?: string | null;
note?: string | null;
}
input: RestoreCommitInput
): Promise<{ commit: SectionCommit; state: SectionState }> {
return requestJson<{ commit: SectionCommit; state: SectionState }>(
sectionUrl(sectionId, "restore"),

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

File diff suppressed because it is too large Load Diff

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

@@ -9,52 +9,45 @@ import { fetchGeometriesByBBox } from "@/api/geometries";
import { ApiError } from "@/api/http";
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import { Feature, FeatureCollection } from "@/lib/useEditorState";
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: CURRENT_YEAR - 5000,
max: CURRENT_YEAR + 100,
};
const TIMELINE_DEBOUNCE_MS = 180;
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
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);
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
// 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)
);
const [isTimelineReady, setIsTimelineReady] = useState(false);
// 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 =
@@ -75,53 +68,6 @@ export default function Page() {
}, [initialData, selectedFeatureId]);
useEffect(() => {
let disposed = false;
async function loadTimelineBounds() {
setIsTimelineLoading(true);
try {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
});
if (disposed) return;
const range = deriveTimelineRange(data);
const initialYear = clampYear(CURRENT_YEAR, range);
setTimelineRange(range);
setTimelineWindowStart(range.min);
setTimelineWindowEnd(range.max);
setTimelineYear(initialYear);
setTimelineDraftYear(initialYear);
setTimelineStatus(null);
} catch (err) {
if (disposed) return;
console.error("Load timeline bounds failed", err);
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
setTimelineRange(FALLBACK_TIMELINE_RANGE);
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
setTimelineYear(fallbackYear);
setTimelineDraftYear(fallbackYear);
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
} finally {
if (!disposed) {
setIsTimelineLoading(false);
setIsTimelineReady(true);
}
}
}
loadTimelineBounds();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!isTimelineReady) return;
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) {
setTimelineYear(timelineDraftYear);
@@ -129,7 +75,7 @@ export default function Page() {
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear, isTimelineReady]);
}, [timelineDraftYear, timelineYear]);
useEffect(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
@@ -137,14 +83,6 @@ export default function Page() {
}, []);
useEffect(() => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
}, [timelineWindowStart, timelineWindowEnd]);
useEffect(() => {
if (!isTimelineReady) return;
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
@@ -182,7 +120,7 @@ export default function Page() {
return () => {
disposed = true;
};
}, [timelineYear, isTimelineReady]);
}, [timelineYear]);
const updateBackgroundVisibility = (
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
@@ -194,7 +132,7 @@ export default function Page() {
});
};
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
@@ -209,23 +147,10 @@ export default function Page() {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
const handleTimelineWindowStartChange = (nextYear: number) => {
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
};
const handleTimelineWindowEndChange = (nextYear: number) => {
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
};
const handleTimelineYearChange = (nextYear: number) => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const timelineDisabled = !isTimelineReady;
const selectedType = resolveSelectedTypeSummary(selectedFeature);
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
@@ -248,16 +173,10 @@ export default function Page() {
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<TimelineBar
minYear={timelineRange.min}
maxYear={timelineRange.max}
windowStartYear={timelineWindowStart}
windowEndYear={timelineWindowEnd}
onWindowStartYearChange={handleTimelineWindowStartChange}
onWindowEndYearChange={handleTimelineWindowEndChange}
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
disabled={false}
statusText={timelineStatus}
/>
</div>
@@ -325,46 +244,6 @@ export default function Page() {
);
}
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const feature of collection.features) {
const { time_start, time_end } = feature.properties;
if (isYearNumber(time_start)) {
min = Math.min(min, time_start);
max = Math.max(max, time_start);
}
if (isYearNumber(time_end)) {
min = Math.min(min, time_end);
max = Math.max(max, time_end);
}
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return FALLBACK_TIMELINE_RANGE;
}
return {
min: Math.floor(min),
max: Math.ceil(max),
};
}
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);
}
@@ -432,53 +311,3 @@ function normalizeTypeId(value: unknown): string | null {
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
try {
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeBackgroundLayerVisibility(parsed);
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
} catch (err) {
console.warn("Load background layer visibility from storage failed", err);
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
}
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
JSON.stringify(visibility)
);
} catch (err) {
console.warn("Persist background layer visibility failed", err);
}
}
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
};
for (const layer of BACKGROUND_LAYER_OPTIONS) {
const value = source[layer.id];
if (typeof value === "boolean") {
next[layer.id] = value;
}
}
return next;
}

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",
@@ -627,6 +627,8 @@ function formatUndoLabel(action: UndoAction) {
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
default:
return "Tác vụ";
}

View File

@@ -5,18 +5,40 @@ import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
import { initDrawing } from "@/lib/drawingEngine";
import { initSelect } from "@/lib/selectingEngine";
import { initPoint } from "@/lib/pointEngine";
import { initLine } from "@/lib/lineEngine";
import { initPath } from "@/lib/pathEngine";
import { initCircle } from "@/lib/circleEngine";
import { createEditingEngine } from "@/lib/editingEngine";
import { initDrawing } from "@/lib/engine/drawingEngine";
import { initSelect } from "@/lib/engine/selectingEngine";
import { initPoint } from "@/lib/engine/pointEngine";
import { initLine } from "@/lib/engine/lineEngine";
import { initPath } from "@/lib/engine/pathEngine";
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;
@@ -31,91 +53,10 @@ type MapProps = {
fitBoundsKey?: string | number | null;
};
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,
type EngineBinding = {
cleanup: () => void;
cancel?: () => void;
clearSelection?: () => void;
};
export default function Map({
@@ -133,29 +74,76 @@ 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]);
useEffect(() => {
const previousMode = previousModeRef.current;
if (previousMode !== mode) {
engineBindingsRef.current[previousMode]?.cancel?.();
previousModeRef.current = mode;
}
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "draw") {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
}
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
@@ -184,6 +172,12 @@ export default function Map({
selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]);
useEffect(() => {
if (mode !== "select" || selectedFeatureId === null) {
editingEngineRef.current?.clearEditing();
}
}, [mode, selectedFeatureId]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
@@ -240,7 +234,7 @@ export default function Map({
}
}
const visibleDraft = respectBindingFilter
const visibleDraft = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc;
const { polygons, points } = splitDraftFeatures(visibleDraft);
@@ -257,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,
@@ -408,6 +405,7 @@ export default function Map({
mapRef.current = map;
map.on("load", async () => {
try {
const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom()));
};
@@ -791,11 +789,11 @@ export default function Map({
addPointSymbolLayer(map);
// init drawing
const cleanup = initDrawing(
const drawingEngine = initDrawing(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -813,7 +811,7 @@ export default function Map({
}
);
const cleanupSelect = initSelect(
const selectEngine = initSelect(
map,
() => modeRef.current,
allowGeometryEditing
@@ -834,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: {
@@ -852,11 +850,11 @@ export default function Map({
}
);
const cleanupLine = initLine(
const lineEngine = initLine(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -874,11 +872,11 @@ export default function Map({
}
);
const cleanupPath = initPath(
const pathEngine = initPath(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -896,11 +894,11 @@ export default function Map({
}
);
const cleanupCircle = initCircle(
const circleEngine = initCircle(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
const id = buildClientFeatureId();
onCreateRef.current?.({
type: "Feature",
properties: {
@@ -918,13 +916,21 @@ export default function Map({
}
);
engineBindingsRef.current = {
draw: drawingEngine,
select: selectEngine,
"add-line": lineEngine,
"add-path": pathEngine,
"add-circle": circleEngine,
};
mapCleanupFnsRef.current = [
cleanupCircle,
cleanupPath,
cleanupLine,
circleEngine.cleanup,
pathEngine.cleanup,
lineEngine.cleanup,
cleanupPoint,
cleanupSelect,
cleanup,
selectEngine.cleanup,
drawingEngine.cleanup,
() => map.off("zoom", syncZoomLevel),
];
@@ -934,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 () => {
@@ -941,6 +951,7 @@ export default function Map({
cleanupFn();
}
mapCleanupFnsRef.current = [];
engineBindingsRef.current = {};
if (mapRef.current === map) {
mapRef.current = null;
}
@@ -976,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={{
@@ -1168,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 };
@@ -1505,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({
@@ -1522,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);
}
});
}
@@ -1567,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,12 +1,8 @@
"use client";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline";
type Props = {
minYear: number;
maxYear: number;
windowStartYear: number;
windowEndYear: number;
onWindowStartYearChange: (year: number) => void;
onWindowEndYearChange: (year: number) => void;
year: number;
onYearChange: (year: number) => void;
isLoading: boolean;
@@ -15,33 +11,24 @@ type Props = {
};
export default function TimelineBar({
minYear,
maxYear,
windowStartYear,
windowEndYear,
onWindowStartYearChange,
onWindowEndYearChange,
year,
onYearChange,
isLoading,
disabled,
statusText,
}: Props) {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
const globalLocked = lower === upper;
const effectiveDisabled = disabled || globalLocked;
const safeWindowStart = clampYear(windowStartYear, lower, upper);
const safeWindowEnd = clampYear(windowEndYear, safeWindowStart, upper);
const windowLocked = safeWindowStart === safeWindowEnd;
const safeYear = clampYear(year, safeWindowStart, safeWindowEnd);
const pointDisabled = effectiveDisabled || windowLocked;
const windowStartPercent = toPercent(safeWindowStart, lower, upper);
const windowEndPercent = toPercent(safeWindowEnd, lower, upper);
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYearValue(year, lower, upper);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || (windowLocked ? "Khoảng lớn đang thu về một mốc duy nhất." : "Kéo mốc nhỏ để query trong khoảng lớn.");
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
const handleYearChange = (nextYear: number) => {
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
};
return (
<div
@@ -76,86 +63,54 @@ export default function TimelineBar({
</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginBottom: "6px" }}>
Khoảng thời gian lớn
</div>
<div
className="dual-range"
style={{
opacity: effectiveDisabled ? 0.6 : 1,
}}
>
<div className="dual-range-track" />
<div
className="dual-range-selected"
style={{
left: `${windowStartPercent}%`,
width: `${Math.max(windowEndPercent - windowStartPercent, 0)}%`,
}}
/>
<input
className="dual-range-input"
type="range"
min={lower}
max={upper}
step={1}
value={safeWindowStart}
onChange={(event) =>
onWindowStartYearChange(
Math.min(Number(event.target.value), safeWindowEnd)
)
}
disabled={effectiveDisabled}
aria-label="Timeline window start"
/>
<input
className="dual-range-input"
type="range"
min={lower}
max={upper}
step={1}
value={safeWindowEnd}
onChange={(event) =>
onWindowEndYearChange(
Math.max(Number(event.target.value), safeWindowStart)
)
}
disabled={effectiveDisabled}
aria-label="Timeline window end"
/>
</div>
<div
style={{
marginTop: "6px",
display: "flex",
justifyContent: "space-between",
fontSize: "12px",
color: "#94a3b8",
}}
>
<span>{formatYear(safeWindowStart)}</span>
<span>{formatYear(safeWindowEnd)}</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
Mốc thời gian chi tiết
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
alignItems: "center",
gap: "10px",
}}
>
<input
type="range"
min={safeWindowStart}
max={safeWindowEnd}
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => onYearChange(Number(event.target.value))}
disabled={pointDisabled}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline year"
style={{
width: "100%",
accentColor: "#22c55e",
cursor: pointDisabled ? "not-allowed" : "pointer",
opacity: pointDisabled ? 0.6 : 1,
cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1,
}}
/>
<input
type="number"
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline exact year"
style={{
width: "100%",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
background: "rgba(15, 23, 42, 0.7)",
color: "#f8fafc",
fontSize: "13px",
outline: "none",
}}
/>
</div>
<div
style={{
@@ -167,103 +122,19 @@ export default function TimelineBar({
fontSize: "12px",
}}
>
<span style={{ color: "#94a3b8" }}>{formatYear(safeWindowStart)}</span>
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
{helperText}
</span>
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(safeWindowEnd)}</span>
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
</div>
<style jsx>{`
.dual-range {
position: relative;
height: 22px;
}
.dual-range-track {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 4px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.35);
}
.dual-range-selected {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 4px;
border-radius: 999px;
background: #22c55e;
}
.dual-range-input {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 22px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
}
.dual-range-input::-webkit-slider-runnable-track {
height: 4px;
background: transparent;
}
.dual-range-input::-webkit-slider-thumb {
pointer-events: auto;
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border-radius: 999px;
border: 2px solid #0f172a;
background: #22c55e;
cursor: pointer;
}
.dual-range-input::-moz-range-track {
height: 4px;
background: transparent;
}
.dual-range-input::-moz-range-thumb {
pointer-events: auto;
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid #0f172a;
background: #22c55e;
cursor: pointer;
}
`}</style>
</div>
);
}
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`;
}
return `${year}`;
}
function toPercent(value: number, minValue: number, maxValue: number): number {
if (maxValue <= minValue) return 0;
return ((value - minValue) / (maxValue - minValue)) * 100;
}

View File

@@ -0,0 +1,58 @@
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
try {
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeBackgroundLayerVisibility(parsed);
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
} catch (err) {
console.warn("Load background layer visibility from storage failed", err);
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
}
export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
JSON.stringify(visibility)
);
} catch (err) {
console.warn("Persist background layer visibility failed", err);
}
}
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
};
for (const layer of BACKGROUND_LAYER_OPTIONS) {
const value = source[layer.id];
if (typeof value === "boolean") {
next[layer.id] = value;
}
}
return next;
}

View File

@@ -0,0 +1,55 @@
import type {
Feature,
FeatureCollection,
FeatureProperties,
Geometry,
} from "@/types/geo";
import type { Change } from "@/lib/editor/draft/editorTypes";
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
JSON.stringify(a.properties) === JSON.stringify(b.properties);
}
export function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Feature>();
for (const feature of fc.features) {
map.set(feature.properties.id, deepClone(feature));
}
return map;
}
export function diffDraftToInitial(
draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Feature>
) {
const next = new Map<FeatureProperties["id"], Change>();
const seen = new Set<FeatureProperties["id"]>();
for (const feature of draft.features) {
const id = feature.properties.id;
seen.add(id);
const initialFeature = initialMap.get(id);
if (!initialFeature) {
next.set(id, { action: "create", feature: deepClone(feature) });
} else if (!featureEquals(initialFeature, feature)) {
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
}
}
for (const [id] of initialMap.entries()) {
if (!seen.has(id)) {
next.set(id, { action: "delete", id });
}
}
return next;
}

View File

@@ -0,0 +1,15 @@
import type {
Feature,
FeatureProperties,
Geometry,
GeometryChange,
} from "@/types/geo";
export type Change = GeometryChange;
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef, useState } from "react";
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) => {
const cloned = deepClone(nextDraft);
draftRef.current = cloned;
setDraft(cloned);
}, []);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
commitDraft(nextDraft);
}, [commitDraft]);
return {
draft,
draftRef,
commitDraft,
resetDraft,
};
}

View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from "react";
import type { UndoAction } from "@/lib/editor/draft/editorTypes";
import { geometryEquals } from "@/lib/editor/draft/draftDiff";
type Options = {
applyUndoAction: (action: UndoAction) => boolean;
};
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) => {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev;
return [...prev, action];
});
}, []);
const undo = useCallback(() => {
let applied = false;
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
applied = true;
const didApply = applyUndoAction(last);
return didApply ? remaining : prev;
});
}, [applyUndoAction]);
const clearUndo = useCallback(() => {
setUndoStack([]);
}, []);
return {
undoStack,
pushUndo,
undo,
clearUndo,
};
}
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false;
if (a.type !== b.type) return false;
switch (a.type) {
case "create": {
const next = b as Extract<UndoAction, { type: "create" }>;
return a.id === next.id;
}
case "delete": {
const next = b as Extract<UndoAction, { type: "delete" }>;
return (
a.feature.properties.id === next.feature.properties.id &&
geometryEquals(a.feature.geometry, next.feature.geometry)
);
}
case "update": {
const next = b as Extract<UndoAction, { type: "update" }>;
return (
a.id === next.id &&
geometryEquals(a.prevGeometry, next.prevGeometry)
);
}
case "properties": {
const next = b as Extract<UndoAction, { type: "properties" }>;
return (
a.id === next.id &&
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
);
}
default:
return false;
}
}

View File

@@ -0,0 +1,109 @@
import type { Entity } from "@/types/entities";
import type { Feature, FeatureProperties } from "@/types/geo";
import type { PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
import { normalizeFeatureEntityIds } from "@/lib/editor/snapshot/editorSnapshot";
export function mergeEntitiesWithPending(
persistedEntities: Entity[],
pendingCreates: PendingEntityCreate[]
): Entity[] {
if (!pendingCreates.length) {
return persistedEntities;
}
const seen = new Set<string>();
const pendingAsEntities: Entity[] = [];
for (const pending of pendingCreates) {
if (seen.has(pending.id)) continue;
seen.add(pending.id);
pendingAsEntities.push({
id: pending.id,
name: pending.name,
slug: pending.slug,
type_id: pending.type_id,
status: pending.status,
geometry_count: 0,
created_at: undefined,
updated_at: undefined,
});
}
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
return [...pendingAsEntities, ...nextPersisted];
}
export function mergeEntitySearchResults(
remoteRows: Entity[],
localRows: Entity[]
): Entity[] {
const merged: Entity[] = [];
const seen = new Set<string>();
for (const row of localRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
for (const row of remoteRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
return merged;
}
export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
const entityIds = normalizeFeatureEntityIds(feature);
if (!entityIds.length) return "Chưa gắn";
const names = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
.filter((name) => name.trim().length > 0);
return names.join(", ");
}
export function buildClientEntityId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
export function buildFeatureEntityPatch(
feature: Feature,
entityIds: string[],
entities: Entity[]
): Partial<FeatureProperties> {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
feature.properties.type ||
null;
const entityNames = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
return {
type: nextGeometryType,
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
};
}
function resolveGeometryTypeFromEntityIds(
entityIds: string[],
entities: Entity[]
): string | null {
const primaryEntityId = entityIds[0] || null;
if (!primaryEntityId) return null;
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null;
}

View File

@@ -0,0 +1,50 @@
import type { Feature, FeatureProperties } from "@/types/geo";
import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
import {
normalizeFeatureBindingIds,
parseBindingInput,
} from "@/lib/editor/snapshot/editorSnapshot";
export type GeometryMetadataPatch = {
patch: Partial<FeatureProperties>;
formState: GeometryMetaFormState;
};
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw new Error("time_start phải <= time_end.");
}
const bindingIds = parseBindingInput(form.binding);
return {
patch: {
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
},
formState: {
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
},
};
}
export function formatBindingIdsForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature);
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
}
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim();
if (!value.length) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} phải là số.`);
}
return Math.trunc(parsed);
}

View File

@@ -0,0 +1,267 @@
import { useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import { ApiError } from "@/api/http";
import {
createSection,
createSectionCommit,
fetchSectionCommits,
fetchSections,
openSectionEditor,
restoreSectionCommit,
submitSection,
} from "@/api/sections";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/lib/editor/draft/editorTypes";
import type { CreatedEntitySummary, PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
import type { Feature, FeatureCollection, FeatureId } from "@/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/types/sections";
type EditorDraftApi = {
draft: FeatureCollection;
buildPayload: () => Change[];
clearChanges: () => void;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
};
type Options = {
editor: EditorDraftApi;
editorUserId: string;
emptyFeatureCollection: FeatureCollection;
activeSection: Section | null;
sectionState: SectionState | null;
selectedSectionId: string;
newSectionTitle: string;
pendingSaveCount: number;
pendingEntityCreates: PendingEntityCreate[];
lastSectionSnapshot: EditorSnapshot | null;
commitTitle: string;
commitNote: string;
setActiveSection: Dispatch<SetStateAction<Section | null>>;
setSelectedSectionId: Dispatch<SetStateAction<string>>;
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
setIsSaving: Dispatch<SetStateAction<boolean>>;
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
setNewSectionTitle: Dispatch<SetStateAction<string>>;
setCommitTitle: Dispatch<SetStateAction<string>>;
setCommitNote: Dispatch<SetStateAction<string>>;
};
export function useSectionCommands(options: Options) {
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId, options.editorUserId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
const commits = await fetchSectionCommits(sectionId);
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
options.setActiveSection(editorPayload.section);
options.setSelectedSectionId(editorPayload.section.id);
options.setSectionState(editorPayload.state);
options.setLastSectionSnapshot(snapshot);
options.setInitialData(nextInitialData);
options.setSectionCommits(commits);
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setSelectedFeatureId(null);
options.setEntityFormStatus(null);
}, [options]);
const commitSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
return;
}
const geometryChanges = options.editor.buildPayload();
options.setIsSaving(true);
options.setEntityStatus(null);
try {
const snapshot = buildEditorSnapshot({
section: options.activeSection,
draft: options.editor.draft,
changes: geometryChanges,
pendingEntities: options.pendingEntityCreates,
previousSnapshot: options.lastSectionSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature,
});
const result = await createSectionCommit(options.activeSection.id, {
snapshot,
created_by: options.editorUserId,
expected_version: options.sectionState.version,
expected_head_commit_id: options.sectionState.head_commit_id,
title: options.commitTitle.trim() || `Commit ${new Date().toLocaleString()}`,
note: options.commitNote.trim() || null,
});
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
options.setInitialData(options.editor.draft);
options.editor.clearChanges();
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setCommitTitle("");
options.setCommitNote("");
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
console.error("Commit failed", err.body);
options.setEntityStatus(`Commit thất bại: ${err.body}`);
return;
}
console.error("Commit error", err);
options.setEntityStatus("Commit thất bại.");
} finally {
options.setIsSaving(false);
}
}, [options]);
const openSelectedSection = useCallback(async () => {
const sectionId = options.selectedSectionId.trim();
if (!sectionId) {
options.setEntityStatus("Hãy chọn section để mở.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
await openSectionForEditing(sectionId);
options.setEntityStatus("Đã mở section để chỉnh sửa.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
} else {
options.setEntityStatus("Mở section thất bại.");
}
} finally {
options.setIsOpeningSection(false);
}
}, [openSectionForEditing, options]);
const createAndOpenSection = useCallback(async () => {
const title = options.newSectionTitle.trim();
if (!title) {
options.setEntityStatus("Tên section là bắt buộc.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
const section = await createSection({
title,
user_id: options.editorUserId,
created_by: options.editorUserId,
});
const sections = await fetchSections();
options.setAvailableSections(sections);
options.setNewSectionTitle("");
await openSectionForEditing(section.id);
options.setEntityStatus("Đã tạo và mở section mới.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
} else {
options.setEntityStatus("Tạo section thất bại.");
}
} finally {
options.setIsOpeningSection(false);
}
}, [openSectionForEditing, options]);
const submitCurrentSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState?.head_commit_id) {
options.setEntityStatus("Section hiện tại chưa có head để submit.");
return;
}
if (options.pendingSaveCount > 0) {
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
return;
}
options.setIsSubmitting(true);
options.setEntityStatus(null);
try {
const submission = await submitSection(options.activeSection.id, {
submitted_by: options.editorUserId,
});
options.setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
options.setEntityStatus(`Đã submit section, submission ${submission.id}.`);
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Submit thất bại: ${err.body}`);
} else {
options.setEntityStatus("Submit thất bại.");
}
} finally {
options.setIsSubmitting(false);
}
}, [options]);
const restoreCommit = useCallback(async (commitId: string) => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
return;
}
if (options.pendingSaveCount > 0) {
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
return;
}
options.setIsSaving(true);
options.setEntityStatus(null);
try {
const result = await restoreSectionCommit(options.activeSection.id, {
commit_id: commitId,
created_by: options.editorUserId,
expected_version: options.sectionState.version,
expected_head_commit_id: options.sectionState.head_commit_id,
});
const editorPayload = await openSectionEditor(options.activeSection.id, options.editorUserId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
if (snapshot?.editor_feature_collection) {
options.setInitialData(snapshot.editor_feature_collection);
}
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Restore thất bại: ${err.body}`);
} else {
options.setEntityStatus("Restore thất bại.");
}
} finally {
options.setIsSaving(false);
}
}, [options]);
return {
openSectionForEditing,
commitSection,
openSelectedSection,
createAndOpenSection,
submitCurrentSection,
restoreCommit,
};
}

View File

@@ -0,0 +1,44 @@
import type { EntityGeometryPreset } from "@/lib/entityTypeOptions";
export type EditorMode =
| "idle"
| "draw"
| "select"
| "add-point"
| "add-line"
| "add-path"
| "add-circle";
export type TimelineRange = {
min: number;
max: number;
};
export type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
export type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export type PendingEntityCreate = {
id: string;
name: string;
slug: string | null;
type_id: string;
status: number;
};
export type CreatedEntitySummary = {
id: string;
name: string;
type_id?: string | null;
};
export type GeometryPreset = EntityGeometryPreset;

View File

@@ -0,0 +1,21 @@
import { useState } from "react";
import {
BackgroundLayerVisibility,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} 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 {
backgroundVisibility,
setBackgroundVisibility,
isBackgroundVisibilityReady,
setIsBackgroundVisibilityReady,
};
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import type { Entity } from "@/types/entities";
import type { FeatureId } from "@/types/geo";
import { DEFAULT_ENTITY_TYPE_ID } from "@/lib/entityTypeOptions";
import type {
CreatedEntitySummary,
EntityFormState,
GeometryMetaFormState,
PendingEntityCreate,
} 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 {
persistedEntities,
setPersistedEntities,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,
setCreatedEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,
setSelectedFeatureId,
entityForm,
setEntityForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
setIsEntitySubmitting,
entityFormStatus,
setEntityFormStatus,
entitySearchQuery,
setEntitySearchQuery,
entitySearchResults,
setEntitySearchResults,
selectedSearchEntityId,
setSelectedSearchEntityId,
isEntitySearchLoading,
setIsEntitySearchLoading,
};
}

View File

@@ -0,0 +1,85 @@
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/types/sections";
type Options = {
defaultEditorUserId: string;
};
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) => {
const currentValue = prev === task;
const nextValue = typeof next === "function" ? next(currentValue) : next;
if (nextValue) return task;
return prev === task ? "idle" : prev;
});
}, []);
const isSaving = sectionTask === "saving";
const isSubmitting = sectionTask === "submitting";
const isOpeningSection = sectionTask === "opening-section";
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("saving", next);
}, [setTaskFlag]);
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("submitting", next);
}, [setTaskFlag]);
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
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 {
isSaving,
setIsSaving,
isSubmitting,
setIsSubmitting,
isOpeningSection,
setIsOpeningSection,
availableSections,
setAvailableSections,
selectedSectionId,
setSelectedSectionId,
newSectionTitle,
setNewSectionTitle,
commitTitle,
setCommitTitle,
commitNote,
setCommitNote,
editorUserIdInput,
setEditorUserIdInput,
activeSection,
setActiveSection,
sectionState,
setSectionState,
sectionCommits,
setSectionCommits,
lastSectionSnapshot,
setLastSectionSnapshot,
};
}

View File

@@ -0,0 +1,42 @@
import { useState } from "react";
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
import { clampYearValue } from "@/lib/timeline";
type Options = {
currentYear: number;
fallbackTimelineRange: TimelineRange;
};
export function useTimelineState(options: Options) {
// Năm timeline "đã chốt" để fetch dữ liệu.
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
options.fallbackTimelineRange.min,
options.fallbackTimelineRange.max
)
);
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
options.fallbackTimelineRange.min,
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 {
timelineYear,
setTimelineYear,
timelineDraftYear,
setTimelineDraftYear,
isTimelineLoading,
setIsTimelineLoading,
timelineStatus,
setTimelineStatus,
};
}

View File

@@ -0,0 +1,254 @@
import { DEFAULT_ENTITY_TYPE_ID } from "@/lib/entityTypeOptions";
import type { Change } from "@/lib/editor/draft/editorTypes";
import type { PendingEntityCreate } from "@/lib/editor/session/sessionTypes";
import type { EntitySnapshot } from "@/types/entities";
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/types/geo";
import type { EditorSnapshot, Section } from "@/types/sections";
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const snapshot = raw as EditorSnapshot;
if (
snapshot.editor_feature_collection &&
snapshot.editor_feature_collection.type === "FeatureCollection" &&
Array.isArray(snapshot.editor_feature_collection.features)
) {
return snapshot;
}
return {
...snapshot,
editor_feature_collection: undefined,
};
}
export function buildEditorSnapshot(options: {
section: Section;
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
const changedIds = new Set(options.changes.map((change) =>
String(change.action === "create" ? change.feature.properties.id : change.id)
));
const deletedIds = new Set(
options.changes
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
.map((change) => String(change.id))
);
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
const previousFeatures = new globalThis.Map<string, Feature>();
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
previousFeatures.set(String(feature.properties.id), feature);
if (!currentDraftIds.has(String(feature.properties.id))) {
deletedIds.add(String(feature.properties.id));
}
}
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
for (const item of options.previousSnapshot?.geometries || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
const operation = item.operation;
if (id && operation) previousGeometryOps.set(id, operation);
}
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, EntitySnapshot>();
for (const item of options.previousSnapshot?.entities || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
if (id) entityRows.set(id, { ...item });
}
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
operation: "create",
name: entity.name,
slug: entity.slug,
description: null,
type_id: entity.type_id,
status: entity.status,
is_deleted: 0,
});
}
for (const feature of options.draft.features) {
for (const entityId of normalizeFeatureEntityIds(feature)) {
if (entityRows.has(entityId)) continue;
entityRows.set(entityId, {
id: entityId,
operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
slug: null,
description: null,
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1,
is_deleted: 0,
});
}
}
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
const id = String(feature.properties.id);
const previousOperation = previousGeometryOps.get(id);
const previousFeature = previousFeatures.get(id);
const changedFromPreviousSnapshot = previousFeature
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
: false;
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
? "create"
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: "reference";
const bbox = getFeatureBBox(feature);
return {
id,
operation,
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
time_end: feature.properties.time_end ?? null,
bbox: bbox
? {
min_lng: bbox.minLng,
min_lat: bbox.minLat,
max_lng: bbox.maxLng,
max_lat: bbox.maxLat,
}
: null,
is_deleted: 0,
};
});
for (const id of deletedIds) {
geometries.push({
id,
operation: "delete",
is_deleted: 1,
});
}
const linkScopes: LinkScopeSnapshot[] = options.draft.features
.map((feature) => ({
geometry_id: String(feature.properties.id),
operation: "replace" as const,
entity_ids: normalizeFeatureEntityIds(feature),
}))
.filter((scope) => scope.entity_ids.length > 0);
return {
schema_version: 1,
section: {
id: options.section.id,
title: options.section.title,
},
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity;
return entity;
}),
geometries,
link_scopes: linkScopes,
};
}
export function getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";
if (preset === "point") return "city";
if (preset === "circle-area") return "war";
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
const geometryType = feature.geometry.type;
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "defense_line";
}
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "city";
}
return DEFAULT_ENTITY_TYPE_ID;
}
export function normalizeFeatureEntityIds(feature: Feature): string[] {
const fromArray = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
: [];
if (fromArray.length) {
return uniqueEntityIds(fromArray);
}
const single = feature.properties.entity_id;
if (typeof single === "string" && single.trim().length > 0) {
return [single.trim()];
}
return [];
}
export function normalizeFeatureBindingIds(feature: Feature): string[] {
const rawBinding = feature.properties.binding;
if (!Array.isArray(rawBinding)) return [];
return uniqueEntityIds(rawBinding
.map((id) => {
if (typeof id !== "string" && typeof id !== "number") return "";
return String(id).trim();
})
.filter((id) => id.length > 0));
}
export function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
);
}
export function uniqueEntityIds(ids: string[]): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of ids) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
const points = collectCoordinatePairs(feature.geometry.coordinates);
if (!points.length) return null;
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const [lng, lat] of points) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { minLng, minLat, maxLng, maxLat };
}
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
if (!Array.isArray(value)) return [];
if (
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number" &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
) {
return [[value[0], value[1]]];
}
return value.flatMap((item) => collectCoordinatePairs(item));
}

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;
@@ -148,7 +147,7 @@ export function initCircle(
map.on("mouseup", onMouseUp);
document.addEventListener("keydown", onKeyDown);
return () => {
const cleanup = () => {
map.off("mousedown", onMouseDown);
map.off("mousemove", onMouseMove);
map.off("mouseup", onMouseUp);
@@ -158,6 +157,11 @@ export function initCircle(
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: resetDrawingState,
};
}
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.

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(
@@ -11,6 +10,18 @@ export function initDrawing(
) {
let coords: [number, number][] = [];
const clearPreview = () => {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
};
const cancelDrawing = () => {
coords = [];
clearPreview();
};
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
function closePolygon(c: [number, number][]) {
if (c.length < 3) return c;
@@ -71,19 +82,30 @@ export function initDrawing(
};
onComplete(geometry);
coords = [];
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
cancelDrawing();
}
// Lắng nghe Enter để chốt polygon.
function onKeyDown(e: KeyboardEvent) {
if (getMode() !== "draw") return;
if (e.key === "Enter") {
e.preventDefault();
finishDrawing();
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelDrawing();
return;
}
if (e.key === "Backspace") {
e.preventDefault();
coords = coords.slice(0, -1);
if (coords.length) {
update(coords);
} else {
clearPreview();
}
}
}
@@ -91,9 +113,15 @@ export function initDrawing(
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
return () => {
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
cancelDrawing();
};
return {
cleanup,
cancel: cancelDrawing,
};
}

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",
@@ -124,7 +123,7 @@ export function initLine(
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
return () => {
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
@@ -133,4 +132,9 @@ export function initLine(
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: cancelLine,
};
}

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",
@@ -126,13 +125,18 @@ export function initPath(
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
return () => {
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
clearPreview();
cancelPath();
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: cancelPath,
};
}

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(
@@ -31,12 +30,14 @@ export function initSelect(
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
function clearSelection() {
function clearSelection(emit = true) {
if (!selectedIds.size) return;
selectedIds.forEach((id) => setSelectionStateForId(id, false));
selectedIds.clear();
if (emit) {
onSelectId?.(null);
}
}
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
@@ -144,15 +145,21 @@ export function initSelect(
map.on("contextmenu", onRightClick);
}
return () => {
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
if (hasContextActions) {
map.off("contextmenu", onRightClick);
}
clearSelection(false);
hideContextMenu();
};
return {
cleanup,
clearSelection,
};
// Ẩn và dọn dẹp context menu hiện tại.
function hideContextMenu() {
if (contextMenu) {

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

@@ -0,0 +1,51 @@
import { useState } from "react";
import type { FeatureCollection } from "@/types/geo";
import { useBackgroundSessionState } from "@/lib/editor/session/useBackgroundSessionState";
import { useEntitySessionState } from "@/lib/editor/session/useEntitySessionState";
import { useSectionSessionState } from "@/lib/editor/session/useSectionSessionState";
import { useTimelineState } from "@/lib/editor/session/useTimelineState";
import type { EditorMode, TimelineRange } from "@/lib/editor/session/sessionTypes";
export type {
CreatedEntitySummary,
EditorMode,
EntityFormState,
GeometryMetaFormState,
PendingEntityCreate,
TimelineRange,
} from "@/lib/editor/session/sessionTypes";
type Options = {
emptyFeatureCollection: FeatureCollection;
defaultEditorUserId: string;
fallbackTimelineRange: TimelineRange;
currentYear: number;
};
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({
defaultEditorUserId: options.defaultEditorUserId,
});
const entity = useEntitySessionState();
const timeline = useTimelineState({
currentYear: options.currentYear,
fallbackTimelineRange: options.fallbackTimelineRange,
});
const background = useBackgroundSessionState();
return {
mode,
setMode,
initialData,
setInitialData,
...section,
...entity,
...timeline,
...background,
};
}

View File

@@ -1,166 +1,91 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
Feature,
FeatureCollection,
FeatureProperties,
Geometry,
} from "@/types/geo";
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/lib/editor/draft/draftDiff";
import { useDraftState } from "@/lib/editor/draft/useDraftState";
import { useUndoStack } from "@/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
export type Geometry =
| { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] }
| { type: "LineString"; coordinates: [number, number][] }
| { type: "MultiLineString"; coordinates: [number, number][][] }
| { type: "Polygon"; coordinates: [number, number][][] }
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
export type FeatureProperties = {
id: string | number;
type?: string | null;
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
entity_id?: string | null;
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_type_id?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
export type Change =
| { action: "create"; feature: Feature }
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
| { action: "delete"; id: FeatureProperties["id"] };
// Kiểu bản ghi undo tối thiểu.
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };
// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ.
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
// So sánh hai geometry theo nội dung tuần tự JSON.
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
JSON.stringify(a.properties) === JSON.stringify(b.properties);
}
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Feature>();
for (const f of fc.features) {
map.set(f.properties.id, deepClone(f));
}
return map;
}
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
function diffDraftToInitial(
draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Feature>
) {
const next = new Map<FeatureProperties["id"], Change>();
// track which initial ids are still present
const seen = new Set<FeatureProperties["id"]>();
// additions & updates
for (const f of draft.features) {
const id = f.properties.id;
seen.add(id);
const initialFeature = initialMap.get(id);
if (!initialFeature) {
next.set(id, { action: "create", feature: deepClone(f) });
} else if (!featureEquals(initialFeature, f)) {
next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
}
}
// deletions
for (const [id] of initialMap.entries()) {
if (!seen.has(id)) {
next.set(id, { action: "delete", id });
}
}
return next;
}
// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng).
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false;
if (a.type !== b.type) return false;
switch (a.type) {
case "create": {
const next = b as Extract<UndoAction, { type: "create" }>;
return a.id === next.id;
}
case "delete": {
const next = b as Extract<UndoAction, { type: "delete" }>;
return (
a.feature.properties.id === next.feature.properties.id &&
geometryEquals(a.feature.geometry, next.feature.geometry)
);
}
case "update": {
const next = b as Extract<UndoAction, { type: "update" }>;
return (
a.id === next.id &&
geometryEquals(a.prevGeometry, next.prevGeometry)
);
}
default:
return false;
}
}
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/types/geo";
export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
// State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI
// - changes: map các thay đổi chờ lưu
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
export function useEditorState(initialData: FeatureCollection) {
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
// baseline to know what is "saved" state
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
buildInitialMap(initialData)
);
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
// central entrypoint: keep draftRef + React state in sync
const commitDraft = (nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
draftRef.current = cloned;
setDraft(cloned);
};
// reset when initialData changes (e.g., after first load or after refresh)
useEffect(() => {
commitDraft(deepClone(initialData));
setUndoStack([]);
initialMapRef.current = buildInitialMap(initialData);
}, [initialData]);
// derive pending changes on every render: source of truth for save + changeCount.
// read baseline from state captured in closure to avoid ref access during render.
// Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0);
const applyUndoAction = useCallback((action: UndoAction): boolean => {
switch (action.type) {
case "create": {
commitDraft({
...draftRef.current,
features: draftRef.current.features.filter((feature) =>
feature.properties.id !== action.id
),
});
return true;
}
case "delete": {
const feature = deepClone(action.feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, feature],
});
return true;
}
case "update": {
const idx = draftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
);
if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...nextFeatures[idx],
geometry: deepClone(action.prevGeometry),
};
commitDraft({ ...draftRef.current, features: nextFeatures });
return true;
}
case "properties": {
const idx = draftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
);
if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...nextFeatures[idx],
properties: deepClone(action.prevProperties),
};
commitDraft({ ...draftRef.current, features: nextFeatures });
return true;
}
default:
return false;
}
}, [commitDraft, draftRef]);
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
useEffect(() => {
resetDraft(deepClone(initialData));
clearUndo();
initialMapRef.current = buildInitialMap(initialData);
setBaselineVersion((version) => version + 1);
}, [clearUndo, initialData, resetDraft]);
const changes = useMemo(() => {
const baseline = initialMapRef.current;
return diffDraftToInitial(draft, baseline);
@@ -168,20 +93,6 @@ export function useEditorState(initialData: FeatureCollection) {
}, [draft, baselineVersion]);
const changeCount = useMemo(() => changes.size, [changes]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
// Đẩy undo action mới vào stack nếu khác action gần nhất.
function pushUndo(action: UndoAction) {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp
return [...prev, action];
});
}
// Thêm feature mới vào draft và ghi nhận thao tác "create".
function createFeature(feature: Feature) {
const featureClone = deepClone(feature);
commitDraft({
@@ -191,15 +102,15 @@ export function useEditorState(initialData: FeatureCollection) {
pushUndo({ type: "create", id: featureClone.properties.id });
}
// Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
function patchFeatureProperties(
id: FeatureProperties["id"],
patch: Partial<FeatureProperties>
) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const nextFeatures = [...draftRef.current.features];
const prevProperties = deepClone(nextFeatures[idx].properties);
nextFeatures[idx] = {
...nextFeatures[idx],
properties: {
@@ -207,101 +118,53 @@ export function useEditorState(initialData: FeatureCollection) {
...deepClone(patch),
},
};
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
return;
}
pushUndo({ type: "properties", id, prevProperties });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
// Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return; // nothing to update
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const prevFeature = draftRef.current.features[idx];
const prevGeometry = prevFeature.geometry;
const updatedFeature = {
const prevGeometry = deepClone(prevFeature.geometry);
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...prevFeature,
geometry: deepClone(newGeometry),
};
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = updatedFeature;
pushUndo({ type: "update", id, prevGeometry });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
// Xóa feature khỏi draft và ghi nhận thao tác delete.
function deleteFeature(id: FeatureProperties["id"]) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const feature = draftRef.current.features[idx];
// store undo
pushUndo({ type: "delete", feature: deepClone(feature) });
const nextFeatures = [...draftRef.current.features];
nextFeatures.splice(idx, 1);
pushUndo({ type: "delete", feature: deepClone(feature) });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
// Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
function undo() {
let applied = false; // guards against React StrictMode double invoke of setState updater
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
applied = true;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
switch (last.type) {
case "create": {
commitDraft({
...draftRef.current,
features: draftRef.current.features.filter((f) => f.properties.id !== last.id),
});
break;
}
case "delete": {
const feature = deepClone(last.feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, feature],
});
break;
}
case "update": {
const { id, prevGeometry } = last;
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return remaining;
const updated = {
...draftRef.current.features[idx],
geometry: deepClone(prevGeometry),
};
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = updated;
commitDraft({ ...draftRef.current, features: nextFeatures });
break;
}
}
return remaining;
});
}
// Dựng mảng payload gửi API save từ map changes hiện tại.
function buildPayload(): Change[] {
return Array.from(changes.values()).map((c) => deepClone(c));
return Array.from(changes.values()).map((change) => deepClone(change));
}
// Xóa thay đổi đang chờ sau khi lưu thành công.
function clearChanges() {
setUndoStack([]);
clearUndo();
initialMapRef.current = buildInitialMap(draftRef.current);
setBaselineVersion((v) => v + 1);
setBaselineVersion((version) => version + 1);
}
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
function hasPersistedFeature(id: FeatureProperties["id"]) {
return initialMapRef.current.has(id);
}

15
types/api.ts Normal file
View File

@@ -0,0 +1,15 @@
export type ApiEnvelope<T> = {
status: "success" | "error" | string;
data: T;
message: string;
errors: unknown[];
};
export type GeometriesBBoxQuery = {
minLng: number;
minLat: number;
maxLng: number;
maxLat: number;
time?: number;
entity_id?: string;
};

26
types/entities.ts Normal file
View File

@@ -0,0 +1,26 @@
export type Entity = {
id: string;
name: string;
slug?: string | null;
description?: string | null;
type_id?: string | null;
status?: number | null;
geometry_count?: number;
created_at?: string;
updated_at?: string;
};
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type EntitySnapshot = {
id: string;
operation: EntitySnapshotOperation;
name?: string;
slug?: string | null;
description?: string | null;
type_id?: string | null;
status?: number | null;
is_deleted?: number;
base_updated_at?: string;
base_hash?: string;
};

70
types/geo.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { EntityGeometryPreset } from "@/lib/entityTypeOptions";
export type Geometry =
| { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] }
| { type: "LineString"; coordinates: [number, number][] }
| { type: "MultiLineString"; coordinates: [number, number][][] }
| { type: "Polygon"; coordinates: [number, number][][] }
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
export type FeatureId = string | number;
export type FeatureProperties = {
id: FeatureId;
type?: string | null;
geometry_preset?: EntityGeometryPreset | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
entity_id?: string | null;
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_type_id?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type GeometrySnapshot = {
id: string;
operation: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry;
binding?: string[];
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
is_deleted?: number;
base_updated_at?: string;
base_hash?: string;
};
export type LinkScopeSnapshot = {
geometry_id: string;
operation: "replace" | "reference";
entity_ids: string[];
base_links_hash?: string;
};
export type GeometryChange =
| { action: "create"; feature: Feature }
| { action: "update"; id: FeatureId; geometry: Geometry }
| { action: "delete"; id: FeatureId };

104
types/sections.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { EntitySnapshot } from "@/types/entities";
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/types/geo";
export type SectionStatus = "editing" | "submitted" | "approved" | "rejected";
export type SectionCommitKind = "manual" | "restore";
export type SectionSubmissionStatus = "pending" | "approved" | "rejected" | "conflicted";
export type SectionState = {
section_id?: string;
status: SectionStatus;
head_commit_id: string | null;
version: number;
locked_by: string | null;
locked_at: string | null;
lock_expires_at: string | null;
updated_at?: string;
};
export type Section = {
id: string;
title: string;
description: string | null;
user_id: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
state: Omit<SectionState, "section_id" | "updated_at">;
};
export type SectionCommit = {
id: string;
section_id: string;
parent_commit_id: string | null;
commit_no: number;
kind: SectionCommitKind;
restored_from_commit_id: string | null;
created_by: string;
created_at: string;
title: string | null;
note: string | null;
snapshot_hash: string | null;
snapshot?: EditorSnapshot | null;
};
export type SectionSubmission = {
id: string;
section_id: string;
commit_id: string;
submitted_by: string;
submitted_at: string;
status: SectionSubmissionStatus;
reviewed_by: string | null;
reviewed_at: string | null;
review_note: string | null;
snapshot_hash: string | null;
snapshot?: EditorSnapshot | null;
};
export type EditorSnapshot = {
schema_version: number;
section: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
};
export type EditorLoadResponse = {
section: Section;
state: SectionState;
commit: SectionCommit | null;
snapshot: EditorSnapshot | null;
};
export type CreateSectionInput = {
id?: string;
title: string;
description?: string | null;
user_id?: string;
created_by?: string;
};
export type CreateCommitInput = {
snapshot: EditorSnapshot;
created_by?: string;
user_id?: string;
expected_version?: number;
expected_head_commit_id?: string | null;
title?: string | null;
note?: string | null;
};
export type RestoreCommitInput = {
commit_id: string;
created_by?: string;
user_id?: string;
expected_version?: number;
expected_head_commit_id?: string | null;
title?: string | null;
note?: string | null;
};