add line | add circle | zoom | selectable geometry | geometry define entities type
This commit is contained in:
372
app/page.tsx
372
app/page.tsx
@@ -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 và 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 có í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 };
|
||||
|
||||
Reference in New Issue
Block a user