preview map editor 60%
This commit is contained in:
@@ -13,6 +13,7 @@ export type GeometriesBBoxQuery = {
|
|||||||
|
|
||||||
export type GeometryCreatePayload = {
|
export type GeometryCreatePayload = {
|
||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
|
type?: string | null;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
binding?: string[];
|
binding?: string[];
|
||||||
@@ -22,6 +23,7 @@ export type GeometryCreatePayload = {
|
|||||||
|
|
||||||
export type GeometryUpdatePayload = {
|
export type GeometryUpdatePayload = {
|
||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
|
type?: string | null;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
binding?: string[];
|
binding?: string[];
|
||||||
|
|||||||
985
app/editor/page.tsx
Normal file
985
app/editor/page.tsx
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Map from "@/components/Map";
|
||||||
|
import Editor from "@/components/Editor";
|
||||||
|
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
||||||
|
import TimelineBar from "@/components/TimelineBar";
|
||||||
|
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
|
||||||
|
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
||||||
|
import { ApiError } from "@/api/http";
|
||||||
|
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||||
|
import {
|
||||||
|
Feature,
|
||||||
|
FeatureCollection,
|
||||||
|
useEditorState,
|
||||||
|
} from "@/lib/useEditorState";
|
||||||
|
import {
|
||||||
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
|
BackgroundLayerId,
|
||||||
|
BackgroundLayerVisibility,
|
||||||
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
|
} from "@/lib/backgroundLayers";
|
||||||
|
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
|
const WORLD_BBOX = {
|
||||||
|
minLng: -180,
|
||||||
|
minLat: -90,
|
||||||
|
maxLng: 180,
|
||||||
|
maxLat: 90,
|
||||||
|
} as const;
|
||||||
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||||
|
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
||||||
|
min: CURRENT_YEAR - 5000,
|
||||||
|
max: CURRENT_YEAR + 100,
|
||||||
|
};
|
||||||
|
const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
|
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||||
|
|
||||||
|
type TimelineRange = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EntityFormState = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeometryMetaFormState = {
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
binding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
|
||||||
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
|
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||||
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | 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);
|
||||||
|
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>(
|
||||||
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
|
);
|
||||||
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
const entitySearchRequestRef = useRef(0);
|
||||||
|
const skipSelectedFeatureSyncRef = useRef(false);
|
||||||
|
|
||||||
|
const editor = useEditorState(initialData);
|
||||||
|
const selectedFeature =
|
||||||
|
selectedFeatureId === null
|
||||||
|
? null
|
||||||
|
: editor.draft.features.find((feature) =>
|
||||||
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
async function loadEntities() {
|
||||||
|
try {
|
||||||
|
const rows = await fetchEntities();
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
setEntities(rows);
|
||||||
|
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed) return;
|
||||||
|
console.error("Load entities failed", err);
|
||||||
|
setEntityStatus("Không tải được danh sách entity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEntities();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = entitySearchQuery.trim();
|
||||||
|
if (!keyword.length) {
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
const requestId = ++entitySearchRequestRef.current;
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setIsEntitySearchLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
||||||
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
|
|
||||||
|
setEntitySearchResults(rows);
|
||||||
|
setSelectedSearchEntityId((prev) =>
|
||||||
|
prev && rows.some((entity) => entity.id === prev)
|
||||||
|
? prev
|
||||||
|
: rows[0]?.id || null
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||||
|
console.error("Search entity by name failed", err);
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === entitySearchRequestRef.current) {
|
||||||
|
setIsEntitySearchLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [entitySearchQuery, selectedFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFeatureId === null) return;
|
||||||
|
const stillExists = editor.draft.features.some((feature) =>
|
||||||
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
|
);
|
||||||
|
if (!stillExists) {
|
||||||
|
setSelectedFeatureId(null);
|
||||||
|
}
|
||||||
|
}, [editor.draft, selectedFeatureId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipSelectedFeatureSyncRef.current) {
|
||||||
|
skipSelectedFeatureSyncRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityForm({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
});
|
||||||
|
setSelectedGeometryEntityIds([]);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
||||||
|
const primaryEntityId = featureEntityIds[0] || null;
|
||||||
|
const linkedEntity = primaryEntityId
|
||||||
|
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||||
|
: null;
|
||||||
|
const nextTypeId =
|
||||||
|
linkedEntity?.type_id ||
|
||||||
|
selectedFeature.properties.type ||
|
||||||
|
selectedFeature.properties.entity_type_id ||
|
||||||
|
getDefaultTypeIdForFeature(selectedFeature);
|
||||||
|
|
||||||
|
setEntityForm({
|
||||||
|
name: "",
|
||||||
|
slug: linkedEntity?.slug || "",
|
||||||
|
type_id: nextTypeId,
|
||||||
|
});
|
||||||
|
setSelectedGeometryEntityIds(featureEntityIds);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: selectedFeature.properties.time_start != null
|
||||||
|
? String(selectedFeature.properties.time_start)
|
||||||
|
: "",
|
||||||
|
time_end: selectedFeature.properties.time_end != null
|
||||||
|
? String(selectedFeature.properties.time_end)
|
||||||
|
: "",
|
||||||
|
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||||
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
if (!featureEntityIds.length) {
|
||||||
|
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
}
|
||||||
|
}, [selectedFeature, entities]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, TIMELINE_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
|
setIsBackgroundVisibilityReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
async function loadByTimeline() {
|
||||||
|
setIsTimelineLoading(true);
|
||||||
|
setTimelineStatus(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchGeometriesByBBox({
|
||||||
|
...WORLD_BBOX,
|
||||||
|
time: timelineYear,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||||
|
setInitialData(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Load timeline data failed", err.body);
|
||||||
|
} else {
|
||||||
|
console.error("Load timeline data failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||||
|
setIsTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadByTimeline();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [timelineYear, isTimelineReady]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const payload = editor.buildPayload();
|
||||||
|
if (!payload.length) return;
|
||||||
|
|
||||||
|
const invalid = payload.find((change) => {
|
||||||
|
if (change.action === "delete") return false;
|
||||||
|
const draftFeature = change.action === "create"
|
||||||
|
? change.feature
|
||||||
|
: editor.draft.features.find((feature) =>
|
||||||
|
String(feature.properties.id) === String(change.id)
|
||||||
|
);
|
||||||
|
if (!draftFeature) return false;
|
||||||
|
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
||||||
|
return entityIds.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalid) {
|
||||||
|
const invalidId = invalid.action === "create"
|
||||||
|
? invalid.feature.properties.id
|
||||||
|
: invalid.id;
|
||||||
|
setSelectedFeatureId(invalidId);
|
||||||
|
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setEntityStatus(null);
|
||||||
|
try {
|
||||||
|
await saveGeometryBatchChanges(payload);
|
||||||
|
editor.clearChanges();
|
||||||
|
await reloadCurrentTimelineData();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
console.error("Save failed", err.body);
|
||||||
|
setEntityStatus(`Save thất bại: ${err.body}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Save error", err);
|
||||||
|
setEntityStatus("Save thất bại.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBackgroundVisibility = (
|
||||||
|
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||||
|
) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
persistBackgroundLayerVisibility(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||||
|
updateBackgroundVisibility((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAllBackgroundLayers = () => {
|
||||||
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideAllBackgroundLayers = () => {
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||||
|
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
|
||||||
|
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityIdsChange = (values: string[]) => {
|
||||||
|
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSelectedSearchEntity = () => {
|
||||||
|
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
|
||||||
|
if (!entityId.length) {
|
||||||
|
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
|
||||||
|
setSelectedGeometryEntityIds(next);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadEntities = async () => {
|
||||||
|
const rows = await fetchEntities();
|
||||||
|
setEntities(rows);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadCurrentTimelineData = async () => {
|
||||||
|
const data = await fetchGeometriesByBBox({
|
||||||
|
...WORLD_BBOX,
|
||||||
|
time: timelineYear,
|
||||||
|
});
|
||||||
|
setInitialData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSelectedGeometryInputsAfterCreate = () => {
|
||||||
|
skipSelectedFeatureSyncRef.current = true;
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
|
setSelectedGeometryEntityIds([]);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: "",
|
||||||
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
|
});
|
||||||
|
setEntityForm({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGeometryMetaFormRange = () => {
|
||||||
|
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
||||||
|
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
||||||
|
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||||
|
throw new Error("time_start phải <= time_end.");
|
||||||
|
}
|
||||||
|
return { timeStart, timeEnd };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveGeometryTypeFromEntityIds = (
|
||||||
|
entityIds: string[],
|
||||||
|
entityRows: Entity[] = entities
|
||||||
|
): string | null => {
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
if (!primaryEntityId) return null;
|
||||||
|
const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null;
|
||||||
|
return primaryEntity?.type_id || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchSelectedFeatureLocally = (
|
||||||
|
feature: Feature,
|
||||||
|
entityIds: string[],
|
||||||
|
timeStart: number | null,
|
||||||
|
timeEnd: number | null,
|
||||||
|
bindingIds: string[],
|
||||||
|
entityRows: Entity[] = entities
|
||||||
|
) => {
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
const primaryEntity = primaryEntityId
|
||||||
|
? entityRows.find((entity) => entity.id === primaryEntityId) || null
|
||||||
|
: null;
|
||||||
|
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null;
|
||||||
|
const entityNames = entityIds
|
||||||
|
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
|
||||||
|
editor.patchFeatureProperties(feature.properties.id, {
|
||||||
|
type: nextGeometryType,
|
||||||
|
entity_id: primaryEntityId,
|
||||||
|
entity_ids: entityIds,
|
||||||
|
entity_name: primaryEntity?.name || null,
|
||||||
|
entity_names: entityNames,
|
||||||
|
entity_type_id: primaryEntity?.type_id || null,
|
||||||
|
time_start: timeStart,
|
||||||
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedGeometryEntityIds(entityIds);
|
||||||
|
setGeometryMetaForm({
|
||||||
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
|
binding: bindingIds.join(", "),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||||
|
if (!entityIds.length) {
|
||||||
|
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeStart: number | null;
|
||||||
|
let timeEnd: number | null;
|
||||||
|
let bindingIds: string[];
|
||||||
|
try {
|
||||||
|
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||||
|
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||||
|
} catch (err) {
|
||||||
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
|
||||||
|
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||||
|
if (editor.changeCount > 0) {
|
||||||
|
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateGeometry(selectedFeature.properties.id, {
|
||||||
|
geometry: selectedFeature.geometry,
|
||||||
|
type: nextGeometryType ?? undefined,
|
||||||
|
time_start: timeStart,
|
||||||
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
|
entity_ids: entityIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
await reloadCurrentTimelineData();
|
||||||
|
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||||
|
} else {
|
||||||
|
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
|
||||||
|
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus("Lưu thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateEntityAndAttach = async () => {
|
||||||
|
if (!selectedFeature) {
|
||||||
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
|
||||||
|
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = entityForm.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
setEntityFormStatus("Tên entity là bắt buộc.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeStart: number | null;
|
||||||
|
let timeEnd: number | null;
|
||||||
|
let bindingIds: string[];
|
||||||
|
try {
|
||||||
|
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||||
|
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||||
|
} catch (err) {
|
||||||
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEntitySubmitting(true);
|
||||||
|
setEntityFormStatus(null);
|
||||||
|
try {
|
||||||
|
const created = await createEntity({
|
||||||
|
name,
|
||||||
|
slug: entityForm.slug.trim() || null,
|
||||||
|
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await reloadEntities();
|
||||||
|
const nextEntityIds = uniqueEntityIds([
|
||||||
|
...selectedGeometryEntityIds,
|
||||||
|
created.id,
|
||||||
|
]);
|
||||||
|
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null;
|
||||||
|
|
||||||
|
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||||
|
await updateGeometry(selectedFeature.properties.id, {
|
||||||
|
geometry: selectedFeature.geometry,
|
||||||
|
type: nextGeometryType ?? undefined,
|
||||||
|
time_start: timeStart,
|
||||||
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
|
entity_ids: nextEntityIds,
|
||||||
|
});
|
||||||
|
await reloadCurrentTimelineData();
|
||||||
|
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
||||||
|
} else {
|
||||||
|
patchSelectedFeatureLocally(
|
||||||
|
selectedFeature,
|
||||||
|
nextEntityIds,
|
||||||
|
timeStart,
|
||||||
|
timeEnd,
|
||||||
|
bindingIds,
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSelectedGeometryInputsAfterCreate();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
||||||
|
} else {
|
||||||
|
setEntityFormStatus("Tạo/gắn entity thất bại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsEntitySubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
||||||
|
const timelineStatusText =
|
||||||
|
editor.changeCount > 0
|
||||||
|
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
||||||
|
: isSaving
|
||||||
|
? "Đang lưu thay đổi..."
|
||||||
|
: timelineStatus;
|
||||||
|
|
||||||
|
const handleCreateFeature = (feature: Feature) => {
|
||||||
|
editor.createFeature(feature);
|
||||||
|
setSelectedFeatureId(feature.properties.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
|
<Editor
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
entityStatus={entityStatus}
|
||||||
|
onUndo={editor.undo}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
changesCount={editor.changeCount}
|
||||||
|
undoStack={editor.undoStack}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
|
{isBackgroundVisibilityReady ? (
|
||||||
|
<Map
|
||||||
|
mode={mode}
|
||||||
|
draft={editor.draft}
|
||||||
|
selectedFeatureId={selectedFeatureId}
|
||||||
|
onSelectFeatureId={setSelectedFeatureId}
|
||||||
|
onCreateFeature={handleCreateFeature}
|
||||||
|
onDeleteFeature={editor.deleteFeature}
|
||||||
|
onUpdateFeature={editor.updateFeature}
|
||||||
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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}
|
||||||
|
statusText={timelineStatusText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackgroundLayersPanel
|
||||||
|
visibility={backgroundVisibility}
|
||||||
|
onToggleLayer={handleToggleBackgroundLayer}
|
||||||
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
|
topContent={
|
||||||
|
<SelectedGeometryPanel
|
||||||
|
selectedFeature={selectedFeature}
|
||||||
|
selectedFeatureEntitySummary={
|
||||||
|
selectedFeature
|
||||||
|
? formatEntityNamesForDisplay(selectedFeature, entities)
|
||||||
|
: "Chưa gắn"
|
||||||
|
}
|
||||||
|
selectedFeatureBindingSummary={
|
||||||
|
selectedFeature
|
||||||
|
? formatBindingIdsForDisplay(selectedFeature)
|
||||||
|
: "Không có"
|
||||||
|
}
|
||||||
|
entities={entities}
|
||||||
|
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||||
|
onEntityIdsChange={handleEntityIdsChange}
|
||||||
|
entitySearchQuery={entitySearchQuery}
|
||||||
|
onEntitySearchQueryChange={setEntitySearchQuery}
|
||||||
|
entitySearchResults={entitySearchResults}
|
||||||
|
selectedSearchEntityId={selectedSearchEntityId}
|
||||||
|
onSelectSearchEntityId={setSelectedSearchEntityId}
|
||||||
|
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
||||||
|
isEntitySearchLoading={isEntitySearchLoading}
|
||||||
|
entityForm={entityForm}
|
||||||
|
onEntityFormChange={handleEntityFormChange}
|
||||||
|
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||||
|
geometryMetaForm={geometryMetaForm}
|
||||||
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
|
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
||||||
|
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||||
|
changeCount={editor.changeCount}
|
||||||
|
entityFormStatus={entityFormStatus}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
||||||
|
let min = Number.POSITIVE_INFINITY;
|
||||||
|
let max = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const feature of collection.features) {
|
||||||
|
const { time_start, time_end } = feature.properties;
|
||||||
|
|
||||||
|
if (isYearNumber(time_start)) {
|
||||||
|
min = Math.min(min, time_start);
|
||||||
|
max = Math.max(max, time_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isYearNumber(time_end)) {
|
||||||
|
min = Math.min(min, time_end);
|
||||||
|
max = Math.max(max, time_end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||||
|
return FALLBACK_TIMELINE_RANGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: Math.floor(min),
|
||||||
|
max: Math.ceil(max),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampYear(year: number, range: TimelineRange): number {
|
||||||
|
return clampYearValue(year, range.min, range.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||||
|
const lower = Math.min(minYear, maxYear);
|
||||||
|
const upper = Math.max(minYear, maxYear);
|
||||||
|
if (year < lower) return lower;
|
||||||
|
if (year > upper) return upper;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYearNumber(value: number | null | undefined): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBindingInput(raw: string): string[] {
|
||||||
|
if (!raw.trim().length) return [];
|
||||||
|
return uniqueEntityIds(
|
||||||
|
raw
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBindingIdsForDisplay(feature: Feature): string {
|
||||||
|
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||||
|
if (!bindingIds.length) return "Không có";
|
||||||
|
return bindingIds.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
755
app/page.tsx
755
app/page.tsx
@@ -1,27 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Map from "@/components/Map";
|
import Map from "@/components/Map";
|
||||||
import Editor from "@/components/Editor";
|
|
||||||
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
||||||
import TimelineBar from "@/components/TimelineBar";
|
import TimelineBar from "@/components/TimelineBar";
|
||||||
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
|
import { fetchGeometriesByBBox } from "@/api/geometries";
|
||||||
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
|
|
||||||
import { ApiError } from "@/api/http";
|
import { ApiError } from "@/api/http";
|
||||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
||||||
import {
|
|
||||||
Feature,
|
|
||||||
FeatureCollection,
|
|
||||||
useEditorState,
|
|
||||||
} from "@/lib/useEditorState";
|
|
||||||
import {
|
import {
|
||||||
BACKGROUND_LAYER_OPTIONS,
|
BACKGROUND_LAYER_OPTIONS,
|
||||||
BackgroundLayerId,
|
|
||||||
BackgroundLayerVisibility,
|
BackgroundLayerVisibility,
|
||||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||||
} from "@/lib/backgroundLayers";
|
} from "@/lib/backgroundLayers";
|
||||||
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
|
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
||||||
|
|
||||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
const WORLD_BBOX = {
|
const WORLD_BBOX = {
|
||||||
@@ -43,43 +36,9 @@ type TimelineRange = {
|
|||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EntityFormState = {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
type_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeometryMetaFormState = {
|
|
||||||
time_start: string;
|
|
||||||
time_end: string;
|
|
||||||
binding: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
|
|
||||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
|
||||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
|
||||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
|
||||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | 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);
|
|
||||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
||||||
@@ -93,170 +52,27 @@ export default function Page() {
|
|||||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||||
() => loadBackgroundLayerVisibilityFromStorage()
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||||
);
|
);
|
||||||
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
const entitySearchRequestRef = useRef(0);
|
|
||||||
const skipSelectedFeatureSyncRef = useRef(false);
|
|
||||||
|
|
||||||
const editor = useEditorState(initialData);
|
|
||||||
const selectedEntity =
|
|
||||||
entities.find((entity) => entity.id === selectedEntityId) || null;
|
|
||||||
const selectedFeature =
|
const selectedFeature =
|
||||||
selectedFeatureId === null
|
selectedFeatureId === null
|
||||||
? null
|
? null
|
||||||
: editor.draft.features.find((feature) =>
|
: initialData.features.find((feature) =>
|
||||||
String(feature.properties.id) === String(selectedFeatureId)
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
async function loadEntities() {
|
|
||||||
try {
|
|
||||||
const rows = await fetchEntities();
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
setEntities(rows);
|
|
||||||
setSelectedEntityId((prev) => {
|
|
||||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return rows[0]?.id || null;
|
|
||||||
});
|
|
||||||
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed) return;
|
|
||||||
console.error("Load entities failed", err);
|
|
||||||
setEntityStatus("Không tải được danh sách entity.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEntities();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedFeature) {
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setIsEntitySearchLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = entitySearchQuery.trim();
|
|
||||||
if (!keyword.length) {
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setIsEntitySearchLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let disposed = false;
|
|
||||||
const requestId = ++entitySearchRequestRef.current;
|
|
||||||
const timeoutId = window.setTimeout(async () => {
|
|
||||||
setIsEntitySearchLoading(true);
|
|
||||||
try {
|
|
||||||
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
|
||||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
|
||||||
|
|
||||||
setEntitySearchResults(rows);
|
|
||||||
setSelectedSearchEntityId((prev) =>
|
|
||||||
prev && rows.some((entity) => entity.id === prev)
|
|
||||||
? prev
|
|
||||||
: rows[0]?.id || null
|
|
||||||
);
|
|
||||||
setEntities((prev) => mergeEntitiesById(prev, rows));
|
|
||||||
} catch (err) {
|
|
||||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
|
||||||
console.error("Search entity by name failed", err);
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
} finally {
|
|
||||||
if (!disposed && requestId === entitySearchRequestRef.current) {
|
|
||||||
setIsEntitySearchLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 220);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
}, [entitySearchQuery, selectedFeature]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFeatureId === null) return;
|
if (selectedFeatureId === null) return;
|
||||||
const stillExists = editor.draft.features.some((feature) =>
|
const stillExists = initialData.features.some((feature) =>
|
||||||
String(feature.properties.id) === String(selectedFeatureId)
|
String(feature.properties.id) === String(selectedFeatureId)
|
||||||
);
|
);
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
setSelectedFeatureId(null);
|
setSelectedFeatureId(null);
|
||||||
}
|
}
|
||||||
}, [editor.draft, selectedFeatureId]);
|
}, [initialData, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipSelectedFeatureSyncRef.current) {
|
|
||||||
skipSelectedFeatureSyncRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFeature) {
|
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
|
||||||
setSelectedGeometryEntityIds([]);
|
|
||||||
setGeometryMetaForm({
|
|
||||||
time_start: "",
|
|
||||||
time_end: "",
|
|
||||||
binding: "",
|
|
||||||
});
|
|
||||||
setEntitySearchQuery("");
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setEntityFormStatus(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
|
||||||
const primaryEntityId = featureEntityIds[0] || null;
|
|
||||||
const linkedEntity = primaryEntityId
|
|
||||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
|
||||||
: null;
|
|
||||||
const nextTypeId =
|
|
||||||
linkedEntity?.type_id ||
|
|
||||||
selectedFeature.properties.entity_type_id ||
|
|
||||||
DEFAULT_ENTITY_TYPE_ID;
|
|
||||||
|
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: linkedEntity?.slug || "",
|
|
||||||
type_id: nextTypeId,
|
|
||||||
});
|
|
||||||
setSelectedGeometryEntityIds(featureEntityIds);
|
|
||||||
setGeometryMetaForm({
|
|
||||||
time_start: selectedFeature.properties.time_start != null
|
|
||||||
? String(selectedFeature.properties.time_start)
|
|
||||||
: "",
|
|
||||||
time_end: selectedFeature.properties.time_end != null
|
|
||||||
? String(selectedFeature.properties.time_end)
|
|
||||||
: "",
|
|
||||||
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
|
||||||
});
|
|
||||||
setEntitySearchQuery("");
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
if (!featureEntityIds.length) {
|
|
||||||
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
|
||||||
} else {
|
|
||||||
setEntityFormStatus(null);
|
|
||||||
}
|
|
||||||
}, [selectedFeature, entities]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -316,8 +132,9 @@ export default function Page() {
|
|||||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
persistBackgroundLayerVisibility(backgroundVisibility);
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||||
}, [backgroundVisibility]);
|
setIsBackgroundVisibilityReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||||
@@ -367,63 +184,29 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
}, [timelineYear, isTimelineReady]);
|
}, [timelineYear, isTimelineReady]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const updateBackgroundVisibility = (
|
||||||
const payload = editor.buildPayload();
|
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||||
if (!payload.length) return;
|
) => {
|
||||||
|
setBackgroundVisibility((prev) => {
|
||||||
const invalid = payload.find((change) => {
|
const next = updater(prev);
|
||||||
if (change.type === "delete") return false;
|
persistBackgroundLayerVisibility(next);
|
||||||
const draftFeature = change.type === "create"
|
return next;
|
||||||
? change.feature
|
|
||||||
: editor.draft.features.find((feature) =>
|
|
||||||
String(feature.properties.id) === String(change.id)
|
|
||||||
);
|
|
||||||
if (!draftFeature) return false;
|
|
||||||
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
|
||||||
return entityIds.length === 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invalid) {
|
|
||||||
const invalidId = invalid.type === "create"
|
|
||||||
? invalid.feature.properties.id
|
|
||||||
: invalid.id;
|
|
||||||
setSelectedFeatureId(invalidId);
|
|
||||||
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
setEntityStatus(null);
|
|
||||||
try {
|
|
||||||
await saveGeometryBatchChanges(payload);
|
|
||||||
editor.clearChanges();
|
|
||||||
await reloadCurrentTimelineData();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
console.error("Save failed", err.body);
|
|
||||||
setEntityStatus(`Save thất bại: ${err.body}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error("Save error", err);
|
|
||||||
setEntityStatus("Save thất bại.");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
|
||||||
setBackgroundVisibility((prev) => ({
|
updateBackgroundVisibility((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[id]: !prev[id],
|
[id]: !prev[id],
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowAllBackgroundLayers = () => {
|
const handleShowAllBackgroundLayers = () => {
|
||||||
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY });
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHideAllBackgroundLayers = () => {
|
const handleHideAllBackgroundLayers = () => {
|
||||||
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||||
@@ -442,291 +225,28 @@ export default function Page() {
|
|||||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
const timelineDisabled = !isTimelineReady;
|
||||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
||||||
};
|
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
||||||
|
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
||||||
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
|
const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
|
||||||
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEntityIdsChange = (values: string[]) => {
|
|
||||||
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddSelectedSearchEntity = () => {
|
|
||||||
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
|
|
||||||
if (!entityId.length) {
|
|
||||||
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
|
|
||||||
setSelectedGeometryEntityIds(next);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setEntityFormStatus(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadEntities = async () => {
|
|
||||||
const rows = await fetchEntities();
|
|
||||||
setEntities(rows);
|
|
||||||
setSelectedEntityId((prev) => {
|
|
||||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return rows[0]?.id || null;
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadCurrentTimelineData = async () => {
|
|
||||||
const data = await fetchGeometriesByBBox({
|
|
||||||
...WORLD_BBOX,
|
|
||||||
time: timelineYear,
|
|
||||||
});
|
|
||||||
setInitialData(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSelectedGeometryInputsAfterCreate = () => {
|
|
||||||
skipSelectedFeatureSyncRef.current = true;
|
|
||||||
setEntitySearchQuery("");
|
|
||||||
setEntitySearchResults([]);
|
|
||||||
setSelectedSearchEntityId(null);
|
|
||||||
setSelectedGeometryEntityIds([]);
|
|
||||||
setGeometryMetaForm({
|
|
||||||
time_start: "",
|
|
||||||
time_end: "",
|
|
||||||
binding: "",
|
|
||||||
});
|
|
||||||
setEntityForm({
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseGeometryMetaFormRange = () => {
|
|
||||||
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
|
||||||
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
|
||||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
|
||||||
throw new Error("time_start phải <= time_end.");
|
|
||||||
}
|
|
||||||
return { timeStart, timeEnd };
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchSelectedFeatureLocally = (
|
|
||||||
feature: Feature,
|
|
||||||
entityIds: string[],
|
|
||||||
timeStart: number | null,
|
|
||||||
timeEnd: number | null,
|
|
||||||
bindingIds: string[],
|
|
||||||
entityRows: Entity[] = entities
|
|
||||||
) => {
|
|
||||||
const primaryEntityId = entityIds[0] || null;
|
|
||||||
const primaryEntity = primaryEntityId
|
|
||||||
? entityRows.find((entity) => entity.id === primaryEntityId) || null
|
|
||||||
: null;
|
|
||||||
const entityNames = entityIds
|
|
||||||
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
|
|
||||||
.filter((name) => name.length > 0);
|
|
||||||
|
|
||||||
editor.patchFeatureProperties(feature.properties.id, {
|
|
||||||
entity_id: primaryEntityId,
|
|
||||||
entity_ids: entityIds,
|
|
||||||
entity_name: primaryEntity?.name || null,
|
|
||||||
entity_names: entityNames,
|
|
||||||
entity_type_id: primaryEntity?.type_id || null,
|
|
||||||
time_start: timeStart,
|
|
||||||
time_end: timeEnd,
|
|
||||||
binding: bindingIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedGeometryEntityIds(entityIds);
|
|
||||||
setGeometryMetaForm({
|
|
||||||
time_start: timeStart != null ? String(timeStart) : "",
|
|
||||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
|
||||||
binding: bindingIds.join(", "),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
|
||||||
if (!selectedFeature) {
|
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
|
||||||
if (!entityIds.length) {
|
|
||||||
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeStart: number | null;
|
|
||||||
let timeEnd: number | null;
|
|
||||||
let bindingIds: string[];
|
|
||||||
try {
|
|
||||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
|
||||||
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
|
||||||
} catch (err) {
|
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEntitySubmitting(true);
|
|
||||||
setEntityFormStatus(null);
|
|
||||||
try {
|
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
|
||||||
if (editor.changeCount > 0) {
|
|
||||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateGeometry(selectedFeature.properties.id, {
|
|
||||||
geometry: selectedFeature.geometry,
|
|
||||||
time_start: timeStart,
|
|
||||||
time_end: timeEnd,
|
|
||||||
binding: bindingIds,
|
|
||||||
entity_ids: entityIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
await reloadCurrentTimelineData();
|
|
||||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
|
||||||
} else {
|
|
||||||
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
|
|
||||||
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
|
||||||
} else {
|
|
||||||
setEntityFormStatus("Lưu thất bại.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsEntitySubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateEntityAndAttach = async () => {
|
|
||||||
if (!selectedFeature) {
|
|
||||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
|
|
||||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = entityForm.name.trim();
|
|
||||||
if (!name) {
|
|
||||||
setEntityFormStatus("Tên entity là bắt buộc.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeStart: number | null;
|
|
||||||
let timeEnd: number | null;
|
|
||||||
let bindingIds: string[];
|
|
||||||
try {
|
|
||||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
|
||||||
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
|
||||||
} catch (err) {
|
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEntitySubmitting(true);
|
|
||||||
setEntityFormStatus(null);
|
|
||||||
try {
|
|
||||||
const created = await createEntity({
|
|
||||||
name,
|
|
||||||
slug: entityForm.slug.trim() || null,
|
|
||||||
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = await reloadEntities();
|
|
||||||
const nextEntityIds = uniqueEntityIds([
|
|
||||||
...selectedGeometryEntityIds,
|
|
||||||
created.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
|
||||||
await updateGeometry(selectedFeature.properties.id, {
|
|
||||||
geometry: selectedFeature.geometry,
|
|
||||||
time_start: timeStart,
|
|
||||||
time_end: timeEnd,
|
|
||||||
binding: bindingIds,
|
|
||||||
entity_ids: nextEntityIds,
|
|
||||||
});
|
|
||||||
await reloadCurrentTimelineData();
|
|
||||||
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
|
||||||
} else {
|
|
||||||
patchSelectedFeatureLocally(
|
|
||||||
selectedFeature,
|
|
||||||
nextEntityIds,
|
|
||||||
timeStart,
|
|
||||||
timeEnd,
|
|
||||||
bindingIds,
|
|
||||||
rows
|
|
||||||
);
|
|
||||||
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedEntityId(created.id);
|
|
||||||
resetSelectedGeometryInputsAfterCreate();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
|
||||||
} else {
|
|
||||||
setEntityFormStatus("Tạo/gắn entity thất bại.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsEntitySubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
|
||||||
const timelineStatusText =
|
|
||||||
editor.changeCount > 0
|
|
||||||
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
|
||||||
: isSaving
|
|
||||||
? "Đang lưu thay đổi..."
|
|
||||||
: timelineStatus;
|
|
||||||
|
|
||||||
const handleCreateFeature = (feature: Feature) => {
|
|
||||||
editor.createFeature(feature);
|
|
||||||
setSelectedFeatureId(feature.properties.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||||
<Editor
|
|
||||||
mode={mode}
|
|
||||||
setMode={setMode}
|
|
||||||
entities={entities}
|
|
||||||
selectedEntityId={selectedEntityId}
|
|
||||||
onSelectEntityId={setSelectedEntityId}
|
|
||||||
entityStatus={entityStatus}
|
|
||||||
onUndo={editor.undo}
|
|
||||||
onSave={handleSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
changesCount={editor.changeCount}
|
|
||||||
undoStack={editor.undoStack}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
mode={mode}
|
mode="select"
|
||||||
draft={editor.draft}
|
draft={initialData}
|
||||||
selectedFeatureId={selectedFeatureId}
|
selectedFeatureId={selectedFeatureId}
|
||||||
selectedEntityId={selectedEntityId}
|
|
||||||
selectedEntityName={selectedEntity?.name || null}
|
|
||||||
selectedEntityTypeId={selectedEntity?.type_id || null}
|
|
||||||
onSelectFeatureId={setSelectedFeatureId}
|
onSelectFeatureId={setSelectedFeatureId}
|
||||||
onCreateFeature={handleCreateFeature}
|
|
||||||
onDeleteFeature={editor.deleteFeature}
|
|
||||||
onUpdateFeature={editor.updateFeature}
|
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
|
allowGeometryEditing={false}
|
||||||
|
respectBindingFilter={false}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
|
)}
|
||||||
<TimelineBar
|
<TimelineBar
|
||||||
minYear={timelineRange.min}
|
minYear={timelineRange.min}
|
||||||
maxYear={timelineRange.max}
|
maxYear={timelineRange.max}
|
||||||
@@ -738,7 +258,7 @@ export default function Page() {
|
|||||||
onYearChange={handleTimelineYearChange}
|
onYearChange={handleTimelineYearChange}
|
||||||
isLoading={isTimelineLoading}
|
isLoading={isTimelineLoading}
|
||||||
disabled={timelineDisabled}
|
disabled={timelineDisabled}
|
||||||
statusText={timelineStatusText}
|
statusText={timelineStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -748,39 +268,57 @@ export default function Page() {
|
|||||||
onShowAll={handleShowAllBackgroundLayers}
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
topContent={
|
topContent={
|
||||||
<SelectedGeometryPanel
|
<div
|
||||||
selectedFeature={selectedFeature}
|
style={{
|
||||||
selectedFeatureEntitySummary={
|
padding: "10px",
|
||||||
selectedFeature
|
background: "#0b1220",
|
||||||
? formatEntityNamesForDisplay(selectedFeature, entities)
|
borderRadius: "8px",
|
||||||
: "Chưa gắn"
|
border: "1px solid #1f2937",
|
||||||
}
|
display: "grid",
|
||||||
selectedFeatureBindingSummary={
|
gap: "8px",
|
||||||
selectedFeature
|
}}
|
||||||
? formatBindingIdsForDisplay(selectedFeature)
|
>
|
||||||
: "Không có"
|
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
|
||||||
}
|
Viewer
|
||||||
entities={entities}
|
</div>
|
||||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
onEntityIdsChange={handleEntityIdsChange}
|
Click trực tiếp trên map để chọn geometry.
|
||||||
entitySearchQuery={entitySearchQuery}
|
</div>
|
||||||
onEntitySearchQueryChange={setEntitySearchQuery}
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
entitySearchResults={entitySearchResults}
|
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
||||||
selectedSearchEntityId={selectedSearchEntityId}
|
</div>
|
||||||
onSelectSearchEntityId={setSelectedSearchEntityId}
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
Type: {selectedType}
|
||||||
isEntitySearchLoading={isEntitySearchLoading}
|
</div>
|
||||||
entityForm={entityForm}
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
onEntityFormChange={handleEntityFormChange}
|
Time: {selectedTimeSummary}
|
||||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
</div>
|
||||||
geometryMetaForm={geometryMetaForm}
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
Entities: {selectedEntitySummary}
|
||||||
isEntitySubmitting={isEntitySubmitting}
|
</div>
|
||||||
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
Binding: {selectedBindingSummary}
|
||||||
changeCount={editor.changeCount}
|
</div>
|
||||||
entityFormStatus={entityFormStatus}
|
|
||||||
/>
|
<Link
|
||||||
|
href="/editor"
|
||||||
|
style={{
|
||||||
|
marginTop: "4px",
|
||||||
|
display: "inline-flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#ffffff",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mở Editor
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number {
|
|||||||
return typeof value === "number" && Number.isFinite(value);
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
function formatSelectedTimeSummary(feature: Feature | null): string {
|
||||||
const value = raw.trim();
|
if (!feature) return "Không có";
|
||||||
if (!value.length) return null;
|
const start = isYearNumber(feature.properties.time_start)
|
||||||
const parsed = Number(value);
|
? String(feature.properties.time_start)
|
||||||
if (!Number.isFinite(parsed)) {
|
: "?";
|
||||||
throw new Error(`${fieldName} phải là số.`);
|
const end = isYearNumber(feature.properties.time_end)
|
||||||
}
|
? String(feature.properties.time_end)
|
||||||
return Math.trunc(parsed);
|
: "?";
|
||||||
|
return `${start} → ${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFeatureEntityIds(feature: Feature): string[] {
|
function formatSelectedEntitySummary(feature: Feature | null): string {
|
||||||
const fromArray = Array.isArray(feature.properties.entity_ids)
|
if (!feature) return "Không có";
|
||||||
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
|
||||||
|
const names = Array.isArray(feature.properties.entity_names)
|
||||||
|
? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
if (names.length) return names.join(", ");
|
||||||
|
|
||||||
if (fromArray.length) {
|
if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
|
||||||
return uniqueEntityIds(fromArray);
|
return feature.properties.entity_name.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const single = feature.properties.entity_id;
|
const ids = Array.isArray(feature.properties.entity_ids)
|
||||||
if (typeof single === "string" && single.trim().length > 0) {
|
? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||||
return [single.trim()];
|
: [];
|
||||||
|
if (ids.length) return ids.join(", ");
|
||||||
|
|
||||||
|
if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) {
|
||||||
|
return feature.properties.entity_id.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return "Chưa gắn";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFeatureBindingIds(feature: Feature): string[] {
|
function formatSelectedBindingSummary(feature: Feature | null): string {
|
||||||
const rawBinding = feature.properties.binding;
|
if (!feature) return "Không có";
|
||||||
if (!Array.isArray(rawBinding)) return [];
|
const binding = Array.isArray(feature.properties.binding)
|
||||||
return uniqueEntityIds(rawBinding
|
? feature.properties.binding
|
||||||
.map((id) => {
|
.map((item) => {
|
||||||
if (typeof id !== "string" && typeof id !== "number") return "";
|
if (typeof item !== "string" && typeof item !== "number") return "";
|
||||||
return String(id).trim();
|
return String(item).trim();
|
||||||
})
|
})
|
||||||
.filter((id) => id.length > 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBindingInput(raw: string): string[] {
|
|
||||||
if (!raw.trim().length) return [];
|
|
||||||
return uniqueEntityIds(
|
|
||||||
raw
|
|
||||||
.split(/[,\n]/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0)
|
.filter((item) => item.length > 0)
|
||||||
);
|
: [];
|
||||||
|
if (!binding.length) return "Không có";
|
||||||
|
return binding.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBindingIdsForDisplay(feature: Feature): string {
|
function resolveSelectedTypeSummary(feature: Feature | null): string {
|
||||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
if (!feature) return "Không có";
|
||||||
if (!bindingIds.length) return "Không có";
|
const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||||
return bindingIds.join(", ");
|
if (!typeId) return "Không có";
|
||||||
|
const option = findEntityTypeOption(typeId);
|
||||||
|
if (!option) return typeId;
|
||||||
|
return `${option.label} (${option.groupLabel})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueEntityIds(ids: string[]): string[] {
|
function normalizeTypeId(value: unknown): string | null {
|
||||||
const deduped: string[] = [];
|
if (typeof value !== "string") return null;
|
||||||
const seen = new Set<string>();
|
const normalized = value.trim().toLowerCase();
|
||||||
for (const rawId of ids) {
|
return normalized.length ? normalized : null;
|
||||||
const id = rawId.trim();
|
|
||||||
if (!id || seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
deduped.push(id);
|
|
||||||
}
|
|
||||||
return deduped;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] {
|
|
||||||
if (!incoming.length) return current;
|
|
||||||
|
|
||||||
const map = new globalThis.Map<string, Entity>();
|
|
||||||
for (const entity of current) {
|
|
||||||
map.set(entity.id, entity);
|
|
||||||
}
|
|
||||||
for (const entity of incoming) {
|
|
||||||
map.set(entity.id, entity);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||||
|
|||||||
@@ -3,18 +3,10 @@
|
|||||||
import { UndoAction } from "@/lib/useEditorState";
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
type EntityOption = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
geometry_count?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
setMode: (mode: Mode) => void;
|
setMode: (mode: Mode) => void;
|
||||||
entities: EntityOption[];
|
|
||||||
selectedEntityId: string | null;
|
|
||||||
onSelectEntityId: (entityId: string | null) => void;
|
|
||||||
entityStatus?: string | null;
|
entityStatus?: string | null;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -26,9 +18,6 @@ type Props = {
|
|||||||
export default function Editor({
|
export default function Editor({
|
||||||
mode,
|
mode,
|
||||||
setMode,
|
setMode,
|
||||||
entities,
|
|
||||||
selectedEntityId,
|
|
||||||
onSelectEntityId,
|
|
||||||
entityStatus,
|
entityStatus,
|
||||||
onUndo,
|
onUndo,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -148,6 +137,7 @@ export default function Editor({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{entityStatus ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: "12px",
|
marginTop: "12px",
|
||||||
@@ -155,41 +145,13 @@ export default function Editor({
|
|||||||
background: "#0b1220",
|
background: "#0b1220",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
|
color: "#fca5a5",
|
||||||
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "13px", color: "#e2e8f0" }}>
|
|
||||||
Entity mặc định cho geometry mới
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={selectedEntityId || ""}
|
|
||||||
onChange={(event) => onSelectEntityId(event.target.value || null)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "6px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
background: "#111827",
|
|
||||||
color: "#f8fafc",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Không gắn entity</option>
|
|
||||||
{entities.map((entity) => (
|
|
||||||
<option key={entity.id} value={entity.id}>
|
|
||||||
{entity.name}
|
|
||||||
{typeof entity.geometry_count === "number" ? ` (${entity.geometry_count})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div style={{ marginTop: "6px", color: "#94a3b8", fontSize: "12px" }}>
|
|
||||||
Geometry mới tạo sẽ gắn sẵn entity này, bạn có thể thêm nhiều entity ở panel bên phải.
|
|
||||||
</div>
|
|
||||||
{entityStatus ? (
|
|
||||||
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
|
|
||||||
{entityStatus}
|
{entityStatus}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -20,13 +20,12 @@ type MapProps = {
|
|||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
backgroundVisibility: BackgroundLayerVisibility;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
selectedFeatureId: string | number | null;
|
selectedFeatureId: string | number | null;
|
||||||
selectedEntityId: string | null;
|
|
||||||
selectedEntityName: string | null;
|
|
||||||
selectedEntityTypeId: string | null;
|
|
||||||
onSelectFeatureId: (id: string | number | null) => void;
|
onSelectFeatureId: (id: string | number | null) => void;
|
||||||
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||||
onDeleteFeature: (id: string | number) => void;
|
onDeleteFeature?: (id: string | number) => void;
|
||||||
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
|
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||||
|
allowGeometryEditing?: boolean;
|
||||||
|
respectBindingFilter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PointIconSpec = {
|
type PointIconSpec = {
|
||||||
@@ -40,6 +39,9 @@ const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
|||||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||||
const MAP_MIN_ZOOM = 1;
|
const MAP_MIN_ZOOM = 1;
|
||||||
const MAP_MAX_ZOOM = 10;
|
const MAP_MAX_ZOOM = 10;
|
||||||
|
const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||||
|
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||||
|
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||||
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
"coalesce",
|
"coalesce",
|
||||||
["get", "MAPCOLOR7"],
|
["get", "MAPCOLOR7"],
|
||||||
@@ -103,6 +105,16 @@ const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
|||||||
shipping_route: "#2563eb",
|
shipping_route: "#2563eb",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
|
||||||
|
attack_route: true,
|
||||||
|
retreat_route: true,
|
||||||
|
invasion_route: true,
|
||||||
|
migration_route: true,
|
||||||
|
refugee_route: true,
|
||||||
|
trade_route: true,
|
||||||
|
shipping_route: true,
|
||||||
|
};
|
||||||
|
|
||||||
const POINT_COLOR_BY_TYPE: Record<string, string> = {
|
const POINT_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
person_deathplace: "#dc2626",
|
person_deathplace: "#dc2626",
|
||||||
person_birthplace: "#2563eb",
|
person_birthplace: "#2563eb",
|
||||||
@@ -130,7 +142,7 @@ const POINT_ICON_SPECS: PointIconSpec[] = [
|
|||||||
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
|
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
|
||||||
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
|
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
|
||||||
|
|
||||||
// Backward-compatible icon ids used by older type_id values.
|
// Backward-compatible icon ids used by older naming values.
|
||||||
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
||||||
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
||||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
||||||
@@ -161,26 +173,22 @@ export default function Map({
|
|||||||
draft,
|
draft,
|
||||||
backgroundVisibility,
|
backgroundVisibility,
|
||||||
selectedFeatureId,
|
selectedFeatureId,
|
||||||
selectedEntityId,
|
|
||||||
selectedEntityName,
|
|
||||||
selectedEntityTypeId,
|
|
||||||
onSelectFeatureId,
|
onSelectFeatureId,
|
||||||
onCreateFeature,
|
onCreateFeature,
|
||||||
onDeleteFeature,
|
onDeleteFeature,
|
||||||
onUpdateFeature,
|
onUpdateFeature,
|
||||||
|
allowGeometryEditing = true,
|
||||||
|
respectBindingFilter = true,
|
||||||
}: MapProps) {
|
}: MapProps) {
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
const draftRef = useRef<FeatureCollection>(draft);
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||||
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
|
||||||
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
|
||||||
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
|
||||||
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
|
||||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||||
const onCreateRef = useRef(onCreateFeature);
|
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||||
const onDeleteRef = useRef(onDeleteFeature);
|
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||||
const onUpdateRef = useRef(onUpdateFeature);
|
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||||
const [zoomLevel, setZoomLevel] = useState(2);
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
@@ -192,7 +200,7 @@ export default function Map({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
if (mode !== "add-line") {
|
if (mode !== "add-line") {
|
||||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -221,18 +229,6 @@ export default function Map({
|
|||||||
selectedFeatureIdRef.current = selectedFeatureId;
|
selectedFeatureIdRef.current = selectedFeatureId;
|
||||||
}, [selectedFeatureId]);
|
}, [selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedEntityIdRef.current = selectedEntityId;
|
|
||||||
}, [selectedEntityId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedEntityNameRef.current = selectedEntityName;
|
|
||||||
}, [selectedEntityName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedEntityTypeIdRef.current = selectedEntityTypeId;
|
|
||||||
}, [selectedEntityTypeId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||||
}, [onSelectFeatureId]);
|
}, [onSelectFeatureId]);
|
||||||
@@ -281,12 +277,14 @@ export default function Map({
|
|||||||
// clear all feature-state (selection) to prevent ghost layers after undo
|
// clear all feature-state (selection) to prevent ghost layers after undo
|
||||||
map.removeFeatureState({ source: "countries" });
|
map.removeFeatureState({ source: "countries" });
|
||||||
|
|
||||||
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
|
const visibleDraft = respectBindingFilter
|
||||||
|
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||||
|
: fc;
|
||||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
|
|
||||||
countriesSource.setData(polygons);
|
countriesSource.setData(polygons);
|
||||||
placesSource.setData(points);
|
placesSource.setData(points);
|
||||||
}, []);
|
}, [respectBindingFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
@@ -297,13 +295,6 @@ export default function Map({
|
|||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
rasterBase: {
|
|
||||||
type: "raster",
|
|
||||||
tiles: [getRasterTileTemplateUrl()],
|
|
||||||
tileSize: 256,
|
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 6,
|
|
||||||
},
|
|
||||||
base: {
|
base: {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
tiles: [getVectorTileTemplateUrl()],
|
tiles: [getVectorTileTemplateUrl()],
|
||||||
@@ -319,15 +310,6 @@ export default function Map({
|
|||||||
"background-color": "#0b1220",
|
"background-color": "#0b1220",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "raster-base-layer",
|
|
||||||
type: "raster",
|
|
||||||
source: "rasterBase",
|
|
||||||
paint: {
|
|
||||||
"raster-opacity": 0.92,
|
|
||||||
"raster-resampling": "linear",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "graticules-line",
|
id: "graticules-line",
|
||||||
type: "line",
|
type: "line",
|
||||||
@@ -450,7 +432,6 @@ export default function Map({
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
const geometryMinZoom = 5;
|
|
||||||
const syncZoomLevel = () => {
|
const syncZoomLevel = () => {
|
||||||
setZoomLevel(roundZoom(map.getZoom()));
|
setZoomLevel(roundZoom(map.getZoom()));
|
||||||
};
|
};
|
||||||
@@ -633,7 +614,6 @@ export default function Map({
|
|||||||
id: "routes-line",
|
id: "routes-line",
|
||||||
type: "line",
|
type: "line",
|
||||||
source: "countries",
|
source: "countries",
|
||||||
minzoom: geometryMinZoom,
|
|
||||||
filter: ["==", ["geometry-type"], "LineString"],
|
filter: ["==", ["geometry-type"], "LineString"],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": [
|
"line-color": [
|
||||||
@@ -661,11 +641,10 @@ export default function Map({
|
|||||||
id: "routes-arrow",
|
id: "routes-arrow",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
source: "countries",
|
source: "countries",
|
||||||
minzoom: geometryMinZoom,
|
|
||||||
filter: [
|
filter: [
|
||||||
"all",
|
"all",
|
||||||
["==", ["geometry-type"], "LineString"],
|
["==", ["geometry-type"], "LineString"],
|
||||||
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
|
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
||||||
],
|
],
|
||||||
layout: {
|
layout: {
|
||||||
"symbol-placement": "line",
|
"symbol-placement": "line",
|
||||||
@@ -723,7 +702,6 @@ export default function Map({
|
|||||||
id: "places-circle",
|
id: "places-circle",
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: "places",
|
source: "places",
|
||||||
minzoom: geometryMinZoom,
|
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"case",
|
"case",
|
||||||
@@ -749,7 +727,6 @@ export default function Map({
|
|||||||
id: "places-symbol",
|
id: "places-symbol",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
source: "places",
|
source: "places",
|
||||||
minzoom: geometryMinZoom,
|
|
||||||
layout: {
|
layout: {
|
||||||
"icon-image": buildPointIconExpression(),
|
"icon-image": buildPointIconExpression(),
|
||||||
"icon-size": 0.5,
|
"icon-size": 0.5,
|
||||||
@@ -765,14 +742,16 @@ export default function Map({
|
|||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
onCreateRef.current({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
entity_id: selectedEntityIdRef.current || null,
|
type: null,
|
||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
geometry_preset: "polygon",
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_id: null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
binding: [],
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
@@ -783,13 +762,17 @@ export default function Map({
|
|||||||
const cleanupSelect = initSelect(
|
const cleanupSelect = initSelect(
|
||||||
map,
|
map,
|
||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(id: string | number) => {
|
allowGeometryEditing
|
||||||
|
? (id: string | number) => {
|
||||||
// ensure edit overlays are cleared when a feature gets removed
|
// ensure edit overlays are cleared when a feature gets removed
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
onSelectFeatureIdRef.current?.(null);
|
onSelectFeatureIdRef.current?.(null);
|
||||||
onDeleteRef.current(id);
|
onDeleteRef.current?.(id);
|
||||||
},
|
}
|
||||||
(feature) => editingEngineRef.current?.beginEditing(feature),
|
: undefined,
|
||||||
|
allowGeometryEditing
|
||||||
|
? (feature) => editingEngineRef.current?.beginEditing(feature)
|
||||||
|
: undefined,
|
||||||
(id) => onSelectFeatureIdRef.current?.(id)
|
(id) => onSelectFeatureIdRef.current?.(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -798,14 +781,16 @@ export default function Map({
|
|||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
onCreateRef.current({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
entity_id: selectedEntityIdRef.current || null,
|
type: null,
|
||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
geometry_preset: "point",
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_id: null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
binding: [],
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
@@ -818,15 +803,16 @@ export default function Map({
|
|||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
onCreateRef.current({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
entity_id: selectedEntityIdRef.current || null,
|
type: "defense_line",
|
||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
geometry_preset: "line",
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_id: null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_ids: [],
|
||||||
line_mode: "line",
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
binding: [],
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
@@ -839,15 +825,16 @@ export default function Map({
|
|||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
onCreateRef.current({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
entity_id: selectedEntityIdRef.current || null,
|
type: "attack_route",
|
||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
geometry_preset: "line",
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_id: null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_ids: [],
|
||||||
line_mode: "path",
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
binding: [],
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
@@ -860,14 +847,16 @@ export default function Map({
|
|||||||
() => modeRef.current,
|
() => modeRef.current,
|
||||||
(geometry: Geometry) => {
|
(geometry: Geometry) => {
|
||||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
onCreateRef.current({
|
onCreateRef.current?.({
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
entity_id: selectedEntityIdRef.current || null,
|
type: null,
|
||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
geometry_preset: "circle-area",
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_id: null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_ids: [],
|
||||||
|
entity_name: null,
|
||||||
|
entity_type_id: null,
|
||||||
binding: [],
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
@@ -888,11 +877,18 @@ export default function Map({
|
|||||||
// after everything mounted, push current draft to sources
|
// after everything mounted, push current draft to sources
|
||||||
applyDraftToMap(draftRef.current);
|
applyDraftToMap(draftRef.current);
|
||||||
|
|
||||||
|
if (allowGeometryEditing) {
|
||||||
editingEngineRef.current?.bindEditEvents(map);
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => map.remove();
|
return () => {
|
||||||
}, [applyDraftToMap]);
|
if (mapRef.current === map) {
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
}, [allowGeometryEditing, applyDraftToMap]);
|
||||||
|
|
||||||
const handleZoomByStep = (delta: number) => {
|
const handleZoomByStep = (delta: number) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -912,13 +908,13 @@ export default function Map({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyDraftToMap(draft);
|
applyDraftToMap(draft);
|
||||||
const editingId = editingEngineRef.current?.editingRef.current?.id;
|
const editingId = editingEngineRef.current?.editingRef.current?.id;
|
||||||
if (editingId !== undefined && editingId !== null) {
|
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
|
||||||
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [draft, selectedFeatureId, applyDraftToMap]);
|
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
|
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
|
||||||
@@ -1004,7 +1000,10 @@ function applyBackgroundLayerVisibility(
|
|||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
visibility: BackgroundLayerVisibility
|
visibility: BackgroundLayerVisibility
|
||||||
) {
|
) {
|
||||||
|
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
||||||
|
|
||||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||||
|
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||||
if (!map.getLayer(layer.id)) continue;
|
if (!map.getLayer(layer.id)) continue;
|
||||||
map.setLayoutProperty(
|
map.setLayoutProperty(
|
||||||
layer.id,
|
layer.id,
|
||||||
@@ -1014,6 +1013,62 @@ function applyBackgroundLayerVisibility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
|
||||||
|
if (shouldShow) {
|
||||||
|
ensureRasterBaseLayer(map);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRasterBaseLayer(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRasterBaseLayer(map: maplibregl.Map) {
|
||||||
|
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||||
|
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||||
|
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
|
||||||
|
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
|
||||||
|
: undefined;
|
||||||
|
map.addLayer(createRasterBaseLayer(), beforeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRasterBaseLayer(map: maplibregl.Map) {
|
||||||
|
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
|
||||||
|
map.removeLayer(RASTER_BASE_LAYER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
|
||||||
|
map.removeSource(RASTER_BASE_SOURCE_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRasterBaseSource() {
|
||||||
|
return {
|
||||||
|
type: "raster" as const,
|
||||||
|
tiles: [getRasterTileTemplateUrl()],
|
||||||
|
tileSize: 256,
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRasterBaseLayer() {
|
||||||
|
return {
|
||||||
|
id: RASTER_BASE_LAYER_ID,
|
||||||
|
type: "raster" as const,
|
||||||
|
source: RASTER_BASE_SOURCE_ID,
|
||||||
|
paint: {
|
||||||
|
"raster-opacity": 0.92,
|
||||||
|
"raster-resampling": "linear" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function filterDraftByBinding(
|
function filterDraftByBinding(
|
||||||
fc: FeatureCollection,
|
fc: FeatureCollection,
|
||||||
selectedFeatureId: string | number | null
|
selectedFeatureId: string | number | null
|
||||||
@@ -1158,7 +1213,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
||||||
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||||
|
|
||||||
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
|
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
|
||||||
expression.push(typeId, iconId);
|
expression.push(typeId, iconId);
|
||||||
@@ -1169,10 +1224,10 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTypeMatchExpression(
|
function buildTypeMatchExpression(
|
||||||
valueByType: Record<string, string | number>,
|
valueByType: Record<string, string | number | boolean>,
|
||||||
fallback: string | number
|
fallback: string | number | boolean
|
||||||
): maplibregl.ExpressionSpecification {
|
): maplibregl.ExpressionSpecification {
|
||||||
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||||
|
|
||||||
for (const [typeId, value] of Object.entries(valueByType)) {
|
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||||
expression.push(typeId, value);
|
expression.push(typeId, value);
|
||||||
@@ -1182,6 +1237,15 @@ function buildTypeMatchExpression(
|
|||||||
return expression as maplibregl.ExpressionSpecification;
|
return expression as maplibregl.ExpressionSpecification;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
|
||||||
|
return [
|
||||||
|
"coalesce",
|
||||||
|
["get", "type"],
|
||||||
|
["get", "entity_type_id"],
|
||||||
|
"",
|
||||||
|
] as maplibregl.ExpressionSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
function roundZoom(value: number): number {
|
function roundZoom(value: number): number {
|
||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
|
|||||||
import { Entity } from "@/api/entities";
|
import { Entity } from "@/api/entities";
|
||||||
import { Feature } from "@/lib/useEditorState";
|
import { Feature } from "@/lib/useEditorState";
|
||||||
import {
|
import {
|
||||||
|
EntityGeometryPreset,
|
||||||
EntityTypeGroupId,
|
EntityTypeGroupId,
|
||||||
EntityTypeOption,
|
EntityTypeOption,
|
||||||
findEntityTypeOption,
|
findEntityTypeOption,
|
||||||
@@ -74,19 +75,21 @@ export default function SelectedGeometryPanel({
|
|||||||
entityFormStatus,
|
entityFormStatus,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||||
const allowedGroupIds = selectedFeature
|
const featureGeometryPreset = selectedFeature
|
||||||
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
|
? resolveFeatureGeometryPreset(selectedFeature)
|
||||||
|
: null;
|
||||||
|
const allowedGroupIds = featureGeometryPreset
|
||||||
|
? getAllowedGroupIdsForPreset(featureGeometryPreset)
|
||||||
: [];
|
: [];
|
||||||
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
allowedGroupIds.includes(group.id)
|
allowedGroupIds.includes(group.id)
|
||||||
);
|
);
|
||||||
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
||||||
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
|
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
|
||||||
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
|
group.options.some((option) => option.value === entityForm.type_id)
|
||||||
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
|
);
|
||||||
const isGeometryCompatible =
|
const isGeometryCompatible = selectedTypeOption
|
||||||
geometryBucket && selectedTypeBucket
|
? allowedGroupIds.includes(selectedTypeOption.groupId)
|
||||||
? geometryBucket === selectedTypeBucket
|
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,6 +120,9 @@ export default function SelectedGeometryPanel({
|
|||||||
<div style={{ color: "#cbd5e1" }}>
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
Binding hiện tại: {selectedFeatureBindingSummary}
|
Binding hiện tại: {selectedFeatureBindingSummary}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
|
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
Entities đã chọn:
|
Entities đã chọn:
|
||||||
@@ -166,6 +172,66 @@ export default function SelectedGeometryPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
Geometry phải có ít nhất 1 entity để Save.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #243244",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||||
|
`time_start`, `time_end`, `binding` sẽ được dùng cho cả luồng gắn entity có sẵn
|
||||||
|
và luồng tạo entity mới.
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_start}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||||
|
placeholder="time_start"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_end}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||||
|
placeholder="time_end"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.binding}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
|
||||||
|
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1f3b5a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Luồng 1: Gắn entity có sẵn
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
value={entitySearchQuery}
|
value={entitySearchQuery}
|
||||||
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
||||||
@@ -194,16 +260,46 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||||
style={secondaryActionButtonStyle}
|
style={secondaryActionButtonStyle}
|
||||||
>
|
>
|
||||||
Chọn để thêm
|
Thêm entity đã chọn vào danh sách gắn
|
||||||
</button>
|
</button>
|
||||||
{isEntitySearchLoading ? (
|
{isEntitySearchLoading ? (
|
||||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||||
Đang tìm entity...
|
Đang tìm entity...
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
onClick={onApplyEntitiesForSelectedGeometry}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
background: "#0f766e",
|
||||||
|
color: "#ffffff",
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Áp dụng danh sách entity + metadata
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
<div
|
||||||
Geometry phải có ít nhất 1 entity để Save.
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #1e3a8a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
|
||||||
|
Luồng 2: Tạo entity mới rồi gắn
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
|
||||||
|
Dùng khi chưa có entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -220,82 +316,8 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gap: "8px",
|
|
||||||
border: "1px solid #243244",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
background: "#0f172a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||||
Entity type presets
|
Chọn loại entity
|
||||||
</div>
|
|
||||||
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
|
||||||
Chọn nhanh theo nhóm hình học bạn cần vẽ.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visibleGroupedEntityTypeOptions.map((group) => {
|
|
||||||
const color = GROUP_COLORS[group.id];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={group.id}
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${color.border}`,
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "8px",
|
|
||||||
background: color.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: "8px",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: color.text, fontWeight: 600, fontSize: "12px" }}>
|
|
||||||
{group.label}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: color.mutedText, fontSize: "11px" }}>
|
|
||||||
{group.geometryLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
|
||||||
{group.options.map((option) => {
|
|
||||||
const active = option.value === entityForm.type_id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onEntityFormChange("type_id", option.value)}
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={{
|
|
||||||
border: active
|
|
||||||
? `1px solid ${color.activeBorder}`
|
|
||||||
: `1px solid ${color.chipBorder}`,
|
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "4px 9px",
|
|
||||||
background: active ? color.activeBackground : color.chipBackground,
|
|
||||||
color: active ? color.activeText : color.text,
|
|
||||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
|
||||||
fontSize: "11px",
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={entityForm.type_id}
|
value={entityForm.type_id}
|
||||||
@@ -303,7 +325,7 @@ export default function SelectedGeometryPanel({
|
|||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
style={entityInputStyle}
|
style={entityInputStyle}
|
||||||
>
|
>
|
||||||
{!hasCurrentTypeOption && entityForm.type_id ? (
|
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||||
<option value={entityForm.type_id}>
|
<option value={entityForm.type_id}>
|
||||||
Custom Type ({entityForm.type_id})
|
Custom Type ({entityForm.type_id})
|
||||||
</option>
|
</option>
|
||||||
@@ -334,32 +356,10 @@ export default function SelectedGeometryPanel({
|
|||||||
|
|
||||||
{!isGeometryCompatible && selectedTypeOption ? (
|
{!isGeometryCompatible && selectedTypeOption ? (
|
||||||
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
|
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
|
||||||
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại là {geometryBucket}.
|
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại là <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<input
|
|
||||||
value={geometryMetaForm.time_start}
|
|
||||||
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
|
||||||
placeholder="time_start"
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={geometryMetaForm.time_end}
|
|
||||||
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
|
||||||
placeholder="time_end"
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={geometryMetaForm.binding}
|
|
||||||
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
|
|
||||||
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={entityInputStyle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onCreateEntityAndAttach}
|
onClick={onCreateEntityAndAttach}
|
||||||
disabled={isEntitySubmitting}
|
disabled={isEntitySubmitting}
|
||||||
@@ -371,26 +371,12 @@ export default function SelectedGeometryPanel({
|
|||||||
background: "#2563eb",
|
background: "#2563eb",
|
||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Tạo Entity + Gắn
|
Tạo entity mới + gắn + áp metadata
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onApplyEntitiesForSelectedGeometry}
|
|
||||||
disabled={isEntitySubmitting}
|
|
||||||
style={{
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "7px 8px",
|
|
||||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
|
||||||
background: "#0f766e",
|
|
||||||
color: "#ffffff",
|
|
||||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Áp dụng Entities + Metadata
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{changeCount > 0 ? (
|
{changeCount > 0 ? (
|
||||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = {
|
|||||||
color: "#ffffff",
|
color: "#ffffff",
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_COLORS: Record<
|
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||||
EntityTypeGroupId,
|
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||||
{
|
if (explicitPreset) return explicitPreset;
|
||||||
background: string;
|
|
||||||
border: string;
|
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||||
text: string;
|
if (semanticType) {
|
||||||
mutedText: string;
|
const option = findEntityTypeOption(semanticType);
|
||||||
chipBackground: string;
|
if (option) return option.geometryPreset;
|
||||||
chipBorder: string;
|
|
||||||
activeBackground: string;
|
|
||||||
activeBorder: string;
|
|
||||||
activeText: string;
|
|
||||||
}
|
}
|
||||||
> = {
|
|
||||||
split: {
|
|
||||||
background: "#3f1d24",
|
|
||||||
border: "#7f1d1d",
|
|
||||||
text: "#fecaca",
|
|
||||||
mutedText: "#fda4af",
|
|
||||||
chipBackground: "#4c1d27",
|
|
||||||
chipBorder: "#7f1d1d",
|
|
||||||
activeBackground: "#7f1d1d",
|
|
||||||
activeBorder: "#fecaca",
|
|
||||||
activeText: "#ffffff",
|
|
||||||
},
|
|
||||||
route: {
|
|
||||||
background: "#082f49",
|
|
||||||
border: "#0c4a6e",
|
|
||||||
text: "#bae6fd",
|
|
||||||
mutedText: "#7dd3fc",
|
|
||||||
chipBackground: "#0c4a6e",
|
|
||||||
chipBorder: "#155e75",
|
|
||||||
activeBackground: "#0369a1",
|
|
||||||
activeBorder: "#bae6fd",
|
|
||||||
activeText: "#f0f9ff",
|
|
||||||
},
|
|
||||||
area_polygon: {
|
|
||||||
background: "#1f3a2a",
|
|
||||||
border: "#166534",
|
|
||||||
text: "#bbf7d0",
|
|
||||||
mutedText: "#86efac",
|
|
||||||
chipBackground: "#14532d",
|
|
||||||
chipBorder: "#166534",
|
|
||||||
activeBackground: "#15803d",
|
|
||||||
activeBorder: "#bbf7d0",
|
|
||||||
activeText: "#f0fdf4",
|
|
||||||
},
|
|
||||||
area_circle: {
|
|
||||||
background: "#3f2b05",
|
|
||||||
border: "#92400e",
|
|
||||||
text: "#fde68a",
|
|
||||||
mutedText: "#fcd34d",
|
|
||||||
chipBackground: "#78350f",
|
|
||||||
chipBorder: "#92400e",
|
|
||||||
activeBackground: "#b45309",
|
|
||||||
activeBorder: "#fde68a",
|
|
||||||
activeText: "#fffbeb",
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
background: "#2e1065",
|
|
||||||
border: "#6d28d9",
|
|
||||||
text: "#ddd6fe",
|
|
||||||
mutedText: "#c4b5fd",
|
|
||||||
chipBackground: "#4c1d95",
|
|
||||||
chipBorder: "#6d28d9",
|
|
||||||
activeBackground: "#7c3aed",
|
|
||||||
activeBorder: "#ddd6fe",
|
|
||||||
activeText: "#f5f3ff",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type GeometryBucket = "point" | "line" | "polygon";
|
return mapGeometryTypeToPreset(feature.geometry.type);
|
||||||
|
}
|
||||||
|
|
||||||
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
|
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === "point" ||
|
||||||
|
normalized === "line" ||
|
||||||
|
normalized === "polygon" ||
|
||||||
|
normalized === "circle-area"
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTypeId(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.length ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGeometryTypeToPreset(
|
||||||
|
geometryType: Feature["geometry"]["type"]
|
||||||
|
): EntityGeometryPreset {
|
||||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
return "point";
|
return "point";
|
||||||
}
|
}
|
||||||
@@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu
|
|||||||
return "polygon";
|
return "polygon";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
|
function getAllowedGroupIdsForPreset(
|
||||||
if (option.geometryPreset === "point") {
|
geometryPreset: EntityGeometryPreset
|
||||||
return "point";
|
|
||||||
}
|
|
||||||
if (option.geometryPreset === "line") {
|
|
||||||
return "line";
|
|
||||||
}
|
|
||||||
return "polygon";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllowedGroupIdsForGeometry(
|
|
||||||
geometryType: Feature["geometry"]["type"]
|
|
||||||
): EntityTypeGroupId[] {
|
): EntityTypeGroupId[] {
|
||||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
if (geometryPreset === "point") {
|
||||||
return ["point"];
|
return ["point"];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
if (geometryPreset === "line") {
|
||||||
return ["split", "route"];
|
return ["line"];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["area_polygon", "area_circle"];
|
if (geometryPreset === "circle-area") {
|
||||||
|
return ["circle"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["polygon"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
|
||||||
|
if (preset === "point") return "point - Điểm";
|
||||||
|
if (preset === "line") return "line - Tuyến";
|
||||||
|
if (preset === "circle-area") return "circle - Tròn";
|
||||||
|
if (preset === "polygon") return "polygon - Đa giác";
|
||||||
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const BACKGROUND_LAYER_OPTIONS = [
|
|||||||
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
||||||
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||||
|
|
||||||
|
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||||
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||||
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||||
acc[option.id] = value;
|
acc[option.id] = value;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
|||||||
features: [],
|
features: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||||
export function initCircle(
|
export function initCircle(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
@@ -21,12 +22,14 @@ export function initCircle(
|
|||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let dragPanDisabledByCircle = false;
|
let dragPanDisabledByCircle = false;
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview circle trên map.
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
EMPTY_PREVIEW
|
EMPTY_PREVIEW
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||||
const releaseDragPan = () => {
|
const releaseDragPan = () => {
|
||||||
if (!dragPanDisabledByCircle) return;
|
if (!dragPanDisabledByCircle) return;
|
||||||
dragPanDisabledByCircle = false;
|
dragPanDisabledByCircle = false;
|
||||||
@@ -35,6 +38,7 @@ export function initCircle(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||||
const resetDrawingState = () => {
|
const resetDrawingState = () => {
|
||||||
center = null;
|
center = null;
|
||||||
radiusMeters = 0;
|
radiusMeters = 0;
|
||||||
@@ -43,6 +47,7 @@ export function initCircle(
|
|||||||
releaseDragPan();
|
releaseDragPan();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||||
const updatePreview = () => {
|
const updatePreview = () => {
|
||||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||||
clearPreview();
|
clearPreview();
|
||||||
@@ -65,6 +70,7 @@ export function initCircle(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||||
if (getMode() !== "add-circle") return;
|
if (getMode() !== "add-circle") return;
|
||||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
@@ -82,6 +88,7 @@ export function initCircle(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
const canvas = map.getCanvas();
|
const canvas = map.getCanvas();
|
||||||
if (getMode() !== "add-circle") {
|
if (getMode() !== "add-circle") {
|
||||||
@@ -101,6 +108,7 @@ export function initCircle(
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hoàn tất circle và trả geometry cho callback.
|
||||||
const finishCircle = () => {
|
const finishCircle = () => {
|
||||||
if (!isDragging || !center) {
|
if (!isDragging || !center) {
|
||||||
resetDrawingState();
|
resetDrawingState();
|
||||||
@@ -120,12 +128,14 @@ export function initCircle(
|
|||||||
resetDrawingState();
|
resetDrawingState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||||
if (getMode() !== "add-circle") return;
|
if (getMode() !== "add-circle") return;
|
||||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||||
finishCircle();
|
finishCircle();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (getMode() !== "add-circle") return;
|
if (getMode() !== "add-circle") return;
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
@@ -150,6 +160,7 @@ export function initCircle(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
function buildCircleRing(
|
function buildCircleRing(
|
||||||
center: [number, number],
|
center: [number, number],
|
||||||
radiusMeters: number,
|
radiusMeters: number,
|
||||||
@@ -163,6 +174,7 @@ function buildCircleRing(
|
|||||||
return ring;
|
return ring;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
|
||||||
function distanceMeters(a: [number, number], b: [number, number]): number {
|
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||||
const lat1 = toRad(a[1]);
|
const lat1 = toRad(a[1]);
|
||||||
const lat2 = toRad(b[1]);
|
const lat2 = toRad(b[1]);
|
||||||
@@ -178,6 +190,7 @@ function distanceMeters(a: [number, number], b: [number, number]): number {
|
|||||||
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||||
function destinationPoint(
|
function destinationPoint(
|
||||||
center: [number, number],
|
center: [number, number],
|
||||||
distance: number,
|
distance: number,
|
||||||
@@ -205,22 +218,26 @@ function destinationPoint(
|
|||||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||||
function normalizeLng(lng: number): number {
|
function normalizeLng(lng: number): number {
|
||||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||||
if (normalized === -180) normalized = 180;
|
if (normalized === -180) normalized = 180;
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kẹp giá trị trong đoạn [min, max].
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
if (value < min) return min;
|
if (value < min) return min;
|
||||||
if (value > max) return max;
|
if (value > max) return max;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Đổi đơn vị góc từ độ sang radian.
|
||||||
function toRad(value: number): number {
|
function toRad(value: number): number {
|
||||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Đổi đơn vị góc từ radian sang độ.
|
||||||
function toDeg(value: number): number {
|
function toDeg(value: number): number {
|
||||||
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Geometry } from "@/lib/useEditorState";
|
|||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||||
export function initDrawing(
|
export function initDrawing(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
@@ -10,9 +11,7 @@ export function initDrawing(
|
|||||||
) {
|
) {
|
||||||
let coords: [number, number][] = [];
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
/**
|
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||||
* Close polygon ring if not closed.
|
|
||||||
*/
|
|
||||||
function closePolygon(c: [number, number][]) {
|
function closePolygon(c: [number, number][]) {
|
||||||
if (c.length < 3) return c;
|
if (c.length < 3) return c;
|
||||||
const first = c[0];
|
const first = c[0];
|
||||||
@@ -24,9 +23,7 @@ export function initDrawing(
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cập nhật layer preview trong lúc đang vẽ.
|
||||||
* Update preview layer while drawing.
|
|
||||||
*/
|
|
||||||
function update(c: [number, number][]) {
|
function update(c: [number, number][]) {
|
||||||
const closed = closePolygon(c);
|
const closed = closePolygon(c);
|
||||||
|
|
||||||
@@ -45,6 +42,7 @@ export function initDrawing(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ghi nhận đỉnh polygon mới khi click map.
|
||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "draw") return;
|
if (getMode() !== "draw") return;
|
||||||
|
|
||||||
@@ -52,6 +50,7 @@ export function initDrawing(
|
|||||||
update(coords);
|
update(coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render preview polygon với điểm chuột hiện tại.
|
||||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "draw" || coords.length === 0) return;
|
if (getMode() !== "draw" || coords.length === 0) return;
|
||||||
|
|
||||||
@@ -62,9 +61,7 @@ export function initDrawing(
|
|||||||
update(preview);
|
update(preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||||
* Finalize polygon, emit geometry to caller, reset preview.
|
|
||||||
*/
|
|
||||||
function finishDrawing() {
|
function finishDrawing() {
|
||||||
if (getMode() !== "draw" || coords.length < 3) return;
|
if (getMode() !== "draw" || coords.length < 3) return;
|
||||||
|
|
||||||
@@ -83,6 +80,7 @@ export function initDrawing(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lắng nghe Enter để chốt polygon.
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
finishDrawing();
|
finishDrawing();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type EditingAPI = {
|
|||||||
bindEditEvents: (map: maplibregl.Map) => void;
|
bindEditEvents: (map: maplibregl.Map) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||||
export function createEditingEngine(options: {
|
export function createEditingEngine(options: {
|
||||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||||
@@ -22,6 +23,7 @@ export function createEditingEngine(options: {
|
|||||||
const dragStateRef = { current: null as { idx: number } | null };
|
const dragStateRef = { current: null as { idx: number } | null };
|
||||||
const modifierRef = { current: { ctrl: false, meta: false } };
|
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||||
|
|
||||||
|
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||||
const clearEditing = () => {
|
const clearEditing = () => {
|
||||||
editingRef.current = null;
|
editingRef.current = null;
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
@@ -32,6 +34,7 @@ export function createEditingEngine(options: {
|
|||||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||||
const updateEditSources = () => {
|
const updateEditSources = () => {
|
||||||
const editing = editingRef.current;
|
const editing = editingRef.current;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -62,6 +65,7 @@ export function createEditingEngine(options: {
|
|||||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||||
const finishEditing = () => {
|
const finishEditing = () => {
|
||||||
const editing = editingRef.current;
|
const editing = editingRef.current;
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -73,10 +77,12 @@ export function createEditingEngine(options: {
|
|||||||
clearEditing();
|
clearEditing();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
clearEditing();
|
clearEditing();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||||
if (feature.geometry.type !== "Polygon") return;
|
if (feature.geometry.type !== "Polygon") return;
|
||||||
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||||
@@ -92,6 +98,7 @@ export function createEditingEngine(options: {
|
|||||||
updateEditSources();
|
updateEditSources();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||||
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||||
const oe = e?.originalEvent as MouseEvent | undefined;
|
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +109,9 @@ export function createEditingEngine(options: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||||
const bindEditEvents = (map: maplibregl.Map) => {
|
const bindEditEvents = (map: maplibregl.Map) => {
|
||||||
|
// Bắt đầu kéo một handle point.
|
||||||
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
if (!editingRef.current) return;
|
if (!editingRef.current) return;
|
||||||
const feature = e.features?.[0];
|
const feature = e.features?.[0];
|
||||||
@@ -114,6 +123,7 @@ export function createEditingEngine(options: {
|
|||||||
map.dragPan.disable();
|
map.dragPan.disable();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||||
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||||
const drag = dragStateRef.current;
|
const drag = dragStateRef.current;
|
||||||
const editing = editingRef.current;
|
const editing = editingRef.current;
|
||||||
@@ -123,12 +133,14 @@ export function createEditingEngine(options: {
|
|||||||
updateEditSources();
|
updateEditSources();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||||
const stopDragging = () => {
|
const stopDragging = () => {
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
map.getCanvas().style.cursor = "";
|
map.getCanvas().style.cursor = "";
|
||||||
map.dragPan.enable();
|
map.dragPan.enable();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Control") {
|
if (e.key === "Control") {
|
||||||
modifierRef.current.ctrl = true;
|
modifierRef.current.ctrl = true;
|
||||||
@@ -143,6 +155,7 @@ export function createEditingEngine(options: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hạ cờ modifier khi nhả phím.
|
||||||
const onKeyUp = (e: KeyboardEvent) => {
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Control") {
|
if (e.key === "Control") {
|
||||||
modifierRef.current.ctrl = false;
|
modifierRef.current.ctrl = false;
|
||||||
@@ -151,6 +164,7 @@ export function createEditingEngine(options: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||||
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
if (!editingRef.current) return;
|
if (!editingRef.current) return;
|
||||||
if (!isModifierPressed(e)) return;
|
if (!isModifierPressed(e)) return;
|
||||||
@@ -177,6 +191,7 @@ export function createEditingEngine(options: {
|
|||||||
updateEditSources();
|
updateEditSources();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ngắt kéo nếu con trỏ rời canvas.
|
||||||
const onCanvasLeave = () => {
|
const onCanvasLeave = () => {
|
||||||
stopDragging();
|
stopDragging();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export type EntityTypeGroupId =
|
export type EntityTypeGroupId =
|
||||||
| "split"
|
| "line"
|
||||||
| "route"
|
| "polygon"
|
||||||
| "area_polygon"
|
| "circle"
|
||||||
| "area_circle"
|
|
||||||
| "point";
|
| "point";
|
||||||
|
|
||||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
@@ -24,43 +23,36 @@ export type EntityTypeOption = {
|
|||||||
|
|
||||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||||
{
|
{
|
||||||
id: "split",
|
id: "line",
|
||||||
label: "Split",
|
label: "line - Tuyến",
|
||||||
geometryLabel: "Line",
|
geometryLabel: "Line",
|
||||||
description: "Tuyến chia cắt/phòng thủ.",
|
description: "Các tuyến line/path (gấp khúc).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "polygon",
|
||||||
label: "Route",
|
label: "polygon - Đa giác",
|
||||||
geometryLabel: "Line",
|
|
||||||
description: "Các tuyến di chuyển theo hướng.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "area_polygon",
|
|
||||||
label: "Area - Đa giác",
|
|
||||||
geometryLabel: "Polygon",
|
geometryLabel: "Polygon",
|
||||||
description: "Vùng lãnh thổ dạng đa giác.",
|
description: "Vùng lãnh thổ dạng đa giác.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "area_circle",
|
id: "circle",
|
||||||
label: "Area - Tròn",
|
label: "circle - Tròn",
|
||||||
geometryLabel: "Polygon tròn",
|
geometryLabel: "Circle",
|
||||||
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "point",
|
id: "point",
|
||||||
label: "Point",
|
label: "point - Điểm",
|
||||||
geometryLabel: "Point",
|
geometryLabel: "Point",
|
||||||
description: "Địa điểm đơn lẻ.",
|
description: "Địa điểm đơn lẻ.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||||
split: ENTITY_TYPE_GROUPS[0],
|
line: ENTITY_TYPE_GROUPS[0],
|
||||||
route: ENTITY_TYPE_GROUPS[1],
|
polygon: ENTITY_TYPE_GROUPS[1],
|
||||||
area_polygon: ENTITY_TYPE_GROUPS[2],
|
circle: ENTITY_TYPE_GROUPS[2],
|
||||||
area_circle: ENTITY_TYPE_GROUPS[3],
|
point: ENTITY_TYPE_GROUPS[3],
|
||||||
point: ENTITY_TYPE_GROUPS[4],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||||
@@ -69,25 +61,25 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
|||||||
groupId: EntityTypeGroupId;
|
groupId: EntityTypeGroupId;
|
||||||
geometryPreset: EntityGeometryPreset;
|
geometryPreset: EntityGeometryPreset;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" },
|
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
{ value: "attack_route", label: "Attack Route", groupId: "route", geometryPreset: "line" },
|
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" },
|
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" },
|
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" },
|
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" },
|
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" },
|
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||||
{ value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" },
|
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
|
||||||
|
|
||||||
{ value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" },
|
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
{ value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" },
|
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
{ value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" },
|
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
{ value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" },
|
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||||
|
|
||||||
{ value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" },
|
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
{ value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" },
|
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
{ value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" },
|
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" },
|
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
|
||||||
|
|
||||||
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||||
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||||
@@ -109,6 +101,7 @@ export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.m
|
|||||||
|
|
||||||
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||||
|
|
||||||
|
// Gom option theo group để render select phân nhóm.
|
||||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||||
id: EntityTypeGroupId;
|
id: EntityTypeGroupId;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -122,8 +115,8 @@ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE
|
|||||||
})).filter((group) => group.options.length > 0);
|
})).filter((group) => group.options.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tìm option theo type id, trả null nếu không tồn tại.
|
||||||
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||||
if (!typeId) return null;
|
if (!typeId) return null;
|
||||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
|||||||
features: [],
|
features: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||||
export function initLine(
|
export function initLine(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
@@ -15,17 +16,20 @@ export function initLine(
|
|||||||
) {
|
) {
|
||||||
let coords: [number, number][] = [];
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview line.
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
EMPTY_PREVIEW
|
EMPTY_PREVIEW
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ line hiện tại.
|
||||||
const cancelLine = () => {
|
const cancelLine = () => {
|
||||||
coords = [];
|
coords = [];
|
||||||
clearPreview();
|
clearPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||||
const updatePreview = (lineCoords: [number, number][]) => {
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
if (lineCoords.length < 2) {
|
if (lineCoords.length < 2) {
|
||||||
clearPreview();
|
clearPreview();
|
||||||
@@ -47,6 +51,7 @@ export function initLine(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||||
const finishLine = () => {
|
const finishLine = () => {
|
||||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||||
|
|
||||||
@@ -59,12 +64,14 @@ export function initLine(
|
|||||||
cancelLine();
|
cancelLine();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||||
const removeLastVertex = () => {
|
const removeLastVertex = () => {
|
||||||
if (!coords.length) return;
|
if (!coords.length) return;
|
||||||
coords = coords.slice(0, -1);
|
coords = coords.slice(0, -1);
|
||||||
updatePreview(coords);
|
updatePreview(coords);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh line khi click map.
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
if (getMode() !== "add-line") return;
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
@@ -72,6 +79,7 @@ export function initLine(
|
|||||||
updatePreview(coords);
|
updatePreview(coords);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview động theo vị trí chuột.
|
||||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const canvas = map.getCanvas();
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
@@ -90,6 +98,7 @@ export function initLine(
|
|||||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (getMode() !== "add-line") return;
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
|||||||
features: [],
|
features: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||||
export function initPath(
|
export function initPath(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
@@ -15,12 +16,14 @@ export function initPath(
|
|||||||
) {
|
) {
|
||||||
let coords: [number, number][] = [];
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
// Xóa dữ liệu preview path.
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
EMPTY_PREVIEW
|
EMPTY_PREVIEW
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||||
const updatePreview = (lineCoords: [number, number][]) => {
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
if (lineCoords.length < 2) {
|
if (lineCoords.length < 2) {
|
||||||
clearPreview();
|
clearPreview();
|
||||||
@@ -42,6 +45,7 @@ export function initPath(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||||
const finishPath = () => {
|
const finishPath = () => {
|
||||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||||
|
|
||||||
@@ -55,17 +59,20 @@ export function initPath(
|
|||||||
clearPreview();
|
clearPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hủy phiên vẽ path hiện tại.
|
||||||
const cancelPath = () => {
|
const cancelPath = () => {
|
||||||
coords = [];
|
coords = [];
|
||||||
clearPreview();
|
clearPreview();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||||
const removeLastVertex = () => {
|
const removeLastVertex = () => {
|
||||||
if (coords.length === 0) return;
|
if (coords.length === 0) return;
|
||||||
coords = coords.slice(0, -1);
|
coords = coords.slice(0, -1);
|
||||||
updatePreview(coords);
|
updatePreview(coords);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Thêm một đỉnh path khi click map.
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
if (getMode() !== "add-path") return;
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ export function initPath(
|
|||||||
updatePreview(coords);
|
updatePreview(coords);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cập nhật preview path động theo vị trí chuột.
|
||||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const canvas = map.getCanvas();
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ export function initPath(
|
|||||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (getMode() !== "add-path") return;
|
if (getMode() !== "add-path") return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { Geometry } from "@/lib/useEditorState";
|
|||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
|
// Khởi tạo engine thêm point bằng click đơn.
|
||||||
export function initPoint(
|
export function initPoint(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
onComplete: (geometry: Geometry) => void
|
onComplete: (geometry: Geometry) => void
|
||||||
) {
|
) {
|
||||||
/**
|
// Thêm point mới khi đang ở chế độ add-point.
|
||||||
* Add a new point when in add-point mode.
|
|
||||||
*/
|
|
||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "add-point") return;
|
if (getMode() !== "add-point") return;
|
||||||
|
|
||||||
@@ -22,6 +21,7 @@ export function initPoint(
|
|||||||
onComplete?.(geometry);
|
onComplete?.(geometry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||||
function onMove() {
|
function onMove() {
|
||||||
const canvas = map.getCanvas();
|
const canvas = map.getCanvas();
|
||||||
if (getMode() === "add-point") {
|
if (getMode() === "add-point") {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import maplibregl from "maplibre-gl";
|
|||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
|
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||||
export function initSelect(
|
export function initSelect(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
getMode: ModeGetter,
|
getMode: ModeGetter,
|
||||||
onDelete: (id: string | number) => void,
|
onDelete?: (id: string | number) => void,
|
||||||
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void,
|
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||||
onSelectId?: (id: string | number | null) => void
|
onSelectId?: (id: string | number | null) => void
|
||||||
) {
|
) {
|
||||||
const SELECTABLE_LAYERS = [
|
const SELECTABLE_LAYERS = [
|
||||||
@@ -17,12 +18,11 @@ export function initSelect(
|
|||||||
"places-symbol",
|
"places-symbol",
|
||||||
] as const;
|
] as const;
|
||||||
const selectedIds = new Set<number | string>();
|
const selectedIds = new Set<number | string>();
|
||||||
|
const hasContextActions = Boolean(onDelete || onEdit);
|
||||||
let contextMenu: HTMLDivElement | null = null;
|
let contextMenu: HTMLDivElement | null = null;
|
||||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
/**
|
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||||
* Clear feature-state highlight for all selected features.
|
|
||||||
*/
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
if (!selectedIds.size) return;
|
if (!selectedIds.size) return;
|
||||||
selectedIds.forEach((id) => {
|
selectedIds.forEach((id) => {
|
||||||
@@ -32,9 +32,7 @@ export function initSelect(
|
|||||||
onSelectId?.(null);
|
onSelectId?.(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||||
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
|
|
||||||
*/
|
|
||||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||||
const id = feature.id ?? feature.properties?.id;
|
const id = feature.id ?? feature.properties?.id;
|
||||||
if (id === undefined || id === null) return;
|
if (id === undefined || id === null) return;
|
||||||
@@ -56,6 +54,7 @@ export function initSelect(
|
|||||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
@@ -72,9 +71,8 @@ export function initSelect(
|
|||||||
selectFeature(features[0], additive);
|
selectFeature(features[0], additive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||||
* Show context menu (edit/delete) on right click.
|
// Mở menu thao tác khi click phải lên feature.
|
||||||
*/
|
|
||||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
@@ -103,6 +101,7 @@ export function initSelect(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
if (getMode() !== "select") return;
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
@@ -115,15 +114,20 @@ export function initSelect(
|
|||||||
|
|
||||||
map.on("click", onClick);
|
map.on("click", onClick);
|
||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
map.on("contextmenu", onRightClick);
|
map.on("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.off("click", onClick);
|
map.off("click", onClick);
|
||||||
map.off("mousemove", onMove);
|
map.off("mousemove", onMove);
|
||||||
|
if (hasContextActions) {
|
||||||
map.off("contextmenu", onRightClick);
|
map.off("contextmenu", onRightClick);
|
||||||
|
}
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ẩn và dọn dẹp context menu hiện tại.
|
||||||
function hideContextMenu() {
|
function hideContextMenu() {
|
||||||
if (contextMenu) {
|
if (contextMenu) {
|
||||||
contextMenu.remove();
|
contextMenu.remove();
|
||||||
@@ -135,9 +139,7 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||||
* Render a minimal context menu near cursor.
|
|
||||||
*/
|
|
||||||
function showContextMenu(
|
function showContextMenu(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
@@ -159,6 +161,7 @@ export function initSelect(
|
|||||||
menu.style.fontSize = "14px";
|
menu.style.fontSize = "14px";
|
||||||
menu.style.padding = "4px 0";
|
menu.style.padding = "4px 0";
|
||||||
|
|
||||||
|
// Tạo một item thao tác trong context menu.
|
||||||
const createItem = (label: string, onClick: () => void) => {
|
const createItem = (label: string, onClick: () => void) => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.textContent = label;
|
item.textContent = label;
|
||||||
@@ -174,12 +177,15 @@ export function initSelect(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedCount = selectedIds.size || 1;
|
const selectedCount = selectedIds.size || 1;
|
||||||
|
let hasMenuItems = false;
|
||||||
|
|
||||||
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon") {
|
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon" && onEdit) {
|
||||||
const single = clickedFeature;
|
const single = clickedFeature;
|
||||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||||
|
hasMenuItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onDelete) {
|
||||||
menu.appendChild(
|
menu.appendChild(
|
||||||
createItem(
|
createItem(
|
||||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||||
@@ -194,10 +200,15 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
hasMenuItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMenuItems) return;
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
contextMenu = menu;
|
contextMenu = menu;
|
||||||
|
|
||||||
|
// Đóng menu khi click ra ngoài vùng menu.
|
||||||
const onDocClick = (ev: MouseEvent) => {
|
const onDocClick = (ev: MouseEvent) => {
|
||||||
if (!menu.contains(ev.target as Node)) {
|
if (!menu.contains(ev.target as Node)) {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
|
||||||
* Basic GeoJSON geometry union (no GeometryCollection).
|
|
||||||
*/
|
|
||||||
export type Geometry =
|
export type Geometry =
|
||||||
| { type: "Point"; coordinates: [number, number] }
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
@@ -13,10 +11,11 @@ export type Geometry =
|
|||||||
|
|
||||||
export type FeatureProperties = {
|
export type FeatureProperties = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
type?: string | null;
|
||||||
|
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
binding?: string[];
|
binding?: string[];
|
||||||
line_mode?: string | null;
|
|
||||||
entity_id?: string | null;
|
entity_id?: string | null;
|
||||||
entity_ids?: string[];
|
entity_ids?: string[];
|
||||||
entity_name?: string | null;
|
entity_name?: string | null;
|
||||||
@@ -35,29 +34,28 @@ export type FeatureCollection = {
|
|||||||
features: Feature[];
|
features: Feature[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
|
||||||
* Change map entry for saving.
|
|
||||||
*/
|
|
||||||
export type Change =
|
export type Change =
|
||||||
| { type: "create"; feature: Feature }
|
| { action: "create"; feature: Feature }
|
||||||
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||||
| { type: "delete"; id: FeatureProperties["id"] };
|
| { action: "delete"; id: FeatureProperties["id"] };
|
||||||
|
|
||||||
/**
|
// Kiểu bản ghi undo tối thiểu.
|
||||||
* Minimal undo record.
|
|
||||||
*/
|
|
||||||
export type UndoAction =
|
export type UndoAction =
|
||||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
| { type: "delete"; feature: Feature }
|
| { type: "delete"; feature: Feature }
|
||||||
| { type: "create"; id: FeatureProperties["id"] };
|
| { 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));
|
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 {
|
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||||
function buildInitialMap(fc: FeatureCollection) {
|
function buildInitialMap(fc: FeatureCollection) {
|
||||||
const map = new Map<FeatureProperties["id"], Geometry>();
|
const map = new Map<FeatureProperties["id"], Geometry>();
|
||||||
for (const f of fc.features) {
|
for (const f of fc.features) {
|
||||||
@@ -66,6 +64,7 @@ function buildInitialMap(fc: FeatureCollection) {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
|
||||||
function diffDraftToInitial(
|
function diffDraftToInitial(
|
||||||
draft: FeatureCollection,
|
draft: FeatureCollection,
|
||||||
initialMap: Map<FeatureProperties["id"], Geometry>
|
initialMap: Map<FeatureProperties["id"], Geometry>
|
||||||
@@ -81,22 +80,23 @@ function diffDraftToInitial(
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
const initialGeom = initialMap.get(id);
|
const initialGeom = initialMap.get(id);
|
||||||
if (!initialGeom) {
|
if (!initialGeom) {
|
||||||
next.set(id, { type: "create", feature: deepClone(f) });
|
next.set(id, { action: "create", feature: deepClone(f) });
|
||||||
} else if (!geometryEquals(initialGeom, f.geometry)) {
|
} else if (!geometryEquals(initialGeom, f.geometry)) {
|
||||||
next.set(id, { type: "update", id, geometry: deepClone(f.geometry) });
|
next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deletions
|
// deletions
|
||||||
for (const [id] of initialMap.entries()) {
|
for (const [id] of initialMap.entries()) {
|
||||||
if (!seen.has(id)) {
|
if (!seen.has(id)) {
|
||||||
next.set(id, { type: "delete", id });
|
next.set(id, { action: "delete", id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
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) {
|
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||||
if (!a) return false;
|
if (!a) return false;
|
||||||
if (a.type !== b.type) return false;
|
if (a.type !== b.type) return false;
|
||||||
@@ -124,12 +124,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// State trung tâm của editor:
|
||||||
* Central state for the editor.
|
// - draft: dữ liệu nguồn để render UI
|
||||||
* - draft: source of truth for UI rendering
|
// - changes: map các thay đổi chờ lưu
|
||||||
* - changes: map of pending changes for save
|
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||||
* - undoStack: minimal actions to revert last step
|
|
||||||
*/
|
|
||||||
export function useEditorState(initialData: FeatureCollection) {
|
export function useEditorState(initialData: FeatureCollection) {
|
||||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||||
@@ -168,6 +166,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
draftRef.current = draft;
|
draftRef.current = draft;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
|
// Đẩy undo action mới vào stack nếu khác action gần nhất.
|
||||||
function pushUndo(action: UndoAction) {
|
function pushUndo(action: UndoAction) {
|
||||||
setUndoStack((prev) => {
|
setUndoStack((prev) => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
@@ -176,9 +175,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Thêm feature mới vào draft và ghi nhận thao tác "create".
|
||||||
* Add new feature to draft and record "create".
|
|
||||||
*/
|
|
||||||
function createFeature(feature: Feature) {
|
function createFeature(feature: Feature) {
|
||||||
const featureClone = deepClone(feature);
|
const featureClone = deepClone(feature);
|
||||||
commitDraft({
|
commitDraft({
|
||||||
@@ -188,9 +185,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
|
||||||
* Patch non-geometry properties on a feature (used for entity/time metadata).
|
|
||||||
*/
|
|
||||||
function patchFeatureProperties(
|
function patchFeatureProperties(
|
||||||
id: FeatureProperties["id"],
|
id: FeatureProperties["id"],
|
||||||
patch: Partial<FeatureProperties>
|
patch: Partial<FeatureProperties>
|
||||||
@@ -209,9 +204,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
|
||||||
* Update geometry of an existing feature and record change.
|
|
||||||
*/
|
|
||||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||||
if (idx === -1) return; // nothing to update
|
if (idx === -1) return; // nothing to update
|
||||||
@@ -231,9 +224,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Xóa feature khỏi draft và ghi nhận thao tác delete.
|
||||||
* Remove a feature from draft and record delete.
|
|
||||||
*/
|
|
||||||
function deleteFeature(id: FeatureProperties["id"]) {
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@@ -247,9 +238,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
|
||||||
* Undo last action, reverting both draft and change map.
|
|
||||||
*/
|
|
||||||
function undo() {
|
function undo() {
|
||||||
let applied = false; // guards against React StrictMode double invoke of setState updater
|
let applied = false; // guards against React StrictMode double invoke of setState updater
|
||||||
setUndoStack((prev) => {
|
setUndoStack((prev) => {
|
||||||
@@ -294,22 +283,19 @@ export function useEditorState(initialData: FeatureCollection) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Dựng mảng payload gửi API save từ map changes hiện tại.
|
||||||
* Build payload array for API save.
|
|
||||||
*/
|
|
||||||
function buildPayload(): Change[] {
|
function buildPayload(): Change[] {
|
||||||
return Array.from(changes.values()).map((c) => deepClone(c));
|
return Array.from(changes.values()).map((c) => deepClone(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Xóa thay đổi đang chờ sau khi lưu thành công.
|
||||||
* Clear pending changes after successful save.
|
|
||||||
*/
|
|
||||||
function clearChanges() {
|
function clearChanges() {
|
||||||
setUndoStack([]);
|
setUndoStack([]);
|
||||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||||
setBaselineVersion((v) => v + 1);
|
setBaselineVersion((v) => v + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
|
||||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||||
return initialMapRef.current.has(id);
|
return initialMapRef.current.has(id);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user