diff --git a/api/entities.ts b/api/entities.ts index 6f56125..696359a 100644 --- a/api/entities.ts +++ b/api/entities.ts @@ -31,6 +31,21 @@ export async function fetchEntities(query?: { q?: string }): Promise { return requestJson(url); } +export async function searchEntitiesByName( + name: string, + options?: { limit?: number } +): Promise { + 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(`${API_ENDPOINTS.entities}/search?${params.toString()}`); +} + export async function createEntity(payload: CreateEntityPayload): Promise { return requestJson(API_ENDPOINTS.entities, { method: "POST", diff --git a/api/geometries.ts b/api/geometries.ts index 67027fe..a1200f2 100644 --- a/api/geometries.ts +++ b/api/geometries.ts @@ -15,6 +15,7 @@ export type GeometryCreatePayload = { geometry: Geometry; time_start?: number | null; time_end?: number | null; + binding?: string[]; entity_id?: string | null; entity_ids?: string[]; }; @@ -23,6 +24,7 @@ export type GeometryUpdatePayload = { geometry: Geometry; time_start?: number | null; time_end?: number | null; + binding?: string[]; entity_id?: string | null; entity_ids?: string[]; }; diff --git a/app/page.tsx b/app/page.tsx index 995ccc1..04510b1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { useEffect, useRef, useState } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; -import { createEntity, Entity, fetchEntities } from "@/api/entities"; +import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; +import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities"; import { ApiError } from "@/api/http"; import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; import { @@ -20,6 +21,7 @@ import { DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; +import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const WORLD_BBOX = { @@ -36,15 +38,6 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = { const TIMELINE_DEBOUNCE_MS = 180; const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1"; -const ENTITY_TYPE_OPTIONS = [ - { value: "country", label: "Country" }, - { value: "castle", label: "Castle" }, - { value: "kingdom", label: "Kingdom" }, - { value: "city", label: "City" }, - { value: "region", label: "Region" }, - { value: "event", label: "Event" }, -] as const; - type TimelineRange = { min: number; max: number; @@ -59,10 +52,11 @@ type EntityFormState = { type GeometryMetaFormState = { time_start: string; time_end: string; + binding: string; }; export default function Page() { - const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle">("idle"); + const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); const [initialData, setInitialData] = useState(EMPTY_FC); const [isSaving, setIsSaving] = useState(false); const [entities, setEntities] = useState([]); @@ -72,15 +66,20 @@ export default function Page() { const [entityForm, setEntityForm] = useState({ name: "", slug: "", - type_id: ENTITY_TYPE_OPTIONS[0].value, + type_id: DEFAULT_ENTITY_TYPE_ID, }); const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); const [geometryMetaForm, setGeometryMetaForm] = useState({ time_start: "", time_end: "", + binding: "", }); const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); const [entityFormStatus, setEntityFormStatus] = useState(null); + const [entitySearchQuery, setEntitySearchQuery] = useState(""); + const [entitySearchResults, setEntitySearchResults] = useState([]); + const [selectedSearchEntityId, setSelectedSearchEntityId] = useState(null); + const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); @@ -97,6 +96,8 @@ export default function Page() { () => loadBackgroundLayerVisibilityFromStorage() ); const timelineFetchRequestRef = useRef(0); + const entitySearchRequestRef = useRef(0); + const skipSelectedFeatureSyncRef = useRef(false); const editor = useEditorState(initialData); const selectedEntity = @@ -138,6 +139,55 @@ export default function Page() { }; }, []); + useEffect(() => { + if (!selectedFeature) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + const keyword = entitySearchQuery.trim(); + if (!keyword.length) { + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setIsEntitySearchLoading(false); + return; + } + + let disposed = false; + const requestId = ++entitySearchRequestRef.current; + const timeoutId = window.setTimeout(async () => { + setIsEntitySearchLoading(true); + try { + const rows = await searchEntitiesByName(keyword, { limit: 30 }); + if (disposed || requestId !== entitySearchRequestRef.current) return; + + setEntitySearchResults(rows); + setSelectedSearchEntityId((prev) => + prev && rows.some((entity) => entity.id === prev) + ? prev + : rows[0]?.id || null + ); + setEntities((prev) => mergeEntitiesById(prev, rows)); + } catch (err) { + if (disposed || requestId !== entitySearchRequestRef.current) return; + console.error("Search entity by name failed", err); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + } finally { + if (!disposed && requestId === entitySearchRequestRef.current) { + setIsEntitySearchLoading(false); + } + } + }, 220); + + return () => { + disposed = true; + window.clearTimeout(timeoutId); + }; + }, [entitySearchQuery, selectedFeature]); + useEffect(() => { if (selectedFeatureId === null) return; const stillExists = editor.draft.features.some((feature) => @@ -149,17 +199,26 @@ export default function Page() { }, [editor.draft, selectedFeatureId]); useEffect(() => { + if (skipSelectedFeatureSyncRef.current) { + skipSelectedFeatureSyncRef.current = false; + return; + } + if (!selectedFeature) { setEntityForm({ name: "", slug: "", - type_id: ENTITY_TYPE_OPTIONS[0].value, + type_id: DEFAULT_ENTITY_TYPE_ID, }); setSelectedGeometryEntityIds([]); setGeometryMetaForm({ time_start: "", time_end: "", + binding: "", }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); setEntityFormStatus(null); return; } @@ -172,7 +231,7 @@ export default function Page() { const nextTypeId = linkedEntity?.type_id || selectedFeature.properties.entity_type_id || - ENTITY_TYPE_OPTIONS[0].value; + DEFAULT_ENTITY_TYPE_ID; setEntityForm({ name: "", @@ -187,7 +246,11 @@ export default function Page() { time_end: selectedFeature.properties.time_end != null ? String(selectedFeature.properties.time_end) : "", + binding: normalizeFeatureBindingIds(selectedFeature).join(", "), }); + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); if (!featureEntityIds.length) { setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); } else { @@ -383,7 +446,7 @@ export default function Page() { setEntityForm((prev) => ({ ...prev, [key]: value })); }; - const handleGeometryMetaFormChange = (key: "time_start" | "time_end", value: string) => { + const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => { setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); }; @@ -391,6 +454,19 @@ export default function Page() { setSelectedGeometryEntityIds(uniqueEntityIds(values)); }; + const handleAddSelectedSearchEntity = () => { + const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : ""; + if (!entityId.length) { + setEntityFormStatus("Hãy chọn một entity từ kết quả search trước."); + return; + } + + const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]); + setSelectedGeometryEntityIds(next); + setSelectedSearchEntityId(null); + setEntityFormStatus(null); + }; + const reloadEntities = async () => { const rows = await fetchEntities(); setEntities(rows); @@ -411,6 +487,24 @@ export default function Page() { setInitialData(data); }; + const resetSelectedGeometryInputsAfterCreate = () => { + skipSelectedFeatureSyncRef.current = true; + setEntitySearchQuery(""); + setEntitySearchResults([]); + setSelectedSearchEntityId(null); + setSelectedGeometryEntityIds([]); + setGeometryMetaForm({ + time_start: "", + time_end: "", + binding: "", + }); + setEntityForm({ + name: "", + slug: "", + type_id: DEFAULT_ENTITY_TYPE_ID, + }); + }; + const parseGeometryMetaFormRange = () => { const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); @@ -425,6 +519,7 @@ export default function Page() { entityIds: string[], timeStart: number | null, timeEnd: number | null, + bindingIds: string[], entityRows: Entity[] = entities ) => { const primaryEntityId = entityIds[0] || null; @@ -443,12 +538,14 @@ export default function Page() { entity_type_id: primaryEntity?.type_id || null, time_start: timeStart, time_end: timeEnd, + binding: bindingIds, }); setSelectedGeometryEntityIds(entityIds); setGeometryMetaForm({ time_start: timeStart != null ? String(timeStart) : "", time_end: timeEnd != null ? String(timeEnd) : "", + binding: bindingIds.join(", "), }); }; @@ -466,8 +563,10 @@ export default function Page() { let timeStart: number | null; let timeEnd: number | null; + let bindingIds: string[]; try { ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + bindingIds = parseBindingInput(geometryMetaForm.binding); } catch (err) { setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); return; @@ -486,13 +585,14 @@ export default function Page() { geometry: selectedFeature.geometry, time_start: timeStart, time_end: timeEnd, + binding: bindingIds, entity_ids: entityIds, }); await reloadCurrentTimelineData(); setEntityFormStatus("Đã cập nhật entities + metadata geometry."); } else { - patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd); + patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds); setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới."); } } catch (err) { @@ -525,8 +625,10 @@ export default function Page() { let timeStart: number | null; let timeEnd: number | null; + let bindingIds: string[]; try { ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); + bindingIds = parseBindingInput(geometryMetaForm.binding); } catch (err) { setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ."); return; @@ -538,7 +640,7 @@ export default function Page() { const created = await createEntity({ name, slug: entityForm.slug.trim() || null, - type_id: entityForm.type_id || ENTITY_TYPE_OPTIONS[0].value, + type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID, }); const rows = await reloadEntities(); @@ -552,21 +654,25 @@ export default function Page() { geometry: selectedFeature.geometry, time_start: timeStart, time_end: timeEnd, + binding: bindingIds, entity_ids: nextEntityIds, }); await reloadCurrentTimelineData(); setEntityFormStatus("Đã tạo entity và gắn vào geometry."); } else { - patchSelectedFeatureLocally(selectedFeature, nextEntityIds, timeStart, timeEnd, rows); + patchSelectedFeatureLocally( + selectedFeature, + nextEntityIds, + timeStart, + timeEnd, + bindingIds, + rows + ); setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới."); } setSelectedEntityId(created.id); - setEntityForm((prev) => ({ - ...prev, - name: "", - slug: "", - })); + resetSelectedGeometryInputsAfterCreate(); } catch (err) { if (err instanceof ApiError) { setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`); @@ -611,6 +717,7 @@ export default function Page() { -
- Selected Geometry -
- {!selectedFeature ? ( -
- Vào mode Select và chọn 1 geometry để điền entity. -
- ) : ( -
-
- ID: {String(selectedFeature.properties.id)} -
-
- Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)} -
- - -
- Geometry phải có ít nhất 1 entity để Save. -
- - handleEntityFormChange("name", event.target.value)} - placeholder="Tên entity mới" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - handleEntityFormChange("slug", event.target.value)} - placeholder="Slug" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - - - handleGeometryMetaFormChange("time_start", event.target.value)} - placeholder="time_start" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - handleGeometryMetaFormChange("time_end", event.target.value)} - placeholder="time_end" - disabled={isEntitySubmitting} - style={entityInputStyle} - /> - - - - - - {editor.changeCount > 0 ? ( -
- Geometry mới sẽ lưu entity khi bấm Save. -
- ) : null} - - {entityFormStatus ? ( -
- {entityFormStatus} -
- ) : null} -
- )} - + } /> ); } -const entityInputStyle: CSSProperties = { - width: "100%", - borderRadius: "6px", - border: "1px solid #334155", - background: "#111827", - color: "#f8fafc", - padding: "6px 8px", - fontSize: "13px", -}; - function deriveTimelineRange(collection: FeatureCollection): TimelineRange { let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; @@ -862,6 +858,33 @@ function normalizeFeatureEntityIds(feature: Feature): string[] { return []; } +function normalizeFeatureBindingIds(feature: Feature): string[] { + const rawBinding = feature.properties.binding; + if (!Array.isArray(rawBinding)) return []; + return uniqueEntityIds(rawBinding + .map((id) => { + if (typeof id !== "string" && typeof id !== "number") return ""; + return String(id).trim(); + }) + .filter((id) => id.length > 0)); +} + +function parseBindingInput(raw: string): string[] { + if (!raw.trim().length) return []; + return uniqueEntityIds( + raw + .split(/[,\n]/) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + ); +} + +function formatBindingIdsForDisplay(feature: Feature): string { + const bindingIds = normalizeFeatureBindingIds(feature); + if (!bindingIds.length) return "Không có"; + return bindingIds.join(", "); +} + function uniqueEntityIds(ids: string[]): string[] { const deduped: string[] = []; const seen = new Set(); @@ -884,6 +907,19 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri return names.join(", "); } +function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] { + if (!incoming.length) return current; + + const map = new globalThis.Map(); + for (const entity of current) { + map.set(entity.id, entity); + } + for (const entity of incoming) { + map.set(entity.id, entity); + } + return Array.from(map.values()); +} + function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility { if (typeof window === "undefined") { return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; diff --git a/components/Editor.tsx b/components/Editor.tsx index 945011b..04d59f4 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -2,7 +2,7 @@ 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 = { id: string; name: string; @@ -108,6 +108,13 @@ export default function Editor({ Add point + + + + handleZoomSliderChange(Number(event.target.value))} + style={{ + flex: 1, + accentColor: "#38bdf8", + cursor: "pointer", + }} + aria-label="Map zoom" + /> + + + +
+ {zoomLevel.toFixed(1)}x +
+ + + + ); } 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(); + 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) { const polygons = { type: "FeatureCollection", @@ -835,7 +1151,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null { ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; 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); return ctx.getImageData(0, 0, size, size); @@ -851,3 +1167,41 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification { expression.push(DEFAULT_POINT_ICON_ID); return expression as maplibregl.ExpressionSpecification; } + +function buildTypeMatchExpression( + valueByType: Record, + 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", +}; diff --git a/components/SelectedGeometryPanel.tsx b/components/SelectedGeometryPanel.tsx new file mode 100644 index 0000000..48d385d --- /dev/null +++ b/components/SelectedGeometryPanel.tsx @@ -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 ( +
+
+ Selected Geometry +
+ + {!selectedFeature ? ( +
+ Vào mode Select và chọn 1 geometry để điền entity. +
+ ) : ( +
+
+ ID: {String(selectedFeature.properties.id)} +
+
+ Entities hiện tại: {selectedFeatureEntitySummary} +
+
+ Binding hiện tại: {selectedFeatureBindingSummary} +
+ +
+ Entities đã chọn: +
+ {selectedGeometryEntityIds.length ? ( +
+ {selectedGeometryEntityIds.map((entityId) => { + const entity = entities.find((item) => item.id === entityId) || null; + const label = entity?.name + ? `${entity.name} (${entityId})` + : entityId; + + return ( +
+ {label} + +
+ ); + })} +
+ ) : ( +
+ Chưa có entity nào được gắn. +
+ )} + + onEntitySearchQueryChange(event.target.value)} + placeholder="Search entity theo name..." + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + + {isEntitySearchLoading ? ( +
+ Đang tìm entity... +
+ ) : null} + +
+ Geometry phải có ít nhất 1 entity để Save. +
+ + onEntityFormChange("name", event.target.value)} + placeholder="Tên entity mới" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onEntityFormChange("slug", event.target.value)} + placeholder="Slug" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> +
+
+ Entity type presets +
+
+ Chọn nhanh theo nhóm hình học bạn cần vẽ. +
+ + {visibleGroupedEntityTypeOptions.map((group) => { + const color = GROUP_COLORS[group.id]; + return ( +
+
+ + {group.label} + + + {group.geometryLabel} + +
+
+ {group.options.map((option) => { + const active = option.value === entityForm.type_id; + return ( + + ); + })} +
+
+ ); + })} +
+ + + {selectedTypeOption ? ( +
+ Type đang chọn: {selectedTypeOption.label} ({selectedTypeOption.groupLabel}) +
+ ) : entityForm.type_id ? ( +
+ Type đang chọn: {entityForm.type_id} +
+ ) : null} + + {!isGeometryCompatible && selectedTypeOption ? ( +
+ Type {selectedTypeOption.label} hợp với {selectedTypeBucket}, nhưng geometry hiện tại là {geometryBucket}. +
+ ) : null} + + onGeometryMetaFormChange("time_start", event.target.value)} + placeholder="time_start" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onGeometryMetaFormChange("time_end", event.target.value)} + placeholder="time_end" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + onGeometryMetaFormChange("binding", event.target.value)} + placeholder="binding ids (vd: geo-id-1, geo-id-2)" + disabled={isEntitySubmitting} + style={entityInputStyle} + /> + + + + + + {changeCount > 0 ? ( +
+ Geometry mới sẽ lưu entity khi bấm Save. +
+ ) : null} + + {entityFormStatus ? ( +
+ {entityFormStatus} +
+ ) : null} +
+ )} +
+ ); +} + +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"]; +} diff --git a/lib/circleEngine.ts b/lib/circleEngine.ts index c85c4ec..4b67ee3 100644 --- a/lib/circleEngine.ts +++ b/lib/circleEngine.ts @@ -1,7 +1,7 @@ import maplibregl from "maplibre-gl"; 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 CIRCLE_SEGMENTS = 72; @@ -157,7 +157,7 @@ function buildCircleRing( ): [number, number][] { const ring: [number, number][] = []; 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)); } return ring; @@ -166,16 +166,16 @@ function buildCircleRing( function distanceMeters(a: [number, number], b: [number, number]): number { const lat1 = toRad(a[1]); const lat2 = toRad(b[1]); - const dLat = lat2 - lat1; - const dLng = toRad(b[0] - a[0]); + const dLat = lat2 - lat1; // Delta vĩ độ (radian). + const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian). - const sinLat = Math.sin(dLat / 2); - const sinLng = Math.sin(dLng / 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); // Thành phần sin(dLng/2) của công thức Haversine. const h = sinLat * sinLat + - Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; - const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); - return EARTH_RADIUS_METERS * c; + 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)); // Góc tâm (radian) giữa hai điểm trên mặt cầu. + return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c. } function destinationPoint( @@ -186,7 +186,7 @@ function destinationPoint( const lat1 = toRad(center[1]); const lng1 = toRad(center[0]); 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 cosLat1 = Math.cos(lat1); @@ -195,18 +195,18 @@ function destinationPoint( const sinLat2 = sinLat1 * cosAngular + - cosLat1 * sinAngular * Math.cos(bearing); - const lat2 = Math.asin(clamp(sinLat2, -1, 1)); + cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle. + 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 x = cosAngular - sinLat1 * Math.sin(lat2); - const lng2 = lng1 + Math.atan2(y, x); + const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ. + const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ. + const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ. return [normalizeLng(toDeg(lng2)), toDeg(lat2)]; } 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; return normalized; } @@ -218,9 +218,9 @@ function clamp(value: number, min: number, max: 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 { - return (value * 180) / Math.PI; + return (value * 180) / Math.PI; // Đổi radian sang độ. } diff --git a/lib/drawingEngine.ts b/lib/drawingEngine.ts index a5007dc..c3c632b 100644 --- a/lib/drawingEngine.ts +++ b/lib/drawingEngine.ts @@ -1,7 +1,7 @@ import maplibregl from "maplibre-gl"; 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( map: maplibregl.Map, diff --git a/lib/editingEngine.ts b/lib/editingEngine.ts index 8257389..f2e9b81 100644 --- a/lib/editingEngine.ts +++ b/lib/editingEngine.ts @@ -163,7 +163,7 @@ export function createEditingEngine(options: { ring.forEach((pt, idx) => { const dx = pt[0] - click[0]; 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) { bestDist = d; nearestIdx = idx; diff --git a/lib/entityTypeOptions.ts b/lib/entityTypeOptions.ts new file mode 100644 index 0000000..8530754 --- /dev/null +++ b/lib/entityTypeOptions.ts @@ -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 = { + 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; +} + diff --git a/lib/lineEngine.ts b/lib/lineEngine.ts new file mode 100644 index 0000000..a1a0693 --- /dev/null +++ b/lib/lineEngine.ts @@ -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 = ""; + } + }; +} diff --git a/lib/pathEngine.ts b/lib/pathEngine.ts index a9863e0..06a5ee5 100644 --- a/lib/pathEngine.ts +++ b/lib/pathEngine.ts @@ -1,7 +1,7 @@ import maplibregl from "maplibre-gl"; 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 = { type: "FeatureCollection", diff --git a/lib/pointEngine.ts b/lib/pointEngine.ts index c8265c2..2f6bcc7 100644 --- a/lib/pointEngine.ts +++ b/lib/pointEngine.ts @@ -1,7 +1,7 @@ import maplibregl from "maplibre-gl"; 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( map: maplibregl.Map, diff --git a/lib/selectingEngine.ts b/lib/selectingEngine.ts index fa6f809..0c05702 100644 --- a/lib/selectingEngine.ts +++ b/lib/selectingEngine.ts @@ -1,6 +1,6 @@ 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( map: maplibregl.Map, diff --git a/lib/useEditorState.ts b/lib/useEditorState.ts index 247cf87..3514cf3 100644 --- a/lib/useEditorState.ts +++ b/lib/useEditorState.ts @@ -15,6 +15,8 @@ export type FeatureProperties = { id: string | number; time_start?: number | null; time_end?: number | null; + binding?: string[]; + line_mode?: string | null; entity_id?: string | null; entity_ids?: string[]; entity_name?: string | null;