refactor
This commit is contained in:
@@ -1,17 +1,8 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { requestJson } from "@/api/http";
|
||||
import type { Entity } from "@/types/entities";
|
||||
|
||||
export type Entity = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
type_id?: string | null;
|
||||
status?: number | null;
|
||||
geometry_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
export type { Entity } from "@/types/entities";
|
||||
|
||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { requestJson } from "@/api/http";
|
||||
import { FeatureCollection } from "@/lib/useEditorState";
|
||||
import type { GeometriesBBoxQuery } from "@/types/api";
|
||||
import type { FeatureCollection } from "@/types/geo";
|
||||
|
||||
export type GeometriesBBoxQuery = {
|
||||
minLng: number;
|
||||
minLat: number;
|
||||
maxLng: number;
|
||||
maxLat: number;
|
||||
time?: number;
|
||||
entity_id?: string;
|
||||
};
|
||||
export type { GeometriesBBoxQuery } from "@/types/api";
|
||||
|
||||
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||
const query = new URLSearchParams({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ApiEnvelope } from "@/types/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: string;
|
||||
@@ -12,13 +14,6 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
status: "success" | "error" | string;
|
||||
data: T;
|
||||
message: string;
|
||||
errors: unknown[];
|
||||
};
|
||||
|
||||
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(input, init);
|
||||
const payload = await parseJsonResponse(res);
|
||||
|
||||
105
api/sections.ts
105
api/sections.ts
@@ -1,81 +1,26 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { jsonRequestInit, requestJson } from "@/api/http";
|
||||
import type {
|
||||
CreateCommitInput,
|
||||
CreateSectionInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Section,
|
||||
SectionCommit,
|
||||
SectionState,
|
||||
SectionSubmission,
|
||||
} from "@/types/sections";
|
||||
|
||||
export type SectionState = {
|
||||
section_id?: string;
|
||||
status: "editing" | "submitted" | "approved" | "rejected";
|
||||
head_commit_id: string | null;
|
||||
version: number;
|
||||
locked_by: string | null;
|
||||
locked_at: string | null;
|
||||
lock_expires_at: string | null;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type Section = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
user_id: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
state: Omit<SectionState, "section_id" | "updated_at">;
|
||||
};
|
||||
|
||||
export type SectionCommit = {
|
||||
id: string;
|
||||
section_id: string;
|
||||
parent_commit_id: string | null;
|
||||
commit_no: number;
|
||||
kind: "manual" | "restore";
|
||||
restored_from_commit_id: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
title: string | null;
|
||||
note: string | null;
|
||||
snapshot_hash: string | null;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
|
||||
export type SectionSubmission = {
|
||||
id: string;
|
||||
section_id: string;
|
||||
commit_id: string;
|
||||
submitted_by: string;
|
||||
submitted_at: string;
|
||||
status: "pending" | "approved" | "rejected" | "conflicted";
|
||||
reviewed_by: string | null;
|
||||
reviewed_at: string | null;
|
||||
review_note: string | null;
|
||||
snapshot_hash: string | null;
|
||||
snapshot?: unknown;
|
||||
};
|
||||
|
||||
export type EditorLoadResponse = {
|
||||
section: Section;
|
||||
state: SectionState;
|
||||
commit: SectionCommit | null;
|
||||
snapshot: unknown;
|
||||
};
|
||||
|
||||
export type CreateSectionInput = {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
user_id?: string;
|
||||
created_by?: string;
|
||||
};
|
||||
|
||||
export type CreateCommitInput = {
|
||||
snapshot: unknown;
|
||||
created_by?: string;
|
||||
user_id?: string;
|
||||
expected_version?: number;
|
||||
expected_head_commit_id?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
};
|
||||
export type {
|
||||
CreateCommitInput,
|
||||
CreateSectionInput,
|
||||
EditorLoadResponse,
|
||||
RestoreCommitInput,
|
||||
Section,
|
||||
SectionCommit,
|
||||
SectionState,
|
||||
SectionSubmission,
|
||||
} from "@/types/sections";
|
||||
|
||||
export async function fetchSections(): Promise<Section[]> {
|
||||
return requestJson<Section[]>(API_ENDPOINTS.sections);
|
||||
@@ -126,15 +71,7 @@ export async function fetchSectionCommits(
|
||||
|
||||
export async function restoreSectionCommit(
|
||||
sectionId: string,
|
||||
input: {
|
||||
commit_id: string;
|
||||
created_by?: string;
|
||||
user_id?: string;
|
||||
expected_version?: number;
|
||||
expected_head_commit_id?: string | null;
|
||||
title?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
input: RestoreCommitInput
|
||||
): Promise<{ commit: SectionCommit; state: SectionState }> {
|
||||
return requestJson<{ commit: SectionCommit; state: SectionState }>(
|
||||
sectionUrl(sectionId, "restore"),
|
||||
|
||||
1182
app/editor/page.tsx
1182
app/editor/page.tsx
File diff suppressed because it is too large
Load Diff
177
app/page.tsx
177
app/page.tsx
@@ -9,12 +9,16 @@ import { fetchGeometriesByBBox } from "@/api/geometries";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
||||
import {
|
||||
loadBackgroundLayerVisibilityFromStorage,
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/lib/editor/background/backgroundVisibilityStorage";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
const WORLD_BBOX = {
|
||||
@@ -25,11 +29,10 @@ const WORLD_BBOX = {
|
||||
} as const;
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
||||
min: CURRENT_YEAR - 5000,
|
||||
max: CURRENT_YEAR + 100,
|
||||
min: -2000,
|
||||
max: 2000,
|
||||
};
|
||||
const TIMELINE_DEBOUNCE_MS = 180;
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
|
||||
type TimelineRange = {
|
||||
min: number;
|
||||
@@ -39,16 +42,12 @@ type TimelineRange = {
|
||||
export default function Page() {
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [isTimelineReady, setIsTimelineReady] = useState(false);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
@@ -75,53 +74,6 @@ export default function Page() {
|
||||
}, [initialData, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadTimelineBounds() {
|
||||
setIsTimelineLoading(true);
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
});
|
||||
if (disposed) return;
|
||||
|
||||
const range = deriveTimelineRange(data);
|
||||
const initialYear = clampYear(CURRENT_YEAR, range);
|
||||
|
||||
setTimelineRange(range);
|
||||
setTimelineWindowStart(range.min);
|
||||
setTimelineWindowEnd(range.max);
|
||||
setTimelineYear(initialYear);
|
||||
setTimelineDraftYear(initialYear);
|
||||
setTimelineStatus(null);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
console.error("Load timeline bounds failed", err);
|
||||
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineRange(FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
|
||||
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
|
||||
setTimelineYear(fallbackYear);
|
||||
setTimelineDraftYear(fallbackYear);
|
||||
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsTimelineLoading(false);
|
||||
setIsTimelineReady(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadTimelineBounds();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (timelineDraftYear !== timelineYear) {
|
||||
setTimelineYear(timelineDraftYear);
|
||||
@@ -129,7 +81,7 @@ export default function Page() {
|
||||
}, TIMELINE_DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||
}, [timelineDraftYear, timelineYear]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
@@ -137,14 +89,6 @@ export default function Page() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
|
||||
}, [timelineWindowStart, timelineWindowEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++timelineFetchRequestRef.current;
|
||||
|
||||
@@ -182,7 +126,7 @@ export default function Page() {
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
}, [timelineYear]);
|
||||
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||
@@ -194,7 +138,7 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
|
||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
@@ -209,23 +153,10 @@ export default function Page() {
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
|
||||
};
|
||||
|
||||
const handleTimelineWindowEndChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
|
||||
};
|
||||
|
||||
const handleTimelineYearChange = (nextYear: number) => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||
setTimelineDraftYear(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
|
||||
};
|
||||
|
||||
const timelineDisabled = !isTimelineReady;
|
||||
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
||||
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
||||
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
||||
@@ -248,16 +179,10 @@ export default function Page() {
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
<TimelineBar
|
||||
minYear={timelineRange.min}
|
||||
maxYear={timelineRange.max}
|
||||
windowStartYear={timelineWindowStart}
|
||||
windowEndYear={timelineWindowEnd}
|
||||
onWindowStartYearChange={handleTimelineWindowStartChange}
|
||||
onWindowEndYearChange={handleTimelineWindowEndChange}
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={timelineDisabled}
|
||||
disabled={false}
|
||||
statusText={timelineStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -325,34 +250,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);
|
||||
}
|
||||
@@ -432,53 +329,3 @@ function normalizeTypeId(value: unknown): string | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
if (typeof window === "undefined") {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
} catch (err) {
|
||||
console.warn("Load background layer visibility from storage failed", err);
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
}
|
||||
|
||||
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||
JSON.stringify(visibility)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Persist background layer visibility failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const source = raw as Record<string, unknown>;
|
||||
const next: BackgroundLayerVisibility = {
|
||||
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
};
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
const value = source[layer.id];
|
||||
if (typeof value === "boolean") {
|
||||
next[layer.id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -627,6 +627,8 @@ function formatUndoLabel(action: UndoAction) {
|
||||
return `Xóa #${action.feature.properties.id}`;
|
||||
case "update":
|
||||
return `Chỉnh sửa #${action.id}`;
|
||||
case "properties":
|
||||
return `Cập nhật thuộc tính #${action.id}`;
|
||||
default:
|
||||
return "Tác vụ";
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
|
||||
import { initDrawing } from "@/lib/drawingEngine";
|
||||
import { initSelect } from "@/lib/selectingEngine";
|
||||
import { initPoint } from "@/lib/pointEngine";
|
||||
import { initLine } from "@/lib/lineEngine";
|
||||
import { initPath } from "@/lib/pathEngine";
|
||||
import { initCircle } from "@/lib/circleEngine";
|
||||
import { createEditingEngine } from "@/lib/editingEngine";
|
||||
import { initDrawing } from "@/lib/engine/drawingEngine";
|
||||
import { initSelect } from "@/lib/engine/selectingEngine";
|
||||
import { initPoint } from "@/lib/engine/pointEngine";
|
||||
import { initLine } from "@/lib/engine/lineEngine";
|
||||
import { initPath } from "@/lib/engine/pathEngine";
|
||||
import { initCircle } from "@/lib/engine/circleEngine";
|
||||
import { createEditingEngine } from "@/lib/engine/editingEngine";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||
|
||||
@@ -31,6 +31,12 @@ type MapProps = {
|
||||
fitBoundsKey?: string | number | null;
|
||||
};
|
||||
|
||||
type EngineBinding = {
|
||||
cleanup: () => void;
|
||||
cancel?: () => void;
|
||||
clearSelection?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
const POINT_ICON_URL = "/point.png";
|
||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||
@@ -148,14 +154,28 @@ export default function Map({
|
||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||
const fitBoundsAppliedRef = useRef(false);
|
||||
const mapCleanupFnsRef = useRef<Array<() => void>>([]);
|
||||
const engineBindingsRef = useRef<Partial<Record<MapProps["mode"], EngineBinding>>>({});
|
||||
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
||||
|
||||
useEffect(() => {
|
||||
modeRef.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousMode = previousModeRef.current;
|
||||
if (previousMode !== mode) {
|
||||
engineBindingsRef.current[previousMode]?.cancel?.();
|
||||
previousModeRef.current = mode;
|
||||
}
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (mode !== "draw") {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-line") {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
@@ -184,6 +204,12 @@ export default function Map({
|
||||
selectedFeatureIdRef.current = selectedFeatureId;
|
||||
}, [selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select" || selectedFeatureId === null) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}, [mode, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
fitBoundsAppliedRef.current = false;
|
||||
}, [fitBoundsKey]);
|
||||
@@ -791,7 +817,7 @@ export default function Map({
|
||||
addPointSymbolLayer(map);
|
||||
|
||||
// init drawing
|
||||
const cleanup = initDrawing(
|
||||
const drawingEngine = initDrawing(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
@@ -813,7 +839,7 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupSelect = initSelect(
|
||||
const selectEngine = initSelect(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
allowGeometryEditing
|
||||
@@ -852,7 +878,7 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupLine = initLine(
|
||||
const lineEngine = initLine(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
@@ -874,7 +900,7 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupPath = initPath(
|
||||
const pathEngine = initPath(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
@@ -896,7 +922,7 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupCircle = initCircle(
|
||||
const circleEngine = initCircle(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
@@ -918,13 +944,21 @@ export default function Map({
|
||||
}
|
||||
);
|
||||
|
||||
engineBindingsRef.current = {
|
||||
draw: drawingEngine,
|
||||
select: selectEngine,
|
||||
"add-line": lineEngine,
|
||||
"add-path": pathEngine,
|
||||
"add-circle": circleEngine,
|
||||
};
|
||||
|
||||
mapCleanupFnsRef.current = [
|
||||
cleanupCircle,
|
||||
cleanupPath,
|
||||
cleanupLine,
|
||||
circleEngine.cleanup,
|
||||
pathEngine.cleanup,
|
||||
lineEngine.cleanup,
|
||||
cleanupPoint,
|
||||
cleanupSelect,
|
||||
cleanup,
|
||||
selectEngine.cleanup,
|
||||
drawingEngine.cleanup,
|
||||
() => map.off("zoom", syncZoomLevel),
|
||||
];
|
||||
|
||||
@@ -941,6 +975,7 @@ export default function Map({
|
||||
cleanupFn();
|
||||
}
|
||||
mapCleanupFnsRef.current = [];
|
||||
engineBindingsRef.current = {};
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
type Props = {
|
||||
minYear: number;
|
||||
maxYear: number;
|
||||
windowStartYear: number;
|
||||
windowEndYear: number;
|
||||
onWindowStartYearChange: (year: number) => void;
|
||||
onWindowEndYearChange: (year: number) => void;
|
||||
year: number;
|
||||
onYearChange: (year: number) => void;
|
||||
isLoading: boolean;
|
||||
@@ -14,34 +8,28 @@ type Props = {
|
||||
statusText?: string | null;
|
||||
};
|
||||
|
||||
const FIXED_TIMELINE_START_YEAR = -2000;
|
||||
const FIXED_TIMELINE_END_YEAR = 2000;
|
||||
|
||||
export default function TimelineBar({
|
||||
minYear,
|
||||
maxYear,
|
||||
windowStartYear,
|
||||
windowEndYear,
|
||||
onWindowStartYearChange,
|
||||
onWindowEndYearChange,
|
||||
year,
|
||||
onYearChange,
|
||||
isLoading,
|
||||
disabled,
|
||||
statusText,
|
||||
}: Props) {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
const globalLocked = lower === upper;
|
||||
const effectiveDisabled = disabled || globalLocked;
|
||||
const safeWindowStart = clampYear(windowStartYear, lower, upper);
|
||||
const safeWindowEnd = clampYear(windowEndYear, safeWindowStart, upper);
|
||||
const windowLocked = safeWindowStart === safeWindowEnd;
|
||||
const safeYear = clampYear(year, safeWindowStart, safeWindowEnd);
|
||||
const pointDisabled = effectiveDisabled || windowLocked;
|
||||
const windowStartPercent = toPercent(safeWindowStart, lower, upper);
|
||||
const windowEndPercent = toPercent(safeWindowEnd, lower, upper);
|
||||
const lower = FIXED_TIMELINE_START_YEAR;
|
||||
const upper = FIXED_TIMELINE_END_YEAR;
|
||||
const effectiveDisabled = disabled;
|
||||
const safeYear = clampYear(year, lower, upper);
|
||||
|
||||
const helperText = isLoading
|
||||
? "Đang tải geometry theo mốc thời gian..."
|
||||
: statusText || (windowLocked ? "Khoảng lớn đang thu về một mốc duy nhất." : "Kéo mốc nhỏ để query trong khoảng lớn.");
|
||||
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
|
||||
|
||||
const handleYearChange = (nextYear: number) => {
|
||||
onYearChange(clampYear(Math.trunc(nextYear), lower, upper));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -76,86 +64,54 @@ export default function TimelineBar({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "12px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||
Khoảng thời gian lớn
|
||||
</div>
|
||||
<div
|
||||
className="dual-range"
|
||||
style={{
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="dual-range-track" />
|
||||
<div
|
||||
className="dual-range-selected"
|
||||
style={{
|
||||
left: `${windowStartPercent}%`,
|
||||
width: `${Math.max(windowEndPercent - windowStartPercent, 0)}%`,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="dual-range-input"
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeWindowStart}
|
||||
onChange={(event) =>
|
||||
onWindowStartYearChange(
|
||||
Math.min(Number(event.target.value), safeWindowEnd)
|
||||
)
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline window start"
|
||||
/>
|
||||
<input
|
||||
className="dual-range-input"
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeWindowEnd}
|
||||
onChange={(event) =>
|
||||
onWindowEndYearChange(
|
||||
Math.max(Number(event.target.value), safeWindowStart)
|
||||
)
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline window end"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "6px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "12px",
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
<span>{formatYear(safeWindowStart)}</span>
|
||||
<span>{formatYear(safeWindowEnd)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
|
||||
Mốc thời gian chi tiết
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) 120px",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={safeWindowStart}
|
||||
max={safeWindowEnd}
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => onYearChange(Number(event.target.value))}
|
||||
disabled={pointDisabled}
|
||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline year"
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "#22c55e",
|
||||
cursor: pointDisabled ? "not-allowed" : "pointer",
|
||||
opacity: pointDisabled ? 0.6 : 1,
|
||||
cursor: effectiveDisabled ? "not-allowed" : "pointer",
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => handleYearChange(Number(event.target.value))}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline exact year"
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 8px",
|
||||
background: "rgba(15, 23, 42, 0.7)",
|
||||
color: "#f8fafc",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -167,85 +123,12 @@ export default function TimelineBar({
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#94a3b8" }}>{formatYear(safeWindowStart)}</span>
|
||||
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
|
||||
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
||||
{helperText}
|
||||
</span>
|
||||
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(safeWindowEnd)}</span>
|
||||
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.dual-range {
|
||||
position: relative;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dual-range-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.dual-range-selected {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.dual-range-input {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.dual-range-input::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dual-range-input::-webkit-slider-thumb {
|
||||
pointer-events: auto;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #0f172a;
|
||||
background: #22c55e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dual-range-input::-moz-range-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dual-range-input::-moz-range-thumb {
|
||||
pointer-events: auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #0f172a;
|
||||
background: #22c55e;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -262,8 +145,3 @@ function formatYear(year: number): string {
|
||||
}
|
||||
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"] };
|
||||
|
||||
29
lib/editor/draft/useDraftState.ts
Normal file
29
lib/editor/draft/useDraftState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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) {
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
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,
|
||||
};
|
||||
}
|
||||
80
lib/editor/draft/useUndoStack.ts
Normal file
80
lib/editor/draft/useUndoStack.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
20
lib/editor/session/useBackgroundSessionState.ts
Normal file
20
lib/editor/session/useBackgroundSessionState.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BackgroundLayerVisibility,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
|
||||
export function useBackgroundSessionState() {
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
|
||||
return {
|
||||
backgroundVisibility,
|
||||
setBackgroundVisibility,
|
||||
isBackgroundVisibilityReady,
|
||||
setIsBackgroundVisibilityReady,
|
||||
};
|
||||
}
|
||||
|
||||
66
lib/editor/session/useEntitySessionState.ts
Normal file
66
lib/editor/session/useEntitySessionState.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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() {
|
||||
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
|
||||
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
|
||||
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
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,
|
||||
};
|
||||
}
|
||||
74
lib/editor/session/useSectionSessionState.ts
Normal file
74
lib/editor/session/useSectionSessionState.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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) {
|
||||
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]);
|
||||
|
||||
const [availableSections, setAvailableSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState("");
|
||||
const [newSectionTitle, setNewSectionTitle] = useState("");
|
||||
const [commitTitle, setCommitTitle] = useState("");
|
||||
const [commitNote, setCommitNote] = useState("");
|
||||
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
|
||||
const [activeSection, setActiveSection] = useState<Section | null>(null);
|
||||
const [sectionState, setSectionState] = useState<SectionState | null>(null);
|
||||
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
45
lib/editor/session/useTimelineState.ts
Normal file
45
lib/editor/session/useTimelineState.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import type { TimelineRange } from "@/lib/editor/session/sessionTypes";
|
||||
|
||||
type Options = {
|
||||
currentYear: number;
|
||||
fallbackTimelineRange: TimelineRange;
|
||||
};
|
||||
|
||||
export function useTimelineState(options: Options) {
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(
|
||||
options.currentYear,
|
||||
options.fallbackTimelineRange.min,
|
||||
options.fallbackTimelineRange.max
|
||||
)
|
||||
);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
|
||||
return {
|
||||
timelineYear,
|
||||
setTimelineYear,
|
||||
timelineDraftYear,
|
||||
setTimelineDraftYear,
|
||||
isTimelineLoading,
|
||||
setIsTimelineLoading,
|
||||
timelineStatus,
|
||||
setTimelineStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
if (year < lower) return lower;
|
||||
if (year > upper) return upper;
|
||||
return year;
|
||||
}
|
||||
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));
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function initCircle(
|
||||
map.on("mouseup", onMouseUp);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("mousedown", onMouseDown);
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseup", onMouseUp);
|
||||
@@ -158,6 +158,11 @@ export function initCircle(
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: resetDrawingState,
|
||||
};
|
||||
}
|
||||
|
||||
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
|
||||
@@ -11,6 +11,18 @@ export function initDrawing(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
};
|
||||
|
||||
const cancelDrawing = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||
function closePolygon(c: [number, number][]) {
|
||||
if (c.length < 3) return c;
|
||||
@@ -71,19 +83,30 @@ export function initDrawing(
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
|
||||
coords = [];
|
||||
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
cancelDrawing();
|
||||
}
|
||||
|
||||
// Lắng nghe Enter để chốt polygon.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelDrawing();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
coords = coords.slice(0, -1);
|
||||
if (coords.length) {
|
||||
update(coords);
|
||||
} else {
|
||||
clearPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +114,15 @@ export function initDrawing(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelDrawing();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelDrawing,
|
||||
};
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function initLine(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
@@ -133,4 +133,9 @@ export function initLine(
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelLine,
|
||||
};
|
||||
}
|
||||
@@ -126,13 +126,18 @@ export function initPath(
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
clearPreview();
|
||||
cancelPath();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
cancel: cancelPath,
|
||||
};
|
||||
}
|
||||
@@ -31,12 +31,14 @@ export function initSelect(
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||
function clearSelection() {
|
||||
function clearSelection(emit = true) {
|
||||
if (!selectedIds.size) return;
|
||||
selectedIds.forEach((id) => setSelectionStateForId(id, false));
|
||||
selectedIds.clear();
|
||||
if (emit) {
|
||||
onSelectId?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||
@@ -144,15 +146,21 @@ export function initSelect(
|
||||
map.on("contextmenu", onRightClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const cleanup = () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (hasContextActions) {
|
||||
map.off("contextmenu", onRightClick);
|
||||
}
|
||||
clearSelection(false);
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
clearSelection,
|
||||
};
|
||||
|
||||
// Ẩn và dọn dẹp context menu hiện tại.
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
49
lib/useEditorSessionState.ts
Normal file
49
lib/useEditorSessionState.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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) {
|
||||
const [mode, setMode] = useState<EditorMode>("idle");
|
||||
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,89 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
FeatureProperties,
|
||||
Geometry,
|
||||
} from "@/types/geo";
|
||||
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/lib/editor/draft/draftDiff";
|
||||
import { useDraftState } from "@/lib/editor/draft/useDraftState";
|
||||
import { useUndoStack } from "@/lib/editor/draft/useUndoStack";
|
||||
import type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||
|
||||
// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
| { type: "LineString"; coordinates: [number, number][] }
|
||||
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: string | number;
|
||||
type?: string | null;
|
||||
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
type: "Feature";
|
||||
properties: FeatureProperties;
|
||||
geometry: Geometry;
|
||||
};
|
||||
|
||||
export type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
|
||||
export type Change =
|
||||
| { action: "create"; feature: Feature }
|
||||
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||
| { action: "delete"; id: FeatureProperties["id"] };
|
||||
|
||||
// Kiểu bản ghi undo tối thiểu.
|
||||
export type UndoAction =
|
||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||
| { type: "delete"; feature: Feature }
|
||||
| { type: "create"; id: FeatureProperties["id"] };
|
||||
|
||||
// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ.
|
||||
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
// So sánh hai geometry theo nội dung tuần tự JSON.
|
||||
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
|
||||
JSON.stringify(a.properties) === JSON.stringify(b.properties);
|
||||
}
|
||||
|
||||
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||
function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Feature>();
|
||||
for (const f of fc.features) {
|
||||
map.set(f.properties.id, deepClone(f));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
|
||||
function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Feature>
|
||||
) {
|
||||
const next = new Map<FeatureProperties["id"], Change>();
|
||||
|
||||
// track which initial ids are still present
|
||||
const seen = new Set<FeatureProperties["id"]>();
|
||||
|
||||
// additions & updates
|
||||
for (const f of draft.features) {
|
||||
const id = f.properties.id;
|
||||
seen.add(id);
|
||||
const initialFeature = initialMap.get(id);
|
||||
if (!initialFeature) {
|
||||
next.set(id, { action: "create", feature: deepClone(f) });
|
||||
} else if (!featureEquals(initialFeature, f)) {
|
||||
next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
|
||||
}
|
||||
}
|
||||
|
||||
// deletions
|
||||
for (const [id] of initialMap.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
next.set(id, { action: "delete", id });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng).
|
||||
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
if (!a) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
switch (a.type) {
|
||||
case "create": {
|
||||
const next = b as Extract<UndoAction, { type: "create" }>;
|
||||
return a.id === next.id;
|
||||
}
|
||||
case "delete": {
|
||||
const next = b as Extract<UndoAction, { type: "delete" }>;
|
||||
return (
|
||||
a.feature.properties.id === next.feature.properties.id &&
|
||||
geometryEquals(a.feature.geometry, next.feature.geometry)
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
const next = b as Extract<UndoAction, { type: "update" }>;
|
||||
return (
|
||||
a.id === next.id &&
|
||||
geometryEquals(a.prevGeometry, next.prevGeometry)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/types/geo";
|
||||
export type { Change, UndoAction } from "@/lib/editor/draft/editorTypes";
|
||||
|
||||
// State trung tâm của editor:
|
||||
// - draft: dữ liệu nguồn để render UI
|
||||
// - changes: map các thay đổi chờ lưu
|
||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||
export function useEditorState(initialData: FeatureCollection) {
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
|
||||
|
||||
// baseline to know what is "saved" state
|
||||
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
|
||||
buildInitialMap(initialData)
|
||||
);
|
||||
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||
|
||||
// central entrypoint: keep draftRef + React state in sync
|
||||
const commitDraft = (nextDraft: FeatureCollection) => {
|
||||
const cloned = deepClone(nextDraft);
|
||||
draftRef.current = cloned;
|
||||
setDraft(cloned);
|
||||
};
|
||||
|
||||
// reset when initialData changes (e.g., after first load or after refresh)
|
||||
useEffect(() => {
|
||||
commitDraft(deepClone(initialData));
|
||||
setUndoStack([]);
|
||||
initialMapRef.current = buildInitialMap(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
// derive pending changes on every render: source of truth for save + changeCount.
|
||||
// read baseline from state captured in closure to avoid ref access during render.
|
||||
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||
|
||||
const applyUndoAction = useCallback((action: UndoAction): boolean => {
|
||||
switch (action.type) {
|
||||
case "create": {
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: draftRef.current.features.filter((feature) =>
|
||||
feature.properties.id !== action.id
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "delete": {
|
||||
const feature = deepClone(action.feature);
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: [...draftRef.current.features, feature],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "update": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
geometry: deepClone(action.prevGeometry),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
case "properties": {
|
||||
const idx = draftRef.current.features.findIndex((feature) =>
|
||||
feature.properties.id === action.id
|
||||
);
|
||||
if (idx === -1) return false;
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: deepClone(action.prevProperties),
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [commitDraft, draftRef]);
|
||||
|
||||
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
|
||||
|
||||
useEffect(() => {
|
||||
resetDraft(deepClone(initialData));
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(initialData);
|
||||
setBaselineVersion((version) => version + 1);
|
||||
}, [clearUndo, initialData, resetDraft]);
|
||||
|
||||
const changes = useMemo(() => {
|
||||
const baseline = initialMapRef.current;
|
||||
return diffDraftToInitial(draft, baseline);
|
||||
@@ -168,20 +91,6 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
}, [draft, baselineVersion]);
|
||||
const changeCount = useMemo(() => changes.size, [changes]);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
// Đẩy undo action mới vào stack nếu khác action gần nhất.
|
||||
function pushUndo(action: UndoAction) {
|
||||
setUndoStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp
|
||||
return [...prev, action];
|
||||
});
|
||||
}
|
||||
|
||||
// Thêm feature mới vào draft và ghi nhận thao tác "create".
|
||||
function createFeature(feature: Feature) {
|
||||
const featureClone = deepClone(feature);
|
||||
commitDraft({
|
||||
@@ -191,15 +100,15 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||
}
|
||||
|
||||
// Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
|
||||
function patchFeatureProperties(
|
||||
id: FeatureProperties["id"],
|
||||
patch: Partial<FeatureProperties>
|
||||
) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
const prevProperties = deepClone(nextFeatures[idx].properties);
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: {
|
||||
@@ -207,101 +116,53 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
...deepClone(patch),
|
||||
},
|
||||
};
|
||||
|
||||
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo({ type: "properties", id, prevProperties });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
// Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
|
||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return; // nothing to update
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const prevFeature = draftRef.current.features[idx];
|
||||
const prevGeometry = prevFeature.geometry;
|
||||
|
||||
const updatedFeature = {
|
||||
const prevGeometry = deepClone(prevFeature.geometry);
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...prevFeature,
|
||||
geometry: deepClone(newGeometry),
|
||||
};
|
||||
|
||||
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = updatedFeature;
|
||||
pushUndo({ type: "update", id, prevGeometry });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
// Xóa feature khỏi draft và ghi nhận thao tác delete.
|
||||
function deleteFeature(id: FeatureProperties["id"]) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const feature = draftRef.current.features[idx];
|
||||
|
||||
// store undo
|
||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures.splice(idx, 1);
|
||||
|
||||
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
// Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
|
||||
function undo() {
|
||||
let applied = false; // guards against React StrictMode double invoke of setState updater
|
||||
setUndoStack((prev) => {
|
||||
if (applied) return prev;
|
||||
if (!prev.length) return prev;
|
||||
applied = true;
|
||||
const last = prev[prev.length - 1];
|
||||
const remaining = prev.slice(0, -1);
|
||||
|
||||
switch (last.type) {
|
||||
case "create": {
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: draftRef.current.features.filter((f) => f.properties.id !== last.id),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const feature = deepClone(last.feature);
|
||||
commitDraft({
|
||||
...draftRef.current,
|
||||
features: [...draftRef.current.features, feature],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
const { id, prevGeometry } = last;
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return remaining;
|
||||
const updated = {
|
||||
...draftRef.current.features[idx],
|
||||
geometry: deepClone(prevGeometry),
|
||||
};
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = updated;
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return remaining;
|
||||
});
|
||||
}
|
||||
|
||||
// Dựng mảng payload gửi API save từ map changes hiện tại.
|
||||
function buildPayload(): Change[] {
|
||||
return Array.from(changes.values()).map((c) => deepClone(c));
|
||||
return Array.from(changes.values()).map((change) => deepClone(change));
|
||||
}
|
||||
|
||||
// Xóa thay đổi đang chờ sau khi lưu thành công.
|
||||
function clearChanges() {
|
||||
setUndoStack([]);
|
||||
clearUndo();
|
||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||
setBaselineVersion((v) => v + 1);
|
||||
setBaselineVersion((version) => version + 1);
|
||||
}
|
||||
|
||||
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
|
||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||
return initialMapRef.current.has(id);
|
||||
}
|
||||
|
||||
15
types/api.ts
Normal file
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