add line | add circle | zoom | selectable geometry | geometry define entities type
This commit is contained in:
@@ -31,6 +31,21 @@ export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
|||||||
return requestJson<Entity[]>(url);
|
return requestJson<Entity[]>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchEntitiesByName(
|
||||||
|
name: string,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<Entity[]> {
|
||||||
|
const keyword = name.trim();
|
||||||
|
if (!keyword.length) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ name: keyword });
|
||||||
|
if (options?.limit && Number.isFinite(options.limit)) {
|
||||||
|
params.set("limit", String(Math.trunc(options.limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}/search?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createEntity(payload: CreateEntityPayload): Promise<Entity> {
|
export async function createEntity(payload: CreateEntityPayload): Promise<Entity> {
|
||||||
return requestJson<Entity>(API_ENDPOINTS.entities, {
|
return requestJson<Entity>(API_ENDPOINTS.entities, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type GeometryCreatePayload = {
|
|||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
|
binding?: string[];
|
||||||
entity_id?: string | null;
|
entity_id?: string | null;
|
||||||
entity_ids?: string[];
|
entity_ids?: string[];
|
||||||
};
|
};
|
||||||
@@ -23,6 +24,7 @@ export type GeometryUpdatePayload = {
|
|||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
|
binding?: string[];
|
||||||
entity_id?: string | null;
|
entity_id?: string | null;
|
||||||
entity_ids?: string[];
|
entity_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
372
app/page.tsx
372
app/page.tsx
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Map from "@/components/Map";
|
import Map from "@/components/Map";
|
||||||
import Editor from "@/components/Editor";
|
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 { 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 { ApiError } from "@/api/http";
|
||||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
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";
|
||||||
|
|
||||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
const WORLD_BBOX = {
|
const WORLD_BBOX = {
|
||||||
@@ -36,15 +38,6 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
|||||||
const TIMELINE_DEBOUNCE_MS = 180;
|
const TIMELINE_DEBOUNCE_MS = 180;
|
||||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
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 = {
|
type TimelineRange = {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
@@ -59,10 +52,11 @@ type EntityFormState = {
|
|||||||
type GeometryMetaFormState = {
|
type GeometryMetaFormState = {
|
||||||
time_start: string;
|
time_start: string;
|
||||||
time_end: string;
|
time_end: string;
|
||||||
|
binding: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
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 [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
@@ -72,15 +66,20 @@ export default function Page() {
|
|||||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
type_id: ENTITY_TYPE_OPTIONS[0].value,
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
});
|
});
|
||||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||||
time_start: "",
|
time_start: "",
|
||||||
time_end: "",
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
});
|
});
|
||||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
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);
|
||||||
@@ -97,6 +96,8 @@ export default function Page() {
|
|||||||
() => loadBackgroundLayerVisibilityFromStorage()
|
() => loadBackgroundLayerVisibilityFromStorage()
|
||||||
);
|
);
|
||||||
const timelineFetchRequestRef = useRef(0);
|
const timelineFetchRequestRef = useRef(0);
|
||||||
|
const entitySearchRequestRef = useRef(0);
|
||||||
|
const skipSelectedFeatureSyncRef = useRef(false);
|
||||||
|
|
||||||
const editor = useEditorState(initialData);
|
const editor = useEditorState(initialData);
|
||||||
const selectedEntity =
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedFeatureId === null) return;
|
if (selectedFeatureId === null) return;
|
||||||
const stillExists = editor.draft.features.some((feature) =>
|
const stillExists = editor.draft.features.some((feature) =>
|
||||||
@@ -149,17 +199,26 @@ export default function Page() {
|
|||||||
}, [editor.draft, selectedFeatureId]);
|
}, [editor.draft, selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (skipSelectedFeatureSyncRef.current) {
|
||||||
|
skipSelectedFeatureSyncRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedFeature) {
|
if (!selectedFeature) {
|
||||||
setEntityForm({
|
setEntityForm({
|
||||||
name: "",
|
name: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
type_id: ENTITY_TYPE_OPTIONS[0].value,
|
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||||
});
|
});
|
||||||
setSelectedGeometryEntityIds([]);
|
setSelectedGeometryEntityIds([]);
|
||||||
setGeometryMetaForm({
|
setGeometryMetaForm({
|
||||||
time_start: "",
|
time_start: "",
|
||||||
time_end: "",
|
time_end: "",
|
||||||
|
binding: "",
|
||||||
});
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
setEntityFormStatus(null);
|
setEntityFormStatus(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,7 +231,7 @@ export default function Page() {
|
|||||||
const nextTypeId =
|
const nextTypeId =
|
||||||
linkedEntity?.type_id ||
|
linkedEntity?.type_id ||
|
||||||
selectedFeature.properties.entity_type_id ||
|
selectedFeature.properties.entity_type_id ||
|
||||||
ENTITY_TYPE_OPTIONS[0].value;
|
DEFAULT_ENTITY_TYPE_ID;
|
||||||
|
|
||||||
setEntityForm({
|
setEntityForm({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -187,7 +246,11 @@ export default function Page() {
|
|||||||
time_end: selectedFeature.properties.time_end != null
|
time_end: selectedFeature.properties.time_end != null
|
||||||
? String(selectedFeature.properties.time_end)
|
? String(selectedFeature.properties.time_end)
|
||||||
: "",
|
: "",
|
||||||
|
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||||
});
|
});
|
||||||
|
setEntitySearchQuery("");
|
||||||
|
setEntitySearchResults([]);
|
||||||
|
setSelectedSearchEntityId(null);
|
||||||
if (!featureEntityIds.length) {
|
if (!featureEntityIds.length) {
|
||||||
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
||||||
} else {
|
} else {
|
||||||
@@ -383,7 +446,7 @@ export default function Page() {
|
|||||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
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 }));
|
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,6 +454,19 @@ export default function Page() {
|
|||||||
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
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 reloadEntities = async () => {
|
||||||
const rows = await fetchEntities();
|
const rows = await fetchEntities();
|
||||||
setEntities(rows);
|
setEntities(rows);
|
||||||
@@ -411,6 +487,24 @@ export default function Page() {
|
|||||||
setInitialData(data);
|
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 parseGeometryMetaFormRange = () => {
|
||||||
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
||||||
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
||||||
@@ -425,6 +519,7 @@ export default function Page() {
|
|||||||
entityIds: string[],
|
entityIds: string[],
|
||||||
timeStart: number | null,
|
timeStart: number | null,
|
||||||
timeEnd: number | null,
|
timeEnd: number | null,
|
||||||
|
bindingIds: string[],
|
||||||
entityRows: Entity[] = entities
|
entityRows: Entity[] = entities
|
||||||
) => {
|
) => {
|
||||||
const primaryEntityId = entityIds[0] || null;
|
const primaryEntityId = entityIds[0] || null;
|
||||||
@@ -443,12 +538,14 @@ export default function Page() {
|
|||||||
entity_type_id: primaryEntity?.type_id || null,
|
entity_type_id: primaryEntity?.type_id || null,
|
||||||
time_start: timeStart,
|
time_start: timeStart,
|
||||||
time_end: timeEnd,
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedGeometryEntityIds(entityIds);
|
setSelectedGeometryEntityIds(entityIds);
|
||||||
setGeometryMetaForm({
|
setGeometryMetaForm({
|
||||||
time_start: timeStart != null ? String(timeStart) : "",
|
time_start: timeStart != null ? String(timeStart) : "",
|
||||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||||
|
binding: bindingIds.join(", "),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,8 +563,10 @@ export default function Page() {
|
|||||||
|
|
||||||
let timeStart: number | null;
|
let timeStart: number | null;
|
||||||
let timeEnd: number | null;
|
let timeEnd: number | null;
|
||||||
|
let bindingIds: string[];
|
||||||
try {
|
try {
|
||||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||||
|
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
return;
|
return;
|
||||||
@@ -486,13 +585,14 @@ export default function Page() {
|
|||||||
geometry: selectedFeature.geometry,
|
geometry: selectedFeature.geometry,
|
||||||
time_start: timeStart,
|
time_start: timeStart,
|
||||||
time_end: timeEnd,
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
entity_ids: entityIds,
|
entity_ids: entityIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
await reloadCurrentTimelineData();
|
await reloadCurrentTimelineData();
|
||||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||||
} else {
|
} 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.");
|
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -525,8 +625,10 @@ export default function Page() {
|
|||||||
|
|
||||||
let timeStart: number | null;
|
let timeStart: number | null;
|
||||||
let timeEnd: number | null;
|
let timeEnd: number | null;
|
||||||
|
let bindingIds: string[];
|
||||||
try {
|
try {
|
||||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||||
|
bindingIds = parseBindingInput(geometryMetaForm.binding);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||||
return;
|
return;
|
||||||
@@ -538,7 +640,7 @@ export default function Page() {
|
|||||||
const created = await createEntity({
|
const created = await createEntity({
|
||||||
name,
|
name,
|
||||||
slug: entityForm.slug.trim() || null,
|
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();
|
const rows = await reloadEntities();
|
||||||
@@ -552,21 +654,25 @@ export default function Page() {
|
|||||||
geometry: selectedFeature.geometry,
|
geometry: selectedFeature.geometry,
|
||||||
time_start: timeStart,
|
time_start: timeStart,
|
||||||
time_end: timeEnd,
|
time_end: timeEnd,
|
||||||
|
binding: bindingIds,
|
||||||
entity_ids: nextEntityIds,
|
entity_ids: nextEntityIds,
|
||||||
});
|
});
|
||||||
await reloadCurrentTimelineData();
|
await reloadCurrentTimelineData();
|
||||||
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
||||||
} else {
|
} 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.");
|
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedEntityId(created.id);
|
setSelectedEntityId(created.id);
|
||||||
setEntityForm((prev) => ({
|
resetSelectedGeometryInputsAfterCreate();
|
||||||
...prev,
|
|
||||||
name: "",
|
|
||||||
slug: "",
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
||||||
@@ -611,6 +717,7 @@ export default function Page() {
|
|||||||
<Map
|
<Map
|
||||||
mode={mode}
|
mode={mode}
|
||||||
draft={editor.draft}
|
draft={editor.draft}
|
||||||
|
selectedFeatureId={selectedFeatureId}
|
||||||
selectedEntityId={selectedEntityId}
|
selectedEntityId={selectedEntityId}
|
||||||
selectedEntityName={selectedEntity?.name || null}
|
selectedEntityName={selectedEntity?.name || null}
|
||||||
selectedEntityTypeId={selectedEntity?.type_id || null}
|
selectedEntityTypeId={selectedEntity?.type_id || null}
|
||||||
@@ -641,156 +748,45 @@ export default function Page() {
|
|||||||
onShowAll={handleShowAllBackgroundLayers}
|
onShowAll={handleShowAllBackgroundLayers}
|
||||||
onHideAll={handleHideAllBackgroundLayers}
|
onHideAll={handleHideAllBackgroundLayers}
|
||||||
topContent={
|
topContent={
|
||||||
<div
|
<SelectedGeometryPanel
|
||||||
style={{
|
selectedFeature={selectedFeature}
|
||||||
padding: "10px",
|
selectedFeatureEntitySummary={
|
||||||
background: "#0b1220",
|
selectedFeature
|
||||||
borderRadius: "8px",
|
? formatEntityNamesForDisplay(selectedFeature, entities)
|
||||||
border: "1px solid #1f2937",
|
: "Chưa gắn"
|
||||||
}}
|
}
|
||||||
>
|
selectedFeatureBindingSummary={
|
||||||
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
selectedFeature
|
||||||
Selected Geometry
|
? formatBindingIdsForDisplay(selectedFeature)
|
||||||
</div>
|
: "Không có"
|
||||||
{!selectedFeature ? (
|
}
|
||||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
entities={entities}
|
||||||
Vào mode Select và chọn 1 geometry để điền entity.
|
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||||
</div>
|
onEntityIdsChange={handleEntityIdsChange}
|
||||||
) : (
|
entitySearchQuery={entitySearchQuery}
|
||||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
onEntitySearchQueryChange={setEntitySearchQuery}
|
||||||
<div style={{ color: "#e2e8f0" }}>
|
entitySearchResults={entitySearchResults}
|
||||||
ID: {String(selectedFeature.properties.id)}
|
selectedSearchEntityId={selectedSearchEntityId}
|
||||||
</div>
|
onSelectSearchEntityId={setSelectedSearchEntityId}
|
||||||
<div style={{ color: "#cbd5e1" }}>
|
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
||||||
Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)}
|
isEntitySearchLoading={isEntitySearchLoading}
|
||||||
</div>
|
entityForm={entityForm}
|
||||||
|
onEntityFormChange={handleEntityFormChange}
|
||||||
<select
|
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||||
multiple
|
geometryMetaForm={geometryMetaForm}
|
||||||
value={selectedGeometryEntityIds}
|
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||||
onChange={(event) =>
|
isEntitySubmitting={isEntitySubmitting}
|
||||||
handleEntityIdsChange(
|
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
||||||
Array.from(event.target.selectedOptions, (option) => option.value)
|
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||||
)
|
changeCount={editor.changeCount}
|
||||||
}
|
entityFormStatus={entityFormStatus}
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
||||||
let min = Number.POSITIVE_INFINITY;
|
let min = Number.POSITIVE_INFINITY;
|
||||||
let max = Number.NEGATIVE_INFINITY;
|
let max = Number.NEGATIVE_INFINITY;
|
||||||
@@ -862,6 +858,33 @@ function normalizeFeatureEntityIds(feature: Feature): string[] {
|
|||||||
return [];
|
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[] {
|
function uniqueEntityIds(ids: string[]): string[] {
|
||||||
const deduped: string[] = [];
|
const deduped: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -884,6 +907,19 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri
|
|||||||
return names.join(", ");
|
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 {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { UndoAction } from "@/lib/useEditorState";
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-path" | "add-circle";
|
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
type EntityOption = {
|
type EntityOption = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -108,6 +108,13 @@ export default function Editor({
|
|||||||
Add point
|
Add point
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={getButtonStyle("add-line")}
|
||||||
|
onClick={() => setMode("add-line")}
|
||||||
|
>
|
||||||
|
Add line
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
style={getButtonStyle("add-path")}
|
style={getButtonStyle("add-path")}
|
||||||
onClick={() => setMode("add-path")}
|
onClick={() => setMode("add-path")}
|
||||||
@@ -125,6 +132,11 @@ export default function Editor({
|
|||||||
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
||||||
Mode: <b>{mode}</b>
|
Mode: <b>{mode}</b>
|
||||||
</div>
|
</div>
|
||||||
|
{mode === "add-line" ? (
|
||||||
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||||
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{mode === "add-path" ? (
|
{mode === "add-path" ? (
|
||||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||||
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
Click để thêm điểm, Enter để hoàn tất, Esc để hủy.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"
|
|||||||
import { initDrawing } from "@/lib/drawingEngine";
|
import { initDrawing } from "@/lib/drawingEngine";
|
||||||
import { initSelect } from "@/lib/selectingEngine";
|
import { initSelect } from "@/lib/selectingEngine";
|
||||||
import { initPoint } from "@/lib/pointEngine";
|
import { initPoint } from "@/lib/pointEngine";
|
||||||
|
import { initLine } from "@/lib/lineEngine";
|
||||||
import { initPath } from "@/lib/pathEngine";
|
import { initPath } from "@/lib/pathEngine";
|
||||||
import { initCircle } from "@/lib/circleEngine";
|
import { initCircle } from "@/lib/circleEngine";
|
||||||
import { createEditingEngine } from "@/lib/editingEngine";
|
import { createEditingEngine } from "@/lib/editingEngine";
|
||||||
@@ -15,9 +16,10 @@ import { FeatureCollection, Geometry } from "@/lib/useEditorState";
|
|||||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
mode: "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
mode: "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
draft: FeatureCollection;
|
draft: FeatureCollection;
|
||||||
backgroundVisibility: BackgroundLayerVisibility;
|
backgroundVisibility: BackgroundLayerVisibility;
|
||||||
|
selectedFeatureId: string | number | null;
|
||||||
selectedEntityId: string | null;
|
selectedEntityId: string | null;
|
||||||
selectedEntityName: string | null;
|
selectedEntityName: string | null;
|
||||||
selectedEntityTypeId: string | null;
|
selectedEntityTypeId: string | null;
|
||||||
@@ -36,22 +38,120 @@ type PointIconSpec = {
|
|||||||
|
|
||||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
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_MAX_ZOOM = 10;
|
||||||
|
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"coalesce",
|
||||||
|
["get", "MAPCOLOR7"],
|
||||||
|
["get", "MAPCOLOR9"],
|
||||||
|
["get", "scalerank"],
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
|
||||||
|
"match",
|
||||||
|
COUNTRY_COLOR_KEY_EXPRESSION,
|
||||||
|
1, "#ef4444",
|
||||||
|
2, "#f97316",
|
||||||
|
3, "#f59e0b",
|
||||||
|
4, "#22c55e",
|
||||||
|
5, "#06b6d4",
|
||||||
|
6, "#3b82f6",
|
||||||
|
7, "#8b5cf6",
|
||||||
|
8, "#a855f7",
|
||||||
|
9, "#d946ef",
|
||||||
|
10, "#14b8a6",
|
||||||
|
"#64748b",
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLYGON_FILL_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#2563eb",
|
||||||
|
state: "#0ea5e9",
|
||||||
|
empire: "#f59e0b",
|
||||||
|
kingdom: "#d97706",
|
||||||
|
war: "#dc2626",
|
||||||
|
battle: "#f43f5e",
|
||||||
|
civilization: "#14b8a6",
|
||||||
|
rebellion_zone: "#7c3aed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
|
||||||
|
country: "#1e3a8a",
|
||||||
|
state: "#0c4a6e",
|
||||||
|
empire: "#7c2d12",
|
||||||
|
kingdom: "#9a3412",
|
||||||
|
war: "#7f1d1d",
|
||||||
|
battle: "#9f1239",
|
||||||
|
civilization: "#134e4a",
|
||||||
|
rebellion_zone: "#4c1d95",
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
|
||||||
|
war: 0.3,
|
||||||
|
battle: 0.34,
|
||||||
|
civilization: 0.38,
|
||||||
|
rebellion_zone: 0.32,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LINE_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
defense_line: "#f97316",
|
||||||
|
attack_route: "#ef4444",
|
||||||
|
retreat_route: "#94a3b8",
|
||||||
|
invasion_route: "#b91c1c",
|
||||||
|
migration_route: "#0ea5e9",
|
||||||
|
refugee_route: "#06b6d4",
|
||||||
|
trade_route: "#eab308",
|
||||||
|
shipping_route: "#2563eb",
|
||||||
|
};
|
||||||
|
|
||||||
|
const POINT_COLOR_BY_TYPE: Record<string, string> = {
|
||||||
|
person_deathplace: "#dc2626",
|
||||||
|
person_birthplace: "#2563eb",
|
||||||
|
person_activity: "#14b8a6",
|
||||||
|
temple: "#7c3aed",
|
||||||
|
capital: "#f59e0b",
|
||||||
|
city: "#0f766e",
|
||||||
|
fortress: "#b91c1c",
|
||||||
|
castle: "#6d28d9",
|
||||||
|
ruin: "#57534e",
|
||||||
|
port: "#0c4a6e",
|
||||||
|
bridge: "#9a3412",
|
||||||
|
};
|
||||||
|
|
||||||
const POINT_ICON_SPECS: PointIconSpec[] = [
|
const POINT_ICON_SPECS: PointIconSpec[] = [
|
||||||
|
{ id: "point-icon-person-deathplace", fill: "#dc2626", stroke: "#7f1d1d", label: "D" },
|
||||||
|
{ id: "point-icon-person-birthplace", fill: "#2563eb", stroke: "#1e3a8a", label: "B" },
|
||||||
|
{ id: "point-icon-person-activity", fill: "#14b8a6", stroke: "#134e4a", label: "A" },
|
||||||
|
{ id: "point-icon-temple", fill: "#7c3aed", stroke: "#4c1d95", label: "T" },
|
||||||
|
{ id: "point-icon-capital", fill: "#f59e0b", stroke: "#92400e", label: "Ca" },
|
||||||
|
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "Ci" },
|
||||||
|
{ id: "point-icon-fortress", fill: "#b91c1c", stroke: "#7f1d1d", label: "F" },
|
||||||
|
{ id: "point-icon-castle", fill: "#6d28d9", stroke: "#4c1d95", label: "Cs" },
|
||||||
|
{ id: "point-icon-ruin", fill: "#57534e", stroke: "#292524", label: "R" },
|
||||||
|
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
|
||||||
|
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
|
||||||
|
|
||||||
|
// Backward-compatible icon ids used by older type_id values.
|
||||||
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
|
||||||
{ id: "point-icon-castle", fill: "#7c3aed", stroke: "#4c1d95", label: "F" },
|
|
||||||
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
|
||||||
{ id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "T" },
|
|
||||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
||||||
{ id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" },
|
{ id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" },
|
||||||
{ id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" },
|
{ id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const POINT_ICON_BY_TYPE: Record<string, string> = {
|
const POINT_ICON_BY_TYPE: Record<string, string> = {
|
||||||
|
person_deathplace: "point-icon-person-deathplace",
|
||||||
|
person_birthplace: "point-icon-person-birthplace",
|
||||||
|
person_activity: "point-icon-person-activity",
|
||||||
|
temple: "point-icon-temple",
|
||||||
|
capital: "point-icon-capital",
|
||||||
|
city: "point-icon-city",
|
||||||
|
fortress: "point-icon-fortress",
|
||||||
country: "point-icon-country",
|
country: "point-icon-country",
|
||||||
castle: "point-icon-castle",
|
castle: "point-icon-castle",
|
||||||
kingdom: "point-icon-kingdom",
|
kingdom: "point-icon-kingdom",
|
||||||
city: "point-icon-city",
|
ruin: "point-icon-ruin",
|
||||||
|
port: "point-icon-port",
|
||||||
|
bridge: "point-icon-bridge",
|
||||||
region: "point-icon-region",
|
region: "point-icon-region",
|
||||||
event: "point-icon-event",
|
event: "point-icon-event",
|
||||||
};
|
};
|
||||||
@@ -60,6 +160,7 @@ export default function Map({
|
|||||||
mode,
|
mode,
|
||||||
draft,
|
draft,
|
||||||
backgroundVisibility,
|
backgroundVisibility,
|
||||||
|
selectedFeatureId,
|
||||||
selectedEntityId,
|
selectedEntityId,
|
||||||
selectedEntityName,
|
selectedEntityName,
|
||||||
selectedEntityTypeId,
|
selectedEntityTypeId,
|
||||||
@@ -72,6 +173,7 @@ export default function Map({
|
|||||||
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 selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
||||||
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
||||||
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
||||||
@@ -79,6 +181,8 @@ export default function Map({
|
|||||||
const onCreateRef = useRef(onCreateFeature);
|
const onCreateRef = useRef(onCreateFeature);
|
||||||
const onDeleteRef = useRef(onDeleteFeature);
|
const onDeleteRef = useRef(onDeleteFeature);
|
||||||
const onUpdateRef = useRef(onUpdateFeature);
|
const onUpdateRef = useRef(onUpdateFeature);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
|
||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
|
||||||
@@ -89,6 +193,12 @@ export default function Map({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
if (mode !== "add-line") {
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (mode !== "add-path") {
|
if (mode !== "add-path") {
|
||||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -107,6 +217,10 @@ export default function Map({
|
|||||||
draftRef.current = draft;
|
draftRef.current = draft;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedFeatureIdRef.current = selectedFeatureId;
|
||||||
|
}, [selectedFeatureId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedEntityIdRef.current = selectedEntityId;
|
selectedEntityIdRef.current = selectedEntityId;
|
||||||
}, [selectedEntityId]);
|
}, [selectedEntityId]);
|
||||||
@@ -167,7 +281,8 @@ 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 { polygons, points } = splitDraftFeatures(fc);
|
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
|
||||||
|
const { polygons, points } = splitDraftFeatures(visibleDraft);
|
||||||
|
|
||||||
countriesSource.setData(polygons);
|
countriesSource.setData(polygons);
|
||||||
placesSource.setData(points);
|
placesSource.setData(points);
|
||||||
@@ -177,6 +292,8 @@ export default function Map({
|
|||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: "map",
|
container: "map",
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
|
minZoom: MAP_MIN_ZOOM,
|
||||||
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -245,8 +362,8 @@ export default function Map({
|
|||||||
source: "base",
|
source: "base",
|
||||||
"source-layer": "countries",
|
"source-layer": "countries",
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#334155",
|
"fill-color": COUNTRY_FILL_COLOR_EXPRESSION,
|
||||||
"fill-opacity": 0.28,
|
"fill-opacity": 0.38,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -333,10 +450,16 @@ export default function Map({
|
|||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
map.on("load", async () => {
|
map.on("load", async () => {
|
||||||
const placesMinZoom = 5;
|
const geometryMinZoom = 5;
|
||||||
|
const syncZoomLevel = () => {
|
||||||
|
setZoomLevel(roundZoom(map.getZoom()));
|
||||||
|
};
|
||||||
|
|
||||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||||
|
setZoomBounds({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
syncZoomLevel();
|
||||||
|
map.on("zoom", syncZoomLevel);
|
||||||
|
|
||||||
// preview (drawing)
|
// preview (drawing)
|
||||||
map.addSource("draw-preview", {
|
map.addSource("draw-preview", {
|
||||||
@@ -396,6 +519,26 @@ export default function Map({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
map.addSource("draw-line-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-line-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-line-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": 3,
|
||||||
|
"line-opacity": 0.9,
|
||||||
|
"line-dasharray": [1.2, 0.9],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
map.addSource("draw-path-preview", {
|
map.addSource("draw-path-preview", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: {
|
data: {
|
||||||
@@ -459,9 +602,14 @@ export default function Map({
|
|||||||
"",
|
"",
|
||||||
],
|
],
|
||||||
"#ef4444", // no entity
|
"#ef4444", // no entity
|
||||||
"#f59e0b", // linked entity
|
buildTypeMatchExpression(POLYGON_FILL_BY_TYPE, "#f59e0b"),
|
||||||
|
],
|
||||||
|
"fill-opacity": [
|
||||||
|
"case",
|
||||||
|
["boolean", ["feature-state", "selected"], false],
|
||||||
|
0.6,
|
||||||
|
buildTypeMatchExpression(POLYGON_OPACITY_BY_TYPE, 0.5),
|
||||||
],
|
],
|
||||||
"fill-opacity": 0.5,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,7 +619,12 @@ export default function Map({
|
|||||||
source: "countries",
|
source: "countries",
|
||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#fbbf24",
|
"line-color": [
|
||||||
|
"case",
|
||||||
|
["boolean", ["feature-state", "selected"], false],
|
||||||
|
"#14532d",
|
||||||
|
buildTypeMatchExpression(POLYGON_STROKE_BY_TYPE, "#fbbf24"),
|
||||||
|
],
|
||||||
"line-width": 2,
|
"line-width": 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -480,6 +633,7 @@ 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": [
|
||||||
@@ -488,7 +642,7 @@ export default function Map({
|
|||||||
"#22c55e",
|
"#22c55e",
|
||||||
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||||
"#ef4444",
|
"#ef4444",
|
||||||
"#38bdf8",
|
buildTypeMatchExpression(LINE_COLOR_BY_TYPE, "#38bdf8"),
|
||||||
],
|
],
|
||||||
"line-width": [
|
"line-width": [
|
||||||
"interpolate",
|
"interpolate",
|
||||||
@@ -507,7 +661,12 @@ export default function Map({
|
|||||||
id: "routes-arrow",
|
id: "routes-arrow",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
source: "countries",
|
source: "countries",
|
||||||
filter: ["==", ["geometry-type"], "LineString"],
|
minzoom: geometryMinZoom,
|
||||||
|
filter: [
|
||||||
|
"all",
|
||||||
|
["==", ["geometry-type"], "LineString"],
|
||||||
|
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
|
||||||
|
],
|
||||||
layout: {
|
layout: {
|
||||||
"symbol-placement": "line",
|
"symbol-placement": "line",
|
||||||
"symbol-spacing": 60,
|
"symbol-spacing": 60,
|
||||||
@@ -564,7 +723,7 @@ export default function Map({
|
|||||||
id: "places-circle",
|
id: "places-circle",
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: "places",
|
source: "places",
|
||||||
minzoom: placesMinZoom,
|
minzoom: geometryMinZoom,
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"case",
|
"case",
|
||||||
@@ -574,7 +733,7 @@ export default function Map({
|
|||||||
"",
|
"",
|
||||||
],
|
],
|
||||||
"#ef4444",
|
"#ef4444",
|
||||||
"#10b981",
|
buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"),
|
||||||
],
|
],
|
||||||
"circle-radius": 4,
|
"circle-radius": 4,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": "#ffffff",
|
||||||
@@ -590,7 +749,7 @@ export default function Map({
|
|||||||
id: "places-symbol",
|
id: "places-symbol",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
source: "places",
|
source: "places",
|
||||||
minzoom: placesMinZoom,
|
minzoom: geometryMinZoom,
|
||||||
layout: {
|
layout: {
|
||||||
"icon-image": buildPointIconExpression(),
|
"icon-image": buildPointIconExpression(),
|
||||||
"icon-size": 0.5,
|
"icon-size": 0.5,
|
||||||
@@ -614,6 +773,7 @@ export default function Map({
|
|||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_name: selectedEntityNameRef.current || null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||||
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
});
|
});
|
||||||
@@ -646,6 +806,28 @@ export default function Map({
|
|||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_name: selectedEntityNameRef.current || null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupLine = initLine(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
|
onCreateRef.current({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id,
|
||||||
|
entity_id: selectedEntityIdRef.current || null,
|
||||||
|
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||||
|
entity_name: selectedEntityNameRef.current || null,
|
||||||
|
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||||
|
line_mode: "line",
|
||||||
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
});
|
});
|
||||||
@@ -665,6 +847,8 @@ export default function Map({
|
|||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_name: selectedEntityNameRef.current || null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||||
|
line_mode: "path",
|
||||||
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
});
|
});
|
||||||
@@ -684,6 +868,7 @@ export default function Map({
|
|||||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||||
entity_name: selectedEntityNameRef.current || null,
|
entity_name: selectedEntityNameRef.current || null,
|
||||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||||
|
binding: [],
|
||||||
},
|
},
|
||||||
geometry,
|
geometry,
|
||||||
});
|
});
|
||||||
@@ -692,11 +877,13 @@ export default function Map({
|
|||||||
|
|
||||||
map.on("remove", cleanupCircle);
|
map.on("remove", cleanupCircle);
|
||||||
map.on("remove", cleanupPath);
|
map.on("remove", cleanupPath);
|
||||||
|
map.on("remove", cleanupLine);
|
||||||
map.on("remove", cleanupPoint);
|
map.on("remove", cleanupPoint);
|
||||||
|
|
||||||
map.on("remove", cleanupSelect);
|
map.on("remove", cleanupSelect);
|
||||||
|
|
||||||
map.on("remove", cleanup);
|
map.on("remove", cleanup);
|
||||||
|
map.on("remove", () => map.off("zoom", syncZoomLevel));
|
||||||
|
|
||||||
// after everything mounted, push current draft to sources
|
// after everything mounted, push current draft to sources
|
||||||
applyDraftToMap(draftRef.current);
|
applyDraftToMap(draftRef.current);
|
||||||
@@ -707,6 +894,20 @@ export default function Map({
|
|||||||
return () => map.remove();
|
return () => map.remove();
|
||||||
}, [applyDraftToMap]);
|
}, [applyDraftToMap]);
|
||||||
|
|
||||||
|
const handleZoomByStep = (delta: number) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const next = clampNumber(zoomLevel + delta, zoomBounds.min, zoomBounds.max);
|
||||||
|
map.easeTo({ zoom: next, duration: 120 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomSliderChange = (nextRaw: number) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !Number.isFinite(nextRaw)) return;
|
||||||
|
const next = clampNumber(nextRaw, zoomBounds.min, zoomBounds.max);
|
||||||
|
map.easeTo({ zoom: next, duration: 80 });
|
||||||
|
};
|
||||||
|
|
||||||
// sync draft -> map sources and drop edit overlays if feature vanished
|
// sync draft -> map sources and drop edit overlays if feature vanished
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyDraftToMap(draft);
|
applyDraftToMap(draft);
|
||||||
@@ -717,9 +918,86 @@ export default function Map({
|
|||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [draft, applyDraftToMap]);
|
}, [draft, selectedFeatureId, applyDraftToMap]);
|
||||||
|
|
||||||
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
|
return (
|
||||||
|
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
|
||||||
|
<div id="map" style={{ width: "100%", height: "100%" }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
left: "16px",
|
||||||
|
right: "16px",
|
||||||
|
zIndex: 12,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "520px",
|
||||||
|
margin: "0 auto",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
background: "rgba(15, 23, 42, 0.88)",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.38)",
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
backdropFilter: "blur(3px)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleZoomByStep(-0.8)}
|
||||||
|
style={zoomButtonStyle}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={zoomBounds.min}
|
||||||
|
max={zoomBounds.max}
|
||||||
|
step={0.1}
|
||||||
|
value={zoomLevel}
|
||||||
|
onChange={(event) => handleZoomSliderChange(Number(event.target.value))}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
accentColor: "#38bdf8",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
aria-label="Map zoom"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleZoomByStep(0.8)}
|
||||||
|
style={zoomButtonStyle}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: "56px",
|
||||||
|
textAlign: "right",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#cbd5e1",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zoomLevel.toFixed(1)}x
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBackgroundLayerVisibility(
|
function applyBackgroundLayerVisibility(
|
||||||
@@ -736,6 +1014,44 @@ function applyBackgroundLayerVisibility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterDraftByBinding(
|
||||||
|
fc: FeatureCollection,
|
||||||
|
selectedFeatureId: string | number | null
|
||||||
|
): FeatureCollection {
|
||||||
|
const selectedId = selectedFeatureId !== null ? String(selectedFeatureId) : null;
|
||||||
|
if (selectedId === null) {
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
features: fc.features.filter((feature) => !normalizeBindingIds(feature.properties.binding).length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fc,
|
||||||
|
features: fc.features.filter((feature) => {
|
||||||
|
const featureId = String(feature.properties.id);
|
||||||
|
if (featureId === selectedId) return true;
|
||||||
|
const bindingIds = normalizeBindingIds(feature.properties.binding);
|
||||||
|
if (!bindingIds.length) return true;
|
||||||
|
return bindingIds.includes(selectedId);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingIds(rawBinding: unknown): string[] {
|
||||||
|
if (!Array.isArray(rawBinding)) return [];
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const rawId of rawBinding) {
|
||||||
|
if (typeof rawId !== "string" && typeof rawId !== "number") continue;
|
||||||
|
const id = String(rawId).trim();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(id);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
function splitDraftFeatures(fc: FeatureCollection) {
|
function splitDraftFeatures(fc: FeatureCollection) {
|
||||||
const polygons = {
|
const polygons = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
@@ -835,7 +1151,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
|||||||
ctx.fillStyle = "#ffffff";
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
ctx.font = "700 20px sans-serif";
|
ctx.font = spec.label.length > 1 ? "700 15px sans-serif" : "700 20px sans-serif";
|
||||||
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
|
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
|
||||||
|
|
||||||
return ctx.getImageData(0, 0, size, size);
|
return ctx.getImageData(0, 0, size, size);
|
||||||
@@ -851,3 +1167,41 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
|||||||
expression.push(DEFAULT_POINT_ICON_ID);
|
expression.push(DEFAULT_POINT_ICON_ID);
|
||||||
return expression as maplibregl.ExpressionSpecification;
|
return expression as maplibregl.ExpressionSpecification;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTypeMatchExpression(
|
||||||
|
valueByType: Record<string, string | number>,
|
||||||
|
fallback: string | number
|
||||||
|
): maplibregl.ExpressionSpecification {
|
||||||
|
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
||||||
|
|
||||||
|
for (const [typeId, value] of Object.entries(valueByType)) {
|
||||||
|
expression.push(typeId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
expression.push(fallback);
|
||||||
|
return expression as maplibregl.ExpressionSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundZoom(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomButtonStyle: React.CSSProperties = {
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#1e293b",
|
||||||
|
color: "#f8fafc",
|
||||||
|
fontSize: "18px",
|
||||||
|
lineHeight: "1",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
};
|
||||||
|
|||||||
546
components/SelectedGeometryPanel.tsx
Normal file
546
components/SelectedGeometryPanel.tsx
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type CSSProperties } from "react";
|
||||||
|
import { Entity } from "@/api/entities";
|
||||||
|
import { Feature } from "@/lib/useEditorState";
|
||||||
|
import {
|
||||||
|
EntityTypeGroupId,
|
||||||
|
EntityTypeOption,
|
||||||
|
findEntityTypeOption,
|
||||||
|
groupEntityTypeOptions,
|
||||||
|
} from "@/lib/entityTypeOptions";
|
||||||
|
|
||||||
|
type EntityFormState = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeometryMetaFormState = {
|
||||||
|
time_start: string;
|
||||||
|
time_end: string;
|
||||||
|
binding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedFeature: Feature | null;
|
||||||
|
selectedFeatureEntitySummary: string;
|
||||||
|
selectedFeatureBindingSummary: string;
|
||||||
|
entities: Entity[];
|
||||||
|
selectedGeometryEntityIds: string[];
|
||||||
|
onEntityIdsChange: (values: string[]) => void;
|
||||||
|
entitySearchQuery: string;
|
||||||
|
onEntitySearchQueryChange: (value: string) => void;
|
||||||
|
entitySearchResults: Entity[];
|
||||||
|
selectedSearchEntityId: string | null;
|
||||||
|
onSelectSearchEntityId: (value: string | null) => void;
|
||||||
|
onAddSelectedSearchEntity: () => void;
|
||||||
|
isEntitySearchLoading: boolean;
|
||||||
|
entityForm: EntityFormState;
|
||||||
|
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
|
||||||
|
entityTypeOptions: EntityTypeOption[];
|
||||||
|
geometryMetaForm: GeometryMetaFormState;
|
||||||
|
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
|
||||||
|
isEntitySubmitting: boolean;
|
||||||
|
onCreateEntityAndAttach: () => void;
|
||||||
|
onApplyEntitiesForSelectedGeometry: () => void;
|
||||||
|
changeCount: number;
|
||||||
|
entityFormStatus: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SelectedGeometryPanel({
|
||||||
|
selectedFeature,
|
||||||
|
selectedFeatureEntitySummary,
|
||||||
|
selectedFeatureBindingSummary,
|
||||||
|
entities,
|
||||||
|
selectedGeometryEntityIds,
|
||||||
|
onEntityIdsChange,
|
||||||
|
entitySearchQuery,
|
||||||
|
onEntitySearchQueryChange,
|
||||||
|
entitySearchResults,
|
||||||
|
selectedSearchEntityId,
|
||||||
|
onSelectSearchEntityId,
|
||||||
|
onAddSelectedSearchEntity,
|
||||||
|
isEntitySearchLoading,
|
||||||
|
entityForm,
|
||||||
|
onEntityFormChange,
|
||||||
|
entityTypeOptions,
|
||||||
|
geometryMetaForm,
|
||||||
|
onGeometryMetaFormChange,
|
||||||
|
isEntitySubmitting,
|
||||||
|
onCreateEntityAndAttach,
|
||||||
|
onApplyEntitiesForSelectedGeometry,
|
||||||
|
changeCount,
|
||||||
|
entityFormStatus,
|
||||||
|
}: Props) {
|
||||||
|
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
|
||||||
|
const allowedGroupIds = selectedFeature
|
||||||
|
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
|
||||||
|
: [];
|
||||||
|
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
|
||||||
|
allowedGroupIds.includes(group.id)
|
||||||
|
);
|
||||||
|
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
|
||||||
|
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
|
||||||
|
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
|
||||||
|
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
|
||||||
|
const isGeometryCompatible =
|
||||||
|
geometryBucket && selectedTypeBucket
|
||||||
|
? geometryBucket === selectedTypeBucket
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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: {selectedFeatureEntitySummary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#cbd5e1" }}>
|
||||||
|
Binding hiện tại: {selectedFeatureBindingSummary}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
Entities đã chọn:
|
||||||
|
</div>
|
||||||
|
{selectedGeometryEntityIds.length ? (
|
||||||
|
<div style={{ display: "grid", gap: "6px" }}>
|
||||||
|
{selectedGeometryEntityIds.map((entityId) => {
|
||||||
|
const entity = entities.find((item) => item.id === entityId) || null;
|
||||||
|
const label = entity?.name
|
||||||
|
? `${entity.name} (${entityId})`
|
||||||
|
: entityId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entityId}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
background: "#111827",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "6px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#e2e8f0" }}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onEntityIdsChange(
|
||||||
|
selectedGeometryEntityIds.filter((id) => id !== entityId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={removeButtonStyle}
|
||||||
|
>
|
||||||
|
Bỏ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||||
|
Chưa có entity nào được gắn.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={entitySearchQuery}
|
||||||
|
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
|
||||||
|
placeholder="Search entity theo name..."
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={selectedSearchEntityId || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
onSelectSearchEntityId(event.target.value ? event.target.value : null)
|
||||||
|
}
|
||||||
|
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
<option value="">-- Chọn entity từ kết quả search --</option>
|
||||||
|
{entitySearchResults.map((entity) => (
|
||||||
|
<option key={entity.id} value={entity.id}>
|
||||||
|
{entity.name} ({entity.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddSelectedSearchEntity}
|
||||||
|
disabled={isEntitySubmitting || isEntitySearchLoading}
|
||||||
|
style={secondaryActionButtonStyle}
|
||||||
|
>
|
||||||
|
Chọn để thêm
|
||||||
|
</button>
|
||||||
|
{isEntitySearchLoading ? (
|
||||||
|
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||||
|
Đang tìm entity...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||||
|
Geometry phải có ít nhất 1 entity để Save.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={entityForm.name}
|
||||||
|
onChange={(event) => onEntityFormChange("name", event.target.value)}
|
||||||
|
placeholder="Tên entity mới"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={entityForm.slug}
|
||||||
|
onChange={(event) => onEntityFormChange("slug", event.target.value)}
|
||||||
|
placeholder="Slug"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: "8px",
|
||||||
|
border: "1px solid #243244",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: "#0f172a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
|
||||||
|
Entity type presets
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
|
||||||
|
Chọn nhanh theo nhóm hình học bạn cần vẽ.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleGroupedEntityTypeOptions.map((group) => {
|
||||||
|
const color = GROUP_COLORS[group.id];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${color.border}`,
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px",
|
||||||
|
background: color.background,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: color.text, fontWeight: 600, fontSize: "12px" }}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: color.mutedText, fontSize: "11px" }}>
|
||||||
|
{group.geometryLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
||||||
|
{group.options.map((option) => {
|
||||||
|
const active = option.value === entityForm.type_id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEntityFormChange("type_id", option.value)}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={{
|
||||||
|
border: active
|
||||||
|
? `1px solid ${color.activeBorder}`
|
||||||
|
: `1px solid ${color.chipBorder}`,
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "4px 9px",
|
||||||
|
background: active ? color.activeBackground : color.chipBackground,
|
||||||
|
color: active ? color.activeText : color.text,
|
||||||
|
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={entityForm.type_id}
|
||||||
|
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
>
|
||||||
|
{!hasCurrentTypeOption && entityForm.type_id ? (
|
||||||
|
<option value={entityForm.type_id}>
|
||||||
|
Custom Type ({entityForm.type_id})
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
{visibleGroupedEntityTypeOptions.map((group) => (
|
||||||
|
<optgroup
|
||||||
|
key={group.id}
|
||||||
|
label={`${group.label} (${group.geometryLabel})`}
|
||||||
|
>
|
||||||
|
{group.options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{selectedTypeOption ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
|
||||||
|
</div>
|
||||||
|
) : entityForm.type_id ? (
|
||||||
|
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
|
||||||
|
Type đang chọn: <b>{entityForm.type_id}</b>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isGeometryCompatible && selectedTypeOption ? (
|
||||||
|
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
|
||||||
|
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại là {geometryBucket}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_start}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
|
||||||
|
placeholder="time_start"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.time_end}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
|
||||||
|
placeholder="time_end"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={geometryMetaForm.binding}
|
||||||
|
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
|
||||||
|
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
style={entityInputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onCreateEntityAndAttach}
|
||||||
|
disabled={isEntitySubmitting}
|
||||||
|
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={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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityInputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #334155",
|
||||||
|
background: "#111827",
|
||||||
|
color: "#f8fafc",
|
||||||
|
padding: "6px 8px",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "4px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#7f1d1d",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryActionButtonStyle: CSSProperties = {
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "7px 8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_COLORS: Record<
|
||||||
|
EntityTypeGroupId,
|
||||||
|
{
|
||||||
|
background: string;
|
||||||
|
border: string;
|
||||||
|
text: string;
|
||||||
|
mutedText: string;
|
||||||
|
chipBackground: string;
|
||||||
|
chipBorder: string;
|
||||||
|
activeBackground: string;
|
||||||
|
activeBorder: string;
|
||||||
|
activeText: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
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";
|
||||||
|
|
||||||
|
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return "point";
|
||||||
|
}
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
return "polygon";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
|
||||||
|
if (option.geometryPreset === "point") {
|
||||||
|
return "point";
|
||||||
|
}
|
||||||
|
if (option.geometryPreset === "line") {
|
||||||
|
return "line";
|
||||||
|
}
|
||||||
|
return "polygon";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedGroupIdsForGeometry(
|
||||||
|
geometryType: Feature["geometry"]["type"]
|
||||||
|
): EntityTypeGroupId[] {
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||||
|
return ["point"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||||
|
return ["split", "route"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["area_polygon", "area_circle"];
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
const EARTH_RADIUS_METERS = 6371008.8;
|
const EARTH_RADIUS_METERS = 6371008.8;
|
||||||
const CIRCLE_SEGMENTS = 72;
|
const CIRCLE_SEGMENTS = 72;
|
||||||
@@ -157,7 +157,7 @@ function buildCircleRing(
|
|||||||
): [number, number][] {
|
): [number, number][] {
|
||||||
const ring: [number, number][] = [];
|
const ring: [number, number][] = [];
|
||||||
for (let i = 0; i <= segments; i += 1) {
|
for (let i = 0; i <= segments; i += 1) {
|
||||||
const bearingDeg = (i / segments) * 360;
|
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
|
||||||
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
||||||
}
|
}
|
||||||
return ring;
|
return ring;
|
||||||
@@ -166,16 +166,16 @@ function buildCircleRing(
|
|||||||
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]);
|
||||||
const dLat = lat2 - lat1;
|
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
||||||
const dLng = toRad(b[0] - a[0]);
|
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
||||||
|
|
||||||
const sinLat = Math.sin(dLat / 2);
|
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
|
||||||
const sinLng = Math.sin(dLng / 2);
|
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
|
||||||
const h =
|
const h =
|
||||||
sinLat * sinLat +
|
sinLat * sinLat +
|
||||||
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
|
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
|
||||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
|
||||||
return EARTH_RADIUS_METERS * c;
|
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||||
}
|
}
|
||||||
|
|
||||||
function destinationPoint(
|
function destinationPoint(
|
||||||
@@ -186,7 +186,7 @@ function destinationPoint(
|
|||||||
const lat1 = toRad(center[1]);
|
const lat1 = toRad(center[1]);
|
||||||
const lng1 = toRad(center[0]);
|
const lng1 = toRad(center[0]);
|
||||||
const bearing = toRad(bearingDeg);
|
const bearing = toRad(bearingDeg);
|
||||||
const angularDistance = distance / EARTH_RADIUS_METERS;
|
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
|
||||||
|
|
||||||
const sinLat1 = Math.sin(lat1);
|
const sinLat1 = Math.sin(lat1);
|
||||||
const cosLat1 = Math.cos(lat1);
|
const cosLat1 = Math.cos(lat1);
|
||||||
@@ -195,18 +195,18 @@ function destinationPoint(
|
|||||||
|
|
||||||
const sinLat2 =
|
const sinLat2 =
|
||||||
sinLat1 * cosAngular +
|
sinLat1 * cosAngular +
|
||||||
cosLat1 * sinAngular * Math.cos(bearing);
|
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
|
||||||
const lat2 = Math.asin(clamp(sinLat2, -1, 1));
|
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
|
||||||
|
|
||||||
const y = Math.sin(bearing) * sinAngular * cosLat1;
|
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
|
||||||
const x = cosAngular - sinLat1 * Math.sin(lat2);
|
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
|
||||||
const lng2 = lng1 + Math.atan2(y, x);
|
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
|
||||||
|
|
||||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLng(lng: number): number {
|
function normalizeLng(lng: number): number {
|
||||||
let normalized = ((lng + 540) % 360) - 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;
|
||||||
}
|
}
|
||||||
@@ -218,9 +218,9 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toRad(value: number): number {
|
function toRad(value: number): number {
|
||||||
return (value * Math.PI) / 180;
|
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDeg(value: number): number {
|
function toDeg(value: number): number {
|
||||||
return (value * 180) / Math.PI;
|
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
export function initDrawing(
|
export function initDrawing(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function createEditingEngine(options: {
|
|||||||
ring.forEach((pt, idx) => {
|
ring.forEach((pt, idx) => {
|
||||||
const dx = pt[0] - click[0];
|
const dx = pt[0] - click[0];
|
||||||
const dy = pt[1] - click[1];
|
const dy = pt[1] - click[1];
|
||||||
const d = dx * dx + dy * dy;
|
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||||
if (d < bestDist) {
|
if (d < bestDist) {
|
||||||
bestDist = d;
|
bestDist = d;
|
||||||
nearestIdx = idx;
|
nearestIdx = idx;
|
||||||
|
|||||||
129
lib/entityTypeOptions.ts
Normal file
129
lib/entityTypeOptions.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
export type EntityTypeGroupId =
|
||||||
|
| "split"
|
||||||
|
| "route"
|
||||||
|
| "area_polygon"
|
||||||
|
| "area_circle"
|
||||||
|
| "point";
|
||||||
|
|
||||||
|
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
|
|
||||||
|
export type EntityTypeGroup = {
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityTypeOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
groupLabel: string;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||||
|
{
|
||||||
|
id: "split",
|
||||||
|
label: "Split",
|
||||||
|
geometryLabel: "Line",
|
||||||
|
description: "Tuyến chia cắt/phòng thủ.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
label: "Route",
|
||||||
|
geometryLabel: "Line",
|
||||||
|
description: "Các tuyến di chuyển theo hướng.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "area_polygon",
|
||||||
|
label: "Area - Đa giác",
|
||||||
|
geometryLabel: "Polygon",
|
||||||
|
description: "Vùng lãnh thổ dạng đa giác.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "area_circle",
|
||||||
|
label: "Area - Tròn",
|
||||||
|
geometryLabel: "Polygon tròn",
|
||||||
|
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "point",
|
||||||
|
label: "Point",
|
||||||
|
geometryLabel: "Point",
|
||||||
|
description: "Địa điểm đơn lẻ.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||||
|
split: ENTITY_TYPE_GROUPS[0],
|
||||||
|
route: ENTITY_TYPE_GROUPS[1],
|
||||||
|
area_polygon: ENTITY_TYPE_GROUPS[2],
|
||||||
|
area_circle: ENTITY_TYPE_GROUPS[3],
|
||||||
|
point: ENTITY_TYPE_GROUPS[4],
|
||||||
|
};
|
||||||
|
|
||||||
|
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
groupId: EntityTypeGroupId;
|
||||||
|
geometryPreset: EntityGeometryPreset;
|
||||||
|
}> = [
|
||||||
|
{ value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "attack_route", label: "Attack Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
{ value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" },
|
||||||
|
|
||||||
|
{ value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||||
|
{ value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||||
|
|
||||||
|
{ value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||||
|
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||||
|
|
||||||
|
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
|
||||||
|
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||||
|
|
||||||
|
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||||
|
id: EntityTypeGroupId;
|
||||||
|
label: string;
|
||||||
|
geometryLabel: string;
|
||||||
|
description: string;
|
||||||
|
options: EntityTypeOption[];
|
||||||
|
}> {
|
||||||
|
return ENTITY_TYPE_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
options: options.filter((option) => option.groupId === group.id),
|
||||||
|
})).filter((group) => group.options.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||||
|
if (!typeId) return null;
|
||||||
|
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
127
lib/lineEngine.ts
Normal file
127
lib/lineEngine.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initLine(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||||
|
EMPTY_PREVIEW
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelLine = () => {
|
||||||
|
coords = [];
|
||||||
|
clearPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreview = (lineCoords: [number, number][]) => {
|
||||||
|
if (lineCoords.length < 2) {
|
||||||
|
clearPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: lineCoords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishLine = () => {
|
||||||
|
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||||
|
|
||||||
|
const geometry: Geometry = {
|
||||||
|
type: "LineString",
|
||||||
|
coordinates: [...coords],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
cancelLine();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLastVertex = () => {
|
||||||
|
if (!coords.length) return;
|
||||||
|
coords = coords.slice(0, -1);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
updatePreview(coords);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
|
const canvas = map.getCanvas();
|
||||||
|
|
||||||
|
if (getMode() !== "add-line") {
|
||||||
|
if (coords.length) {
|
||||||
|
cancelLine();
|
||||||
|
}
|
||||||
|
if (canvas.style.cursor === "crosshair") {
|
||||||
|
canvas.style.cursor = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.style.cursor = "crosshair";
|
||||||
|
if (coords.length === 0) return;
|
||||||
|
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (getMode() !== "add-line") return;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
finishLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
removeLastVertex();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
cancelLine();
|
||||||
|
if (map.getCanvas().style.cursor === "crosshair") {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import { Geometry } from "@/lib/useEditorState";
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
export function initPoint(
|
export function initPoint(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||||
|
|
||||||
export function initSelect(
|
export function initSelect(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type FeatureProperties = {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user