preview map editor 60%
This commit is contained in:
@@ -13,6 +13,7 @@ export type GeometriesBBoxQuery = {
|
||||
|
||||
export type GeometryCreatePayload = {
|
||||
geometry: Geometry;
|
||||
type?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
@@ -22,6 +23,7 @@ export type GeometryCreatePayload = {
|
||||
|
||||
export type GeometryUpdatePayload = {
|
||||
geometry: Geometry;
|
||||
type?: string | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
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;
|
||||
}
|
||||
767
app/page.tsx
767
app/page.tsx
@@ -1,27 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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 { fetchGeometriesByBBox } from "@/api/geometries";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||
import {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
useEditorState,
|
||||
} from "@/lib/useEditorState";
|
||||
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
||||
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";
|
||||
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
const WORLD_BBOX = {
|
||||
@@ -43,43 +36,9 @@ type TimelineRange = {
|
||||
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 [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
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);
|
||||
@@ -93,170 +52,27 @@ export default function Page() {
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => loadBackgroundLayerVisibilityFromStorage()
|
||||
() => ({ ...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 selectedEntity =
|
||||
entities.find((entity) => entity.id === selectedEntityId) || null;
|
||||
const selectedFeature =
|
||||
selectedFeatureId === null
|
||||
? null
|
||||
: editor.draft.features.find((feature) =>
|
||||
: initialData.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);
|
||||
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(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = editor.draft.features.some((feature) =>
|
||||
const stillExists = initialData.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.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]);
|
||||
}, [initialData, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -316,8 +132,9 @@ export default function Page() {
|
||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||
|
||||
useEffect(() => {
|
||||
persistBackgroundLayerVisibility(backgroundVisibility);
|
||||
}, [backgroundVisibility]);
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
@@ -367,63 +184,29 @@ export default function Page() {
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = editor.buildPayload();
|
||||
if (!payload.length) return;
|
||||
|
||||
const invalid = payload.find((change) => {
|
||||
if (change.type === "delete") return false;
|
||||
const draftFeature = change.type === "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;
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||
) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
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) => {
|
||||
setBackgroundVisibility((prev) => ({
|
||||
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShowAllBackgroundLayers = () => {
|
||||
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY });
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAllBackgroundLayers = () => {
|
||||
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||
@@ -442,291 +225,28 @@ export default function Page() {
|
||||
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);
|
||||
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);
|
||||
};
|
||||
const timelineDisabled = !isTimelineReady;
|
||||
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
||||
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
||||
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
||||
const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
|
||||
|
||||
return (
|
||||
<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" }}>
|
||||
<Map
|
||||
mode={mode}
|
||||
draft={editor.draft}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
selectedEntityId={selectedEntityId}
|
||||
selectedEntityName={selectedEntity?.name || null}
|
||||
selectedEntityTypeId={selectedEntity?.type_id || null}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
onCreateFeature={handleCreateFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode="select"
|
||||
draft={initialData}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
<TimelineBar
|
||||
minYear={timelineRange.min}
|
||||
maxYear={timelineRange.max}
|
||||
@@ -738,7 +258,7 @@ export default function Page() {
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={timelineDisabled}
|
||||
statusText={timelineStatusText}
|
||||
statusText={timelineStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -748,39 +268,57 @@ export default function Page() {
|
||||
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
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
|
||||
Viewer
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Click trực tiếp trên map để chọn geometry.
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Type: {selectedType}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Time: {selectedTimeSummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Entities: {selectedEntitySummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Binding: {selectedBindingSummary}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
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 formatSelectedTimeSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const start = isYearNumber(feature.properties.time_start)
|
||||
? String(feature.properties.time_start)
|
||||
: "?";
|
||||
const end = isYearNumber(feature.properties.time_end)
|
||||
? String(feature.properties.time_end)
|
||||
: "?";
|
||||
return `${start} → ${end}`;
|
||||
}
|
||||
|
||||
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)
|
||||
function formatSelectedEntitySummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
|
||||
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) {
|
||||
return uniqueEntityIds(fromArray);
|
||||
if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
|
||||
return feature.properties.entity_name.trim();
|
||||
}
|
||||
|
||||
const single = feature.properties.entity_id;
|
||||
if (typeof single === "string" && single.trim().length > 0) {
|
||||
return [single.trim()];
|
||||
const ids = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||
: [];
|
||||
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[] {
|
||||
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())
|
||||
function formatSelectedBindingSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const binding = Array.isArray(feature.properties.binding)
|
||||
? feature.properties.binding
|
||||
.map((item) => {
|
||||
if (typeof item !== "string" && typeof item !== "number") return "";
|
||||
return String(item).trim();
|
||||
})
|
||||
.filter((item) => item.length > 0)
|
||||
);
|
||||
: [];
|
||||
if (!binding.length) return "Không có";
|
||||
return binding.join(", ");
|
||||
}
|
||||
|
||||
function formatBindingIdsForDisplay(feature: Feature): string {
|
||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||
if (!bindingIds.length) return "Không có";
|
||||
return bindingIds.join(", ");
|
||||
function resolveSelectedTypeSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||
if (!typeId) return "Không có";
|
||||
const option = findEntityTypeOption(typeId);
|
||||
if (!option) return typeId;
|
||||
return `${option.label} (${option.groupLabel})`;
|
||||
}
|
||||
|
||||
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 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 normalizeTypeId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
|
||||
@@ -3,18 +3,10 @@
|
||||
import { UndoAction } from "@/lib/useEditorState";
|
||||
|
||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
type EntityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
geometry_count?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mode: Mode;
|
||||
setMode: (mode: Mode) => void;
|
||||
entities: EntityOption[];
|
||||
selectedEntityId: string | null;
|
||||
onSelectEntityId: (entityId: string | null) => void;
|
||||
entityStatus?: string | null;
|
||||
onUndo: () => void;
|
||||
onSave: () => void;
|
||||
@@ -26,9 +18,6 @@ type Props = {
|
||||
export default function Editor({
|
||||
mode,
|
||||
setMode,
|
||||
entities,
|
||||
selectedEntityId,
|
||||
onSelectEntityId,
|
||||
entityStatus,
|
||||
onUndo,
|
||||
onSave,
|
||||
@@ -148,48 +137,21 @@ export default function Editor({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
{entityStatus ? (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
fontSize: "13px",
|
||||
marginTop: "12px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
color: "#fca5a5",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<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.
|
||||
{entityStatus}
|
||||
</div>
|
||||
{entityStatus ? (
|
||||
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
|
||||
{entityStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
|
||||
@@ -20,13 +20,12 @@ type MapProps = {
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
selectedFeatureId: string | number | null;
|
||||
selectedEntityId: string | null;
|
||||
selectedEntityName: string | null;
|
||||
selectedEntityTypeId: string | null;
|
||||
onSelectFeatureId: (id: string | number | null) => void;
|
||||
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature: (id: string | number) => void;
|
||||
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
|
||||
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature?: (id: string | number) => void;
|
||||
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
|
||||
allowGeometryEditing?: boolean;
|
||||
respectBindingFilter?: boolean;
|
||||
};
|
||||
|
||||
type PointIconSpec = {
|
||||
@@ -40,6 +39,9 @@ const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||
const MAP_MIN_ZOOM = 1;
|
||||
const MAP_MAX_ZOOM = 10;
|
||||
const RASTER_BASE_SOURCE_ID = "rasterBase";
|
||||
const RASTER_BASE_LAYER_ID = "raster-base-layer";
|
||||
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
|
||||
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||
"coalesce",
|
||||
["get", "MAPCOLOR7"],
|
||||
@@ -103,6 +105,16 @@ const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||
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> = {
|
||||
person_deathplace: "#dc2626",
|
||||
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-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-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
||||
@@ -161,26 +173,22 @@ export default function Map({
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
selectedFeatureId,
|
||||
selectedEntityId,
|
||||
selectedEntityName,
|
||||
selectedEntityTypeId,
|
||||
onSelectFeatureId,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onUpdateFeature,
|
||||
allowGeometryEditing = true,
|
||||
respectBindingFilter = true,
|
||||
}: MapProps) {
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
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 onCreateRef = useRef(onCreateFeature);
|
||||
const onDeleteRef = useRef(onDeleteFeature);
|
||||
const onUpdateRef = useRef(onUpdateFeature);
|
||||
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
|
||||
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
|
||||
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||
|
||||
@@ -192,7 +200,7 @@ export default function Map({
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
if (mode !== "add-line") {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
@@ -221,18 +229,6 @@ export default function Map({
|
||||
selectedFeatureIdRef.current = selectedFeatureId;
|
||||
}, [selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityIdRef.current = selectedEntityId;
|
||||
}, [selectedEntityId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityNameRef.current = selectedEntityName;
|
||||
}, [selectedEntityName]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityTypeIdRef.current = selectedEntityTypeId;
|
||||
}, [selectedEntityTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||
}, [onSelectFeatureId]);
|
||||
@@ -281,12 +277,14 @@ export default function Map({
|
||||
// clear all feature-state (selection) to prevent ghost layers after undo
|
||||
map.removeFeatureState({ source: "countries" });
|
||||
|
||||
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
|
||||
const visibleDraft = respectBindingFilter
|
||||
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
|
||||
: fc;
|
||||
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||
|
||||
countriesSource.setData(polygons);
|
||||
placesSource.setData(points);
|
||||
}, []);
|
||||
}, [respectBindingFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = new maplibregl.Map({
|
||||
@@ -297,13 +295,6 @@ export default function Map({
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
rasterBase: {
|
||||
type: "raster",
|
||||
tiles: [getRasterTileTemplateUrl()],
|
||||
tileSize: 256,
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
@@ -319,15 +310,6 @@ export default function Map({
|
||||
"background-color": "#0b1220",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "raster-base-layer",
|
||||
type: "raster",
|
||||
source: "rasterBase",
|
||||
paint: {
|
||||
"raster-opacity": 0.92,
|
||||
"raster-resampling": "linear",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "graticules-line",
|
||||
type: "line",
|
||||
@@ -450,7 +432,6 @@ export default function Map({
|
||||
mapRef.current = map;
|
||||
|
||||
map.on("load", async () => {
|
||||
const geometryMinZoom = 5;
|
||||
const syncZoomLevel = () => {
|
||||
setZoomLevel(roundZoom(map.getZoom()));
|
||||
};
|
||||
@@ -633,7 +614,6 @@ export default function Map({
|
||||
id: "routes-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
minzoom: geometryMinZoom,
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": [
|
||||
@@ -661,11 +641,10 @@ export default function Map({
|
||||
id: "routes-arrow",
|
||||
type: "symbol",
|
||||
source: "countries",
|
||||
minzoom: geometryMinZoom,
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
|
||||
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
|
||||
],
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
@@ -723,7 +702,6 @@ export default function Map({
|
||||
id: "places-circle",
|
||||
type: "circle",
|
||||
source: "places",
|
||||
minzoom: geometryMinZoom,
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"case",
|
||||
@@ -749,7 +727,6 @@ export default function Map({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: geometryMinZoom,
|
||||
layout: {
|
||||
"icon-image": buildPointIconExpression(),
|
||||
"icon-size": 0.5,
|
||||
@@ -765,14 +742,16 @@ export default function Map({
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
type: null,
|
||||
geometry_preset: "polygon",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
@@ -783,13 +762,17 @@ export default function Map({
|
||||
const cleanupSelect = initSelect(
|
||||
map,
|
||||
() => modeRef.current,
|
||||
(id: string | number) => {
|
||||
// ensure edit overlays are cleared when a feature gets removed
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdRef.current?.(null);
|
||||
onDeleteRef.current(id);
|
||||
},
|
||||
(feature) => editingEngineRef.current?.beginEditing(feature),
|
||||
allowGeometryEditing
|
||||
? (id: string | number) => {
|
||||
// ensure edit overlays are cleared when a feature gets removed
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdRef.current?.(null);
|
||||
onDeleteRef.current?.(id);
|
||||
}
|
||||
: undefined,
|
||||
allowGeometryEditing
|
||||
? (feature) => editingEngineRef.current?.beginEditing(feature)
|
||||
: undefined,
|
||||
(id) => onSelectFeatureIdRef.current?.(id)
|
||||
);
|
||||
|
||||
@@ -798,14 +781,16 @@ export default function Map({
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
type: null,
|
||||
geometry_preset: "point",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
@@ -818,15 +803,16 @@ export default function Map({
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
line_mode: "line",
|
||||
type: "defense_line",
|
||||
geometry_preset: "line",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
@@ -839,15 +825,16 @@ export default function Map({
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
line_mode: "path",
|
||||
type: "attack_route",
|
||||
geometry_preset: "line",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
@@ -860,14 +847,16 @@ export default function Map({
|
||||
() => modeRef.current,
|
||||
(geometry: Geometry) => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
onCreateRef.current?.({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
type: null,
|
||||
geometry_preset: "circle-area",
|
||||
entity_id: null,
|
||||
entity_ids: [],
|
||||
entity_name: null,
|
||||
entity_type_id: null,
|
||||
binding: [],
|
||||
},
|
||||
geometry,
|
||||
@@ -888,11 +877,18 @@ export default function Map({
|
||||
// after everything mounted, push current draft to sources
|
||||
applyDraftToMap(draftRef.current);
|
||||
|
||||
editingEngineRef.current?.bindEditEvents(map);
|
||||
if (allowGeometryEditing) {
|
||||
editingEngineRef.current?.bindEditEvents(map);
|
||||
}
|
||||
});
|
||||
|
||||
return () => map.remove();
|
||||
}, [applyDraftToMap]);
|
||||
return () => {
|
||||
if (mapRef.current === map) {
|
||||
mapRef.current = null;
|
||||
}
|
||||
map.remove();
|
||||
};
|
||||
}, [allowGeometryEditing, applyDraftToMap]);
|
||||
|
||||
const handleZoomByStep = (delta: number) => {
|
||||
const map = mapRef.current;
|
||||
@@ -912,13 +908,13 @@ export default function Map({
|
||||
useEffect(() => {
|
||||
applyDraftToMap(draft);
|
||||
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);
|
||||
if (!stillExists) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
}
|
||||
}, [draft, selectedFeatureId, applyDraftToMap]);
|
||||
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
|
||||
@@ -1004,7 +1000,10 @@ function applyBackgroundLayerVisibility(
|
||||
map: maplibregl.Map,
|
||||
visibility: BackgroundLayerVisibility
|
||||
) {
|
||||
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
if (layer.id === RASTER_BASE_LAYER_ID) continue;
|
||||
if (!map.getLayer(layer.id)) continue;
|
||||
map.setLayoutProperty(
|
||||
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(
|
||||
fc: FeatureCollection,
|
||||
selectedFeatureId: string | number | null
|
||||
@@ -1158,7 +1213,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
||||
}
|
||||
|
||||
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)) {
|
||||
expression.push(typeId, iconId);
|
||||
@@ -1169,10 +1224,10 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
||||
}
|
||||
|
||||
function buildTypeMatchExpression(
|
||||
valueByType: Record<string, string | number>,
|
||||
fallback: string | number
|
||||
valueByType: Record<string, string | number | boolean>,
|
||||
fallback: string | number | boolean
|
||||
): maplibregl.ExpressionSpecification {
|
||||
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
||||
const expression: unknown[] = ["match", getFeatureTypeExpression()];
|
||||
|
||||
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||
expression.push(typeId, value);
|
||||
@@ -1182,6 +1237,15 @@ function buildTypeMatchExpression(
|
||||
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 {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
|
||||
import { Entity } from "@/api/entities";
|
||||
import { Feature } from "@/lib/useEditorState";
|
||||
import {
|
||||
EntityGeometryPreset,
|
||||
EntityTypeGroupId,
|
||||
EntityTypeOption,
|
||||
findEntityTypeOption,
|
||||
@@ -74,20 +75,22 @@ export default function SelectedGeometryPanel({
|
||||
entityFormStatus,
|
||||
}: Props) {
|
||||
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||
const allowedGroupIds = selectedFeature
|
||||
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
|
||||
const featureGeometryPreset = selectedFeature
|
||||
? resolveFeatureGeometryPreset(selectedFeature)
|
||||
: null;
|
||||
const allowedGroupIds = featureGeometryPreset
|
||||
? getAllowedGroupIdsForPreset(featureGeometryPreset)
|
||||
: [];
|
||||
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||
allowedGroupIds.includes(group.id)
|
||||
);
|
||||
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
||||
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
|
||||
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
|
||||
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
|
||||
const isGeometryCompatible =
|
||||
geometryBucket && selectedTypeBucket
|
||||
? geometryBucket === selectedTypeBucket
|
||||
: true;
|
||||
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
|
||||
group.options.some((option) => option.value === entityForm.type_id)
|
||||
);
|
||||
const isGeometryCompatible = selectedTypeOption
|
||||
? allowedGroupIds.includes(selectedTypeOption.groupId)
|
||||
: true;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -117,6 +120,9 @@ export default function SelectedGeometryPanel({
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Binding hiện tại: {selectedFeatureBindingSummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
|
||||
</div>
|
||||
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Entities đã chọn:
|
||||
@@ -166,60 +172,10 @@ export default function SelectedGeometryPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
value={entitySearchQuery}
|
||||
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
||||
placeholder="Search entity theo name..."
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={selectedSearchEntityId || ""}
|
||||
onChange={(event) =>
|
||||
onSelectSearchEntityId(event.target.value ? event.target.value : null)
|
||||
}
|
||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
<option value="">-- Chọn entity từ kết quả search --</option>
|
||||
{entitySearchResults.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name} ({entity.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSelectedSearchEntity}
|
||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||
style={secondaryActionButtonStyle}
|
||||
>
|
||||
Chọn để thêm
|
||||
</button>
|
||||
{isEntitySearchLoading ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||
Đang tìm entity...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Geometry phải có ít nhất 1 entity để Save.
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.slug}
|
||||
onChange={(event) => onEntityFormChange("slug", event.target.value)}
|
||||
placeholder="Slug"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -230,80 +186,146 @@ export default function SelectedGeometryPanel({
|
||||
background: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Entity type presets
|
||||
<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" }}>
|
||||
Chọn nhanh theo nhóm hình học bạn cần vẽ.
|
||||
`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
|
||||
value={entitySearchQuery}
|
||||
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
||||
placeholder="Search entity theo name..."
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={selectedSearchEntityId || ""}
|
||||
onChange={(event) =>
|
||||
onSelectSearchEntityId(event.target.value ? event.target.value : null)
|
||||
}
|
||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
<option value="">-- Chọn entity từ kết quả search --</option>
|
||||
{entitySearchResults.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name} ({entity.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSelectedSearchEntity}
|
||||
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||
style={secondaryActionButtonStyle}
|
||||
>
|
||||
Thêm entity đã chọn vào danh sách gắn
|
||||
</button>
|
||||
{isEntitySearchLoading ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||
Đang tìm entity...
|
||||
</div>
|
||||
) : 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={{
|
||||
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>
|
||||
|
||||
{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>
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.slug}
|
||||
onChange={(event) => onEntityFormChange("slug", event.target.value)}
|
||||
placeholder="Slug"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||
Chọn loại entity
|
||||
</div>
|
||||
<select
|
||||
value={entityForm.type_id}
|
||||
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{!hasCurrentTypeOption && entityForm.type_id ? (
|
||||
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
|
||||
<option value={entityForm.type_id}>
|
||||
Custom Type ({entityForm.type_id})
|
||||
</option>
|
||||
@@ -334,32 +356,10 @@ export default function SelectedGeometryPanel({
|
||||
|
||||
{!isGeometryCompatible && selectedTypeOption ? (
|
||||
<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>
|
||||
) : 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
|
||||
onClick={onCreateEntityAndAttach}
|
||||
disabled={isEntitySubmitting}
|
||||
@@ -371,26 +371,12 @@ export default function SelectedGeometryPanel({
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Tạo Entity + Gắn
|
||||
</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
|
||||
Tạo entity mới + gắn + áp metadata
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = {
|
||||
color: "#ffffff",
|
||||
};
|
||||
|
||||
const GROUP_COLORS: Record<
|
||||
EntityTypeGroupId,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
mutedText: string;
|
||||
chipBackground: string;
|
||||
chipBorder: string;
|
||||
activeBackground: string;
|
||||
activeBorder: string;
|
||||
activeText: string;
|
||||
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
|
||||
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
|
||||
if (explicitPreset) return explicitPreset;
|
||||
|
||||
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||
if (semanticType) {
|
||||
const option = findEntityTypeOption(semanticType);
|
||||
if (option) return option.geometryPreset;
|
||||
}
|
||||
> = {
|
||||
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") {
|
||||
return "point";
|
||||
}
|
||||
@@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu
|
||||
return "polygon";
|
||||
}
|
||||
|
||||
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
|
||||
if (option.geometryPreset === "point") {
|
||||
return "point";
|
||||
}
|
||||
if (option.geometryPreset === "line") {
|
||||
return "line";
|
||||
}
|
||||
return "polygon";
|
||||
}
|
||||
|
||||
function getAllowedGroupIdsForGeometry(
|
||||
geometryType: Feature["geometry"]["type"]
|
||||
function getAllowedGroupIdsForPreset(
|
||||
geometryPreset: EntityGeometryPreset
|
||||
): EntityTypeGroupId[] {
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
if (geometryPreset === "point") {
|
||||
return ["point"];
|
||||
}
|
||||
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return ["split", "route"];
|
||||
if (geometryPreset === "line") {
|
||||
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 BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||
|
||||
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.id] = value;
|
||||
|
||||
@@ -11,6 +11,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
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(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -21,12 +22,14 @@ export function initCircle(
|
||||
let isDragging = false;
|
||||
let dragPanDisabledByCircle = false;
|
||||
|
||||
// Xóa dữ liệu preview circle trên map.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||
const releaseDragPan = () => {
|
||||
if (!dragPanDisabledByCircle) return;
|
||||
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 = () => {
|
||||
center = null;
|
||||
radiusMeters = 0;
|
||||
@@ -43,6 +47,7 @@ export function initCircle(
|
||||
releaseDragPan();
|
||||
};
|
||||
|
||||
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||
const updatePreview = () => {
|
||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||
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) => {
|
||||
if (getMode() !== "add-circle") 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 canvas = map.getCanvas();
|
||||
if (getMode() !== "add-circle") {
|
||||
@@ -101,6 +108,7 @@ export function initCircle(
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
// Hoàn tất circle và trả geometry cho callback.
|
||||
const finishCircle = () => {
|
||||
if (!isDragging || !center) {
|
||||
resetDrawingState();
|
||||
@@ -120,12 +128,14 @@ export function initCircle(
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
finishCircle();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-circle") 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(
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
@@ -163,6 +174,7 @@ function buildCircleRing(
|
||||
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 {
|
||||
const lat1 = toRad(a[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.
|
||||
}
|
||||
|
||||
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||
function destinationPoint(
|
||||
center: [number, number],
|
||||
distance: number,
|
||||
@@ -205,22 +218,26 @@ function destinationPoint(
|
||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||
}
|
||||
|
||||
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||
function normalizeLng(lng: number): number {
|
||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Kẹp giá trị trong đoạn [min, max].
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ độ sang radian.
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ radian sang độ.
|
||||
function toDeg(value: number): number {
|
||||
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";
|
||||
|
||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -10,9 +11,7 @@ export function initDrawing(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
/**
|
||||
* Close polygon ring if not closed.
|
||||
*/
|
||||
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||
function closePolygon(c: [number, number][]) {
|
||||
if (c.length < 3) return c;
|
||||
const first = c[0];
|
||||
@@ -24,9 +23,7 @@ export function initDrawing(
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preview layer while drawing.
|
||||
*/
|
||||
// Cập nhật layer preview trong lúc đang vẽ.
|
||||
function update(c: [number, number][]) {
|
||||
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) {
|
||||
if (getMode() !== "draw") return;
|
||||
|
||||
@@ -52,6 +50,7 @@ export function initDrawing(
|
||||
update(coords);
|
||||
}
|
||||
|
||||
// Render preview polygon với điểm chuột hiện tại.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw" || coords.length === 0) return;
|
||||
|
||||
@@ -62,9 +61,7 @@ export function initDrawing(
|
||||
update(preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize polygon, emit geometry to caller, reset preview.
|
||||
*/
|
||||
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||
function finishDrawing() {
|
||||
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) {
|
||||
if (e.key === "Enter") {
|
||||
finishDrawing();
|
||||
|
||||
@@ -13,6 +13,7 @@ export type EditingAPI = {
|
||||
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: {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||
@@ -22,6 +23,7 @@ export function createEditingEngine(options: {
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
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 = () => {
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
@@ -32,6 +34,7 @@ export function createEditingEngine(options: {
|
||||
(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 editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
@@ -62,6 +65,7 @@ export function createEditingEngine(options: {
|
||||
(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 editing = editingRef.current;
|
||||
if (!editing) return;
|
||||
@@ -73,10 +77,12 @@ export function createEditingEngine(options: {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||
const cancelEditing = () => {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
if (feature.geometry.type !== "Polygon") return;
|
||||
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||
@@ -92,6 +98,7 @@ export function createEditingEngine(options: {
|
||||
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 oe = e?.originalEvent as MouseEvent | undefined;
|
||||
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) => {
|
||||
// Bắt đầu kéo một handle point.
|
||||
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
const feature = e.features?.[0];
|
||||
@@ -114,6 +123,7 @@ export function createEditingEngine(options: {
|
||||
map.dragPan.disable();
|
||||
};
|
||||
|
||||
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const drag = dragStateRef.current;
|
||||
const editing = editingRef.current;
|
||||
@@ -123,12 +133,14 @@ export function createEditingEngine(options: {
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||
const stopDragging = () => {
|
||||
dragStateRef.current = null;
|
||||
map.getCanvas().style.cursor = "";
|
||||
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) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = true;
|
||||
@@ -143,6 +155,7 @@ export function createEditingEngine(options: {
|
||||
}
|
||||
};
|
||||
|
||||
// Hạ cờ modifier khi nhả phím.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
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) => {
|
||||
if (!editingRef.current) return;
|
||||
if (!isModifierPressed(e)) return;
|
||||
@@ -177,6 +191,7 @@ export function createEditingEngine(options: {
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Ngắt kéo nếu con trỏ rời canvas.
|
||||
const onCanvasLeave = () => {
|
||||
stopDragging();
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export type EntityTypeGroupId =
|
||||
| "split"
|
||||
| "route"
|
||||
| "area_polygon"
|
||||
| "area_circle"
|
||||
| "line"
|
||||
| "polygon"
|
||||
| "circle"
|
||||
| "point";
|
||||
|
||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
@@ -24,43 +23,36 @@ export type EntityTypeOption = {
|
||||
|
||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||
{
|
||||
id: "split",
|
||||
label: "Split",
|
||||
id: "line",
|
||||
label: "line - Tuyến",
|
||||
geometryLabel: "Line",
|
||||
description: "Tuyến chia cắt/phòng thủ.",
|
||||
description: "Các tuyến line/path (gấp khúc).",
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
label: "Route",
|
||||
geometryLabel: "Line",
|
||||
description: "Các tuyến di chuyển theo hướng.",
|
||||
},
|
||||
{
|
||||
id: "area_polygon",
|
||||
label: "Area - Đa giác",
|
||||
id: "polygon",
|
||||
label: "polygon - Đa giác",
|
||||
geometryLabel: "Polygon",
|
||||
description: "Vùng lãnh thổ dạng đa giác.",
|
||||
},
|
||||
{
|
||||
id: "area_circle",
|
||||
label: "Area - Tròn",
|
||||
geometryLabel: "Polygon tròn",
|
||||
id: "circle",
|
||||
label: "circle - Tròn",
|
||||
geometryLabel: "Circle",
|
||||
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||
},
|
||||
{
|
||||
id: "point",
|
||||
label: "Point",
|
||||
label: "point - Điểm",
|
||||
geometryLabel: "Point",
|
||||
description: "Địa điểm đơn lẻ.",
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||
split: ENTITY_TYPE_GROUPS[0],
|
||||
route: ENTITY_TYPE_GROUPS[1],
|
||||
area_polygon: ENTITY_TYPE_GROUPS[2],
|
||||
area_circle: ENTITY_TYPE_GROUPS[3],
|
||||
point: ENTITY_TYPE_GROUPS[4],
|
||||
line: ENTITY_TYPE_GROUPS[0],
|
||||
polygon: ENTITY_TYPE_GROUPS[1],
|
||||
circle: ENTITY_TYPE_GROUPS[2],
|
||||
point: ENTITY_TYPE_GROUPS[3],
|
||||
};
|
||||
|
||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
@@ -69,25 +61,25 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
groupId: EntityTypeGroupId;
|
||||
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: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
|
||||
|
||||
{ value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||
|
||||
{ value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "civilization", label: "Civilization", groupId: "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_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";
|
||||
|
||||
// Gom option theo group để render select phân nhóm.
|
||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
@@ -122,8 +115,8 @@ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE
|
||||
})).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 {
|
||||
if (!typeId) return null;
|
||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||
export function initLine(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -15,17 +16,20 @@ export function initLine(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview line.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ line hiện tại.
|
||||
const cancelLine = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
@@ -47,6 +51,7 @@ export function initLine(
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||
const finishLine = () => {
|
||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||
|
||||
@@ -59,12 +64,14 @@ export function initLine(
|
||||
cancelLine();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (!coords.length) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh line khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
@@ -72,6 +79,7 @@ export function initLine(
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
@@ -90,6 +98,7 @@ export function initLine(
|
||||
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) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||
export function initPath(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -15,12 +16,14 @@ export function initPath(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview path.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
@@ -42,6 +45,7 @@ export function initPath(
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||
const finishPath = () => {
|
||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||
|
||||
@@ -55,17 +59,20 @@ export function initPath(
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ path hiện tại.
|
||||
const cancelPath = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (coords.length === 0) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh path khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
@@ -73,6 +80,7 @@ export function initPath(
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview path động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
@@ -92,6 +100,7 @@ export function initPath(
|
||||
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) => {
|
||||
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";
|
||||
|
||||
// Khởi tạo engine thêm point bằng click đơn.
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
/**
|
||||
* Add a new point when in add-point mode.
|
||||
*/
|
||||
// Thêm point mới khi đang ở chế độ add-point.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
@@ -22,6 +21,7 @@ export function initPoint(
|
||||
onComplete?.(geometry);
|
||||
}
|
||||
|
||||
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||
function onMove() {
|
||||
const canvas = map.getCanvas();
|
||||
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";
|
||||
|
||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||
export function initSelect(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onDelete: (id: string | number) => void,
|
||||
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onDelete?: (id: string | number) => void,
|
||||
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onSelectId?: (id: string | number | null) => void
|
||||
) {
|
||||
const SELECTABLE_LAYERS = [
|
||||
@@ -17,12 +18,11 @@ export function initSelect(
|
||||
"places-symbol",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
const hasContextActions = Boolean(onDelete || onEdit);
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Clear feature-state highlight for all selected features.
|
||||
*/
|
||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||
function clearSelection() {
|
||||
if (!selectedIds.size) return;
|
||||
selectedIds.forEach((id) => {
|
||||
@@ -32,9 +32,7 @@ export function initSelect(
|
||||
onSelectId?.(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
|
||||
*/
|
||||
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
|
||||
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
|
||||
const id = feature.id ?? feature.properties?.id;
|
||||
if (id === undefined || id === null) return;
|
||||
@@ -56,6 +54,7 @@ export function initSelect(
|
||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||
}
|
||||
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
@@ -72,9 +71,8 @@ export function initSelect(
|
||||
selectFeature(features[0], additive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu (edit/delete) on right click.
|
||||
*/
|
||||
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||
// Mở menu thao tác khi click phải lên feature.
|
||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
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) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
@@ -115,15 +114,20 @@ export function initSelect(
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
map.on("contextmenu", onRightClick);
|
||||
if (hasContextActions) {
|
||||
map.on("contextmenu", onRightClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
map.off("contextmenu", onRightClick);
|
||||
if (hasContextActions) {
|
||||
map.off("contextmenu", onRightClick);
|
||||
}
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
// Ẩn và dọn dẹp context menu hiện tại.
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
contextMenu.remove();
|
||||
@@ -135,9 +139,7 @@ export function initSelect(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal context menu near cursor.
|
||||
*/
|
||||
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||
function showContextMenu(
|
||||
x: number,
|
||||
y: number,
|
||||
@@ -159,6 +161,7 @@ export function initSelect(
|
||||
menu.style.fontSize = "14px";
|
||||
menu.style.padding = "4px 0";
|
||||
|
||||
// Tạo một item thao tác trong context menu.
|
||||
const createItem = (label: string, onClick: () => void) => {
|
||||
const item = document.createElement("div");
|
||||
item.textContent = label;
|
||||
@@ -174,30 +177,38 @@ export function initSelect(
|
||||
};
|
||||
|
||||
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;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
)
|
||||
);
|
||||
if (onDelete) {
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
)
|
||||
);
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (!hasMenuItems) return;
|
||||
|
||||
document.body.appendChild(menu);
|
||||
contextMenu = menu;
|
||||
|
||||
// Đóng menu khi click ra ngoài vùng menu.
|
||||
const onDocClick = (ev: MouseEvent) => {
|
||||
if (!menu.contains(ev.target as Node)) {
|
||||
hideContextMenu();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Basic GeoJSON geometry union (no GeometryCollection).
|
||||
*/
|
||||
// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
@@ -13,10 +11,11 @@ export type Geometry =
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: string | number;
|
||||
type?: string | null;
|
||||
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
line_mode?: string | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
@@ -35,29 +34,28 @@ export type FeatureCollection = {
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Change map entry for saving.
|
||||
*/
|
||||
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
|
||||
export type Change =
|
||||
| { type: "create"; feature: Feature }
|
||||
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||
| { type: "delete"; id: FeatureProperties["id"] };
|
||||
| { action: "create"; feature: Feature }
|
||||
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||
| { action: "delete"; id: FeatureProperties["id"] };
|
||||
|
||||
/**
|
||||
* Minimal undo record.
|
||||
*/
|
||||
// Kiểu bản ghi undo tối thiểu.
|
||||
export type UndoAction =
|
||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||
| { type: "delete"; feature: Feature }
|
||||
| { type: "create"; id: FeatureProperties["id"] };
|
||||
|
||||
// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ.
|
||||
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
// So sánh hai geometry theo nội dung tuần tự JSON.
|
||||
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||
function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Geometry>();
|
||||
for (const f of fc.features) {
|
||||
@@ -66,6 +64,7 @@ function buildInitialMap(fc: FeatureCollection) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
|
||||
function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Geometry>
|
||||
@@ -81,22 +80,23 @@ function diffDraftToInitial(
|
||||
seen.add(id);
|
||||
const initialGeom = initialMap.get(id);
|
||||
if (!initialGeom) {
|
||||
next.set(id, { type: "create", feature: deepClone(f) });
|
||||
next.set(id, { action: "create", feature: deepClone(f) });
|
||||
} 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
|
||||
for (const [id] of initialMap.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
next.set(id, { type: "delete", id });
|
||||
next.set(id, { action: "delete", id });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng).
|
||||
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
if (!a) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
@@ -124,12 +124,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central state for the editor.
|
||||
* - draft: source of truth for UI rendering
|
||||
* - changes: map of pending changes for save
|
||||
* - undoStack: minimal actions to revert last step
|
||||
*/
|
||||
// State trung tâm của editor:
|
||||
// - draft: dữ liệu nguồn để render UI
|
||||
// - changes: map các thay đổi chờ lưu
|
||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||
export function useEditorState(initialData: FeatureCollection) {
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
@@ -168,6 +166,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
// Đẩy undo action mới vào stack nếu khác action gần nhất.
|
||||
function pushUndo(action: UndoAction) {
|
||||
setUndoStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
@@ -176,9 +175,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new feature to draft and record "create".
|
||||
*/
|
||||
// Thêm feature mới vào draft và ghi nhận thao tác "create".
|
||||
function createFeature(feature: Feature) {
|
||||
const featureClone = deepClone(feature);
|
||||
commitDraft({
|
||||
@@ -188,9 +185,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch non-geometry properties on a feature (used for entity/time metadata).
|
||||
*/
|
||||
// Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
|
||||
function patchFeatureProperties(
|
||||
id: FeatureProperties["id"],
|
||||
patch: Partial<FeatureProperties>
|
||||
@@ -209,9 +204,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update geometry of an existing feature and record change.
|
||||
*/
|
||||
// Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
|
||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return; // nothing to update
|
||||
@@ -231,9 +224,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feature from draft and record delete.
|
||||
*/
|
||||
// Xóa feature khỏi draft và ghi nhận thao tác delete.
|
||||
function deleteFeature(id: FeatureProperties["id"]) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
@@ -247,9 +238,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last action, reverting both draft and change map.
|
||||
*/
|
||||
// Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
|
||||
function undo() {
|
||||
let applied = false; // guards against React StrictMode double invoke of setState updater
|
||||
setUndoStack((prev) => {
|
||||
@@ -294,22 +283,19 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload array for API save.
|
||||
*/
|
||||
// Dựng mảng payload gửi API save từ map changes hiện tại.
|
||||
function buildPayload(): Change[] {
|
||||
return Array.from(changes.values()).map((c) => deepClone(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending changes after successful save.
|
||||
*/
|
||||
// Xóa thay đổi đang chờ sau khi lưu thành công.
|
||||
function clearChanges() {
|
||||
setUndoStack([]);
|
||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||
setBaselineVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
|
||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||
return initialMapRef.current.has(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user