draw path | draw area | localstorage layer state | add entities property parallel | timeline bar
This commit is contained in:
@@ -6,6 +6,7 @@ export const API_BASE_URL =
|
||||
export const API_ENDPOINTS = {
|
||||
geometries: `${API_BASE_URL}/geometries`,
|
||||
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
|
||||
entities: `${API_BASE_URL}/entities`,
|
||||
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
|
||||
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
|
||||
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,
|
||||
|
||||
48
api/entities.ts
Normal file
48
api/entities.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { API_ENDPOINTS } from "@/api/config";
|
||||
import { requestJson } from "@/api/http";
|
||||
|
||||
export type Entity = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
type_id?: string | null;
|
||||
status?: number | null;
|
||||
geometry_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type CreateEntityPayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
type_id?: string | null;
|
||||
status?: number | null;
|
||||
};
|
||||
|
||||
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) {
|
||||
params.set("q", query.q);
|
||||
}
|
||||
const suffix = params.toString();
|
||||
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
|
||||
return requestJson<Entity[]>(url);
|
||||
}
|
||||
|
||||
export async function createEntity(payload: CreateEntityPayload): Promise<Entity> {
|
||||
return requestJson<Entity>(API_ENDPOINTS.entities, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEntity(id: string, payload: CreateEntityPayload): Promise<Entity> {
|
||||
return requestJson<Entity>(`${API_ENDPOINTS.entities}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -8,19 +8,23 @@ export type GeometriesBBoxQuery = {
|
||||
maxLng: number;
|
||||
maxLat: number;
|
||||
time?: number;
|
||||
entity_id?: string;
|
||||
};
|
||||
|
||||
export type GeometryCreatePayload = {
|
||||
geometry: Geometry;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
kind?: string | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
};
|
||||
|
||||
export type GeometryUpdatePayload = {
|
||||
geometry: Geometry;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
};
|
||||
|
||||
export type GeometryCreateResponse = {
|
||||
@@ -44,6 +48,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
|
||||
query.set("time", String(params.time));
|
||||
}
|
||||
|
||||
if (params.entity_id) {
|
||||
query.set("entity_id", params.entity_id);
|
||||
}
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
867
app/page.tsx
867
app/page.tsx
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } 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 { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges } from "@/api/geometries";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||
import {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
useEditorState,
|
||||
} from "@/lib/useEditorState";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
@@ -18,48 +22,327 @@ import {
|
||||
} from "@/lib/backgroundLayers";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
const WORLD_BBOX = {
|
||||
minLng: -180,
|
||||
minLat: -90,
|
||||
maxLng: 180,
|
||||
maxLat: 90,
|
||||
} as const;
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
||||
min: CURRENT_YEAR - 5000,
|
||||
max: CURRENT_YEAR + 100,
|
||||
};
|
||||
const TIMELINE_DEBOUNCE_MS = 180;
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type EntityFormState = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type_id: string;
|
||||
};
|
||||
|
||||
type GeometryMetaFormState = {
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle");
|
||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle">("idle");
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })
|
||||
const [entities, setEntities] = useState<Entity[]>([]);
|
||||
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: ENTITY_TYPE_OPTIONS[0].value,
|
||||
});
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
});
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
||||
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
||||
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [isTimelineReady, setIsTimelineReady] = useState(false);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => loadBackgroundLayerVisibilityFromStorage()
|
||||
);
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
const selectedEntity =
|
||||
entities.find((entity) => entity.id === selectedEntityId) || null;
|
||||
const selectedFeature =
|
||||
selectedFeatureId === null
|
||||
? null
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
) || null;
|
||||
|
||||
useEffect(() => {
|
||||
async function loadInitial() {
|
||||
let disposed = false;
|
||||
|
||||
async function loadEntities() {
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
minLng: -180,
|
||||
minLat: -90,
|
||||
maxLng: 180,
|
||||
maxLat: 90,
|
||||
const rows = await fetchEntities();
|
||||
if (disposed) return;
|
||||
|
||||
setEntities(rows);
|
||||
setSelectedEntityId((prev) => {
|
||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return rows[0]?.id || null;
|
||||
});
|
||||
setInitialData(data);
|
||||
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
|
||||
} catch (err) {
|
||||
console.error("Load initial data failed", err);
|
||||
if (disposed) return;
|
||||
console.error("Load entities failed", err);
|
||||
setEntityStatus("Không tải được danh sách entity.");
|
||||
}
|
||||
}
|
||||
|
||||
loadInitial();
|
||||
loadEntities();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = editor.draft.features.some((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
);
|
||||
if (!stillExists) {
|
||||
setSelectedFeatureId(null);
|
||||
}
|
||||
}, [editor.draft, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeature) {
|
||||
setEntityForm({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: ENTITY_TYPE_OPTIONS[0].value,
|
||||
});
|
||||
setSelectedGeometryEntityIds([]);
|
||||
setGeometryMetaForm({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
});
|
||||
setEntityFormStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
|
||||
const primaryEntityId = featureEntityIds[0] || null;
|
||||
const linkedEntity = primaryEntityId
|
||||
? entities.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const nextTypeId =
|
||||
linkedEntity?.type_id ||
|
||||
selectedFeature.properties.entity_type_id ||
|
||||
ENTITY_TYPE_OPTIONS[0].value;
|
||||
|
||||
setEntityForm({
|
||||
name: "",
|
||||
slug: linkedEntity?.slug || "",
|
||||
type_id: nextTypeId,
|
||||
});
|
||||
setSelectedGeometryEntityIds(featureEntityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: selectedFeature.properties.time_start != null
|
||||
? String(selectedFeature.properties.time_start)
|
||||
: "",
|
||||
time_end: selectedFeature.properties.time_end != null
|
||||
? String(selectedFeature.properties.time_end)
|
||||
: "",
|
||||
});
|
||||
if (!featureEntityIds.length) {
|
||||
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
|
||||
} else {
|
||||
setEntityFormStatus(null);
|
||||
}
|
||||
}, [selectedFeature, entities]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadTimelineBounds() {
|
||||
setIsTimelineLoading(true);
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
});
|
||||
if (disposed) return;
|
||||
|
||||
const range = deriveTimelineRange(data);
|
||||
const initialYear = clampYear(CURRENT_YEAR, range);
|
||||
|
||||
setTimelineRange(range);
|
||||
setTimelineWindowStart(range.min);
|
||||
setTimelineWindowEnd(range.max);
|
||||
setTimelineYear(initialYear);
|
||||
setTimelineDraftYear(initialYear);
|
||||
setTimelineStatus(null);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
console.error("Load timeline bounds failed", err);
|
||||
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineRange(FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
|
||||
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
|
||||
setTimelineYear(fallbackYear);
|
||||
setTimelineDraftYear(fallbackYear);
|
||||
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsTimelineLoading(false);
|
||||
setIsTimelineReady(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadTimelineBounds();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (timelineDraftYear !== timelineYear) {
|
||||
setTimelineYear(timelineDraftYear);
|
||||
}
|
||||
}, TIMELINE_DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||
|
||||
useEffect(() => {
|
||||
persistBackgroundLayerVisibility(backgroundVisibility);
|
||||
}, [backgroundVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
|
||||
}, [timelineWindowStart, timelineWindowEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++timelineFetchRequestRef.current;
|
||||
|
||||
async function loadByTimeline() {
|
||||
setIsTimelineLoading(true);
|
||||
setTimelineStatus(null);
|
||||
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setInitialData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load timeline data failed", err.body);
|
||||
} else {
|
||||
console.error("Load timeline data failed", err);
|
||||
}
|
||||
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setIsTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadByTimeline();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = editor.buildPayload();
|
||||
if (!payload.length) return;
|
||||
|
||||
const invalid = payload.find((change) => {
|
||||
if (change.type === "delete") return false;
|
||||
const draftFeature = change.type === "create"
|
||||
? change.feature
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(change.id)
|
||||
);
|
||||
if (!draftFeature) return false;
|
||||
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
||||
return entityIds.length === 0;
|
||||
});
|
||||
|
||||
if (invalid) {
|
||||
const invalidId = invalid.type === "create"
|
||||
? invalid.feature.properties.id
|
||||
: invalid.id;
|
||||
setSelectedFeatureId(invalidId);
|
||||
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
await saveGeometryBatchChanges(payload);
|
||||
editor.clearChanges();
|
||||
await reloadCurrentTimelineData();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Save failed", err.body);
|
||||
setEntityStatus(`Save thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Save error", err);
|
||||
setEntityStatus("Save thất bại.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -80,11 +363,243 @@ export default function Page() {
|
||||
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
|
||||
};
|
||||
|
||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
|
||||
};
|
||||
|
||||
const handleTimelineWindowEndChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
|
||||
};
|
||||
|
||||
const handleTimelineYearChange = (nextYear: number) => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||
};
|
||||
|
||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleGeometryMetaFormChange = (key: "time_start" | "time_end", value: string) => {
|
||||
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleEntityIdsChange = (values: string[]) => {
|
||||
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
||||
};
|
||||
|
||||
const reloadEntities = async () => {
|
||||
const rows = await fetchEntities();
|
||||
setEntities(rows);
|
||||
setSelectedEntityId((prev) => {
|
||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return rows[0]?.id || null;
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
const reloadCurrentTimelineData = async () => {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
setInitialData(data);
|
||||
};
|
||||
|
||||
const parseGeometryMetaFormRange = () => {
|
||||
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
|
||||
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw new Error("time_start phải <= time_end.");
|
||||
}
|
||||
return { timeStart, timeEnd };
|
||||
};
|
||||
|
||||
const patchSelectedFeatureLocally = (
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
timeStart: number | null,
|
||||
timeEnd: number | null,
|
||||
entityRows: Entity[] = entities
|
||||
) => {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
const primaryEntity = primaryEntityId
|
||||
? entityRows.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
editor.patchFeatureProperties(feature.properties.id, {
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
});
|
||||
|
||||
setSelectedGeometryEntityIds(entityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
if (!entityIds.length) {
|
||||
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeStart: number | null;
|
||||
let timeEnd: number | null;
|
||||
try {
|
||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||
} catch (err) {
|
||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
if (editor.changeCount > 0) {
|
||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
entity_ids: entityIds,
|
||||
});
|
||||
|
||||
await reloadCurrentTimelineData();
|
||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||
} else {
|
||||
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd);
|
||||
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityFormStatus("Lưu thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEntityAndAttach = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
|
||||
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = entityForm.name.trim();
|
||||
if (!name) {
|
||||
setEntityFormStatus("Tên entity là bắt buộc.");
|
||||
return;
|
||||
}
|
||||
|
||||
let timeStart: number | null;
|
||||
let timeEnd: number | null;
|
||||
try {
|
||||
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
|
||||
} catch (err) {
|
||||
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
const created = await createEntity({
|
||||
name,
|
||||
slug: entityForm.slug.trim() || null,
|
||||
type_id: entityForm.type_id || ENTITY_TYPE_OPTIONS[0].value,
|
||||
});
|
||||
|
||||
const rows = await reloadEntities();
|
||||
const nextEntityIds = uniqueEntityIds([
|
||||
...selectedGeometryEntityIds,
|
||||
created.id,
|
||||
]);
|
||||
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
entity_ids: nextEntityIds,
|
||||
});
|
||||
await reloadCurrentTimelineData();
|
||||
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
|
||||
} else {
|
||||
patchSelectedFeatureLocally(selectedFeature, nextEntityIds, timeStart, timeEnd, 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: "",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityFormStatus("Tạo/gắn entity thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
||||
const timelineStatusText =
|
||||
editor.changeCount > 0
|
||||
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
||||
: isSaving
|
||||
? "Đang lưu thay đổi..."
|
||||
: timelineStatus;
|
||||
|
||||
const handleCreateFeature = (feature: Feature) => {
|
||||
editor.createFeature(feature);
|
||||
setSelectedFeatureId(feature.properties.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Editor
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
entities={entities}
|
||||
selectedEntityId={selectedEntityId}
|
||||
onSelectEntityId={setSelectedEntityId}
|
||||
entityStatus={entityStatus}
|
||||
onUndo={editor.undo}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
@@ -92,21 +607,329 @@ export default function Page() {
|
||||
undoStack={editor.undoStack}
|
||||
/>
|
||||
|
||||
<Map
|
||||
mode={mode}
|
||||
draft={editor.draft}
|
||||
onCreateFeature={editor.createFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
<Map
|
||||
mode={mode}
|
||||
draft={editor.draft}
|
||||
selectedEntityId={selectedEntityId}
|
||||
selectedEntityName={selectedEntity?.name || null}
|
||||
selectedEntityTypeId={selectedEntity?.type_id || null}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
onCreateFeature={handleCreateFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
<TimelineBar
|
||||
minYear={timelineRange.min}
|
||||
maxYear={timelineRange.max}
|
||||
windowStartYear={timelineWindowStart}
|
||||
windowEndYear={timelineWindowEnd}
|
||||
onWindowStartYearChange={handleTimelineWindowStartChange}
|
||||
onWindowEndYearChange={handleTimelineWindowEndChange}
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={timelineDisabled}
|
||||
statusText={timelineStatusText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
onToggleLayer={handleToggleBackgroundLayer}
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
topContent={
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
|
||||
Selected Geometry
|
||||
</div>
|
||||
{!selectedFeature ? (
|
||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
|
||||
Vào mode Select và chọn 1 geometry để điền entity.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
|
||||
<div style={{ color: "#e2e8f0" }}>
|
||||
ID: {String(selectedFeature.properties.id)}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1" }}>
|
||||
Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
multiple
|
||||
value={selectedGeometryEntityIds}
|
||||
onChange={(event) =>
|
||||
handleEntityIdsChange(
|
||||
Array.from(event.target.selectedOptions, (option) => option.value)
|
||||
)
|
||||
}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{ ...entityInputStyle, minHeight: "96px" }}
|
||||
>
|
||||
{entities.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name}
|
||||
{entity.type_id ? ` [${entity.type_id}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Geometry phải có ít nhất 1 entity để Save.
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={entityForm.name}
|
||||
onChange={(event) => handleEntityFormChange("name", event.target.value)}
|
||||
placeholder="Tên entity mới"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={entityForm.slug}
|
||||
onChange={(event) => handleEntityFormChange("slug", event.target.value)}
|
||||
placeholder="Slug"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={entityForm.type_id}
|
||||
onChange={(event) => handleEntityFormChange("type_id", event.target.value)}
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
>
|
||||
{ENTITY_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={geometryMetaForm.time_start}
|
||||
onChange={(event) => handleGeometryMetaFormChange("time_start", event.target.value)}
|
||||
placeholder="time_start"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
<input
|
||||
value={geometryMetaForm.time_end}
|
||||
onChange={(event) => handleGeometryMetaFormChange("time_end", event.target.value)}
|
||||
placeholder="time_end"
|
||||
disabled={isEntitySubmitting}
|
||||
style={entityInputStyle}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleCreateEntityAndAttach}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
background: "#2563eb",
|
||||
color: "#ffffff",
|
||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Tạo Entity + Gắn
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleApplyEntitiesForSelectedGeometry}
|
||||
disabled={isEntitySubmitting}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
|
||||
background: "#0f766e",
|
||||
color: "#ffffff",
|
||||
opacity: isEntitySubmitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Áp dụng Entities + Metadata
|
||||
</button>
|
||||
|
||||
{editor.changeCount > 0 ? (
|
||||
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
|
||||
Geometry mới sẽ lưu entity khi bấm Save.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{entityFormStatus ? (
|
||||
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
|
||||
{entityFormStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entityInputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
padding: "6px 8px",
|
||||
fontSize: "13px",
|
||||
};
|
||||
|
||||
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const feature of collection.features) {
|
||||
const { time_start, time_end } = feature.properties;
|
||||
|
||||
if (isYearNumber(time_start)) {
|
||||
min = Math.min(min, time_start);
|
||||
max = Math.max(max, time_start);
|
||||
}
|
||||
|
||||
if (isYearNumber(time_end)) {
|
||||
min = Math.min(min, time_end);
|
||||
max = Math.max(max, time_end);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return FALLBACK_TIMELINE_RANGE;
|
||||
}
|
||||
|
||||
return {
|
||||
min: Math.floor(min),
|
||||
max: Math.ceil(max),
|
||||
};
|
||||
}
|
||||
|
||||
function clampYear(year: number, range: TimelineRange): number {
|
||||
return clampYearValue(year, range.min, range.max);
|
||||
}
|
||||
|
||||
function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
if (year < lower) return lower;
|
||||
if (year > upper) return upper;
|
||||
return year;
|
||||
}
|
||||
|
||||
function isYearNumber(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||
const value = raw.trim();
|
||||
if (!value.length) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} phải là số.`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (fromArray.length) {
|
||||
return uniqueEntityIds(fromArray);
|
||||
}
|
||||
|
||||
const single = feature.properties.entity_id;
|
||||
if (typeof single === "string" && single.trim().length > 0) {
|
||||
return [single.trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function uniqueEntityIds(ids: string[]): string[] {
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of ids) {
|
||||
const id = rawId.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return "Chưa gắn";
|
||||
|
||||
const names = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||
.filter((name) => name.trim().length > 0);
|
||||
return names.join(", ");
|
||||
}
|
||||
|
||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
if (typeof window === "undefined") {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
} catch (err) {
|
||||
console.warn("Load background layer visibility from storage failed", err);
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
}
|
||||
|
||||
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||
JSON.stringify(visibility)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Persist background layer visibility failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const source = raw as Record<string, unknown>;
|
||||
const next: BackgroundLayerVisibility = {
|
||||
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
};
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
const value = source[layer.id];
|
||||
if (typeof value === "boolean") {
|
||||
next[layer.id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
@@ -11,6 +12,7 @@ type Props = {
|
||||
onToggleLayer: (id: BackgroundLayerId) => void;
|
||||
onShowAll: () => void;
|
||||
onHideAll: () => void;
|
||||
topContent?: ReactNode;
|
||||
};
|
||||
|
||||
export default function BackgroundLayersPanel({
|
||||
@@ -18,6 +20,7 @@ export default function BackgroundLayersPanel({
|
||||
onToggleLayer,
|
||||
onShowAll,
|
||||
onHideAll,
|
||||
topContent,
|
||||
}: Props) {
|
||||
return (
|
||||
<aside
|
||||
@@ -31,6 +34,8 @@ export default function BackgroundLayersPanel({
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
|
||||
|
||||
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
import { UndoAction } from "@/lib/useEditorState";
|
||||
|
||||
type Mode = "draw" | "select" | "idle" | "add-point";
|
||||
type Mode = "draw" | "select" | "idle" | "add-point" | "add-path" | "add-circle";
|
||||
type EntityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
geometry_count?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
mode: Mode;
|
||||
setMode: (mode: Mode) => void;
|
||||
entities: EntityOption[];
|
||||
selectedEntityId: string | null;
|
||||
onSelectEntityId: (entityId: string | null) => void;
|
||||
entityStatus?: string | null;
|
||||
onUndo: () => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
@@ -17,6 +26,10 @@ type Props = {
|
||||
export default function Editor({
|
||||
mode,
|
||||
setMode,
|
||||
entities,
|
||||
selectedEntityId,
|
||||
onSelectEntityId,
|
||||
entityStatus,
|
||||
onUndo,
|
||||
onSave,
|
||||
isSaving,
|
||||
@@ -95,9 +108,76 @@ export default function Editor({
|
||||
Add point
|
||||
</button>
|
||||
|
||||
<button
|
||||
style={getButtonStyle("add-path")}
|
||||
onClick={() => setMode("add-path")}
|
||||
>
|
||||
Add path
|
||||
</button>
|
||||
|
||||
<button
|
||||
style={getButtonStyle("add-circle")}
|
||||
onClick={() => setMode("add-circle")}
|
||||
>
|
||||
Add circle
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
||||
Mode: <b>{mode}</b>
|
||||
</div>
|
||||
{mode === "add-path" ? (
|
||||
<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-circle" ? (
|
||||
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
|
||||
Giữ chuột trái kéo để mở bán kính, thả chuột để hoàn tất.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1f2937",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "13px", color: "#e2e8f0" }}>
|
||||
Entity mặc định cho geometry mới
|
||||
</div>
|
||||
<select
|
||||
value={selectedEntityId || ""}
|
||||
onChange={(event) => onSelectEntityId(event.target.value || null)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #334155",
|
||||
background: "#111827",
|
||||
color: "#f8fafc",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
<option value="">Không gắn entity</option>
|
||||
{entities.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name}
|
||||
{typeof entity.geometry_count === "number" ? ` (${entity.geometry_count})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: "6px", color: "#94a3b8", fontSize: "12px" }}>
|
||||
Geometry mới tạo sẽ gắn sẵn entity này, bạn có thể thêm nhiều entity ở panel bên phải.
|
||||
</div>
|
||||
{entityStatus ? (
|
||||
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
|
||||
{entityStatus}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
|
||||
@@ -8,23 +8,62 @@ import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles"
|
||||
import { initDrawing } from "@/lib/drawingEngine";
|
||||
import { initSelect } from "@/lib/selectingEngine";
|
||||
import { initPoint } from "@/lib/pointEngine";
|
||||
import { initPath } from "@/lib/pathEngine";
|
||||
import { initCircle } from "@/lib/circleEngine";
|
||||
import { createEditingEngine } from "@/lib/editingEngine";
|
||||
import { FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
|
||||
|
||||
type MapProps = {
|
||||
mode: "idle" | "draw" | "select" | "add-point";
|
||||
mode: "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
selectedEntityId: string | null;
|
||||
selectedEntityName: string | null;
|
||||
selectedEntityTypeId: string | null;
|
||||
onSelectFeatureId: (id: string | number | null) => void;
|
||||
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature: (id: string | number) => void;
|
||||
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
|
||||
};
|
||||
|
||||
type PointIconSpec = {
|
||||
id: string;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const DEFAULT_POINT_ICON_ID = "point-icon-default";
|
||||
const PATH_ARROW_ICON_ID = "path-arrow-icon";
|
||||
|
||||
const POINT_ICON_SPECS: PointIconSpec[] = [
|
||||
{ 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-city", fill: "#0f766e", stroke: "#134e4a", label: "T" },
|
||||
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
|
||||
{ id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" },
|
||||
{ id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" },
|
||||
];
|
||||
|
||||
const POINT_ICON_BY_TYPE: Record<string, string> = {
|
||||
country: "point-icon-country",
|
||||
castle: "point-icon-castle",
|
||||
kingdom: "point-icon-kingdom",
|
||||
city: "point-icon-city",
|
||||
region: "point-icon-region",
|
||||
event: "point-icon-event",
|
||||
};
|
||||
|
||||
export default function Map({
|
||||
mode,
|
||||
draft,
|
||||
backgroundVisibility,
|
||||
selectedEntityId,
|
||||
selectedEntityName,
|
||||
selectedEntityTypeId,
|
||||
onSelectFeatureId,
|
||||
onCreateFeature,
|
||||
onDeleteFeature,
|
||||
onUpdateFeature,
|
||||
@@ -33,6 +72,10 @@ export default function Map({
|
||||
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
|
||||
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
|
||||
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
|
||||
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
|
||||
const onCreateRef = useRef(onCreateFeature);
|
||||
const onDeleteRef = useRef(onDeleteFeature);
|
||||
const onUpdateRef = useRef(onUpdateFeature);
|
||||
@@ -43,10 +86,43 @@ export default function Map({
|
||||
modeRef.current = mode;
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (mode !== "add-path") {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
if (mode !== "add-circle") {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityIdRef.current = selectedEntityId;
|
||||
}, [selectedEntityId]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityNameRef.current = selectedEntityName;
|
||||
}, [selectedEntityName]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEntityTypeIdRef.current = selectedEntityTypeId;
|
||||
}, [selectedEntityTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectFeatureIdRef.current = onSelectFeatureId;
|
||||
}, [onSelectFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
const map = mapRef.current;
|
||||
@@ -260,6 +336,7 @@ export default function Map({
|
||||
const placesMinZoom = 5;
|
||||
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||
const hasPathArrowIcon = ensurePathArrowIcon(map);
|
||||
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
@@ -290,6 +367,71 @@ export default function Map({
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-circle-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-fill",
|
||||
type: "fill",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"fill-color": "#0ea5e9",
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-circle-preview-line",
|
||||
type: "line",
|
||||
source: "draw-circle-preview",
|
||||
paint: {
|
||||
"line-color": "#0284c7",
|
||||
"line-width": 2,
|
||||
"line-opacity": 0.95,
|
||||
},
|
||||
});
|
||||
|
||||
map.addSource("draw-path-preview", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-line",
|
||||
type: "line",
|
||||
source: "draw-path-preview",
|
||||
paint: {
|
||||
"line-color": "#38bdf8",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0.9,
|
||||
"line-dasharray": [1.2, 0.9],
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPathArrowIcon) {
|
||||
map.addLayer({
|
||||
id: "draw-path-preview-arrows",
|
||||
type: "symbol",
|
||||
source: "draw-path-preview",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 56,
|
||||
"icon-image": PATH_ARROW_ICON_ID,
|
||||
"icon-size": 0.45,
|
||||
"icon-allow-overlap": true,
|
||||
"icon-ignore-placement": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// data thật
|
||||
map.addSource("countries", {
|
||||
type: "geojson",
|
||||
@@ -305,12 +447,19 @@ export default function Map({
|
||||
id: "countries-fill",
|
||||
type: "fill",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e", // selected
|
||||
"#f59e0b", // normal
|
||||
[
|
||||
"==",
|
||||
["coalesce", ["get", "entity_id"], ""],
|
||||
"",
|
||||
],
|
||||
"#ef4444", // no entity
|
||||
"#f59e0b", // linked entity
|
||||
],
|
||||
"fill-opacity": 0.5,
|
||||
},
|
||||
@@ -320,12 +469,56 @@ export default function Map({
|
||||
id: "countries-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "Polygon"],
|
||||
paint: {
|
||||
"line-color": "#fbbf24",
|
||||
"line-width": 2,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "routes-line",
|
||||
type: "line",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
paint: {
|
||||
"line-color": [
|
||||
"case",
|
||||
["boolean", ["feature-state", "selected"], false],
|
||||
"#22c55e",
|
||||
["==", ["coalesce", ["get", "entity_id"], ""], ""],
|
||||
"#ef4444",
|
||||
"#38bdf8",
|
||||
],
|
||||
"line-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
1, 2.2,
|
||||
4, 3.2,
|
||||
6, 4.2,
|
||||
],
|
||||
"line-opacity": 0.9,
|
||||
},
|
||||
});
|
||||
|
||||
if (hasPathArrowIcon) {
|
||||
map.addLayer({
|
||||
id: "routes-arrow",
|
||||
type: "symbol",
|
||||
source: "countries",
|
||||
filter: ["==", ["geometry-type"], "LineString"],
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": 60,
|
||||
"icon-image": PATH_ARROW_ICON_ID,
|
||||
"icon-size": 0.5,
|
||||
"icon-allow-overlap": true,
|
||||
"icon-ignore-placement": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.addSource("places", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
@@ -373,37 +566,38 @@ export default function Map({
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
paint: {
|
||||
"circle-color": "#ef4444",
|
||||
"circle-radius": 3,
|
||||
"circle-color": [
|
||||
"case",
|
||||
[
|
||||
"==",
|
||||
["coalesce", ["get", "entity_id"], ""],
|
||||
"",
|
||||
],
|
||||
"#ef4444",
|
||||
"#10b981",
|
||||
],
|
||||
"circle-radius": 4,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1,
|
||||
"circle-opacity": 0.85,
|
||||
},
|
||||
});
|
||||
|
||||
// load icon from /public and hide circle fallback when available
|
||||
try {
|
||||
const imageResponse = await map.loadImage("/point.png");
|
||||
if (!map.hasImage("point-icon")) {
|
||||
map.addImage("point-icon", imageResponse.data);
|
||||
}
|
||||
|
||||
if (!map.getLayer("places-symbol")) {
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
layout: {
|
||||
"icon-image": "point-icon",
|
||||
"icon-size": 0.25,
|
||||
"icon-anchor": "bottom",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
map.setLayoutProperty("places-circle", "visibility", "none");
|
||||
} catch (err) {
|
||||
console.warn("Failed to load point icon, using circle fallback.", err);
|
||||
// Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer.
|
||||
const hasTypeIcons = ensurePointIcons(map);
|
||||
if (hasTypeIcons && !map.getLayer("places-symbol")) {
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
source: "places",
|
||||
minzoom: placesMinZoom,
|
||||
layout: {
|
||||
"icon-image": buildPointIconExpression(),
|
||||
"icon-size": 0.5,
|
||||
"icon-anchor": "center",
|
||||
"icon-allow-overlap": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// init drawing
|
||||
@@ -414,7 +608,13 @@ export default function Map({
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: { id, kind: "country" },
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
@@ -426,9 +626,11 @@ export default function Map({
|
||||
(id: string | number) => {
|
||||
// ensure edit overlays are cleared when a feature gets removed
|
||||
editingEngineRef.current?.clearEditing();
|
||||
onSelectFeatureIdRef.current?.(null);
|
||||
onDeleteRef.current(id);
|
||||
},
|
||||
(feature) => editingEngineRef.current?.beginEditing(feature)
|
||||
(feature) => editingEngineRef.current?.beginEditing(feature),
|
||||
(id) => onSelectFeatureIdRef.current?.(id)
|
||||
);
|
||||
|
||||
const cleanupPoint = initPoint(
|
||||
@@ -438,12 +640,58 @@ export default function Map({
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||
onCreateRef.current({
|
||||
type: "Feature",
|
||||
properties: { id, kind: "place" },
|
||||
properties: {
|
||||
id,
|
||||
entity_id: selectedEntityIdRef.current || null,
|
||||
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
|
||||
entity_name: selectedEntityNameRef.current || null,
|
||||
entity_type_id: selectedEntityTypeIdRef.current || null,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupPath = initPath(
|
||||
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,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupCircle = initCircle(
|
||||
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,
|
||||
},
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
map.on("remove", cleanupCircle);
|
||||
map.on("remove", cleanupPath);
|
||||
map.on("remove", cleanupPoint);
|
||||
|
||||
map.on("remove", cleanupSelect);
|
||||
@@ -471,7 +719,7 @@ export default function Map({
|
||||
}
|
||||
}, [draft, applyDraftToMap]);
|
||||
|
||||
return <div id="map" style={{ flex: 1, height: "100vh" }} />;
|
||||
return <div id="map" style={{ width: "100%", height: "100vh" }} />;
|
||||
}
|
||||
|
||||
function applyBackgroundLayerVisibility(
|
||||
@@ -501,3 +749,105 @@ function splitDraftFeatures(fc: FeatureCollection) {
|
||||
|
||||
return { polygons, points };
|
||||
}
|
||||
|
||||
function ensurePathArrowIcon(map: maplibregl.Map): boolean {
|
||||
if (map.hasImage(PATH_ARROW_ICON_ID)) return true;
|
||||
const imageData = createPathArrowImageData();
|
||||
if (!imageData) return false;
|
||||
map.addImage(PATH_ARROW_ICON_ID, imageData, { pixelRatio: 2 });
|
||||
return true;
|
||||
}
|
||||
|
||||
function createPathArrowImageData(): ImageData | null {
|
||||
const size = 56;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
ctx.strokeStyle = "#0f172a";
|
||||
ctx.fillStyle = "#38bdf8";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8, 16);
|
||||
ctx.lineTo(28, 16);
|
||||
ctx.lineTo(28, 10);
|
||||
ctx.lineTo(46, 28);
|
||||
ctx.lineTo(28, 46);
|
||||
ctx.lineTo(28, 40);
|
||||
ctx.lineTo(8, 40);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
function ensurePointIcons(map: maplibregl.Map): boolean {
|
||||
let added = false;
|
||||
for (const spec of POINT_ICON_SPECS) {
|
||||
if (map.hasImage(spec.id)) {
|
||||
added = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageData = createPointIconImageData(spec);
|
||||
if (!imageData) continue;
|
||||
map.addImage(spec.id, imageData, { pixelRatio: 2 });
|
||||
added = true;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function createPointIconImageData(spec: PointIconSpec): ImageData | null {
|
||||
const size = 64;
|
||||
const radius = 18;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// soft shadow
|
||||
ctx.fillStyle = "rgba(2, 6, 23, 0.28)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// icon body
|
||||
ctx.fillStyle = spec.fill;
|
||||
ctx.strokeStyle = spec.stroke;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// short type mark
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font = "700 20px sans-serif";
|
||||
ctx.fillText(spec.label, size / 2, size / 2 + 0.5);
|
||||
|
||||
return ctx.getImageData(0, 0, size, size);
|
||||
}
|
||||
|
||||
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
|
||||
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
|
||||
|
||||
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
|
||||
expression.push(typeId, iconId);
|
||||
}
|
||||
|
||||
expression.push(DEFAULT_POINT_ICON_ID);
|
||||
return expression as maplibregl.ExpressionSpecification;
|
||||
}
|
||||
|
||||
269
components/TimelineBar.tsx
Normal file
269
components/TimelineBar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
type Props = {
|
||||
minYear: number;
|
||||
maxYear: number;
|
||||
windowStartYear: number;
|
||||
windowEndYear: number;
|
||||
onWindowStartYearChange: (year: number) => void;
|
||||
onWindowEndYearChange: (year: number) => void;
|
||||
year: number;
|
||||
onYearChange: (year: number) => void;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
statusText?: string | null;
|
||||
};
|
||||
|
||||
export default function TimelineBar({
|
||||
minYear,
|
||||
maxYear,
|
||||
windowStartYear,
|
||||
windowEndYear,
|
||||
onWindowStartYearChange,
|
||||
onWindowEndYearChange,
|
||||
year,
|
||||
onYearChange,
|
||||
isLoading,
|
||||
disabled,
|
||||
statusText,
|
||||
}: Props) {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
const globalLocked = lower === upper;
|
||||
const effectiveDisabled = disabled || globalLocked;
|
||||
const safeWindowStart = clampYear(windowStartYear, lower, upper);
|
||||
const safeWindowEnd = clampYear(windowEndYear, safeWindowStart, upper);
|
||||
const windowLocked = safeWindowStart === safeWindowEnd;
|
||||
const safeYear = clampYear(year, safeWindowStart, safeWindowEnd);
|
||||
const pointDisabled = effectiveDisabled || windowLocked;
|
||||
const windowStartPercent = toPercent(safeWindowStart, lower, upper);
|
||||
const windowEndPercent = toPercent(safeWindowEnd, lower, upper);
|
||||
|
||||
const helperText = isLoading
|
||||
? "Đang tải geometry theo mốc thời gian..."
|
||||
: statusText || (windowLocked ? "Khoảng lớn đang thu về một mốc duy nhất." : "Kéo mốc nhỏ để query trong khoảng lớn.");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "18px",
|
||||
right: "18px",
|
||||
bottom: "16px",
|
||||
zIndex: 10,
|
||||
background: "rgba(15, 23, 42, 0.9)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.3)",
|
||||
borderRadius: "10px",
|
||||
padding: "12px 14px",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(2px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "8px",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
|
||||
Timeline
|
||||
</span>
|
||||
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
|
||||
{formatYear(safeYear)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "12px", color: "#cbd5e1", marginBottom: "6px" }}>
|
||||
Khoảng thời gian lớn
|
||||
</div>
|
||||
<div
|
||||
className="dual-range"
|
||||
style={{
|
||||
opacity: effectiveDisabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="dual-range-track" />
|
||||
<div
|
||||
className="dual-range-selected"
|
||||
style={{
|
||||
left: `${windowStartPercent}%`,
|
||||
width: `${Math.max(windowEndPercent - windowStartPercent, 0)}%`,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="dual-range-input"
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeWindowStart}
|
||||
onChange={(event) =>
|
||||
onWindowStartYearChange(
|
||||
Math.min(Number(event.target.value), safeWindowEnd)
|
||||
)
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline window start"
|
||||
/>
|
||||
<input
|
||||
className="dual-range-input"
|
||||
type="range"
|
||||
min={lower}
|
||||
max={upper}
|
||||
step={1}
|
||||
value={safeWindowEnd}
|
||||
onChange={(event) =>
|
||||
onWindowEndYearChange(
|
||||
Math.max(Number(event.target.value), safeWindowStart)
|
||||
)
|
||||
}
|
||||
disabled={effectiveDisabled}
|
||||
aria-label="Timeline window end"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "6px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "12px",
|
||||
color: "#94a3b8",
|
||||
}}
|
||||
>
|
||||
<span>{formatYear(safeWindowStart)}</span>
|
||||
<span>{formatYear(safeWindowEnd)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
|
||||
Mốc thời gian chi tiết
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={safeWindowStart}
|
||||
max={safeWindowEnd}
|
||||
step={1}
|
||||
value={safeYear}
|
||||
onChange={(event) => onYearChange(Number(event.target.value))}
|
||||
disabled={pointDisabled}
|
||||
aria-label="Timeline year"
|
||||
style={{
|
||||
width: "100%",
|
||||
accentColor: "#22c55e",
|
||||
cursor: pointDisabled ? "not-allowed" : "pointer",
|
||||
opacity: pointDisabled ? 0.6 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto 1fr",
|
||||
alignItems: "center",
|
||||
columnGap: "10px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#94a3b8" }}>{formatYear(safeWindowStart)}</span>
|
||||
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
|
||||
{helperText}
|
||||
</span>
|
||||
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(safeWindowEnd)}</span>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.dual-range {
|
||||
position: relative;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dual-range-track {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.dual-range-selected {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.dual-range-input {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.dual-range-input::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dual-range-input::-webkit-slider-thumb {
|
||||
pointer-events: auto;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: -5px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #0f172a;
|
||||
background: #22c55e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dual-range-input::-moz-range-track {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dual-range-input::-moz-range-thumb {
|
||||
pointer-events: auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #0f172a;
|
||||
background: #22c55e;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampYear(year: number, minYear: number, maxYear: number): number {
|
||||
if (year < minYear) return minYear;
|
||||
if (year > maxYear) return maxYear;
|
||||
return year;
|
||||
}
|
||||
|
||||
function formatYear(year: number): string {
|
||||
if (year < 0) {
|
||||
return `${Math.abs(year)} TCN`;
|
||||
}
|
||||
return `${year}`;
|
||||
}
|
||||
|
||||
function toPercent(value: number, minValue: number, maxValue: number): number {
|
||||
if (maxValue <= minValue) return 0;
|
||||
return ((value - minValue) / (maxValue - minValue)) * 100;
|
||||
}
|
||||
1
data
1
data
Submodule data deleted from 3aab49c029
226
lib/circleEngine.ts
Normal file
226
lib/circleEngine.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const CIRCLE_SEGMENTS = 72;
|
||||
const MIN_RADIUS_METERS = 1;
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
export function initCircle(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let center: [number, number] | null = null;
|
||||
let radiusMeters = 0;
|
||||
let isDragging = false;
|
||||
let dragPanDisabledByCircle = false;
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
const releaseDragPan = () => {
|
||||
if (!dragPanDisabledByCircle) return;
|
||||
dragPanDisabledByCircle = false;
|
||||
if (!map.dragPan.isEnabled()) {
|
||||
map.dragPan.enable();
|
||||
}
|
||||
};
|
||||
|
||||
const resetDrawingState = () => {
|
||||
center = null;
|
||||
radiusMeters = 0;
|
||||
isDragging = false;
|
||||
clearPreview();
|
||||
releaseDragPan();
|
||||
};
|
||||
|
||||
const updatePreview = () => {
|
||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
|
||||
center = [e.lngLat.lng, e.lngLat.lat];
|
||||
radiusMeters = 0;
|
||||
isDragging = true;
|
||||
clearPreview();
|
||||
|
||||
if (map.dragPan.isEnabled()) {
|
||||
map.dragPan.disable();
|
||||
dragPanDisabledByCircle = true;
|
||||
} else {
|
||||
dragPanDisabledByCircle = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() !== "add-circle") {
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
if (isDragging) {
|
||||
resetDrawingState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (!isDragging || !center) return;
|
||||
|
||||
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
const finishCircle = () => {
|
||||
if (!isDragging || !center) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (radiusMeters < MIN_RADIUS_METERS) {
|
||||
resetDrawingState();
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
||||
onComplete({
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
});
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
finishCircle();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if (e.key !== "Escape") return;
|
||||
e.preventDefault();
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
map.on("mousedown", onMouseDown);
|
||||
map.on("mousemove", onMouseMove);
|
||||
map.on("mouseup", onMouseUp);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
map.off("mousedown", onMouseDown);
|
||||
map.off("mousemove", onMouseMove);
|
||||
map.off("mouseup", onMouseUp);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
resetDrawingState();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildCircleRing(
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
segments: number
|
||||
): [number, number][] {
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const bearingDeg = (i / segments) * 360;
|
||||
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
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 sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
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;
|
||||
}
|
||||
|
||||
function destinationPoint(
|
||||
center: [number, number],
|
||||
distance: number,
|
||||
bearingDeg: number
|
||||
): [number, number] {
|
||||
const lat1 = toRad(center[1]);
|
||||
const lng1 = toRad(center[0]);
|
||||
const bearing = toRad(bearingDeg);
|
||||
const angularDistance = distance / EARTH_RADIUS_METERS;
|
||||
|
||||
const sinLat1 = Math.sin(lat1);
|
||||
const cosLat1 = Math.cos(lat1);
|
||||
const sinAngular = Math.sin(angularDistance);
|
||||
const cosAngular = Math.cos(angularDistance);
|
||||
|
||||
const sinLat2 =
|
||||
sinLat1 * cosAngular +
|
||||
cosLat1 * sinAngular * Math.cos(bearing);
|
||||
const lat2 = Math.asin(clamp(sinLat2, -1, 1));
|
||||
|
||||
const y = Math.sin(bearing) * sinAngular * cosLat1;
|
||||
const x = cosAngular - sinLat1 * Math.sin(lat2);
|
||||
const lng2 = lng1 + Math.atan2(y, x);
|
||||
|
||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||
}
|
||||
|
||||
function normalizeLng(lng: number): number {
|
||||
let normalized = ((lng + 540) % 360) - 180;
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDeg(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
|
||||
129
lib/pathEngine.ts
Normal file
129
lib/pathEngine.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
export function initPath(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const finishPath = () => {
|
||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
const cancelPath = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
const removeLastVertex = () => {
|
||||
if (coords.length === 0) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-path") {
|
||||
if (coords.length) {
|
||||
cancelPath();
|
||||
}
|
||||
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-path") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishPath();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelPath();
|
||||
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);
|
||||
clearPreview();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
|
||||
export function initSelect(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onDelete: (id: string | number) => void,
|
||||
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void
|
||||
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onSelectId?: (id: string | number | null) => void
|
||||
) {
|
||||
const SELECTABLE_LAYERS = [
|
||||
"countries-fill",
|
||||
"countries-line",
|
||||
"routes-line",
|
||||
"places-circle",
|
||||
"places-symbol",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
@@ -21,6 +29,7 @@ export function initSelect(
|
||||
map.setFeatureState({ source: "countries", id }, { selected: false });
|
||||
});
|
||||
selectedIds.clear();
|
||||
onSelectId?.(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,18 +47,20 @@ export function initSelect(
|
||||
// Alt + click on an already selected feature removes it from the selection
|
||||
map.setFeatureState({ source: "countries", id }, { selected: false });
|
||||
selectedIds.delete(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
|
||||
return;
|
||||
}
|
||||
|
||||
map.setFeatureState({ source: "countries", id }, { selected: true });
|
||||
selectedIds.add(id);
|
||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||
}
|
||||
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: ["countries-fill"],
|
||||
layers: [...SELECTABLE_LAYERS],
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) {
|
||||
@@ -70,7 +81,7 @@ export function initSelect(
|
||||
e.preventDefault(); // block browser menu
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: ["countries-fill"],
|
||||
layers: [...SELECTABLE_LAYERS],
|
||||
}) as maplibregl.MapGeoJSONFeature[];
|
||||
|
||||
if (!features.length) return;
|
||||
@@ -96,7 +107,7 @@ export function initSelect(
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: ["countries-fill"],
|
||||
layers: [...SELECTABLE_LAYERS],
|
||||
});
|
||||
|
||||
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||
@@ -164,7 +175,7 @@ export function initSelect(
|
||||
|
||||
const selectedCount = selectedIds.size || 1;
|
||||
|
||||
if (selectedCount === 1) {
|
||||
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon") {
|
||||
const single = clickedFeature;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ export type FeatureProperties = {
|
||||
id: string | number;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
kind?: string | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
entity_names?: string[];
|
||||
entity_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
@@ -95,20 +99,22 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
if (!a) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
switch (a.type) {
|
||||
case "create":
|
||||
return a.id === (b as UndoAction & { id: typeof a.id }).id;
|
||||
case "create": {
|
||||
const next = b as Extract<UndoAction, { type: "create" }>;
|
||||
return a.id === next.id;
|
||||
}
|
||||
case "delete": {
|
||||
const bb = b as UndoAction & { feature: Feature };
|
||||
const next = b as Extract<UndoAction, { type: "delete" }>;
|
||||
return (
|
||||
a.feature?.properties?.id === bb.feature?.properties?.id &&
|
||||
geometryEquals(a.feature?.geometry, bb.feature?.geometry)
|
||||
a.feature.properties.id === next.feature.properties.id &&
|
||||
geometryEquals(a.feature.geometry, next.feature.geometry)
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
const bb = b as UndoAction & { prevGeometry: Geometry };
|
||||
const next = b as Extract<UndoAction, { type: "update" }>;
|
||||
return (
|
||||
a.id === bb.id &&
|
||||
geometryEquals(a.prevGeometry, bb.prevGeometry)
|
||||
a.id === next.id &&
|
||||
geometryEquals(a.prevGeometry, next.prevGeometry)
|
||||
);
|
||||
}
|
||||
default:
|
||||
@@ -180,6 +186,27 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch non-geometry properties on a feature (used for entity/time metadata).
|
||||
*/
|
||||
function patchFeatureProperties(
|
||||
id: FeatureProperties["id"],
|
||||
patch: Partial<FeatureProperties>
|
||||
) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const nextFeatures = [...draftRef.current.features];
|
||||
nextFeatures[idx] = {
|
||||
...nextFeatures[idx],
|
||||
properties: {
|
||||
...nextFeatures[idx].properties,
|
||||
...deepClone(patch),
|
||||
},
|
||||
};
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update geometry of an existing feature and record change.
|
||||
*/
|
||||
@@ -281,16 +308,22 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
setBaselineVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||
return initialMapRef.current.has(id);
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
changes,
|
||||
undoStack,
|
||||
changeCount,
|
||||
createFeature,
|
||||
patchFeatureProperties,
|
||||
updateFeature,
|
||||
deleteFeature,
|
||||
undo,
|
||||
buildPayload,
|
||||
clearChanges,
|
||||
hasPersistedFeature,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user