preview map editor 60%

This commit is contained in:
taDuc
2026-04-13 21:47:28 +07:00
parent 3023fa947c
commit 458de8dadc
16 changed files with 1664 additions and 1149 deletions

View File

@@ -13,6 +13,7 @@ export type GeometriesBBoxQuery = {
export type GeometryCreatePayload = { export type GeometryCreatePayload = {
geometry: Geometry; geometry: Geometry;
type?: string | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
binding?: string[]; binding?: string[];
@@ -22,6 +23,7 @@ export type GeometryCreatePayload = {
export type GeometryUpdatePayload = { export type GeometryUpdatePayload = {
geometry: Geometry; geometry: Geometry;
type?: string | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
binding?: string[]; binding?: string[];

985
app/editor/page.tsx Normal file
View 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;
}

View File

@@ -1,27 +1,20 @@
"use client"; "use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Map from "@/components/Map"; import Map from "@/components/Map";
import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import TimelineBar from "@/components/TimelineBar"; import TimelineBar from "@/components/TimelineBar";
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; import { fetchGeometriesByBBox } from "@/api/geometries";
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http"; import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; import { findEntityTypeOption } from "@/lib/entityTypeOptions";
import {
Feature,
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
import { import {
BACKGROUND_LAYER_OPTIONS, BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility, BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY, DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers"; } from "@/lib/backgroundLayers";
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions"; import { Feature, FeatureCollection } from "@/lib/useEditorState";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = { const WORLD_BBOX = {
@@ -43,43 +36,9 @@ type TimelineRange = {
max: number; max: number;
}; };
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export default function Page() { export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC); const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const [entities, setEntities] = useState<Entity[]>([]);
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
const [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null); const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
const [entitySearchQuery, setEntitySearchQuery] = useState("");
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE); const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max); const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
@@ -93,170 +52,27 @@ export default function Page() {
const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null); const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>( const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => loadBackgroundLayerVisibilityFromStorage() () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
); );
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0); const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData);
const selectedEntity =
entities.find((entity) => entity.id === selectedEntityId) || null;
const selectedFeature = const selectedFeature =
selectedFeatureId === null selectedFeatureId === null
? null ? null
: editor.draft.features.find((feature) => : initialData.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId) String(feature.properties.id) === String(selectedFeatureId)
) || null; ) || null;
useEffect(() => {
let disposed = false;
async function loadEntities() {
try {
const rows = await fetchEntities();
if (disposed) return;
setEntities(rows);
setSelectedEntityId((prev) => {
if (prev && rows.some((entity) => entity.id === prev)) {
return prev;
}
return rows[0]?.id || null;
});
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
} catch (err) {
if (disposed) return;
console.error("Load entities failed", err);
setEntityStatus("Không tải được danh sách entity.");
}
}
loadEntities();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!selectedFeature) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
const keyword = entitySearchQuery.trim();
if (!keyword.length) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
let disposed = false;
const requestId = ++entitySearchRequestRef.current;
const timeoutId = window.setTimeout(async () => {
setIsEntitySearchLoading(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
setEntitySearchResults(rows);
setSelectedSearchEntityId((prev) =>
prev && rows.some((entity) => entity.id === prev)
? prev
: rows[0]?.id || null
);
setEntities((prev) => mergeEntitiesById(prev, rows));
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature]);
useEffect(() => { useEffect(() => {
if (selectedFeatureId === null) return; if (selectedFeatureId === null) return;
const stillExists = editor.draft.features.some((feature) => const stillExists = initialData.features.some((feature) =>
String(feature.properties.id) === String(selectedFeatureId) String(feature.properties.id) === String(selectedFeatureId)
); );
if (!stillExists) { if (!stillExists) {
setSelectedFeatureId(null); setSelectedFeatureId(null);
} }
}, [editor.draft, selectedFeatureId]); }, [initialData, selectedFeatureId]);
useEffect(() => {
if (skipSelectedFeatureSyncRef.current) {
skipSelectedFeatureSyncRef.current = false;
return;
}
if (!selectedFeature) {
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
const primaryEntityId = featureEntityIds[0] || null;
const linkedEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextTypeId =
linkedEntity?.type_id ||
selectedFeature.properties.entity_type_id ||
DEFAULT_ENTITY_TYPE_ID;
setEntityForm({
name: "",
slug: linkedEntity?.slug || "",
type_id: nextTypeId,
});
setSelectedGeometryEntityIds(featureEntityIds);
setGeometryMetaForm({
time_start: selectedFeature.properties.time_start != null
? String(selectedFeature.properties.time_start)
: "",
time_end: selectedFeature.properties.time_end != null
? String(selectedFeature.properties.time_end)
: "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
if (!featureEntityIds.length) {
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
} else {
setEntityFormStatus(null);
}
}, [selectedFeature, entities]);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -316,8 +132,9 @@ export default function Page() {
}, [timelineDraftYear, timelineYear, isTimelineReady]); }, [timelineDraftYear, timelineYear, isTimelineReady]);
useEffect(() => { useEffect(() => {
persistBackgroundLayerVisibility(backgroundVisibility); setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
}, [backgroundVisibility]); setIsBackgroundVisibilityReady(true);
}, []);
useEffect(() => { useEffect(() => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd); const lower = Math.min(timelineWindowStart, timelineWindowEnd);
@@ -367,63 +184,29 @@ export default function Page() {
}; };
}, [timelineYear, isTimelineReady]); }, [timelineYear, isTimelineReady]);
const handleSave = async () => { const updateBackgroundVisibility = (
const payload = editor.buildPayload(); updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
if (!payload.length) return; ) => {
setBackgroundVisibility((prev) => {
const invalid = payload.find((change) => { const next = updater(prev);
if (change.type === "delete") return false; persistBackgroundLayerVisibility(next);
const draftFeature = change.type === "create" return next;
? change.feature
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(change.id)
);
if (!draftFeature) return false;
const entityIds = normalizeFeatureEntityIds(draftFeature);
return entityIds.length === 0;
}); });
if (invalid) {
const invalidId = invalid.type === "create"
? invalid.feature.properties.id
: invalid.id;
setSelectedFeatureId(invalidId);
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
return;
}
setIsSaving(true);
setEntityStatus(null);
try {
await saveGeometryBatchChanges(payload);
editor.clearChanges();
await reloadCurrentTimelineData();
} catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
setEntityStatus(`Save thất bại: ${err.body}`);
return;
}
console.error("Save error", err);
setEntityStatus("Save thất bại.");
} finally {
setIsSaving(false);
}
}; };
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => { const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
setBackgroundVisibility((prev) => ({ updateBackgroundVisibility((prev) => ({
...prev, ...prev,
[id]: !prev[id], [id]: !prev[id],
})); }));
}; };
const handleShowAllBackgroundLayers = () => { const handleShowAllBackgroundLayers = () => {
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }); updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
}; };
const handleHideAllBackgroundLayers = () => { const handleHideAllBackgroundLayers = () => {
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }); updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
}; };
const handleTimelineWindowStartChange = (nextYear: number) => { const handleTimelineWindowStartChange = (nextYear: number) => {
@@ -442,291 +225,28 @@ export default function Page() {
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper)); setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
}; };
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => { const timelineDisabled = !isTimelineReady;
setEntityForm((prev) => ({ ...prev, [key]: value })); const selectedType = resolveSelectedTypeSummary(selectedFeature);
}; const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
};
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddSelectedSearchEntity = () => {
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
if (!entityId.length) {
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
return;
}
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
setSelectedGeometryEntityIds(next);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
};
const reloadEntities = async () => {
const rows = await fetchEntities();
setEntities(rows);
setSelectedEntityId((prev) => {
if (prev && rows.some((entity) => entity.id === prev)) {
return prev;
}
return rows[0]?.id || null;
});
return rows;
};
const reloadCurrentTimelineData = async () => {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYear,
});
setInitialData(data);
};
const resetSelectedGeometryInputsAfterCreate = () => {
skipSelectedFeatureSyncRef.current = true;
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
};
const parseGeometryMetaFormRange = () => {
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw new Error("time_start phải <= time_end.");
}
return { timeStart, timeEnd };
};
const patchSelectedFeatureLocally = (
feature: Feature,
entityIds: string[],
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entityRows.find((entity) => entity.id === primaryEntityId) || null
: null;
const entityNames = entityIds
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
editor.patchFeatureProperties(feature.properties.id, {
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
});
setSelectedGeometryEntityIds(entityIds);
setGeometryMetaForm({
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
});
};
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
if (!entityIds.length) {
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
if (editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
return;
}
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: entityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
} else {
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
}
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const handleCreateEntityAndAttach = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
return;
}
const name = entityForm.name.trim();
if (!name) {
setEntityFormStatus("Tên entity là bắt buộc.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
const created = await createEntity({
name,
slug: entityForm.slug.trim() || null,
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
});
const rows = await reloadEntities();
const nextEntityIds = uniqueEntityIds([
...selectedGeometryEntityIds,
created.id,
]);
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: nextEntityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
} else {
patchSelectedFeatureLocally(
selectedFeature,
nextEntityIds,
timeStart,
timeEnd,
bindingIds,
rows
);
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
}
setSelectedEntityId(created.id);
resetSelectedGeometryInputsAfterCreate();
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
} else {
setEntityFormStatus("Tạo/gắn entity thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
const timelineStatusText =
editor.changeCount > 0
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
: isSaving
? "Đang lưu thay đổi..."
: timelineStatus;
const handleCreateFeature = (feature: Feature) => {
editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id);
};
return ( return (
<div style={{ display: "flex", minHeight: "100vh" }}> <div style={{ display: "flex", minHeight: "100vh" }}>
<Editor
mode={mode}
setMode={setMode}
entities={entities}
selectedEntityId={selectedEntityId}
onSelectEntityId={setSelectedEntityId}
entityStatus={entityStatus}
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
changesCount={editor.changeCount}
undoStack={editor.undoStack}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}> <div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
<Map {isBackgroundVisibilityReady ? (
mode={mode} <Map
draft={editor.draft} mode="select"
selectedFeatureId={selectedFeatureId} draft={initialData}
selectedEntityId={selectedEntityId} selectedFeatureId={selectedFeatureId}
selectedEntityName={selectedEntity?.name || null} onSelectFeatureId={setSelectedFeatureId}
selectedEntityTypeId={selectedEntity?.type_id || null} backgroundVisibility={backgroundVisibility}
onSelectFeatureId={setSelectedFeatureId} allowGeometryEditing={false}
onCreateFeature={handleCreateFeature} respectBindingFilter={false}
onDeleteFeature={editor.deleteFeature} />
onUpdateFeature={editor.updateFeature} ) : (
backgroundVisibility={backgroundVisibility} <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
/> )}
<TimelineBar <TimelineBar
minYear={timelineRange.min} minYear={timelineRange.min}
maxYear={timelineRange.max} maxYear={timelineRange.max}
@@ -738,7 +258,7 @@ export default function Page() {
onYearChange={handleTimelineYearChange} onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading} isLoading={isTimelineLoading}
disabled={timelineDisabled} disabled={timelineDisabled}
statusText={timelineStatusText} statusText={timelineStatus}
/> />
</div> </div>
@@ -748,39 +268,57 @@ export default function Page() {
onShowAll={handleShowAllBackgroundLayers} onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers} onHideAll={handleHideAllBackgroundLayers}
topContent={ topContent={
<SelectedGeometryPanel <div
selectedFeature={selectedFeature} style={{
selectedFeatureEntitySummary={ padding: "10px",
selectedFeature background: "#0b1220",
? formatEntityNamesForDisplay(selectedFeature, entities) borderRadius: "8px",
: "Chưa gắn" border: "1px solid #1f2937",
} display: "grid",
selectedFeatureBindingSummary={ gap: "8px",
selectedFeature }}
? formatBindingIdsForDisplay(selectedFeature) >
: "Không có" <div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
} Viewer
entities={entities} </div>
selectedGeometryEntityIds={selectedGeometryEntityIds} <div style={{ color: "#94a3b8", fontSize: "12px" }}>
onEntityIdsChange={handleEntityIdsChange} Click trực tiếp trên map đ chọn geometry.
entitySearchQuery={entitySearchQuery} </div>
onEntitySearchQueryChange={setEntitySearchQuery} <div style={{ color: "#cbd5e1", fontSize: "13px" }}>
entitySearchResults={entitySearchResults} {selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
selectedSearchEntityId={selectedSearchEntityId} </div>
onSelectSearchEntityId={setSelectedSearchEntityId} <div style={{ color: "#cbd5e1", fontSize: "13px" }}>
onAddSelectedSearchEntity={handleAddSelectedSearchEntity} Type: {selectedType}
isEntitySearchLoading={isEntitySearchLoading} </div>
entityForm={entityForm} <div style={{ color: "#cbd5e1", fontSize: "13px" }}>
onEntityFormChange={handleEntityFormChange} Time: {selectedTimeSummary}
entityTypeOptions={ENTITY_TYPE_OPTIONS} </div>
geometryMetaForm={geometryMetaForm} <div style={{ color: "#cbd5e1", fontSize: "13px" }}>
onGeometryMetaFormChange={handleGeometryMetaFormChange} Entities: {selectedEntitySummary}
isEntitySubmitting={isEntitySubmitting} </div>
onCreateEntityAndAttach={handleCreateEntityAndAttach} <div style={{ color: "#cbd5e1", fontSize: "13px" }}>
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} Binding: {selectedBindingSummary}
changeCount={editor.changeCount} </div>
entityFormStatus={entityFormStatus}
/> <Link
href="/editor"
style={{
marginTop: "4px",
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "6px",
padding: "7px 8px",
background: "#1d4ed8",
color: "#ffffff",
textDecoration: "none",
fontSize: "13px",
fontWeight: 600,
}}
>
Mở Editor
</Link>
</div>
} }
/> />
</div> </div>
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value); return typeof value === "number" && Number.isFinite(value);
} }
function parseOptionalYearInput(raw: string, fieldName: string): number | null { function formatSelectedTimeSummary(feature: Feature | null): string {
const value = raw.trim(); if (!feature) return "Không có";
if (!value.length) return null; const start = isYearNumber(feature.properties.time_start)
const parsed = Number(value); ? String(feature.properties.time_start)
if (!Number.isFinite(parsed)) { : "?";
throw new Error(`${fieldName} phải là số.`); const end = isYearNumber(feature.properties.time_end)
} ? String(feature.properties.time_end)
return Math.trunc(parsed); : "?";
return `${start}${end}`;
} }
function normalizeFeatureEntityIds(feature: Feature): string[] { function formatSelectedEntitySummary(feature: Feature | null): string {
const fromArray = Array.isArray(feature.properties.entity_ids) if (!feature) return "Không có";
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
const names = Array.isArray(feature.properties.entity_names)
? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: []; : [];
if (names.length) return names.join(", ");
if (fromArray.length) { if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
return uniqueEntityIds(fromArray); return feature.properties.entity_name.trim();
} }
const single = feature.properties.entity_id; const ids = Array.isArray(feature.properties.entity_ids)
if (typeof single === "string" && single.trim().length > 0) { ? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
return [single.trim()]; : [];
if (ids.length) return ids.join(", ");
if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) {
return feature.properties.entity_id.trim();
} }
return []; return "Chưa gắn";
} }
function normalizeFeatureBindingIds(feature: Feature): string[] { function formatSelectedBindingSummary(feature: Feature | null): string {
const rawBinding = feature.properties.binding; if (!feature) return "Không có";
if (!Array.isArray(rawBinding)) return []; const binding = Array.isArray(feature.properties.binding)
return uniqueEntityIds(rawBinding ? feature.properties.binding
.map((id) => { .map((item) => {
if (typeof id !== "string" && typeof id !== "number") return ""; if (typeof item !== "string" && typeof item !== "number") return "";
return String(id).trim(); return String(item).trim();
}) })
.filter((id) => id.length > 0));
}
function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0) .filter((item) => item.length > 0)
); : [];
if (!binding.length) return "Không có";
return binding.join(", ");
} }
function formatBindingIdsForDisplay(feature: Feature): string { function resolveSelectedTypeSummary(feature: Feature | null): string {
const bindingIds = normalizeFeatureBindingIds(feature); if (!feature) return "Không có";
if (!bindingIds.length) return "Không có"; const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
return bindingIds.join(", "); if (!typeId) return "Không có";
const option = findEntityTypeOption(typeId);
if (!option) return typeId;
return `${option.label} (${option.groupLabel})`;
} }
function uniqueEntityIds(ids: string[]): string[] { function normalizeTypeId(value: unknown): string | null {
const deduped: string[] = []; if (typeof value !== "string") return null;
const seen = new Set<string>(); const normalized = value.trim().toLowerCase();
for (const rawId of ids) { return normalized.length ? normalized : null;
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
const entityIds = normalizeFeatureEntityIds(feature);
if (!entityIds.length) return "Chưa gắn";
const names = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
.filter((name) => name.trim().length > 0);
return names.join(", ");
}
function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] {
if (!incoming.length) return current;
const map = new globalThis.Map<string, Entity>();
for (const entity of current) {
map.set(entity.id, entity);
}
for (const entity of incoming) {
map.set(entity.id, entity);
}
return Array.from(map.values());
} }
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {

View File

@@ -3,18 +3,10 @@
import { UndoAction } from "@/lib/useEditorState"; import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle"; type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
type EntityOption = {
id: string;
name: string;
geometry_count?: number;
};
type Props = { type Props = {
mode: Mode; mode: Mode;
setMode: (mode: Mode) => void; setMode: (mode: Mode) => void;
entities: EntityOption[];
selectedEntityId: string | null;
onSelectEntityId: (entityId: string | null) => void;
entityStatus?: string | null; entityStatus?: string | null;
onUndo: () => void; onUndo: () => void;
onSave: () => void; onSave: () => void;
@@ -26,9 +18,6 @@ type Props = {
export default function Editor({ export default function Editor({
mode, mode,
setMode, setMode,
entities,
selectedEntityId,
onSelectEntityId,
entityStatus, entityStatus,
onUndo, onUndo,
onSave, onSave,
@@ -148,48 +137,21 @@ export default function Editor({
</div> </div>
) : null} ) : null}
<div {entityStatus ? (
style={{ <div
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)}
style={{ style={{
width: "100%", marginTop: "12px",
padding: "6px 8px", padding: "10px",
borderRadius: "4px", background: "#0b1220",
border: "1px solid #334155", borderRadius: "6px",
background: "#111827", border: "1px solid #1f2937",
color: "#f8fafc", color: "#fca5a5",
fontSize: "13px", fontSize: "12px",
}} }}
> >
<option value="">Không gắn entity</option> {entityStatus}
{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 thể thêm nhiều entity panel bên phải.
</div> </div>
{entityStatus ? ( ) : null}
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
{entityStatus}
</div>
) : null}
</div>
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}> <div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
<button <button

View File

@@ -20,13 +20,12 @@ type MapProps = {
draft: FeatureCollection; draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility; backgroundVisibility: BackgroundLayerVisibility;
selectedFeatureId: string | number | null; selectedFeatureId: string | number | null;
selectedEntityId: string | null;
selectedEntityName: string | null;
selectedEntityTypeId: string | null;
onSelectFeatureId: (id: string | number | null) => void; onSelectFeatureId: (id: string | number | null) => void;
onCreateFeature: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature: (id: string | number) => void; onDeleteFeature?: (id: string | number) => void;
onUpdateFeature: (id: string | number, geometry: Geometry) => void; onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
}; };
type PointIconSpec = { type PointIconSpec = {
@@ -40,6 +39,9 @@ const DEFAULT_POINT_ICON_ID = "point-icon-default";
const PATH_ARROW_ICON_ID = "path-arrow-icon"; const PATH_ARROW_ICON_ID = "path-arrow-icon";
const MAP_MIN_ZOOM = 1; const MAP_MIN_ZOOM = 1;
const MAP_MAX_ZOOM = 10; const MAP_MAX_ZOOM = 10;
const RASTER_BASE_SOURCE_ID = "rasterBase";
const RASTER_BASE_LAYER_ID = "raster-base-layer";
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [ const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
"coalesce", "coalesce",
["get", "MAPCOLOR7"], ["get", "MAPCOLOR7"],
@@ -103,6 +105,16 @@ const LINE_COLOR_BY_TYPE: Record<string, string> = {
shipping_route: "#2563eb", shipping_route: "#2563eb",
}; };
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
attack_route: true,
retreat_route: true,
invasion_route: true,
migration_route: true,
refugee_route: true,
trade_route: true,
shipping_route: true,
};
const POINT_COLOR_BY_TYPE: Record<string, string> = { const POINT_COLOR_BY_TYPE: Record<string, string> = {
person_deathplace: "#dc2626", person_deathplace: "#dc2626",
person_birthplace: "#2563eb", person_birthplace: "#2563eb",
@@ -130,7 +142,7 @@ const POINT_ICON_SPECS: PointIconSpec[] = [
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" }, { id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" }, { id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
// Backward-compatible icon ids used by older type_id values. // Backward-compatible icon ids used by older naming values.
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" }, { id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" }, { id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" }, { id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
@@ -161,26 +173,22 @@ export default function Map({
draft, draft,
backgroundVisibility, backgroundVisibility,
selectedFeatureId, selectedFeatureId,
selectedEntityId,
selectedEntityName,
selectedEntityTypeId,
onSelectFeatureId, onSelectFeatureId,
onCreateFeature, onCreateFeature,
onDeleteFeature, onDeleteFeature,
onUpdateFeature, onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
}: MapProps) { }: MapProps) {
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility); const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId); const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
const onSelectFeatureIdRef = useRef(onSelectFeatureId); const onSelectFeatureIdRef = useRef(onSelectFeatureId);
const onCreateRef = useRef(onCreateFeature); const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature); const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature); const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
const [zoomLevel, setZoomLevel] = useState(2); const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
@@ -192,7 +200,7 @@ export default function Map({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map || !map.isStyleLoaded()) return;
if (mode !== "add-line") { if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({ (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection", type: "FeatureCollection",
@@ -221,18 +229,6 @@ export default function Map({
selectedFeatureIdRef.current = selectedFeatureId; selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]); }, [selectedFeatureId]);
useEffect(() => {
selectedEntityIdRef.current = selectedEntityId;
}, [selectedEntityId]);
useEffect(() => {
selectedEntityNameRef.current = selectedEntityName;
}, [selectedEntityName]);
useEffect(() => {
selectedEntityTypeIdRef.current = selectedEntityTypeId;
}, [selectedEntityTypeId]);
useEffect(() => { useEffect(() => {
onSelectFeatureIdRef.current = onSelectFeatureId; onSelectFeatureIdRef.current = onSelectFeatureId;
}, [onSelectFeatureId]); }, [onSelectFeatureId]);
@@ -281,12 +277,14 @@ export default function Map({
// clear all feature-state (selection) to prevent ghost layers after undo // clear all feature-state (selection) to prevent ghost layers after undo
map.removeFeatureState({ source: "countries" }); map.removeFeatureState({ source: "countries" });
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current); const visibleDraft = respectBindingFilter
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc;
const { polygons, points } = splitDraftFeatures(visibleDraft); const { polygons, points } = splitDraftFeatures(visibleDraft);
countriesSource.setData(polygons); countriesSource.setData(polygons);
placesSource.setData(points); placesSource.setData(points);
}, []); }, [respectBindingFilter]);
useEffect(() => { useEffect(() => {
const map = new maplibregl.Map({ const map = new maplibregl.Map({
@@ -297,13 +295,6 @@ export default function Map({
style: { style: {
version: 8, version: 8,
sources: { sources: {
rasterBase: {
type: "raster",
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
},
base: { base: {
type: "vector", type: "vector",
tiles: [getVectorTileTemplateUrl()], tiles: [getVectorTileTemplateUrl()],
@@ -319,15 +310,6 @@ export default function Map({
"background-color": "#0b1220", "background-color": "#0b1220",
}, },
}, },
{
id: "raster-base-layer",
type: "raster",
source: "rasterBase",
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear",
},
},
{ {
id: "graticules-line", id: "graticules-line",
type: "line", type: "line",
@@ -450,7 +432,6 @@ export default function Map({
mapRef.current = map; mapRef.current = map;
map.on("load", async () => { map.on("load", async () => {
const geometryMinZoom = 5;
const syncZoomLevel = () => { const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom())); setZoomLevel(roundZoom(map.getZoom()));
}; };
@@ -633,7 +614,6 @@ export default function Map({
id: "routes-line", id: "routes-line",
type: "line", type: "line",
source: "countries", source: "countries",
minzoom: geometryMinZoom,
filter: ["==", ["geometry-type"], "LineString"], filter: ["==", ["geometry-type"], "LineString"],
paint: { paint: {
"line-color": [ "line-color": [
@@ -661,11 +641,10 @@ export default function Map({
id: "routes-arrow", id: "routes-arrow",
type: "symbol", type: "symbol",
source: "countries", source: "countries",
minzoom: geometryMinZoom,
filter: [ filter: [
"all", "all",
["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "LineString"],
["==", ["coalesce", ["get", "line_mode"], "path"], "path"], buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
], ],
layout: { layout: {
"symbol-placement": "line", "symbol-placement": "line",
@@ -723,7 +702,6 @@ export default function Map({
id: "places-circle", id: "places-circle",
type: "circle", type: "circle",
source: "places", source: "places",
minzoom: geometryMinZoom,
paint: { paint: {
"circle-color": [ "circle-color": [
"case", "case",
@@ -749,7 +727,6 @@ export default function Map({
id: "places-symbol", id: "places-symbol",
type: "symbol", type: "symbol",
source: "places", source: "places",
minzoom: geometryMinZoom,
layout: { layout: {
"icon-image": buildPointIconExpression(), "icon-image": buildPointIconExpression(),
"icon-size": 0.5, "icon-size": 0.5,
@@ -765,14 +742,16 @@ export default function Map({
() => modeRef.current, () => modeRef.current,
(geometry: Geometry) => { (geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({ onCreateRef.current?.({
type: "Feature", type: "Feature",
properties: { properties: {
id, id,
entity_id: selectedEntityIdRef.current || null, type: null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], geometry_preset: "polygon",
entity_name: selectedEntityNameRef.current || null, entity_id: null,
entity_type_id: selectedEntityTypeIdRef.current || null, entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [], binding: [],
}, },
geometry, geometry,
@@ -783,13 +762,17 @@ export default function Map({
const cleanupSelect = initSelect( const cleanupSelect = initSelect(
map, map,
() => modeRef.current, () => modeRef.current,
(id: string | number) => { allowGeometryEditing
// ensure edit overlays are cleared when a feature gets removed ? (id: string | number) => {
editingEngineRef.current?.clearEditing(); // ensure edit overlays are cleared when a feature gets removed
onSelectFeatureIdRef.current?.(null); editingEngineRef.current?.clearEditing();
onDeleteRef.current(id); onSelectFeatureIdRef.current?.(null);
}, onDeleteRef.current?.(id);
(feature) => editingEngineRef.current?.beginEditing(feature), }
: undefined,
allowGeometryEditing
? (feature) => editingEngineRef.current?.beginEditing(feature)
: undefined,
(id) => onSelectFeatureIdRef.current?.(id) (id) => onSelectFeatureIdRef.current?.(id)
); );
@@ -798,14 +781,16 @@ export default function Map({
() => modeRef.current, () => modeRef.current,
(geometry: Geometry) => { (geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({ onCreateRef.current?.({
type: "Feature", type: "Feature",
properties: { properties: {
id, id,
entity_id: selectedEntityIdRef.current || null, type: null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], geometry_preset: "point",
entity_name: selectedEntityNameRef.current || null, entity_id: null,
entity_type_id: selectedEntityTypeIdRef.current || null, entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [], binding: [],
}, },
geometry, geometry,
@@ -818,15 +803,16 @@ export default function Map({
() => modeRef.current, () => modeRef.current,
(geometry: Geometry) => { (geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({ onCreateRef.current?.({
type: "Feature", type: "Feature",
properties: { properties: {
id, id,
entity_id: selectedEntityIdRef.current || null, type: "defense_line",
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], geometry_preset: "line",
entity_name: selectedEntityNameRef.current || null, entity_id: null,
entity_type_id: selectedEntityTypeIdRef.current || null, entity_ids: [],
line_mode: "line", entity_name: null,
entity_type_id: null,
binding: [], binding: [],
}, },
geometry, geometry,
@@ -839,15 +825,16 @@ export default function Map({
() => modeRef.current, () => modeRef.current,
(geometry: Geometry) => { (geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({ onCreateRef.current?.({
type: "Feature", type: "Feature",
properties: { properties: {
id, id,
entity_id: selectedEntityIdRef.current || null, type: "attack_route",
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], geometry_preset: "line",
entity_name: selectedEntityNameRef.current || null, entity_id: null,
entity_type_id: selectedEntityTypeIdRef.current || null, entity_ids: [],
line_mode: "path", entity_name: null,
entity_type_id: null,
binding: [], binding: [],
}, },
geometry, geometry,
@@ -860,14 +847,16 @@ export default function Map({
() => modeRef.current, () => modeRef.current,
(geometry: Geometry) => { (geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now(); const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({ onCreateRef.current?.({
type: "Feature", type: "Feature",
properties: { properties: {
id, id,
entity_id: selectedEntityIdRef.current || null, type: null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [], geometry_preset: "circle-area",
entity_name: selectedEntityNameRef.current || null, entity_id: null,
entity_type_id: selectedEntityTypeIdRef.current || null, entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [], binding: [],
}, },
geometry, geometry,
@@ -888,11 +877,18 @@ export default function Map({
// after everything mounted, push current draft to sources // after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current); applyDraftToMap(draftRef.current);
editingEngineRef.current?.bindEditEvents(map); if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
}); });
return () => map.remove(); return () => {
}, [applyDraftToMap]); if (mapRef.current === map) {
mapRef.current = null;
}
map.remove();
};
}, [allowGeometryEditing, applyDraftToMap]);
const handleZoomByStep = (delta: number) => { const handleZoomByStep = (delta: number) => {
const map = mapRef.current; const map = mapRef.current;
@@ -912,13 +908,13 @@ export default function Map({
useEffect(() => { useEffect(() => {
applyDraftToMap(draft); applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef.current?.id; const editingId = editingEngineRef.current?.editingRef.current?.id;
if (editingId !== undefined && editingId !== null) { if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId); const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) { if (!stillExists) {
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
} }
} }
}, [draft, selectedFeatureId, applyDraftToMap]); }, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
return ( return (
<div style={{ width: "100%", height: "100vh", position: "relative" }}> <div style={{ width: "100%", height: "100vh", position: "relative" }}>
@@ -1004,7 +1000,10 @@ function applyBackgroundLayerVisibility(
map: maplibregl.Map, map: maplibregl.Map,
visibility: BackgroundLayerVisibility visibility: BackgroundLayerVisibility
) { ) {
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
for (const layer of BACKGROUND_LAYER_OPTIONS) { for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (layer.id === RASTER_BASE_LAYER_ID) continue;
if (!map.getLayer(layer.id)) continue; if (!map.getLayer(layer.id)) continue;
map.setLayoutProperty( map.setLayoutProperty(
layer.id, layer.id,
@@ -1014,6 +1013,62 @@ function applyBackgroundLayerVisibility(
} }
} }
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
if (shouldShow) {
ensureRasterBaseLayer(map);
return;
}
removeRasterBaseLayer(map);
}
function ensureRasterBaseLayer(map: maplibregl.Map) {
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
}
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
: undefined;
map.addLayer(createRasterBaseLayer(), beforeId);
}
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
}
function removeRasterBaseLayer(map: maplibregl.Map) {
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
map.removeLayer(RASTER_BASE_LAYER_ID);
}
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
map.removeSource(RASTER_BASE_SOURCE_ID);
}
}
function createRasterBaseSource() {
return {
type: "raster" as const,
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
};
}
function createRasterBaseLayer() {
return {
id: RASTER_BASE_LAYER_ID,
type: "raster" as const,
source: RASTER_BASE_SOURCE_ID,
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear" as const,
},
};
}
function filterDraftByBinding( function filterDraftByBinding(
fc: FeatureCollection, fc: FeatureCollection,
selectedFeatureId: string | number | null selectedFeatureId: string | number | null
@@ -1158,7 +1213,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
} }
function buildPointIconExpression(): maplibregl.ExpressionSpecification { function buildPointIconExpression(): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]]; const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) { for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
expression.push(typeId, iconId); expression.push(typeId, iconId);
@@ -1169,10 +1224,10 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
} }
function buildTypeMatchExpression( function buildTypeMatchExpression(
valueByType: Record<string, string | number>, valueByType: Record<string, string | number | boolean>,
fallback: string | number fallback: string | number | boolean
): maplibregl.ExpressionSpecification { ): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]]; const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, value] of Object.entries(valueByType)) { for (const [typeId, value] of Object.entries(valueByType)) {
expression.push(typeId, value); expression.push(typeId, value);
@@ -1182,6 +1237,15 @@ function buildTypeMatchExpression(
return expression as maplibregl.ExpressionSpecification; return expression as maplibregl.ExpressionSpecification;
} }
function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
return [
"coalesce",
["get", "type"],
["get", "entity_type_id"],
"",
] as maplibregl.ExpressionSpecification;
}
function roundZoom(value: number): number { function roundZoom(value: number): number {
return Math.round(value * 10) / 10; return Math.round(value * 10) / 10;
} }

View File

@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { Entity } from "@/api/entities"; import { Entity } from "@/api/entities";
import { Feature } from "@/lib/useEditorState"; import { Feature } from "@/lib/useEditorState";
import { import {
EntityGeometryPreset,
EntityTypeGroupId, EntityTypeGroupId,
EntityTypeOption, EntityTypeOption,
findEntityTypeOption, findEntityTypeOption,
@@ -74,20 +75,22 @@ export default function SelectedGeometryPanel({
entityFormStatus, entityFormStatus,
}: Props) { }: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions); const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const allowedGroupIds = selectedFeature const featureGeometryPreset = selectedFeature
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type) ? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: []; : [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) => const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id) allowedGroupIds.includes(group.id)
); );
const selectedTypeOption = findEntityTypeOption(entityForm.type_id); const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id); const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null; group.options.some((option) => option.value === entityForm.type_id)
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null; );
const isGeometryCompatible = const isGeometryCompatible = selectedTypeOption
geometryBucket && selectedTypeBucket ? allowedGroupIds.includes(selectedTypeOption.groupId)
? geometryBucket === selectedTypeBucket : true;
: true;
return ( return (
<div <div
@@ -117,6 +120,9 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#cbd5e1" }}> <div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary} Binding hiện tại: {selectedFeatureBindingSummary}
</div> </div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}> <div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn: Entities đã chọn:
@@ -166,60 +172,10 @@ export default function SelectedGeometryPanel({
</div> </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" }}> <div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save. Geometry phải ít nhất 1 entity đ Save.
</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 <div
style={{ style={{
display: "grid", display: "grid",
@@ -230,80 +186,146 @@ export default function SelectedGeometryPanel({
background: "#0f172a", background: "#0f172a",
}} }}
> >
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}> <div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Entity type presets Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
</div> </div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}> <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 sẵn
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 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 entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn.
</div> </div>
{visibleGroupedEntityTypeOptions.map((group) => { <input
const color = GROUP_COLORS[group.id]; value={entityForm.name}
return ( onChange={(event) => onEntityFormChange("name", event.target.value)}
<div placeholder="Tên entity mới"
key={group.id} disabled={isEntitySubmitting}
style={{ style={entityInputStyle}
border: `1px solid ${color.border}`, />
borderRadius: "8px", <input
padding: "8px", value={entityForm.slug}
background: color.background, onChange={(event) => onEntityFormChange("slug", event.target.value)}
}} placeholder="Slug"
> disabled={isEntitySubmitting}
<div style={entityInputStyle}
style={{ />
display: "flex", <div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
justifyContent: "space-between", Chọn loại entity
gap: "8px", </div>
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>
<select <select
value={entityForm.type_id} value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)} onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
> >
{!hasCurrentTypeOption && entityForm.type_id ? ( {!hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}> <option value={entityForm.type_id}>
Custom Type ({entityForm.type_id}) Custom Type ({entityForm.type_id})
</option> </option>
@@ -334,32 +356,10 @@ export default function SelectedGeometryPanel({
{!isGeometryCompatible && selectedTypeOption ? ( {!isGeometryCompatible && selectedTypeOption ? (
<div style={{ color: "#fbbf24", fontSize: "12px" }}> <div style={{ color: "#fbbf24", fontSize: "12px" }}>
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại {geometryBucket}. Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div> </div>
) : null} ) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.binding}
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button <button
onClick={onCreateEntityAndAttach} onClick={onCreateEntityAndAttach}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
@@ -371,26 +371,12 @@ export default function SelectedGeometryPanel({
background: "#2563eb", background: "#2563eb",
color: "#ffffff", color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1, opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}} }}
> >
Tạo Entity + Gắn Tạo entity mới + gắn + áp metadata
</button>
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Áp dụng Entities + Metadata
</button> </button>
</div>
{changeCount > 0 ? ( {changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}> <div style={{ color: "#fca5a5", fontSize: "12px" }}>
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = {
color: "#ffffff", color: "#ffffff",
}; };
const GROUP_COLORS: Record< function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
EntityTypeGroupId, const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
{ if (explicitPreset) return explicitPreset;
background: string;
border: string; const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
text: string; if (semanticType) {
mutedText: string; const option = findEntityTypeOption(semanticType);
chipBackground: string; if (option) return option.geometryPreset;
chipBorder: string;
activeBackground: string;
activeBorder: string;
activeText: string;
} }
> = {
split: {
background: "#3f1d24",
border: "#7f1d1d",
text: "#fecaca",
mutedText: "#fda4af",
chipBackground: "#4c1d27",
chipBorder: "#7f1d1d",
activeBackground: "#7f1d1d",
activeBorder: "#fecaca",
activeText: "#ffffff",
},
route: {
background: "#082f49",
border: "#0c4a6e",
text: "#bae6fd",
mutedText: "#7dd3fc",
chipBackground: "#0c4a6e",
chipBorder: "#155e75",
activeBackground: "#0369a1",
activeBorder: "#bae6fd",
activeText: "#f0f9ff",
},
area_polygon: {
background: "#1f3a2a",
border: "#166534",
text: "#bbf7d0",
mutedText: "#86efac",
chipBackground: "#14532d",
chipBorder: "#166534",
activeBackground: "#15803d",
activeBorder: "#bbf7d0",
activeText: "#f0fdf4",
},
area_circle: {
background: "#3f2b05",
border: "#92400e",
text: "#fde68a",
mutedText: "#fcd34d",
chipBackground: "#78350f",
chipBorder: "#92400e",
activeBackground: "#b45309",
activeBorder: "#fde68a",
activeText: "#fffbeb",
},
point: {
background: "#2e1065",
border: "#6d28d9",
text: "#ddd6fe",
mutedText: "#c4b5fd",
chipBackground: "#4c1d95",
chipBorder: "#6d28d9",
activeBackground: "#7c3aed",
activeBorder: "#ddd6fe",
activeText: "#f5f3ff",
},
};
type GeometryBucket = "point" | "line" | "polygon"; return mapGeometryTypeToPreset(feature.geometry.type);
}
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket { function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") { if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point"; return "point";
} }
@@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu
return "polygon"; return "polygon";
} }
function toTypeBucket(option: EntityTypeOption): GeometryBucket { function getAllowedGroupIdsForPreset(
if (option.geometryPreset === "point") { geometryPreset: EntityGeometryPreset
return "point";
}
if (option.geometryPreset === "line") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForGeometry(
geometryType: Feature["geometry"]["type"]
): EntityTypeGroupId[] { ): EntityTypeGroupId[] {
if (geometryType === "Point" || geometryType === "MultiPoint") { if (geometryPreset === "point") {
return ["point"]; return ["point"];
} }
if (geometryType === "LineString" || geometryType === "MultiLineString") { if (geometryPreset === "line") {
return ["split", "route"]; return ["line"];
} }
return ["area_polygon", "area_circle"]; if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
} }

View File

@@ -13,6 +13,7 @@ export const BACKGROUND_LAYER_OPTIONS = [
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]; export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>; export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
// Tạo map visibility mặc định cho toàn bộ background layers.
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility { function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => { return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
acc[option.id] = value; acc[option.id] = value;

View File

@@ -11,6 +11,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
features: [], features: [],
}; };
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
export function initCircle( export function initCircle(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
@@ -21,12 +22,14 @@ export function initCircle(
let isDragging = false; let isDragging = false;
let dragPanDisabledByCircle = false; let dragPanDisabledByCircle = false;
// Xóa dữ liệu preview circle trên map.
const clearPreview = () => { const clearPreview = () => {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData( (map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW EMPTY_PREVIEW
); );
}; };
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
const releaseDragPan = () => { const releaseDragPan = () => {
if (!dragPanDisabledByCircle) return; if (!dragPanDisabledByCircle) return;
dragPanDisabledByCircle = false; dragPanDisabledByCircle = false;
@@ -35,6 +38,7 @@ export function initCircle(
} }
}; };
// Reset toàn bộ trạng thái vẽ circle tạm thời.
const resetDrawingState = () => { const resetDrawingState = () => {
center = null; center = null;
radiusMeters = 0; radiusMeters = 0;
@@ -43,6 +47,7 @@ export function initCircle(
releaseDragPan(); releaseDragPan();
}; };
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
const updatePreview = () => { const updatePreview = () => {
if (!center || radiusMeters < MIN_RADIUS_METERS) { if (!center || radiusMeters < MIN_RADIUS_METERS) {
clearPreview(); clearPreview();
@@ -65,6 +70,7 @@ export function initCircle(
}); });
}; };
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
const onMouseDown = (e: maplibregl.MapMouseEvent) => { const onMouseDown = (e: maplibregl.MapMouseEvent) => {
if (getMode() !== "add-circle") return; if (getMode() !== "add-circle") return;
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return; if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
@@ -82,6 +88,7 @@ export function initCircle(
} }
}; };
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
const onMouseMove = (e: maplibregl.MapMouseEvent) => { const onMouseMove = (e: maplibregl.MapMouseEvent) => {
const canvas = map.getCanvas(); const canvas = map.getCanvas();
if (getMode() !== "add-circle") { if (getMode() !== "add-circle") {
@@ -101,6 +108,7 @@ export function initCircle(
updatePreview(); updatePreview();
}; };
// Hoàn tất circle và trả geometry cho callback.
const finishCircle = () => { const finishCircle = () => {
if (!isDragging || !center) { if (!isDragging || !center) {
resetDrawingState(); resetDrawingState();
@@ -120,12 +128,14 @@ export function initCircle(
resetDrawingState(); resetDrawingState();
}; };
// Kết thúc thao tác kéo bằng mouseup chuột trái.
const onMouseUp = (e: maplibregl.MapMouseEvent) => { const onMouseUp = (e: maplibregl.MapMouseEvent) => {
if (getMode() !== "add-circle") return; if (getMode() !== "add-circle") return;
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return; if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
finishCircle(); finishCircle();
}; };
// Hủy phiên vẽ circle khi nhấn Escape.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-circle") return; if (getMode() !== "add-circle") return;
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
@@ -150,6 +160,7 @@ export function initCircle(
}; };
} }
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
function buildCircleRing( function buildCircleRing(
center: [number, number], center: [number, number],
radiusMeters: number, radiusMeters: number,
@@ -163,6 +174,7 @@ function buildCircleRing(
return ring; return ring;
} }
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
function distanceMeters(a: [number, number], b: [number, number]): number { function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]); const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]); const lat2 = toRad(b[1]);
@@ -178,6 +190,7 @@ function distanceMeters(a: [number, number], b: [number, number]): number {
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c. return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
} }
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
function destinationPoint( function destinationPoint(
center: [number, number], center: [number, number],
distance: number, distance: number,
@@ -205,22 +218,26 @@ function destinationPoint(
return [normalizeLng(toDeg(lng2)), toDeg(lat2)]; return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
} }
// Chuẩn hóa kinh độ về miền [-180, 180].
function normalizeLng(lng: number): number { function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180). let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
if (normalized === -180) normalized = 180; if (normalized === -180) normalized = 180;
return normalized; return normalized;
} }
// Kẹp giá trị trong đoạn [min, max].
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
if (value < min) return min; if (value < min) return min;
if (value > max) return max; if (value > max) return max;
return value; return value;
} }
// Đổi đơn vị góc từ độ sang radian.
function toRad(value: number): number { function toRad(value: number): number {
return (value * Math.PI) / 180; // Đổi độ sang radian. return (value * Math.PI) / 180; // Đổi độ sang radian.
} }
// Đổi đơn vị góc từ radian sang độ.
function toDeg(value: number): number { function toDeg(value: number): number {
return (value * 180) / Math.PI; // Đổi radian sang độ. return (value * 180) / Math.PI; // Đổi radian sang độ.
} }

View File

@@ -3,6 +3,7 @@ import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
export function initDrawing( export function initDrawing(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
@@ -10,9 +11,7 @@ export function initDrawing(
) { ) {
let coords: [number, number][] = []; let coords: [number, number][] = [];
/** // Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
* Close polygon ring if not closed.
*/
function closePolygon(c: [number, number][]) { function closePolygon(c: [number, number][]) {
if (c.length < 3) return c; if (c.length < 3) return c;
const first = c[0]; const first = c[0];
@@ -24,9 +23,7 @@ export function initDrawing(
return c; return c;
} }
/** // Cập nhật layer preview trong lúc đang vẽ.
* Update preview layer while drawing.
*/
function update(c: [number, number][]) { function update(c: [number, number][]) {
const closed = closePolygon(c); const closed = closePolygon(c);
@@ -45,6 +42,7 @@ export function initDrawing(
}); });
} }
// Ghi nhận đỉnh polygon mới khi click map.
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw") return; if (getMode() !== "draw") return;
@@ -52,6 +50,7 @@ export function initDrawing(
update(coords); update(coords);
} }
// Render preview polygon với điểm chuột hiện tại.
function onMove(e: maplibregl.MapLayerMouseEvent) { function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw" || coords.length === 0) return; if (getMode() !== "draw" || coords.length === 0) return;
@@ -62,9 +61,7 @@ export function initDrawing(
update(preview); update(preview);
} }
/** // Hoàn tất polygon, trả geometry ra ngoài và reset preview.
* Finalize polygon, emit geometry to caller, reset preview.
*/
function finishDrawing() { function finishDrawing() {
if (getMode() !== "draw" || coords.length < 3) return; if (getMode() !== "draw" || coords.length < 3) return;
@@ -83,6 +80,7 @@ export function initDrawing(
}); });
} }
// Lắng nghe Enter để chốt polygon.
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") { if (e.key === "Enter") {
finishDrawing(); finishDrawing();

View File

@@ -13,6 +13,7 @@ export type EditingAPI = {
bindEditEvents: (map: maplibregl.Map) => void; bindEditEvents: (map: maplibregl.Map) => void;
}; };
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
export function createEditingEngine(options: { export function createEditingEngine(options: {
mapRef: React.MutableRefObject<maplibregl.Map | null>; mapRef: React.MutableRefObject<maplibregl.Map | null>;
onUpdate: (id: string | number, geometry: Geometry) => void; onUpdate: (id: string | number, geometry: Geometry) => void;
@@ -22,6 +23,7 @@ export function createEditingEngine(options: {
const dragStateRef = { current: null as { idx: number } | null }; const dragStateRef = { current: null as { idx: number } | null };
const modifierRef = { current: { ctrl: false, meta: false } }; const modifierRef = { current: { ctrl: false, meta: false } };
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
const clearEditing = () => { const clearEditing = () => {
editingRef.current = null; editingRef.current = null;
dragStateRef.current = null; dragStateRef.current = null;
@@ -32,6 +34,7 @@ export function createEditingEngine(options: {
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty); (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
}; };
// Đồng bộ polygon tạm và các handle point lên map source.
const updateEditSources = () => { const updateEditSources = () => {
const editing = editingRef.current; const editing = editingRef.current;
const map = mapRef.current; const map = mapRef.current;
@@ -62,6 +65,7 @@ export function createEditingEngine(options: {
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles); (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
}; };
// Chốt chỉnh sửa và emit geometry mới cho caller.
const finishEditing = () => { const finishEditing = () => {
const editing = editingRef.current; const editing = editingRef.current;
if (!editing) return; if (!editing) return;
@@ -73,10 +77,12 @@ export function createEditingEngine(options: {
clearEditing(); clearEditing();
}; };
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
const cancelEditing = () => { const cancelEditing = () => {
clearEditing(); clearEditing();
}; };
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => { const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return; if (feature.geometry.type !== "Polygon") return;
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][]; const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
@@ -92,6 +98,7 @@ export function createEditingEngine(options: {
updateEditSources(); updateEditSources();
}; };
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => { const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
const oe = e?.originalEvent as MouseEvent | undefined; const oe = e?.originalEvent as MouseEvent | undefined;
return ( return (
@@ -102,7 +109,9 @@ export function createEditingEngine(options: {
); );
}; };
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
const bindEditEvents = (map: maplibregl.Map) => { const bindEditEvents = (map: maplibregl.Map) => {
// Bắt đầu kéo một handle point.
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => { const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return; if (!editingRef.current) return;
const feature = e.features?.[0]; const feature = e.features?.[0];
@@ -114,6 +123,7 @@ export function createEditingEngine(options: {
map.dragPan.disable(); map.dragPan.disable();
}; };
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
const onHandleMove = (e: maplibregl.MapMouseEvent) => { const onHandleMove = (e: maplibregl.MapMouseEvent) => {
const drag = dragStateRef.current; const drag = dragStateRef.current;
const editing = editingRef.current; const editing = editingRef.current;
@@ -123,12 +133,14 @@ export function createEditingEngine(options: {
updateEditSources(); updateEditSources();
}; };
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
const stopDragging = () => { const stopDragging = () => {
dragStateRef.current = null; dragStateRef.current = null;
map.getCanvas().style.cursor = ""; map.getCanvas().style.cursor = "";
map.dragPan.enable(); map.dragPan.enable();
}; };
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control") { if (e.key === "Control") {
modifierRef.current.ctrl = true; modifierRef.current.ctrl = true;
@@ -143,6 +155,7 @@ export function createEditingEngine(options: {
} }
}; };
// Hạ cờ modifier khi nhả phím.
const onKeyUp = (e: KeyboardEvent) => { const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control") { if (e.key === "Control") {
modifierRef.current.ctrl = false; modifierRef.current.ctrl = false;
@@ -151,6 +164,7 @@ export function createEditingEngine(options: {
} }
}; };
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => { const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return; if (!editingRef.current) return;
if (!isModifierPressed(e)) return; if (!isModifierPressed(e)) return;
@@ -177,6 +191,7 @@ export function createEditingEngine(options: {
updateEditSources(); updateEditSources();
}; };
// Ngắt kéo nếu con trỏ rời canvas.
const onCanvasLeave = () => { const onCanvasLeave = () => {
stopDragging(); stopDragging();
}; };

View File

@@ -1,8 +1,7 @@
export type EntityTypeGroupId = export type EntityTypeGroupId =
| "split" | "line"
| "route" | "polygon"
| "area_polygon" | "circle"
| "area_circle"
| "point"; | "point";
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point"; export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
@@ -24,43 +23,36 @@ export type EntityTypeOption = {
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [ export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
{ {
id: "split", id: "line",
label: "Split", label: "line - Tuyến",
geometryLabel: "Line", geometryLabel: "Line",
description: "Tuyến chia cắt/phòng thủ.", description: "Các tuyến line/path (gấp khúc).",
}, },
{ {
id: "route", id: "polygon",
label: "Route", label: "polygon - Đa giác",
geometryLabel: "Line",
description: "Các tuyến di chuyển theo hướng.",
},
{
id: "area_polygon",
label: "Area - Đa giác",
geometryLabel: "Polygon", geometryLabel: "Polygon",
description: "Vùng lãnh thổ dạng đa giác.", description: "Vùng lãnh thổ dạng đa giác.",
}, },
{ {
id: "area_circle", id: "circle",
label: "Area - Tròn", label: "circle - Tròn",
geometryLabel: "Polygon tròn", geometryLabel: "Circle",
description: "Vùng sự kiện theo bán kính ảnh hưởng.", description: "Vùng sự kiện theo bán kính ảnh hưởng.",
}, },
{ {
id: "point", id: "point",
label: "Point", label: "point - Điểm",
geometryLabel: "Point", geometryLabel: "Point",
description: "Địa điểm đơn lẻ.", description: "Địa điểm đơn lẻ.",
}, },
]; ];
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = { const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
split: ENTITY_TYPE_GROUPS[0], line: ENTITY_TYPE_GROUPS[0],
route: ENTITY_TYPE_GROUPS[1], polygon: ENTITY_TYPE_GROUPS[1],
area_polygon: ENTITY_TYPE_GROUPS[2], circle: ENTITY_TYPE_GROUPS[2],
area_circle: ENTITY_TYPE_GROUPS[3], point: ENTITY_TYPE_GROUPS[3],
point: ENTITY_TYPE_GROUPS[4],
}; };
const RAW_ENTITY_TYPE_OPTIONS: Array<{ const RAW_ENTITY_TYPE_OPTIONS: Array<{
@@ -69,25 +61,25 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
groupId: EntityTypeGroupId; groupId: EntityTypeGroupId;
geometryPreset: EntityGeometryPreset; geometryPreset: EntityGeometryPreset;
}> = [ }> = [
{ value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" }, { value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
{ value: "attack_route", label: "Attack Route", groupId: "route", geometryPreset: "line" }, { value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
{ value: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" }, { value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
{ value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" }, { value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
{ value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" }, { value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
{ value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" }, { value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
{ value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" }, { value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
{ value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" }, { value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
{ value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" }, { value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
{ value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" }, { value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
{ value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" }, { value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
{ value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" }, { value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
{ value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" }, { value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
{ value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" }, { value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
{ value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" }, { value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" }, { value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" }, { value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" }, { value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
@@ -109,6 +101,7 @@ export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.m
export const DEFAULT_ENTITY_TYPE_ID = "country"; export const DEFAULT_ENTITY_TYPE_ID = "country";
// Gom option theo group để render select phân nhóm.
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
id: EntityTypeGroupId; id: EntityTypeGroupId;
label: string; label: string;
@@ -122,8 +115,8 @@ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE
})).filter((group) => group.options.length > 0); })).filter((group) => group.options.length > 0);
} }
// Tìm option theo type id, trả null nếu không tồn tại.
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null { export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
if (!typeId) return null; if (!typeId) return null;
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null; return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
} }

View File

@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
features: [], features: [],
}; };
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
export function initLine( export function initLine(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
@@ -15,17 +16,20 @@ export function initLine(
) { ) {
let coords: [number, number][] = []; let coords: [number, number][] = [];
// Xóa dữ liệu preview line.
const clearPreview = () => { const clearPreview = () => {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData( (map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW EMPTY_PREVIEW
); );
}; };
// Hủy phiên vẽ line hiện tại.
const cancelLine = () => { const cancelLine = () => {
coords = []; coords = [];
clearPreview(); clearPreview();
}; };
// Cập nhật line preview theo danh sách tọa độ tạm.
const updatePreview = (lineCoords: [number, number][]) => { const updatePreview = (lineCoords: [number, number][]) => {
if (lineCoords.length < 2) { if (lineCoords.length < 2) {
clearPreview(); clearPreview();
@@ -47,6 +51,7 @@ export function initLine(
}); });
}; };
// Chốt line khi đủ số đỉnh tối thiểu.
const finishLine = () => { const finishLine = () => {
if (getMode() !== "add-line" || coords.length < 2) return; if (getMode() !== "add-line" || coords.length < 2) return;
@@ -59,12 +64,14 @@ export function initLine(
cancelLine(); cancelLine();
}; };
// Xóa đỉnh cuối cùng trong line đang vẽ.
const removeLastVertex = () => { const removeLastVertex = () => {
if (!coords.length) return; if (!coords.length) return;
coords = coords.slice(0, -1); coords = coords.slice(0, -1);
updatePreview(coords); updatePreview(coords);
}; };
// Thêm một đỉnh line khi click map.
const onClick = (e: maplibregl.MapLayerMouseEvent) => { const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-line") return; if (getMode() !== "add-line") return;
@@ -72,6 +79,7 @@ export function initLine(
updatePreview(coords); updatePreview(coords);
}; };
// Cập nhật preview động theo vị trí chuột.
const onMove = (e: maplibregl.MapLayerMouseEvent) => { const onMove = (e: maplibregl.MapLayerMouseEvent) => {
const canvas = map.getCanvas(); const canvas = map.getCanvas();
@@ -90,6 +98,7 @@ export function initLine(
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]); updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
}; };
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-line") return; if (getMode() !== "add-line") return;

View File

@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
features: [], features: [],
}; };
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
export function initPath( export function initPath(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
@@ -15,12 +16,14 @@ export function initPath(
) { ) {
let coords: [number, number][] = []; let coords: [number, number][] = [];
// Xóa dữ liệu preview path.
const clearPreview = () => { const clearPreview = () => {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData( (map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW EMPTY_PREVIEW
); );
}; };
// Cập nhật path preview theo danh sách tọa độ tạm.
const updatePreview = (lineCoords: [number, number][]) => { const updatePreview = (lineCoords: [number, number][]) => {
if (lineCoords.length < 2) { if (lineCoords.length < 2) {
clearPreview(); clearPreview();
@@ -42,6 +45,7 @@ export function initPath(
}); });
}; };
// Chốt path khi đủ số đỉnh tối thiểu.
const finishPath = () => { const finishPath = () => {
if (getMode() !== "add-path" || coords.length < 2) return; if (getMode() !== "add-path" || coords.length < 2) return;
@@ -55,17 +59,20 @@ export function initPath(
clearPreview(); clearPreview();
}; };
// Hủy phiên vẽ path hiện tại.
const cancelPath = () => { const cancelPath = () => {
coords = []; coords = [];
clearPreview(); clearPreview();
}; };
// Xóa đỉnh cuối cùng của path đang vẽ.
const removeLastVertex = () => { const removeLastVertex = () => {
if (coords.length === 0) return; if (coords.length === 0) return;
coords = coords.slice(0, -1); coords = coords.slice(0, -1);
updatePreview(coords); updatePreview(coords);
}; };
// Thêm một đỉnh path khi click map.
const onClick = (e: maplibregl.MapLayerMouseEvent) => { const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-path") return; if (getMode() !== "add-path") return;
@@ -73,6 +80,7 @@ export function initPath(
updatePreview(coords); updatePreview(coords);
}; };
// Cập nhật preview path động theo vị trí chuột.
const onMove = (e: maplibregl.MapLayerMouseEvent) => { const onMove = (e: maplibregl.MapLayerMouseEvent) => {
const canvas = map.getCanvas(); const canvas = map.getCanvas();
@@ -92,6 +100,7 @@ export function initPath(
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]); updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
}; };
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-path") return; if (getMode() !== "add-path") return;

View File

@@ -3,14 +3,13 @@ import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
// Khởi tạo engine thêm point bằng click đơn.
export function initPoint( export function initPoint(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
onComplete: (geometry: Geometry) => void onComplete: (geometry: Geometry) => void
) { ) {
/** // Thêm point mới khi đang ở chế độ add-point.
* Add a new point when in add-point mode.
*/
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return; if (getMode() !== "add-point") return;
@@ -22,6 +21,7 @@ export function initPoint(
onComplete?.(geometry); onComplete?.(geometry);
} }
// Cập nhật trạng thái con trỏ theo mode add-point.
function onMove() { function onMove() {
const canvas = map.getCanvas(); const canvas = map.getCanvas();
if (getMode() === "add-point") { if (getMode() === "add-point") {

View File

@@ -2,11 +2,12 @@ import maplibregl from "maplibre-gl";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle"; type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect( export function initSelect(
map: maplibregl.Map, map: maplibregl.Map,
getMode: ModeGetter, getMode: ModeGetter,
onDelete: (id: string | number) => void, onDelete?: (id: string | number) => void,
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void, onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectId?: (id: string | number | null) => void onSelectId?: (id: string | number | null) => void
) { ) {
const SELECTABLE_LAYERS = [ const SELECTABLE_LAYERS = [
@@ -17,12 +18,11 @@ export function initSelect(
"places-symbol", "places-symbol",
] as const; ] as const;
const selectedIds = new Set<number | string>(); const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit);
let contextMenu: HTMLDivElement | null = null; let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null; let docClickHandler: ((ev: MouseEvent) => void) | null = null;
/** // Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
* Clear feature-state highlight for all selected features.
*/
function clearSelection() { function clearSelection() {
if (!selectedIds.size) return; if (!selectedIds.size) return;
selectedIds.forEach((id) => { selectedIds.forEach((id) => {
@@ -32,9 +32,7 @@ export function initSelect(
onSelectId?.(null); onSelectId?.(null);
} }
/** // Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
*/
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) { function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id; const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return; if (id === undefined || id === null) return;
@@ -56,6 +54,7 @@ export function initSelect(
onSelectId?.(selectedIds.size === 1 ? id : null); onSelectId?.(selectedIds.size === 1 ? id : null);
} }
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select") return;
@@ -72,9 +71,8 @@ export function initSelect(
selectFeature(features[0], additive); selectFeature(features[0], additive);
} }
/** // Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
* Show context menu (edit/delete) on right click. // Mở menu thao tác khi click phải lên feature.
*/
function onRightClick(e: maplibregl.MapLayerMouseEvent) { function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select") return;
@@ -103,6 +101,7 @@ export function initSelect(
); );
} }
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
function onMove(e: maplibregl.MapLayerMouseEvent) { function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return; if (getMode() !== "select") return;
@@ -115,15 +114,20 @@ export function initSelect(
map.on("click", onClick); map.on("click", onClick);
map.on("mousemove", onMove); map.on("mousemove", onMove);
map.on("contextmenu", onRightClick); if (hasContextActions) {
map.on("contextmenu", onRightClick);
}
return () => { return () => {
map.off("click", onClick); map.off("click", onClick);
map.off("mousemove", onMove); map.off("mousemove", onMove);
map.off("contextmenu", onRightClick); if (hasContextActions) {
map.off("contextmenu", onRightClick);
}
hideContextMenu(); hideContextMenu();
}; };
// Ẩn và dọn dẹp context menu hiện tại.
function hideContextMenu() { function hideContextMenu() {
if (contextMenu) { if (contextMenu) {
contextMenu.remove(); contextMenu.remove();
@@ -135,9 +139,7 @@ export function initSelect(
} }
} }
/** // Render menu ngữ cảnh tối giản gần vị trí con trỏ.
* Render a minimal context menu near cursor.
*/
function showContextMenu( function showContextMenu(
x: number, x: number,
y: number, y: number,
@@ -159,6 +161,7 @@ export function initSelect(
menu.style.fontSize = "14px"; menu.style.fontSize = "14px";
menu.style.padding = "4px 0"; menu.style.padding = "4px 0";
// Tạo một item thao tác trong context menu.
const createItem = (label: string, onClick: () => void) => { const createItem = (label: string, onClick: () => void) => {
const item = document.createElement("div"); const item = document.createElement("div");
item.textContent = label; item.textContent = label;
@@ -174,30 +177,38 @@ export function initSelect(
}; };
const selectedCount = selectedIds.size || 1; const selectedCount = selectedIds.size || 1;
let hasMenuItems = false;
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon") { if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon" && onEdit) {
const single = clickedFeature; const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single))); menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
hasMenuItems = true;
} }
menu.appendChild( if (onDelete) {
createItem( menu.appendChild(
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa", createItem(
() => { selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
const ids = selectedIds.size () => {
? Array.from(selectedIds) const ids = selectedIds.size
: [clickedFeature.id ?? clickedFeature.properties?.id]; ? Array.from(selectedIds)
ids.forEach((eachId) => { : [clickedFeature.id ?? clickedFeature.properties?.id];
if (eachId !== undefined && eachId !== null) onDelete(eachId); ids.forEach((eachId) => {
}); if (eachId !== undefined && eachId !== null) onDelete(eachId);
clearSelection(); });
} clearSelection();
) }
); )
);
hasMenuItems = true;
}
if (!hasMenuItems) return;
document.body.appendChild(menu); document.body.appendChild(menu);
contextMenu = menu; contextMenu = menu;
// Đóng menu khi click ra ngoài vùng menu.
const onDocClick = (ev: MouseEvent) => { const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) { if (!menu.contains(ev.target as Node)) {
hideContextMenu(); hideContextMenu();

View File

@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
/** // Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
* Basic GeoJSON geometry union (no GeometryCollection).
*/
export type Geometry = export type Geometry =
| { type: "Point"; coordinates: [number, number] } | { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] } | { type: "MultiPoint"; coordinates: [number, number][] }
@@ -13,10 +11,11 @@ export type Geometry =
export type FeatureProperties = { export type FeatureProperties = {
id: string | number; id: string | number;
type?: string | null;
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
time_start?: number | null; time_start?: number | null;
time_end?: number | null; time_end?: number | null;
binding?: string[]; binding?: string[];
line_mode?: string | null;
entity_id?: string | null; entity_id?: string | null;
entity_ids?: string[]; entity_ids?: string[];
entity_name?: string | null; entity_name?: string | null;
@@ -35,29 +34,28 @@ export type FeatureCollection = {
features: Feature[]; features: Feature[];
}; };
/** // Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
* Change map entry for saving.
*/
export type Change = export type Change =
| { type: "create"; feature: Feature } | { action: "create"; feature: Feature }
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry } | { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
| { type: "delete"; id: FeatureProperties["id"] }; | { action: "delete"; id: FeatureProperties["id"] };
/** // Kiểu bản ghi undo tối thiểu.
* Minimal undo record.
*/
export type UndoAction = export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry } | { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "delete"; feature: Feature } | { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] }; | { type: "create"; id: FeatureProperties["id"] };
// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ.
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj)); const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
// So sánh hai geometry theo nội dung tuần tự JSON.
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean { function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false; if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
function buildInitialMap(fc: FeatureCollection) { function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Geometry>(); const map = new Map<FeatureProperties["id"], Geometry>();
for (const f of fc.features) { for (const f of fc.features) {
@@ -66,6 +64,7 @@ function buildInitialMap(fc: FeatureCollection) {
return map; return map;
} }
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
function diffDraftToInitial( function diffDraftToInitial(
draft: FeatureCollection, draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Geometry> initialMap: Map<FeatureProperties["id"], Geometry>
@@ -81,22 +80,23 @@ function diffDraftToInitial(
seen.add(id); seen.add(id);
const initialGeom = initialMap.get(id); const initialGeom = initialMap.get(id);
if (!initialGeom) { if (!initialGeom) {
next.set(id, { type: "create", feature: deepClone(f) }); next.set(id, { action: "create", feature: deepClone(f) });
} else if (!geometryEquals(initialGeom, f.geometry)) { } else if (!geometryEquals(initialGeom, f.geometry)) {
next.set(id, { type: "update", id, geometry: deepClone(f.geometry) }); next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
} }
} }
// deletions // deletions
for (const [id] of initialMap.entries()) { for (const [id] of initialMap.entries()) {
if (!seen.has(id)) { if (!seen.has(id)) {
next.set(id, { type: "delete", id }); next.set(id, { action: "delete", id });
} }
} }
return next; return next;
} }
// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng).
function isSameUndo(a: UndoAction | undefined, b: UndoAction) { function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false; if (!a) return false;
if (a.type !== b.type) return false; if (a.type !== b.type) return false;
@@ -124,12 +124,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
} }
} }
/** // State trung tâm của editor:
* Central state for the editor. // - draft: dữ liệu nguồn để render UI
* - draft: source of truth for UI rendering // - changes: map các thay đổi chờ lưu
* - changes: map of pending changes for save // - undoStack: lịch sử thao tác tối thiểu để hoàn tác
* - undoStack: minimal actions to revert last step
*/
export function useEditorState(initialData: FeatureCollection) { export function useEditorState(initialData: FeatureCollection) {
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData)); const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
const [undoStack, setUndoStack] = useState<UndoAction[]>([]); const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
@@ -168,6 +166,7 @@ export function useEditorState(initialData: FeatureCollection) {
draftRef.current = draft; draftRef.current = draft;
}, [draft]); }, [draft]);
// Đẩy undo action mới vào stack nếu khác action gần nhất.
function pushUndo(action: UndoAction) { function pushUndo(action: UndoAction) {
setUndoStack((prev) => { setUndoStack((prev) => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
@@ -176,9 +175,7 @@ export function useEditorState(initialData: FeatureCollection) {
}); });
} }
/** // Thêm feature mới vào draft và ghi nhận thao tác "create".
* Add new feature to draft and record "create".
*/
function createFeature(feature: Feature) { function createFeature(feature: Feature) {
const featureClone = deepClone(feature); const featureClone = deepClone(feature);
commitDraft({ commitDraft({
@@ -188,9 +185,7 @@ export function useEditorState(initialData: FeatureCollection) {
pushUndo({ type: "create", id: featureClone.properties.id }); pushUndo({ type: "create", id: featureClone.properties.id });
} }
/** // Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
* Patch non-geometry properties on a feature (used for entity/time metadata).
*/
function patchFeatureProperties( function patchFeatureProperties(
id: FeatureProperties["id"], id: FeatureProperties["id"],
patch: Partial<FeatureProperties> patch: Partial<FeatureProperties>
@@ -209,9 +204,7 @@ export function useEditorState(initialData: FeatureCollection) {
commitDraft({ ...draftRef.current, features: nextFeatures }); commitDraft({ ...draftRef.current, features: nextFeatures });
} }
/** // Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
* Update geometry of an existing feature and record change.
*/
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return; // nothing to update if (idx === -1) return; // nothing to update
@@ -231,9 +224,7 @@ export function useEditorState(initialData: FeatureCollection) {
commitDraft({ ...draftRef.current, features: nextFeatures }); commitDraft({ ...draftRef.current, features: nextFeatures });
} }
/** // Xóa feature khỏi draft và ghi nhận thao tác delete.
* Remove a feature from draft and record delete.
*/
function deleteFeature(id: FeatureProperties["id"]) { function deleteFeature(id: FeatureProperties["id"]) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id); const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return; if (idx === -1) return;
@@ -247,9 +238,7 @@ export function useEditorState(initialData: FeatureCollection) {
commitDraft({ ...draftRef.current, features: nextFeatures }); commitDraft({ ...draftRef.current, features: nextFeatures });
} }
/** // Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
* Undo last action, reverting both draft and change map.
*/
function undo() { function undo() {
let applied = false; // guards against React StrictMode double invoke of setState updater let applied = false; // guards against React StrictMode double invoke of setState updater
setUndoStack((prev) => { setUndoStack((prev) => {
@@ -294,22 +283,19 @@ export function useEditorState(initialData: FeatureCollection) {
}); });
} }
/** // Dựng mảng payload gửi API save từ map changes hiện tại.
* Build payload array for API save.
*/
function buildPayload(): Change[] { function buildPayload(): Change[] {
return Array.from(changes.values()).map((c) => deepClone(c)); return Array.from(changes.values()).map((c) => deepClone(c));
} }
/** // Xóa thay đổi đang chờ sau khi lưu thành công.
* Clear pending changes after successful save.
*/
function clearChanges() { function clearChanges() {
setUndoStack([]); setUndoStack([]);
initialMapRef.current = buildInitialMap(draftRef.current); initialMapRef.current = buildInitialMap(draftRef.current);
setBaselineVersion((v) => v + 1); setBaselineVersion((v) => v + 1);
} }
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
function hasPersistedFeature(id: FeatureProperties["id"]) { function hasPersistedFeature(id: FeatureProperties["id"]) {
return initialMapRef.current.has(id); return initialMapRef.current.has(id);
} }