This commit is contained in:
taDuc
2026-04-20 23:27:38 +07:00
parent 2508172489
commit 3ca7098831
36 changed files with 1939 additions and 1695 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

26
types/entities.ts Normal file
View File

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

70
types/geo.ts Normal file
View File

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

104
types/sections.ts Normal file
View File

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