add line | add circle | zoom | selectable geometry | geometry define entities type

This commit is contained in:
taDuc
2026-04-11 10:48:59 +07:00
parent 4969c8cc57
commit 3023fa947c
15 changed files with 1435 additions and 212 deletions

View File

@@ -1,11 +1,12 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
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 { createEntity, Entity, fetchEntities } from "@/api/entities";
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 {
@@ -20,6 +21,7 @@ import {
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 = {
@@ -36,15 +38,6 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = {
const TIMELINE_DEBOUNCE_MS = 180;
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
const ENTITY_TYPE_OPTIONS = [
{ value: "country", label: "Country" },
{ value: "castle", label: "Castle" },
{ value: "kingdom", label: "Kingdom" },
{ value: "city", label: "City" },
{ value: "region", label: "Region" },
{ value: "event", label: "Event" },
] as const;
type TimelineRange = {
min: number;
max: number;
@@ -59,10 +52,11 @@ type EntityFormState = {
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle">("idle");
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[]>([]);
@@ -72,15 +66,20 @@ export default function Page() {
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: ENTITY_TYPE_OPTIONS[0].value,
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);
@@ -97,6 +96,8 @@ export default function Page() {
() => loadBackgroundLayerVisibilityFromStorage()
);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData);
const selectedEntity =
@@ -138,6 +139,55 @@ export default function Page() {
};
}, []);
useEffect(() => {
if (!selectedFeature) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
const keyword = entitySearchQuery.trim();
if (!keyword.length) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
let disposed = false;
const requestId = ++entitySearchRequestRef.current;
const timeoutId = window.setTimeout(async () => {
setIsEntitySearchLoading(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
setEntitySearchResults(rows);
setSelectedSearchEntityId((prev) =>
prev && rows.some((entity) => entity.id === prev)
? prev
: rows[0]?.id || null
);
setEntities((prev) => mergeEntitiesById(prev, rows));
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = editor.draft.features.some((feature) =>
@@ -149,17 +199,26 @@ export default function Page() {
}, [editor.draft, selectedFeatureId]);
useEffect(() => {
if (skipSelectedFeatureSyncRef.current) {
skipSelectedFeatureSyncRef.current = false;
return;
}
if (!selectedFeature) {
setEntityForm({
name: "",
slug: "",
type_id: ENTITY_TYPE_OPTIONS[0].value,
type_id: DEFAULT_ENTITY_TYPE_ID,
});
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
@@ -172,7 +231,7 @@ export default function Page() {
const nextTypeId =
linkedEntity?.type_id ||
selectedFeature.properties.entity_type_id ||
ENTITY_TYPE_OPTIONS[0].value;
DEFAULT_ENTITY_TYPE_ID;
setEntityForm({
name: "",
@@ -187,7 +246,11 @@ export default function Page() {
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 {
@@ -383,7 +446,7 @@ export default function Page() {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const handleGeometryMetaFormChange = (key: "time_start" | "time_end", value: string) => {
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
};
@@ -391,6 +454,19 @@ export default function Page() {
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);
@@ -411,6 +487,24 @@ export default function Page() {
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");
@@ -425,6 +519,7 @@ export default function Page() {
entityIds: string[],
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
@@ -443,12 +538,14 @@ export default function Page() {
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(", "),
});
};
@@ -466,8 +563,10 @@ export default function Page() {
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;
@@ -486,13 +585,14 @@ export default function Page() {
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);
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
}
} catch (err) {
@@ -525,8 +625,10 @@ export default function Page() {
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;
@@ -538,7 +640,7 @@ export default function Page() {
const created = await createEntity({
name,
slug: entityForm.slug.trim() || null,
type_id: entityForm.type_id || ENTITY_TYPE_OPTIONS[0].value,
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
});
const rows = await reloadEntities();
@@ -552,21 +654,25 @@ export default function Page() {
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, rows);
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);
setEntityForm((prev) => ({
...prev,
name: "",
slug: "",
}));
resetSelectedGeometryInputsAfterCreate();
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
@@ -611,6 +717,7 @@ export default function Page() {
<Map
mode={mode}
draft={editor.draft}
selectedFeatureId={selectedFeatureId}
selectedEntityId={selectedEntityId}
selectedEntityName={selectedEntity?.name || null}
selectedEntityTypeId={selectedEntity?.type_id || null}
@@ -641,156 +748,45 @@ export default function Page() {
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Vào mode Select chọn 1 geometry đ điền entity.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)}
</div>
<select
multiple
value={selectedGeometryEntityIds}
onChange={(event) =>
handleEntityIdsChange(
Array.from(event.target.selectedOptions, (option) => option.value)
)
}
disabled={isEntitySubmitting}
style={{ ...entityInputStyle, minHeight: "96px" }}
>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
{entity.type_id ? ` [${entity.type_id}]` : ""}
</option>
))}
</select>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
</div>
<input
value={entityForm.name}
onChange={(event) => handleEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => handleEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={entityForm.type_id}
onChange={(event) => handleEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{ENTITY_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={geometryMetaForm.time_start}
onChange={(event) => handleGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => handleGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
onClick={handleCreateEntityAndAttach}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Tạo Entity + Gắn
</button>
<button
onClick={handleApplyEntitiesForSelectedGeometry}
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>
{editor.changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
{entityFormStatus}
</div>
) : null}
</div>
)}
</div>
<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>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
@@ -862,6 +858,33 @@ function normalizeFeatureEntityIds(feature: Feature): string[] {
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>();
@@ -884,6 +907,19 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri
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 {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };