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
All checks were successful
Build and Release / release (push) Successful in 26s
This commit is contained in:
@@ -1,17 +1,8 @@
|
|||||||
import { API_ENDPOINTS } from "@/api/config";
|
import { API_ENDPOINTS } from "@/api/config";
|
||||||
import { requestJson } from "@/api/http";
|
import { requestJson } from "@/api/http";
|
||||||
|
import type { Entity } from "@/types/entities";
|
||||||
|
|
||||||
export type Entity = {
|
export type { Entity } from "@/types/entities";
|
||||||
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 async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { API_ENDPOINTS } from "@/api/config";
|
import { API_ENDPOINTS } from "@/api/config";
|
||||||
import { requestJson } from "@/api/http";
|
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 = {
|
export type { GeometriesBBoxQuery } from "@/types/api";
|
||||||
minLng: number;
|
|
||||||
minLat: number;
|
|
||||||
maxLng: number;
|
|
||||||
maxLat: number;
|
|
||||||
time?: number;
|
|
||||||
entity_id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ApiEnvelope } from "@/types/api";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
body: string;
|
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> {
|
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(input, init);
|
const res = await fetch(input, init);
|
||||||
const payload = await parseJsonResponse(res);
|
const payload = await parseJsonResponse(res);
|
||||||
|
|||||||
105
api/sections.ts
105
api/sections.ts
@@ -1,81 +1,26 @@
|
|||||||
import { API_ENDPOINTS } from "@/api/config";
|
import { API_ENDPOINTS } from "@/api/config";
|
||||||
import { jsonRequestInit, requestJson } from "@/api/http";
|
import { jsonRequestInit, requestJson } from "@/api/http";
|
||||||
|
import type {
|
||||||
|
CreateCommitInput,
|
||||||
|
CreateSectionInput,
|
||||||
|
EditorLoadResponse,
|
||||||
|
RestoreCommitInput,
|
||||||
|
Section,
|
||||||
|
SectionCommit,
|
||||||
|
SectionState,
|
||||||
|
SectionSubmission,
|
||||||
|
} from "@/types/sections";
|
||||||
|
|
||||||
export type SectionState = {
|
export type {
|
||||||
section_id?: string;
|
CreateCommitInput,
|
||||||
status: "editing" | "submitted" | "approved" | "rejected";
|
CreateSectionInput,
|
||||||
head_commit_id: string | null;
|
EditorLoadResponse,
|
||||||
version: number;
|
RestoreCommitInput,
|
||||||
locked_by: string | null;
|
Section,
|
||||||
locked_at: string | null;
|
SectionCommit,
|
||||||
lock_expires_at: string | null;
|
SectionState,
|
||||||
updated_at?: string;
|
SectionSubmission,
|
||||||
};
|
} from "@/types/sections";
|
||||||
|
|
||||||
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 async function fetchSections(): Promise<Section[]> {
|
export async function fetchSections(): Promise<Section[]> {
|
||||||
return requestJson<Section[]>(API_ENDPOINTS.sections);
|
return requestJson<Section[]>(API_ENDPOINTS.sections);
|
||||||
@@ -126,15 +71,7 @@ export async function fetchSectionCommits(
|
|||||||
|
|
||||||
export async function restoreSectionCommit(
|
export async function restoreSectionCommit(
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
input: {
|
input: 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;
|
|
||||||
}
|
|
||||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||||
return requestJson<{ commit: SectionCommit; state: SectionState }>(
|
return requestJson<{ commit: SectionCommit; state: SectionState }>(
|
||||||
sectionUrl(sectionId, "restore"),
|
sectionUrl(sectionId, "restore"),
|
||||||
|
|||||||
114
app/editor/featureCommands.ts
Normal file
114
app/editor/featureCommands.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import type { Entity } from "@/types/entities";
|
||||||
|
import type { Feature, FeatureProperties } from "@/types/geo";
|
||||||
|
import { ApiError } from "@/api/http";
|
||||||
|
import { buildFeatureEntityPatch } from "@/lib/editor/entity/entityBinding";
|
||||||
|
import { buildGeometryMetadataPatch } from "@/lib/editor/geometry/geometryMetadata";
|
||||||
|
import { uniqueEntityIds } from "@/lib/editor/snapshot/editorSnapshot";
|
||||||
|
import type { GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
type EditorDraftApi = {
|
||||||
|
patchFeatureProperties: (id: FeatureProperties["id"], patch: Partial<FeatureProperties>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
editor: EditorDraftApi;
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
setGeometryMetaForm: Dispatch<SetStateAction<GeometryMetaFormState>>;
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
setSelectedGeometryEntityIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
entities: Entity[];
|
||||||
|
setIsEntitySubmitting: Dispatch<SetStateAction<boolean>>;
|
||||||
|
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFeatureCommands(options: Options) {
|
||||||
|
const {
|
||||||
|
editor,
|
||||||
|
selectedFeature,
|
||||||
|
geometryMetaForm,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
entities,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setEntityFormStatus,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const applyGeometryMetadata = useCallback(async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
||||||
|
} catch (err) {
|
||||||
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
||||||
|
setGeometryMetaForm(metadata.formState);
|
||||||
|
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
geometryMetaForm,
|
||||||
|
selectedFeature,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setGeometryMetaForm,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applyEntitiesToSelectedGeometry = useCallback(async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
editor.patchFeatureProperties(
|
||||||
|
selectedFeature.properties.id,
|
||||||
|
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
||||||
|
);
|
||||||
|
setSelectedGeometryEntityIds(entityIds);
|
||||||
|
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus("Lưu thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
editor,
|
||||||
|
entities,
|
||||||
|
selectedFeature,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
setEntityFormStatus,
|
||||||
|
setIsEntitySubmitting,
|
||||||
|
setSelectedGeometryEntityIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyGeometryMetadata,
|
||||||
|
applyEntitiesToSelectedGeometry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
1251
app/editor/page.tsx
1251
app/editor/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -22,5 +22,5 @@
|
|||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans), system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Ultimate History Map",
|
||||||
description: "Generated by create next app",
|
description: "Map editor and viewer for historical entities, geometries, and timelines.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,7 +23,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="vi">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
219
app/page.tsx
219
app/page.tsx
@@ -9,52 +9,45 @@ import { fetchGeometriesByBBox } from "@/api/geometries";
|
|||||||
import { ApiError } from "@/api/http";
|
import { ApiError } from "@/api/http";
|
||||||
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_LAYER_OPTIONS,
|
BackgroundLayerId,
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/lib/backgroundLayers";
|
} from "@/lib/backgroundLayers";
|
||||||
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
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 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() {
|
export default function Page() {
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
// Dữ liệu GeoJSON hiện đang hiển thị trên map (theo timelineYear).
|
||||||
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
|
||||||
|
// ID geometry đang được chọn trên map (null nếu chưa chọn).
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
// Năm timeline "đã chốt" để trigger fetch dữ liệu.
|
||||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
|
||||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
|
||||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
clampYearToFixedRange(CURRENT_YEAR)
|
||||||
);
|
);
|
||||||
|
// Năm timeline đang kéo/nhập (debounce rồi mới đẩy sang timelineYear).
|
||||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
clampYearToFixedRange(CURRENT_YEAR)
|
||||||
);
|
);
|
||||||
const [isTimelineReady, setIsTimelineReady] = useState(false);
|
// Cờ loading khi fetch geometries theo timeline.
|
||||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
|
// Thông báo trạng thái/lỗi khi load timeline.
|
||||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
|
// Trạng thái bật/tắt các layer nền.
|
||||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
);
|
||||||
|
// Đảm bảo đã load backgroundVisibility từ storage trước khi render map.
|
||||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
// Counter để bỏ qua response cũ khi user đổi timeline liên tục.
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
|
||||||
const selectedFeature =
|
const selectedFeature =
|
||||||
@@ -75,53 +68,6 @@ export default function Page() {
|
|||||||
}, [initialData, selectedFeatureId]);
|
}, [initialData, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
if (timelineDraftYear !== timelineYear) {
|
if (timelineDraftYear !== timelineYear) {
|
||||||
setTimelineYear(timelineDraftYear);
|
setTimelineYear(timelineDraftYear);
|
||||||
@@ -129,7 +75,7 @@ export default function Page() {
|
|||||||
}, TIMELINE_DEBOUNCE_MS);
|
}, TIMELINE_DEBOUNCE_MS);
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
}, [timelineDraftYear, timelineYear]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
@@ -137,14 +83,6 @@ export default function Page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let disposed = false;
|
||||||
const requestId = ++timelineFetchRequestRef.current;
|
const requestId = ++timelineFetchRequestRef.current;
|
||||||
|
|
||||||
@@ -182,7 +120,7 @@ export default function Page() {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [timelineYear, isTimelineReady]);
|
}, [timelineYear]);
|
||||||
|
|
||||||
const updateBackgroundVisibility = (
|
const updateBackgroundVisibility = (
|
||||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
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) => ({
|
updateBackgroundVisibility((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[id]: !prev[id],
|
[id]: !prev[id],
|
||||||
@@ -209,23 +147,10 @@ export default function Page() {
|
|||||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
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 handleTimelineYearChange = (nextYear: number) => {
|
||||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
|
||||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timelineDisabled = !isTimelineReady;
|
|
||||||
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
||||||
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
||||||
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
||||||
@@ -248,16 +173,10 @@ export default function Page() {
|
|||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
)}
|
)}
|
||||||
<TimelineBar
|
<TimelineBar
|
||||||
minYear={timelineRange.min}
|
|
||||||
maxYear={timelineRange.max}
|
|
||||||
windowStartYear={timelineWindowStart}
|
|
||||||
windowEndYear={timelineWindowEnd}
|
|
||||||
onWindowStartYearChange={handleTimelineWindowStartChange}
|
|
||||||
onWindowEndYearChange={handleTimelineWindowEndChange}
|
|
||||||
year={timelineDraftYear}
|
year={timelineDraftYear}
|
||||||
onYearChange={handleTimelineYearChange}
|
onYearChange={handleTimelineYearChange}
|
||||||
isLoading={isTimelineLoading}
|
isLoading={isTimelineLoading}
|
||||||
disabled={timelineDisabled}
|
disabled={false}
|
||||||
statusText={timelineStatus}
|
statusText={timelineStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
function isYearNumber(value: number | null | undefined): value is number {
|
||||||
return typeof value === "number" && Number.isFinite(value);
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
@@ -432,53 +311,3 @@ function normalizeTypeId(value: unknown): string | null {
|
|||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
return normalized.length ? normalized : null;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import CommitTreePopup from "@/components/CommitTreePopup";
|
import CommitTreePopup from "@/components/CommitTreePopup";
|
||||||
import { UndoAction } from "@/lib/useEditorState";
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: Mode;
|
mode: EditorMode;
|
||||||
setMode: (mode: Mode) => void;
|
setMode: (mode: EditorMode) => void;
|
||||||
entityStatus?: string | null;
|
entityStatus?: string | null;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
@@ -103,12 +102,13 @@ export default function Editor({
|
|||||||
createdEntities,
|
createdEntities,
|
||||||
createdGeometries,
|
createdGeometries,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// Bật/tắt popup cây commit.
|
||||||
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
|
const [isCommitTreeOpen, setIsCommitTreeOpen] = useState(false);
|
||||||
|
|
||||||
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
const formatCommitTitle = (commit: Props["commits"][number]) =>
|
||||||
commit.title?.trim() || `Commit #${commit.commit_no}`;
|
commit.title?.trim() || `Commit #${commit.commit_no}`;
|
||||||
|
|
||||||
const toggleMode = (newMode: Mode) => {
|
const toggleMode = (newMode: EditorMode) => {
|
||||||
if (mode === newMode) {
|
if (mode === newMode) {
|
||||||
setMode("idle"); // bấm lại → tắt
|
setMode("idle"); // bấm lại → tắt
|
||||||
} else {
|
} else {
|
||||||
@@ -129,7 +129,7 @@ export default function Editor({
|
|||||||
return labels.reverse();
|
return labels.reverse();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const getButtonStyle = (btnMode: Mode) => ({
|
const getButtonStyle = (btnMode: EditorMode) => ({
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "8px",
|
padding: "8px",
|
||||||
marginBottom: "6px",
|
marginBottom: "6px",
|
||||||
@@ -627,6 +627,8 @@ function formatUndoLabel(action: UndoAction) {
|
|||||||
return `Xóa #${action.feature.properties.id}`;
|
return `Xóa #${action.feature.properties.id}`;
|
||||||
case "update":
|
case "update":
|
||||||
return `Chỉnh sửa #${action.id}`;
|
return `Chỉnh sửa #${action.id}`;
|
||||||
|
case "properties":
|
||||||
|
return `Cập nhật thuộc tính #${action.id}`;
|
||||||
default:
|
default:
|
||||||
return "Tác vụ";
|
return "Tác vụ";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,40 @@ import maplibregl from "maplibre-gl";
|
|||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
|
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
|
||||||
import { initDrawing } from "@/lib/drawingEngine";
|
import { initDrawing } from "@/lib/engine/drawingEngine";
|
||||||
import { initSelect } from "@/lib/selectingEngine";
|
import { initSelect } from "@/lib/engine/selectingEngine";
|
||||||
import { initPoint } from "@/lib/pointEngine";
|
import { initPoint } from "@/lib/engine/pointEngine";
|
||||||
import { initLine } from "@/lib/lineEngine";
|
import { initLine } from "@/lib/engine/lineEngine";
|
||||||
import { initPath } from "@/lib/pathEngine";
|
import { initPath } from "@/lib/engine/pathEngine";
|
||||||
import { initCircle } from "@/lib/circleEngine";
|
import { initCircle } from "@/lib/engine/circleEngine";
|
||||||
import { createEditingEngine } from "@/lib/editingEngine";
|
import { createEditingEngine } from "@/lib/engine/editingEngine";
|
||||||
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
|
import { EMPTY_FEATURE_COLLECTION } from "@/lib/geo/constants";
|
||||||
|
import {
|
||||||
|
DEFAULT_POINT_ICON_ID,
|
||||||
|
FEATURE_STATE_SOURCE_IDS,
|
||||||
|
MAP_MAX_ZOOM,
|
||||||
|
MAP_MIN_ZOOM,
|
||||||
|
PATH_ARROW_ICON_ID,
|
||||||
|
PATH_ARROW_SOURCE_ID,
|
||||||
|
POINT_ICON_URL,
|
||||||
|
RASTER_BASE_INSERT_BEFORE_LAYER_ID,
|
||||||
|
RASTER_BASE_LAYER_ID,
|
||||||
|
RASTER_BASE_SOURCE_ID,
|
||||||
|
} from "@/lib/map/constants";
|
||||||
|
import {
|
||||||
|
COUNTRY_FILL_COLOR_EXPRESSION,
|
||||||
|
LINE_COLOR_BY_TYPE,
|
||||||
|
PATH_RENDER_BY_TYPE,
|
||||||
|
POLYGON_FILL_BY_TYPE,
|
||||||
|
POLYGON_OPACITY_BY_TYPE,
|
||||||
|
POLYGON_STROKE_BY_TYPE,
|
||||||
|
} from "@/lib/map/style";
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
mode: EditorMode;
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
backgroundVisibility: BackgroundLayerVisibility;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
selectedFeatureId: string | number | null;
|
selectedFeatureId: string | number | null;
|
||||||
@@ -31,91 +53,10 @@ type MapProps = {
|
|||||||
fitBoundsKey?: string | number | null;
|
fitBoundsKey?: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
type EngineBinding = {
|
||||||
const POINT_ICON_URL = "/point.png";
|
cleanup: () => void;
|
||||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
cancel?: () => void;
|
||||||
const MAP_MIN_ZOOM = 1;
|
clearSelection?: () => void;
|
||||||
const MAP_MAX_ZOOM = 10;
|
|
||||||
const RASTER_BASE_SOURCE_ID = "rasterBase";
|
|
||||||
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
|
||||||
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
|
||||||
const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
|
||||||
const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
|
||||||
const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [],
|
|
||||||
};
|
|
||||||
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
|
||||||
"coalesce",
|
|
||||||
["get", "MAPCOLOR7"],
|
|
||||||
["get", "MAPCOLOR9"],
|
|
||||||
["get", "scalerank"],
|
|
||||||
0,
|
|
||||||
];
|
|
||||||
const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
|
||||||
"match",
|
|
||||||
COUNTRY_COLOR_KEY_EXPRESSION,
|
|
||||||
1, "#ef4444",
|
|
||||||
2, "#f97316",
|
|
||||||
3, "#f59e0b",
|
|
||||||
4, "#22c55e",
|
|
||||||
5, "#06b6d4",
|
|
||||||
6, "#3b82f6",
|
|
||||||
7, "#8b5cf6",
|
|
||||||
8, "#a855f7",
|
|
||||||
9, "#d946ef",
|
|
||||||
10, "#14b8a6",
|
|
||||||
"#64748b",
|
|
||||||
];
|
|
||||||
|
|
||||||
const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
|
||||||
country: "#2563eb",
|
|
||||||
state: "#0ea5e9",
|
|
||||||
empire: "#f59e0b",
|
|
||||||
kingdom: "#d97706",
|
|
||||||
war: "#dc2626",
|
|
||||||
battle: "#f43f5e",
|
|
||||||
civilization: "#14b8a6",
|
|
||||||
rebellion_zone: "#7c3aed",
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
|
||||||
country: "#1e3a8a",
|
|
||||||
state: "#0c4a6e",
|
|
||||||
empire: "#7c2d12",
|
|
||||||
kingdom: "#9a3412",
|
|
||||||
war: "#7f1d1d",
|
|
||||||
battle: "#9f1239",
|
|
||||||
civilization: "#134e4a",
|
|
||||||
rebellion_zone: "#4c1d95",
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
|
||||||
war: 0.3,
|
|
||||||
battle: 0.34,
|
|
||||||
civilization: 0.38,
|
|
||||||
rebellion_zone: 0.32,
|
|
||||||
};
|
|
||||||
|
|
||||||
const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
|
||||||
defense_line: "#f97316",
|
|
||||||
attack_route: "#ef4444",
|
|
||||||
retreat_route: "#94a3b8",
|
|
||||||
invasion_route: "#b91c1c",
|
|
||||||
migration_route: "#0ea5e9",
|
|
||||||
refugee_route: "#06b6d4",
|
|
||||||
trade_route: "#eab308",
|
|
||||||
shipping_route: "#2563eb",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
|
||||||
attack_route: true,
|
|
||||||
retreat_route: true,
|
|
||||||
invasion_route: true,
|
|
||||||
migration_route: true,
|
|
||||||
refugee_route: true,
|
|
||||||
trade_route: true,
|
|
||||||
shipping_route: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Map({
|
export default function Map({
|
||||||
@@ -133,29 +74,76 @@ export default function Map({
|
|||||||
fitToDraftBounds = false,
|
fitToDraftBounds = false,
|
||||||
fitBoundsKey = null,
|
fitBoundsKey = null,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
|
// DOM container của map (dùng ref để tránh collision khi render nhiều map).
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Nếu init map fail (throw trong onLoad), show overlay thay vì crash âm thầm.
|
||||||
|
const [fatalInitError, setFatalInitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Mirror các flags props để tránh phải re-create map khi props thay đổi.
|
||||||
|
const fitToDraftBoundsRef = useRef(fitToDraftBounds);
|
||||||
|
const respectBindingFilterRef = useRef(respectBindingFilter);
|
||||||
|
|
||||||
|
// Instance maplibre (được tạo 1 lần khi component mount).
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
// Mirror của props mode để event handlers/engines đọc giá trị mới nhất (tránh stale closure).
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
|
// Mirror của draft để engines đọc dữ liệu mới nhất trong callbacks.
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
|
// Mirror của backgroundVisibility để sync visibility khi map đã load.
|
||||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
|
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
|
||||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||||
|
// Mirror của callback onSelectFeatureId.
|
||||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||||
|
// Mirror của callback onCreateFeature.
|
||||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||||
|
// Mirror của callback onDeleteFeature.
|
||||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||||
|
// Mirror của callback onUpdateFeature.
|
||||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||||
|
// Zoom hiện tại để render UI zoom control.
|
||||||
const [zoomLevel, setZoomLevel] = useState(2);
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
|
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
||||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
|
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
// Đánh dấu đã fitBounds cho fitBoundsKey hiện tại (tránh fit lặp).
|
||||||
const fitBoundsAppliedRef = useRef(false);
|
const fitBoundsAppliedRef = useRef(false);
|
||||||
|
// Danh sách cleanup fns để dọn listeners/engines khi unmount map.
|
||||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||||
|
// Các engine bindings theo mode để gọi cancel/cleanup khi đổi mode.
|
||||||
|
const engineBindingsRef = useRef<Partial<Record<MapProps["mode"], EngineBinding>>>({});
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
modeRef.current = mode;
|
modeRef.current = mode;
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const previousMode = previousModeRef.current;
|
||||||
|
if (previousMode !== mode) {
|
||||||
|
engineBindingsRef.current[previousMode]?.cancel?.();
|
||||||
|
previousModeRef.current = mode;
|
||||||
|
}
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !map.isStyleLoaded()) return;
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
if (mode !== "draw") {
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (mode !== "add-line") {
|
if (mode !== "add-line") {
|
||||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -184,6 +172,12 @@ export default function Map({
|
|||||||
selectedFeatureIdRef.current = selectedFeatureId;
|
selectedFeatureIdRef.current = selectedFeatureId;
|
||||||
}, [selectedFeatureId]);
|
}, [selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "select" || selectedFeatureId === null) {
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
}
|
||||||
|
}, [mode, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fitBoundsAppliedRef.current = false;
|
fitBoundsAppliedRef.current = false;
|
||||||
}, [fitBoundsKey]);
|
}, [fitBoundsKey]);
|
||||||
@@ -240,7 +234,7 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleDraft = respectBindingFilter
|
const visibleDraft = respectBindingFilterRef.current
|
||||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||||
: fc;
|
: fc;
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
@@ -257,14 +251,17 @@ export default function Map({
|
|||||||
if (mapRef.current !== map) return;
|
if (mapRef.current !== map) return;
|
||||||
setSelectedFeatureState(map, selectedId, true);
|
setSelectedFeatureState(map, selectedId, true);
|
||||||
});
|
});
|
||||||
if (fitToDraftBounds && !fitBoundsAppliedRef.current) {
|
if (fitToDraftBoundsRef.current && !fitBoundsAppliedRef.current) {
|
||||||
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
fitBoundsAppliedRef.current = fitMapToFeatureCollection(map, visibleDraft);
|
||||||
}
|
}
|
||||||
}, [fitToDraftBounds, respectBindingFilter]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: "map",
|
container,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
minZoom: MAP_MIN_ZOOM,
|
minZoom: MAP_MIN_ZOOM,
|
||||||
maxZoom: MAP_MAX_ZOOM,
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
@@ -408,6 +405,7 @@ export default function Map({
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
|
try {
|
||||||
const syncZoomLevel = () => {
|
const syncZoomLevel = () => {
|
||||||
setZoomLevel(roundZoom(map.getZoom()));
|
setZoomLevel(roundZoom(map.getZoom()));
|
||||||
};
|
};
|
||||||
@@ -791,11 +789,11 @@ export default function Map({
|
|||||||
addPointSymbolLayer(map);
|
addPointSymbolLayer(map);
|
||||||
|
|
||||||
// init drawing
|
// init drawing
|
||||||
const cleanup = initDrawing(
|
const drawingEngine = initDrawing(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -813,7 +811,7 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const cleanupSelect = initSelect(
|
const selectEngine = initSelect(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
allowGeometryEditing
|
allowGeometryEditing
|
||||||
@@ -834,7 +832,7 @@ export default function Map({
|
|||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -852,11 +850,11 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const cleanupLine = initLine(
|
const lineEngine = initLine(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -874,11 +872,11 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const cleanupPath = initPath(
|
const pathEngine = initPath(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -896,11 +894,11 @@ export default function Map({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const cleanupCircle = initCircle(
|
const circleEngine = initCircle(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = buildClientFeatureId();
|
||||||
onCreateRef.current?.({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -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 = [
|
mapCleanupFnsRef.current = [
|
||||||
cleanupCircle,
|
circleEngine.cleanup,
|
||||||
cleanupPath,
|
pathEngine.cleanup,
|
||||||
cleanupLine,
|
lineEngine.cleanup,
|
||||||
cleanupPoint,
|
cleanupPoint,
|
||||||
cleanupSelect,
|
selectEngine.cleanup,
|
||||||
cleanup,
|
drawingEngine.cleanup,
|
||||||
() => map.off("zoom", syncZoomLevel),
|
() => map.off("zoom", syncZoomLevel),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -934,6 +940,10 @@ export default function Map({
|
|||||||
if (allowGeometryEditing) {
|
if (allowGeometryEditing) {
|
||||||
editingEngineRef.current?.bindEditEvents(map);
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Map initialization failed", err);
|
||||||
|
setFatalInitError(err instanceof Error ? err.message : "Map initialization failed.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -941,6 +951,7 @@ export default function Map({
|
|||||||
cleanupFn();
|
cleanupFn();
|
||||||
}
|
}
|
||||||
mapCleanupFnsRef.current = [];
|
mapCleanupFnsRef.current = [];
|
||||||
|
engineBindingsRef.current = {};
|
||||||
if (mapRef.current === map) {
|
if (mapRef.current === map) {
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -976,7 +987,39 @@ export default function Map({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height, position: "relative" }}>
|
<div style={{ width: "100%", height, position: "relative" }}>
|
||||||
<div id="map" style={{ width: "100%", height: "100%" }} />
|
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||||
|
|
||||||
|
{fatalInitError ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
padding: "24px",
|
||||||
|
background: "rgba(2, 6, 23, 0.78)",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "680px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "rgba(15, 23, 42, 0.92)",
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 800, marginBottom: "6px" }}>
|
||||||
|
Map khong khoi tao duoc
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
|
{fatalInitError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -1168,12 +1211,16 @@ function normalizeBindingIds(rawBinding: unknown): string[] {
|
|||||||
function splitDraftFeatures(fc: FeatureCollection) {
|
function splitDraftFeatures(fc: FeatureCollection) {
|
||||||
const polygons = {
|
const polygons = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: fc.features.filter((f) => f.geometry.type !== "Point"),
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type !== "Point" && f.geometry.type !== "MultiPoint"
|
||||||
|
),
|
||||||
} as FeatureCollection;
|
} as FeatureCollection;
|
||||||
|
|
||||||
const points = {
|
const points = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: fc.features.filter((f) => f.geometry.type === "Point"),
|
features: fc.features.filter((f) =>
|
||||||
|
f.geometry.type === "Point" || f.geometry.type === "MultiPoint"
|
||||||
|
),
|
||||||
} as FeatureCollection;
|
} as FeatureCollection;
|
||||||
|
|
||||||
return { polygons, points };
|
return { polygons, points };
|
||||||
@@ -1505,6 +1552,7 @@ function createPathArrowImageData(): ImageData | null {
|
|||||||
|
|
||||||
function addPointSymbolLayer(map: maplibregl.Map) {
|
function addPointSymbolLayer(map: maplibregl.Map) {
|
||||||
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
void ensurePointAssetIcon(map).then((hasPointIcon) => {
|
||||||
|
try {
|
||||||
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return;
|
||||||
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -1522,6 +1570,10 @@ function addPointSymbolLayer(map: maplibregl.Map) {
|
|||||||
if (map.getLayer("places-circle")) {
|
if (map.getLayer("places-circle")) {
|
||||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Map might have been removed while icon was loading.
|
||||||
|
console.warn("Add point symbol layer skipped", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1567,6 +1619,14 @@ function roundZoom(value: number): number {
|
|||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildClientFeatureId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback đảm bảo tránh collision khi user tạo nhiều feature trong cùng 1ms.
|
||||||
|
return `feature-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number): number {
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
if (value < min) return min;
|
if (value < min) return min;
|
||||||
if (value > max) return max;
|
if (value > max) return max;
|
||||||
|
|||||||
@@ -10,18 +10,7 @@ import {
|
|||||||
findEntityTypeOption,
|
findEntityTypeOption,
|
||||||
groupEntityTypeOptions,
|
groupEntityTypeOptions,
|
||||||
} from "@/lib/entityTypeOptions";
|
} from "@/lib/entityTypeOptions";
|
||||||
|
import type { EntityFormState, GeometryMetaFormState } from "@/lib/editor/session/sessionTypes";
|
||||||
type EntityFormState = {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
type_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeometryMetaFormState = {
|
|
||||||
time_start: string;
|
|
||||||
time_end: string;
|
|
||||||
binding: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedFeature: Feature | null;
|
selectedFeature: Feature | null;
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/lib/timeline";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
minYear: number;
|
|
||||||
maxYear: number;
|
|
||||||
windowStartYear: number;
|
|
||||||
windowEndYear: number;
|
|
||||||
onWindowStartYearChange: (year: number) => void;
|
|
||||||
onWindowEndYearChange: (year: number) => void;
|
|
||||||
year: number;
|
year: number;
|
||||||
onYearChange: (year: number) => void;
|
onYearChange: (year: number) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -15,33 +11,24 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TimelineBar({
|
export default function TimelineBar({
|
||||||
minYear,
|
|
||||||
maxYear,
|
|
||||||
windowStartYear,
|
|
||||||
windowEndYear,
|
|
||||||
onWindowStartYearChange,
|
|
||||||
onWindowEndYearChange,
|
|
||||||
year,
|
year,
|
||||||
onYearChange,
|
onYearChange,
|
||||||
isLoading,
|
isLoading,
|
||||||
disabled,
|
disabled,
|
||||||
statusText,
|
statusText,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const lower = Math.min(minYear, maxYear);
|
const lower = FIXED_TIMELINE_START_YEAR;
|
||||||
const upper = Math.max(minYear, maxYear);
|
const upper = FIXED_TIMELINE_END_YEAR;
|
||||||
const globalLocked = lower === upper;
|
const effectiveDisabled = disabled;
|
||||||
const effectiveDisabled = disabled || globalLocked;
|
const safeYear = clampYearValue(year, lower, upper);
|
||||||
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 helperText = isLoading
|
const helperText = isLoading
|
||||||
? "Đang tải geometry theo mốc thời gian..."
|
? "Đang tải geometry theo mốc thời gian..."
|
||||||
: statusText || (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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -76,86 +63,54 @@ export default function TimelineBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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" }}>
|
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
|
||||||
Mốc thời gian chi tiết
|
Mốc thời gian chi tiết
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0, 1fr) 120px",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={safeWindowStart}
|
min={lower}
|
||||||
max={safeWindowEnd}
|
max={upper}
|
||||||
step={1}
|
step={1}
|
||||||
value={safeYear}
|
value={safeYear}
|
||||||
onChange={(event) => onYearChange(Number(event.target.value))}
|
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||||
disabled={pointDisabled}
|
disabled={effectiveDisabled}
|
||||||
aria-label="Timeline year"
|
aria-label="Timeline year"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
accentColor: "#22c55e",
|
accentColor: "#22c55e",
|
||||||
cursor: pointDisabled ? "not-allowed" : "pointer",
|
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||||
opacity: pointDisabled ? 0.6 : 1,
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -167,103 +122,19 @@ export default function TimelineBar({
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: "#94a3b8" }}>{formatYear(safeWindowStart)}</span>
|
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
|
||||||
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
||||||
{helperText}
|
{helperText}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(safeWindowEnd)}</span>
|
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
|
||||||
</div>
|
</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>
|
</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 {
|
function formatYear(year: number): string {
|
||||||
if (year < 0) {
|
if (year < 0) {
|
||||||
return `${Math.abs(year)} TCN`;
|
return `${Math.abs(year)} TCN`;
|
||||||
}
|
}
|
||||||
return `${year}`;
|
return `${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPercent(value: number, minValue: number, maxValue: number): number {
|
|
||||||
if (maxValue <= minValue) return 0;
|
|
||||||
return ((value - minValue) / (maxValue - minValue)) * 100;
|
|
||||||
}
|
|
||||||
|
|||||||
58
lib/editor/background/backgroundVisibilityStorage.ts
Normal file
58
lib/editor/background/backgroundVisibilityStorage.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
55
lib/editor/draft/draftDiff.ts
Normal file
55
lib/editor/draft/draftDiff.ts
Normal 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;
|
||||||
|
}
|
||||||
15
lib/editor/draft/editorTypes.ts
Normal file
15
lib/editor/draft/editorTypes.ts
Normal 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"] };
|
||||||
|
|
||||||
31
lib/editor/draft/useDraftState.ts
Normal file
31
lib/editor/draft/useDraftState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
81
lib/editor/draft/useUndoStack.ts
Normal file
81
lib/editor/draft/useUndoStack.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/editor/entity/entityBinding.ts
Normal file
109
lib/editor/entity/entityBinding.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
50
lib/editor/geometry/geometryMetadata.ts
Normal file
50
lib/editor/geometry/geometryMetadata.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
267
lib/editor/section/useSectionCommands.ts
Normal file
267
lib/editor/section/useSectionCommands.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
44
lib/editor/session/sessionTypes.ts
Normal file
44
lib/editor/session/sessionTypes.ts
Normal 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;
|
||||||
|
|
||||||
21
lib/editor/session/useBackgroundSessionState.ts
Normal file
21
lib/editor/session/useBackgroundSessionState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
lib/editor/session/useEntitySessionState.ts
Normal file
80
lib/editor/session/useEntitySessionState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
lib/editor/session/useSectionSessionState.ts
Normal file
85
lib/editor/session/useSectionSessionState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
lib/editor/session/useTimelineState.ts
Normal file
42
lib/editor/session/useTimelineState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
254
lib/editor/snapshot/editorSnapshot.ts
Normal file
254
lib/editor/snapshot/editorSnapshot.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EARTH_RADIUS_METERS = 6371008.8;
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
const CIRCLE_SEGMENTS = 72;
|
const CIRCLE_SEGMENTS = 72;
|
||||||
@@ -148,7 +147,7 @@ export function initCircle(
|
|||||||
map.on("mouseup", onMouseUp);
|
map.on("mouseup", onMouseUp);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
map.off("mousedown", onMouseDown);
|
map.off("mousedown", onMouseDown);
|
||||||
map.off("mousemove", onMouseMove);
|
map.off("mousemove", onMouseMove);
|
||||||
map.off("mouseup", onMouseUp);
|
map.off("mouseup", onMouseUp);
|
||||||
@@ -158,6 +157,11 @@ export function initCircle(
|
|||||||
map.getCanvas().style.cursor = "";
|
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.
|
// 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.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
export function initDrawing(
|
export function initDrawing(
|
||||||
@@ -11,6 +10,18 @@ export function initDrawing(
|
|||||||
) {
|
) {
|
||||||
let coords: [number, number][] = [];
|
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.
|
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||||
function closePolygon(c: [number, number][]) {
|
function closePolygon(c: [number, number][]) {
|
||||||
if (c.length < 3) return c;
|
if (c.length < 3) return c;
|
||||||
@@ -71,19 +82,30 @@ export function initDrawing(
|
|||||||
};
|
};
|
||||||
|
|
||||||
onComplete(geometry);
|
onComplete(geometry);
|
||||||
|
cancelDrawing();
|
||||||
coords = [];
|
|
||||||
|
|
||||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lắng nghe Enter để chốt polygon.
|
// Lắng nghe Enter để chốt polygon.
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
finishDrawing();
|
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);
|
map.on("mousemove", onMove);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelDrawing();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelDrawing,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
4
lib/engine/engineTypes.ts
Normal file
4
lib/engine/engineTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { EditorMode } from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
export type ModeGetter = () => EditorMode;
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -124,7 +123,7 @@ export function initLine(
|
|||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -133,4 +132,9 @@ export function initLine(
|
|||||||
map.getCanvas().style.cursor = "";
|
map.getCanvas().style.cursor = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelLine,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -126,13 +125,18 @@ export function initPath(
|
|||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
clearPreview();
|
cancelPath();
|
||||||
if (map.getCanvas().style.cursor === "crosshair") {
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
map.getCanvas().style.cursor = "";
|
map.getCanvas().style.cursor = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
cancel: cancelPath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine thêm point bằng click đơn.
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
export function initPoint(
|
export function initPoint(
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
|
import type { ModeGetter } from "@/lib/engine/engineTypes";
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
||||||
|
|
||||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
export function initSelect(
|
export function initSelect(
|
||||||
@@ -31,12 +30,14 @@ export function initSelect(
|
|||||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
// 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;
|
if (!selectedIds.size) return;
|
||||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||||
selectedIds.clear();
|
selectedIds.clear();
|
||||||
|
if (emit) {
|
||||||
onSelectId?.(null);
|
onSelectId?.(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
// 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) {
|
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||||
@@ -144,15 +145,21 @@ export function initSelect(
|
|||||||
map.on("contextmenu", onRightClick);
|
map.on("contextmenu", onRightClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
if (hasContextActions) {
|
if (hasContextActions) {
|
||||||
map.off("contextmenu", onRightClick);
|
map.off("contextmenu", onRightClick);
|
||||||
}
|
}
|
||||||
|
clearSelection(false);
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanup,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
|
||||||
// Ẩn và dọn dẹp context menu hiện tại.
|
// Ẩn và dọn dẹp context menu hiện tại.
|
||||||
function hideContextMenu() {
|
function hideContextMenu() {
|
||||||
if (contextMenu) {
|
if (contextMenu) {
|
||||||
14
lib/geo/constants.ts
Normal file
14
lib/geo/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { FeatureCollection } from "@/types/geo";
|
||||||
|
|
||||||
|
export const WORLD_BBOX = {
|
||||||
|
minLng: -180,
|
||||||
|
minLat: -90,
|
||||||
|
maxLng: 180,
|
||||||
|
maxLat: 90,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
13
lib/map/constants.ts
Normal file
13
lib/map/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||||
|
export const POINT_ICON_URL = "/point.png";
|
||||||
|
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
|
|
||||||
|
export const MAP_MIN_ZOOM = 1;
|
||||||
|
export const MAP_MAX_ZOOM = 10;
|
||||||
|
|
||||||
|
export const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
|
|
||||||
|
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
|
||||||
|
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
|
||||||
76
lib/map/style.ts
Normal file
76
lib/map/style.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"coalesce",
|
||||||
|
["get", "MAPCOLOR7"],
|
||||||
|
["get", "MAPCOLOR9"],
|
||||||
|
["get", "scalerank"],
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"match",
|
||||||
|
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||||
|
1, "#ef4444",
|
||||||
|
2, "#f97316",
|
||||||
|
3, "#f59e0b",
|
||||||
|
4, "#22c55e",
|
||||||
|
5, "#06b6d4",
|
||||||
|
6, "#3b82f6",
|
||||||
|
7, "#8b5cf6",
|
||||||
|
8, "#a855f7",
|
||||||
|
9, "#d946ef",
|
||||||
|
10, "#14b8a6",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#2563eb",
|
||||||
|
state: "#0ea5e9",
|
||||||
|
empire: "#f59e0b",
|
||||||
|
kingdom: "#d97706",
|
||||||
|
war: "#dc2626",
|
||||||
|
battle: "#f43f5e",
|
||||||
|
civilization: "#14b8a6",
|
||||||
|
rebellion_zone: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#1e3a8a",
|
||||||
|
state: "#0c4a6e",
|
||||||
|
empire: "#7c2d12",
|
||||||
|
kingdom: "#9a3412",
|
||||||
|
war: "#7f1d1d",
|
||||||
|
battle: "#9f1239",
|
||||||
|
civilization: "#134e4a",
|
||||||
|
rebellion_zone: "#4c1d95",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||||
|
war: 0.3,
|
||||||
|
battle: 0.34,
|
||||||
|
civilization: 0.38,
|
||||||
|
rebellion_zone: 0.32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
defense_line: "#f97316",
|
||||||
|
attack_route: "#ef4444",
|
||||||
|
retreat_route: "#94a3b8",
|
||||||
|
invasion_route: "#b91c1c",
|
||||||
|
migration_route: "#0ea5e9",
|
||||||
|
refugee_route: "#06b6d4",
|
||||||
|
trade_route: "#eab308",
|
||||||
|
shipping_route: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||||
|
attack_route: true,
|
||||||
|
retreat_route: true,
|
||||||
|
invasion_route: true,
|
||||||
|
migration_route: true,
|
||||||
|
refugee_route: true,
|
||||||
|
trade_route: true,
|
||||||
|
shipping_route: true,
|
||||||
|
};
|
||||||
|
|
||||||
25
lib/timeline.ts
Normal file
25
lib/timeline.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||||
|
|
||||||
|
// Single source of truth for the app-wide timeline range.
|
||||||
|
export const FIXED_TIMELINE_START_YEAR = -2000;
|
||||||
|
export const FIXED_TIMELINE_END_YEAR = 2000;
|
||||||
|
|
||||||
|
export const FIXED_TIMELINE_RANGE: TimelineRange = {
|
||||||
|
min: FIXED_TIMELINE_START_YEAR,
|
||||||
|
max: FIXED_TIMELINE_END_YEAR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI debounce when user drags timeline before triggering data fetch.
|
||||||
|
export const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
export function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||||
|
const lower = Math.min(minYear, maxYear);
|
||||||
|
const upper = Math.max(minYear, maxYear);
|
||||||
|
if (year < lower) return lower;
|
||||||
|
if (year > upper) return upper;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampYearToFixedRange(year: number): number {
|
||||||
|
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
|
||||||
|
}
|
||||||
51
lib/useEditorSessionState.ts
Normal file
51
lib/useEditorSessionState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/types/geo";
|
||||||
export type Geometry =
|
export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||||
| { 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// State trung tâm của editor:
|
// State trung tâm của editor:
|
||||||
// - draft: dữ liệu nguồn để render UI
|
// - draft: dữ liệu nguồn để render UI
|
||||||
// - changes: map các thay đổi chờ lưu
|
// - changes: map các thay đổi chờ lưu
|
||||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
export function useEditorState(initialData: FeatureCollection) {
|
export function useEditorState(initialData: FeatureCollection) {
|
||||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
|
||||||
|
|
||||||
// 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>>(
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||||
buildInitialMap(initialData)
|
buildInitialMap(initialData)
|
||||||
);
|
);
|
||||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
// Version counter để ép diff recalculation sau khi reset/clear baseline.
|
||||||
|
|
||||||
// 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.
|
|
||||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
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 changes = useMemo(() => {
|
||||||
const baseline = initialMapRef.current;
|
const baseline = initialMapRef.current;
|
||||||
return diffDraftToInitial(draft, baseline);
|
return diffDraftToInitial(draft, baseline);
|
||||||
@@ -168,20 +93,6 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
}, [draft, baselineVersion]);
|
}, [draft, baselineVersion]);
|
||||||
const changeCount = useMemo(() => changes.size, [changes]);
|
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) {
|
function createFeature(feature: Feature) {
|
||||||
const featureClone = deepClone(feature);
|
const featureClone = deepClone(feature);
|
||||||
commitDraft({
|
commitDraft({
|
||||||
@@ -191,15 +102,15 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
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(
|
function patchFeatureProperties(
|
||||||
id: FeatureProperties["id"],
|
id: FeatureProperties["id"],
|
||||||
patch: Partial<FeatureProperties>
|
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;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||||
nextFeatures[idx] = {
|
nextFeatures[idx] = {
|
||||||
...nextFeatures[idx],
|
...nextFeatures[idx],
|
||||||
properties: {
|
properties: {
|
||||||
@@ -207,101 +118,53 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
...deepClone(patch),
|
...deepClone(patch),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUndo({ type: "properties", id, prevProperties });
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
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) {
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
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; // nothing to update
|
if (idx === -1) return;
|
||||||
|
|
||||||
const prevFeature = draftRef.current.features[idx];
|
const prevFeature = draftRef.current.features[idx];
|
||||||
const prevGeometry = prevFeature.geometry;
|
const prevGeometry = deepClone(prevFeature.geometry);
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
const updatedFeature = {
|
nextFeatures[idx] = {
|
||||||
...prevFeature,
|
...prevFeature,
|
||||||
geometry: deepClone(newGeometry),
|
geometry: deepClone(newGeometry),
|
||||||
};
|
};
|
||||||
|
|
||||||
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
|
pushUndo({ type: "update", id, prevGeometry });
|
||||||
|
|
||||||
const nextFeatures = [...draftRef.current.features];
|
|
||||||
nextFeatures[idx] = updatedFeature;
|
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Xóa feature khỏi draft và ghi nhận thao tác delete.
|
|
||||||
function deleteFeature(id: FeatureProperties["id"]) {
|
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;
|
if (idx === -1) return;
|
||||||
|
|
||||||
const feature = draftRef.current.features[idx];
|
const feature = draftRef.current.features[idx];
|
||||||
|
|
||||||
// store undo
|
|
||||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
|
||||||
|
|
||||||
const nextFeatures = [...draftRef.current.features];
|
const nextFeatures = [...draftRef.current.features];
|
||||||
nextFeatures.splice(idx, 1);
|
nextFeatures.splice(idx, 1);
|
||||||
|
|
||||||
|
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
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[] {
|
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() {
|
function clearChanges() {
|
||||||
setUndoStack([]);
|
clearUndo();
|
||||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
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"]) {
|
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||||
return initialMapRef.current.has(id);
|
return initialMapRef.current.has(id);
|
||||||
}
|
}
|
||||||
|
|||||||
15
types/api.ts
Normal file
15
types/api.ts
Normal 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
26
types/entities.ts
Normal 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
70
types/geo.ts
Normal 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
104
types/sections.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user