add somenew UI editor feature for more effêcncy
This commit is contained in:
@@ -51,11 +51,26 @@ export function buildFeatureEntityPatch(
|
||||
const entityNames = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
const entityLabelCandidates = entityIds
|
||||
.map((id) => {
|
||||
const entity = entities.find((item) => item.id === id) || null;
|
||||
if (!entity) return null;
|
||||
const name = String(entity.name || "").trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
time_start: entity.time_start ?? null,
|
||||
time_end: entity.time_end ?? null,
|
||||
};
|
||||
})
|
||||
.filter((candidate) => candidate !== null);
|
||||
|
||||
return {
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_label_candidates: entityLabelCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,6 +311,8 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
||||
operation: "reference",
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
description: typeof e.description === "string" ? e.description : e.description ?? null,
|
||||
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
|
||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export type TimelineRange = {
|
||||
export type EntityFormState = {
|
||||
name: string;
|
||||
description: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
};
|
||||
|
||||
export type GeometryMetaFormState = {
|
||||
|
||||
@@ -20,6 +20,8 @@ export function useEntitySessionState() {
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
description: "",
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
});
|
||||
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
|
||||
@@ -31,6 +31,8 @@ interface RawEntityRow extends UnknownRecord {
|
||||
ref?: { id?: string };
|
||||
name?: string;
|
||||
description?: string;
|
||||
time_start?: unknown;
|
||||
time_end?: unknown;
|
||||
}
|
||||
|
||||
interface RawWikiRow extends UnknownRecord {
|
||||
@@ -124,6 +126,8 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
operation,
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
|
||||
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start == null ? undefined : undefined,
|
||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end == null ? undefined : undefined,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
@@ -266,12 +270,17 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
byGeom.set(row.geometry_id, list);
|
||||
}
|
||||
const entityNameById = new Map<string, string>();
|
||||
const entityTimeById = new Map<string, { time_start: number | null; time_end: number | null }>();
|
||||
for (const r of entities || []) {
|
||||
const row = r as RawEntityRow;
|
||||
const id = typeof row?.id === "string" ? row.id : "";
|
||||
if (!id) continue;
|
||||
const name = typeof row.name === "string" ? String(row.name).trim() : "";
|
||||
if (name) entityNameById.set(id, name);
|
||||
entityTimeById.set(id, {
|
||||
time_start: typeof row.time_start === "number" ? row.time_start : null,
|
||||
time_end: typeof row.time_end === "number" ? row.time_end : null,
|
||||
});
|
||||
}
|
||||
const geometryById = new Map<string, GeometrySnapshot>();
|
||||
for (const row of geometries || []) {
|
||||
@@ -299,6 +308,19 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
||||
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
|
||||
p.entity_name = primaryName || null;
|
||||
p.entity_names = names;
|
||||
p.entity_label_candidates = entity_ids
|
||||
.map((id) => {
|
||||
const name = entityNameById.get(id) || "";
|
||||
if (!name) return null;
|
||||
const time = entityTimeById.get(id) || { time_start: null, time_end: null };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
time_start: time.time_start,
|
||||
time_end: time.time_end,
|
||||
};
|
||||
})
|
||||
.filter((candidate) => candidate !== null);
|
||||
}
|
||||
|
||||
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||
@@ -388,6 +410,8 @@ export function buildEditorSnapshot(options: {
|
||||
operation: "reference",
|
||||
name: typeof cloned.name === "string" ? cloned.name : undefined,
|
||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
|
||||
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
|
||||
});
|
||||
}
|
||||
for (const row of options.snapshotEntities || []) {
|
||||
@@ -411,6 +435,8 @@ export function buildEditorSnapshot(options: {
|
||||
name,
|
||||
operation,
|
||||
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||
time_start: typeof cloned.time_start === "number" ? cloned.time_start : cloned.time_start ?? undefined,
|
||||
time_end: typeof cloned.time_end === "number" ? cloned.time_end : cloned.time_end ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,6 +563,7 @@ export function buildEditorSnapshot(options: {
|
||||
delete p.entity_ids;
|
||||
delete p.entity_name;
|
||||
delete p.entity_names;
|
||||
delete p.entity_label_candidates;
|
||||
delete p.entity_type_id;
|
||||
}
|
||||
|
||||
@@ -662,6 +689,8 @@ export function buildEditorSnapshot(options: {
|
||||
operation: e.operation,
|
||||
name: typeof e.name === "string" ? e.name : undefined,
|
||||
description: typeof (e as RawEntityRow).description === "string" ? (e as RawEntityRow).description : (e as RawEntityRow).description ?? null,
|
||||
time_start: typeof e.time_start === "number" ? e.time_start : e.time_start ?? undefined,
|
||||
time_end: typeof e.time_end === "number" ? e.time_end : e.time_end ?? undefined,
|
||||
}))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
|
||||
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
export type EditingHandle = {
|
||||
id: string | number;
|
||||
@@ -25,12 +26,16 @@ export function createEditingEngine(options: {
|
||||
const { mapRef, onUpdate } = options;
|
||||
const editingRef = { current: null as EditingHandle | null };
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||
const deleteVertexModeRef = { current: false };
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||
const clearEditing = () => {
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
setDeleteVertexMode(false);
|
||||
hideContextMenu();
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
@@ -135,6 +140,14 @@ export function createEditingEngine(options: {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
const setDeleteVertexMode = (enabled: boolean) => {
|
||||
deleteVertexModeRef.current = enabled;
|
||||
const map = mapRef.current;
|
||||
if (!map?.getLayer("edit-handles-circle")) return;
|
||||
map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316");
|
||||
map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a");
|
||||
};
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
if (feature.geometry.type !== "Polygon") return;
|
||||
@@ -154,18 +167,19 @@ export function createEditingEngine(options: {
|
||||
circleCenter: geom.circle_center,
|
||||
circleRadius: geom.circle_radius,
|
||||
};
|
||||
setDeleteVertexMode(false);
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||
return (
|
||||
modifierRef.current.ctrl ||
|
||||
modifierRef.current.meta ||
|
||||
!!oe?.ctrlKey ||
|
||||
!!oe?.metaKey
|
||||
);
|
||||
const hideContextMenu = () => {
|
||||
if (contextMenu) {
|
||||
contextMenu.remove();
|
||||
contextMenu = null;
|
||||
}
|
||||
if (docClickHandler) {
|
||||
document.removeEventListener("click", docClickHandler);
|
||||
docClickHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
|
||||
@@ -173,10 +187,16 @@ export function createEditingEngine(options: {
|
||||
// Bắt đầu kéo một handle point.
|
||||
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
if (e.originalEvent.button === 2) return;
|
||||
const feature = e.features?.[0];
|
||||
const idx = feature?.properties?.idx;
|
||||
if (idx === undefined) return;
|
||||
const idx = Number(feature?.properties?.idx);
|
||||
if (!Number.isInteger(idx)) return;
|
||||
e.preventDefault();
|
||||
if (deleteVertexModeRef.current) {
|
||||
e.originalEvent.stopPropagation();
|
||||
deleteVertex(idx);
|
||||
return;
|
||||
}
|
||||
dragStateRef.current = { idx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
@@ -188,19 +208,21 @@ export function createEditingEngine(options: {
|
||||
const editing = editingRef.current;
|
||||
if (!drag || !editing) return;
|
||||
|
||||
const lngLat = e.originalEvent.shiftKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
const nextCoordinate: [number, number] = [lngLat.lng, lngLat.lat];
|
||||
|
||||
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
|
||||
if (drag.idx === 0) {
|
||||
// Move center
|
||||
editing.circleCenter = [e.lngLat.lng, e.lngLat.lat];
|
||||
editing.circleCenter = nextCoordinate;
|
||||
} else if (drag.idx === 1) {
|
||||
// Change radius
|
||||
editing.circleRadius = distanceMeters(editing.circleCenter, [
|
||||
e.lngLat.lng,
|
||||
e.lngLat.lat,
|
||||
]);
|
||||
editing.circleRadius = distanceMeters(editing.circleCenter, nextCoordinate);
|
||||
}
|
||||
} else {
|
||||
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
|
||||
editing.ring[drag.idx] = nextCoordinate;
|
||||
}
|
||||
updateEditSources();
|
||||
};
|
||||
@@ -212,55 +234,39 @@ export function createEditingEngine(options: {
|
||||
map.dragPan.enable();
|
||||
};
|
||||
|
||||
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||
// Bắt phím điều khiển phiên chỉnh sửa.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = true;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = true;
|
||||
}
|
||||
if (!editingRef.current) return;
|
||||
const editing = editingRef.current;
|
||||
if (!editing) return;
|
||||
if (e.key === "Enter") {
|
||||
finishEditing();
|
||||
} else if (e.key === "Delete" && !editing.isCircle) {
|
||||
e.preventDefault();
|
||||
setDeleteVertexMode(!deleteVertexModeRef.current);
|
||||
} else if (e.key === "Escape") {
|
||||
if (deleteVertexModeRef.current) {
|
||||
e.preventDefault();
|
||||
setDeleteVertexMode(false);
|
||||
return;
|
||||
}
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
// Hạ cờ modifier khi nhả phím.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = false;
|
||||
} else if (e.key === "Meta") {
|
||||
modifierRef.current.meta = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current || editingRef.current.isCircle) return;
|
||||
if (!isModifierPressed(e)) return;
|
||||
e.preventDefault();
|
||||
// Chuột phải vào handle để mở menu xóa/thêm đỉnh.
|
||||
const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const editing = editingRef.current;
|
||||
const ring = editing.ring;
|
||||
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
|
||||
let nearestIdx = 0;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
ring.forEach((pt, idx) => {
|
||||
const dx = pt[0] - click[0];
|
||||
const dy = pt[1] - click[1];
|
||||
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;
|
||||
}
|
||||
});
|
||||
const insertIdx = nearestIdx + 1;
|
||||
ring.splice(insertIdx, 0, click);
|
||||
dragStateRef.current = { idx: insertIdx };
|
||||
map.getCanvas().style.cursor = "grabbing";
|
||||
map.dragPan.disable();
|
||||
updateEditSources();
|
||||
if (!editing || editing.isCircle) return;
|
||||
e.preventDefault();
|
||||
e.originalEvent.stopPropagation();
|
||||
const feature = e.features?.[0];
|
||||
const idx = Number(feature?.properties?.idx);
|
||||
if (!Number.isInteger(idx)) return;
|
||||
showHandleContextMenu(
|
||||
e.originalEvent.clientX,
|
||||
e.originalEvent.clientY,
|
||||
idx
|
||||
);
|
||||
};
|
||||
|
||||
// Ngắt kéo nếu con trỏ rời canvas.
|
||||
@@ -269,24 +275,97 @@ export function createEditingEngine(options: {
|
||||
};
|
||||
|
||||
map.on("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.on("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.on("contextmenu", "edit-handles-circle", onHandleContextMenu);
|
||||
map.on("mousemove", onHandleMove);
|
||||
map.on("mouseup", stopDragging);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||
|
||||
map.on("remove", () => {
|
||||
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.off("mousedown", "edit-shape-line", onInsertHandle);
|
||||
map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
|
||||
map.off("mousemove", onHandleMove);
|
||||
map.off("mouseup", stopDragging);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("keyup", onKeyUp);
|
||||
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||
hideContextMenu();
|
||||
});
|
||||
};
|
||||
|
||||
const showHandleContextMenu = (x: number, y: number, idx: number) => {
|
||||
hideContextMenu();
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.style.position = "fixed";
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
menu.style.background = "#0f172a";
|
||||
menu.style.color = "white";
|
||||
menu.style.border = "1px solid #1f2937";
|
||||
menu.style.borderRadius = "6px";
|
||||
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||
menu.style.zIndex = "9999";
|
||||
menu.style.minWidth = "120px";
|
||||
menu.style.fontSize = "14px";
|
||||
menu.style.padding = "4px 0";
|
||||
|
||||
const createItem = (label: string, onClick: () => void, disabled = false) => {
|
||||
const item = document.createElement("div");
|
||||
item.textContent = label;
|
||||
item.style.padding = "8px 12px";
|
||||
item.style.cursor = disabled ? "not-allowed" : "pointer";
|
||||
item.style.opacity = disabled ? "0.45" : "1";
|
||||
item.onmouseenter = () => {
|
||||
if (!disabled) item.style.background = "#1f2937";
|
||||
};
|
||||
item.onmouseleave = () => (item.style.background = "transparent");
|
||||
item.onclick = () => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
hideContextMenu();
|
||||
};
|
||||
return item;
|
||||
};
|
||||
|
||||
const editing = editingRef.current;
|
||||
const canDelete = Boolean(editing && !editing.isCircle && editing.ring.length > 3);
|
||||
menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete));
|
||||
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
contextMenu = menu;
|
||||
|
||||
const onDocClick = (ev: MouseEvent) => {
|
||||
if (!menu.contains(ev.target as Node)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
docClickHandler = onDocClick;
|
||||
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
||||
};
|
||||
|
||||
const deleteVertex = (idx: number) => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing || editing.isCircle || editing.ring.length <= 3) return;
|
||||
if (idx < 0 || idx >= editing.ring.length) return;
|
||||
editing.ring.splice(idx, 1);
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
const insertVertexAfter = (idx: number) => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing || editing.isCircle || editing.ring.length < 2) return;
|
||||
if (idx < 0 || idx >= editing.ring.length) return;
|
||||
const current = editing.ring[idx];
|
||||
const next = editing.ring[(idx + 1) % editing.ring.length];
|
||||
const midpoint: [number, number] = [
|
||||
(current[0] + next[0]) / 2,
|
||||
(current[1] + next[1]) / 2,
|
||||
];
|
||||
editing.ring.splice(idx + 1, 0, midpoint);
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
return {
|
||||
beginEditing,
|
||||
clearEditing,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
@@ -74,7 +75,11 @@ export function initLine(
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
|
||||
coords.push([lngLat.lng, lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
@@ -94,7 +99,11 @@ export function initLine(
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
|
||||
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
@@ -75,7 +76,11 @@ export function initPath(
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
|
||||
coords.push([lngLat.lng, lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
@@ -96,7 +101,10 @@ export function initPath(
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
|
||||
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
|
||||
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
|
||||
|
||||
// Khởi tạo engine thêm point bằng click đơn.
|
||||
export function initPoint(
|
||||
@@ -12,9 +13,13 @@ export function initPoint(
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
|
||||
? snapToNearestGeometry(map, e.lngLat, e.point)
|
||||
: e.lngLat;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "Point",
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
coordinates: [lngLat.lng, lngLat.lat],
|
||||
};
|
||||
|
||||
onComplete?.(geometry);
|
||||
|
||||
@@ -7,8 +7,11 @@ export function initSelect(
|
||||
getMode: ModeGetter,
|
||||
onDelete?: (id: string | number) => void,
|
||||
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onDuplicate?: (id: string | number) => void,
|
||||
onHide?: (id: string | number) => void,
|
||||
onSelectIds?: (ids: (string | number)[]) => void,
|
||||
onReplayEdit?: (id: string | number) => void
|
||||
onReplayEdit?: (id: string | number) => void,
|
||||
isEditSessionActive?: () => boolean
|
||||
) {
|
||||
|
||||
const FEATURE_STATE_SOURCES = [
|
||||
@@ -17,7 +20,7 @@ export function initSelect(
|
||||
"path-arrow-shapes",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
const hasContextActions = Boolean(onDelete || onEdit || onReplayEdit);
|
||||
const hasContextActions = Boolean(onDelete || onEdit || onDuplicate || onHide || onReplayEdit);
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
@@ -56,6 +59,7 @@ export function initSelect(
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select" && getMode() !== "replay") return;
|
||||
if (isEditSessionActive?.()) return;
|
||||
const selectableLayers = getSelectableLayers();
|
||||
if (!selectableLayers.length) return;
|
||||
|
||||
@@ -81,6 +85,7 @@ export function initSelect(
|
||||
|
||||
e.preventDefault(); // block browser menu
|
||||
if (getMode() === "replay") return;
|
||||
if (isEditSessionActive?.()) return;
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, {
|
||||
layers: selectableLayers,
|
||||
@@ -125,7 +130,11 @@ export function initSelect(
|
||||
const style = map.getStyle();
|
||||
if (!style || !style.layers) return [];
|
||||
return style.layers
|
||||
.filter((layer) => "source" in layer && FEATURE_STATE_SOURCES.includes(layer.source as any))
|
||||
.filter((layer) =>
|
||||
"source" in layer &&
|
||||
typeof layer.source === "string" &&
|
||||
FEATURE_STATE_SOURCES.includes(layer.source as (typeof FEATURE_STATE_SOURCES)[number])
|
||||
)
|
||||
.map((layer) => layer.id);
|
||||
}
|
||||
|
||||
@@ -236,6 +245,22 @@ export function initSelect(
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (selectedCount === 1 && onDuplicate) {
|
||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||
if (featureId !== undefined && featureId !== null) {
|
||||
menu.appendChild(createItem("Duplicate", () => onDuplicate(featureId)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 1 && onHide) {
|
||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||
if (featureId !== undefined && featureId !== null) {
|
||||
menu.appendChild(createItem("Hide", () => onHide(featureId)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (onReplayEdit) {
|
||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||
if (featureId) {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { PATH_ARROW_SOURCE_ID } from "@/uhm/lib/map/constants";
|
||||
|
||||
const SNAP_THRESHOLD_PX = 15;
|
||||
// SHIFT/ALT snap should be forgiving while drawing quickly.
|
||||
// Vertices get a larger radius and always win over edges when both are available.
|
||||
const VERTEX_SNAP_THRESHOLD_PX = 34;
|
||||
const EDGE_SNAP_THRESHOLD_PX = 24;
|
||||
const QUERY_THRESHOLD_PX = Math.max(VERTEX_SNAP_THRESHOLD_PX, EDGE_SNAP_THRESHOLD_PX);
|
||||
|
||||
type Coordinate = [number, number];
|
||||
type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollection> & {
|
||||
coordinates: unknown;
|
||||
};
|
||||
|
||||
export function snapToNearestGeometry(
|
||||
map: maplibregl.Map,
|
||||
@@ -8,14 +18,21 @@ export function snapToNearestGeometry(
|
||||
pointPx: maplibregl.Point
|
||||
): maplibregl.LngLat {
|
||||
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
|
||||
[pointPx.x - SNAP_THRESHOLD_PX, pointPx.y - SNAP_THRESHOLD_PX],
|
||||
[pointPx.x + SNAP_THRESHOLD_PX, pointPx.y + SNAP_THRESHOLD_PX],
|
||||
[pointPx.x - QUERY_THRESHOLD_PX, pointPx.y - QUERY_THRESHOLD_PX],
|
||||
[pointPx.x + QUERY_THRESHOLD_PX, pointPx.y + QUERY_THRESHOLD_PX],
|
||||
];
|
||||
|
||||
const features = map.queryRenderedFeatures(bbox);
|
||||
const snapLayerIds = getSnapLayerIds(map);
|
||||
if (!snapLayerIds.length) return lngLat;
|
||||
|
||||
let nearestDist = Infinity;
|
||||
let nearestLngLat: maplibregl.LngLat | null = null;
|
||||
const features = map.queryRenderedFeatures(bbox, {
|
||||
layers: snapLayerIds,
|
||||
});
|
||||
|
||||
let nearestVertexDist = Infinity;
|
||||
let nearestVertexLngLat: maplibregl.LngLat | null = null;
|
||||
let nearestEdgeDist = Infinity;
|
||||
let nearestEdgeLngLat: maplibregl.LngLat | null = null;
|
||||
|
||||
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
|
||||
@@ -34,24 +51,49 @@ export function snapToNearestGeometry(
|
||||
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
|
||||
};
|
||||
|
||||
const processVertex = (coordinate: Coordinate) => {
|
||||
const vertexLngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
|
||||
const vertexPx = map.project(vertexLngLat);
|
||||
const distSq = getDistSq(pointPx, vertexPx);
|
||||
if (
|
||||
distSq < nearestVertexDist &&
|
||||
distSq <= VERTEX_SNAP_THRESHOLD_PX ** 2
|
||||
) {
|
||||
nearestVertexDist = distSq;
|
||||
nearestVertexLngLat = vertexLngLat;
|
||||
}
|
||||
};
|
||||
|
||||
const processLineString = (line: number[][]) => {
|
||||
if (!line || line.length < 2) return;
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const p1LngLat = new maplibregl.LngLat(line[i][0], line[i][1]);
|
||||
const p2LngLat = new maplibregl.LngLat(line[i + 1][0], line[i + 1][1]);
|
||||
const start = toCoordinate(line[i]);
|
||||
const end = toCoordinate(line[i + 1]);
|
||||
if (!start || !end) continue;
|
||||
|
||||
processVertex(start);
|
||||
if (i === line.length - 2) processVertex(end);
|
||||
|
||||
const p1LngLat = new maplibregl.LngLat(start[0], start[1]);
|
||||
const p2LngLat = new maplibregl.LngLat(end[0], end[1]);
|
||||
const p1 = map.project(p1LngLat);
|
||||
const p2 = map.project(p2LngLat);
|
||||
|
||||
const closestPx = getClosestPointOnSegment(pointPx, p1, p2);
|
||||
const distSq = getDistSq(pointPx, closestPx);
|
||||
|
||||
if (distSq < nearestDist && distSq <= SNAP_THRESHOLD_PX ** 2) {
|
||||
nearestDist = distSq;
|
||||
nearestLngLat = map.unproject(closestPx);
|
||||
if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
|
||||
nearestEdgeDist = distSq;
|
||||
nearestEdgeLngLat = map.unproject(closestPx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processPoint = (coordinate: unknown) => {
|
||||
const point = toCoordinate(coordinate);
|
||||
if (point) processVertex(point);
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
if (!feature.geometry) continue;
|
||||
|
||||
@@ -62,21 +104,61 @@ export function snapToNearestGeometry(
|
||||
|
||||
const type = feature.geometry.type;
|
||||
if (type === "GeometryCollection") continue;
|
||||
const coords = (feature.geometry as any).coordinates;
|
||||
const coords = (feature.geometry as GeometryWithCoordinates).coordinates;
|
||||
|
||||
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
|
||||
if (type === "Polygon") {
|
||||
for (const ring of coords) processLineString(ring);
|
||||
for (const ring of asCoordinateMatrix(coords)) processLineString(ring);
|
||||
} else if (type === "MultiPolygon") {
|
||||
for (const poly of coords) {
|
||||
for (const poly of asCoordinateTensor(coords)) {
|
||||
for (const ring of poly) processLineString(ring);
|
||||
}
|
||||
} else if (type === "LineString") {
|
||||
processLineString(coords);
|
||||
processLineString(asCoordinateArray(coords));
|
||||
} else if (type === "MultiLineString") {
|
||||
for (const line of coords) processLineString(line);
|
||||
for (const line of asCoordinateMatrix(coords)) processLineString(line);
|
||||
} else if (type === "Point") {
|
||||
processPoint(coords);
|
||||
} else if (type === "MultiPoint") {
|
||||
for (const point of asCoordinateArray(coords)) processPoint(point);
|
||||
}
|
||||
}
|
||||
|
||||
return nearestLngLat || lngLat;
|
||||
return nearestVertexLngLat || nearestEdgeLngLat || lngLat;
|
||||
}
|
||||
|
||||
function getSnapLayerIds(map: maplibregl.Map): string[] {
|
||||
const systemGeometrySources = new Set(["countries", "places", PATH_ARROW_SOURCE_ID]);
|
||||
const style = map.getStyle();
|
||||
if (!style?.layers?.length) return [];
|
||||
|
||||
return style.layers
|
||||
.filter((layer) => {
|
||||
if (!("source" in layer)) return false;
|
||||
if (!systemGeometrySources.has(String(layer.source))) return false;
|
||||
if (layer.id.includes("preview") || layer.id.includes("edit-")) return false;
|
||||
return true;
|
||||
})
|
||||
.map((layer) => layer.id)
|
||||
.filter((layerId) => Boolean(map.getLayer(layerId)));
|
||||
}
|
||||
|
||||
function toCoordinate(value: unknown): Coordinate | null {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lng = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
|
||||
return [lng, lat];
|
||||
}
|
||||
|
||||
function asCoordinateArray(value: unknown): number[][] {
|
||||
return Array.isArray(value) ? value as number[][] : [];
|
||||
}
|
||||
|
||||
function asCoordinateMatrix(value: unknown): number[][][] {
|
||||
return Array.isArray(value) ? value as number[][][] : [];
|
||||
}
|
||||
|
||||
function asCoordinateTensor(value: unknown): number[][][][] {
|
||||
return Array.isArray(value) ? value as number[][][][] : [];
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
{ "type_key": "trade_route", "geo_type_code": 7 },
|
||||
{ "type_key": "shipping_route", "geo_type_code": 8 },
|
||||
|
||||
{ "type_key": "country", "geo_type_code": 9 },
|
||||
{ "type_key": "country", "geo_type_code": 9, "fixed": true },
|
||||
{ "type_key": "state", "geo_type_code": 10 },
|
||||
{ "type_key": "empire", "geo_type_code": 11 },
|
||||
{ "type_key": "kingdom", "geo_type_code": 12 },
|
||||
{ "type_key": "faction", "geo_type_code": 28 },
|
||||
|
||||
{ "type_key": "war", "geo_type_code": 13 },
|
||||
{ "type_key": "battle", "geo_type_code": 14 },
|
||||
@@ -30,4 +31,3 @@
|
||||
{ "type_key": "port", "geo_type_code": 26 },
|
||||
{ "type_key": "bridge", "geo_type_code": 27 }
|
||||
]
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
|
||||
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "faction", label: "Faction", groupId: "polygon", geometryPreset: "polygon" },
|
||||
|
||||
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCountryLayers } from "./geotypes/country";
|
||||
import { getStateLayers } from "./geotypes/state";
|
||||
import { getEmpireLayers } from "./geotypes/empire";
|
||||
import { getKingdomLayers } from "./geotypes/kingdom";
|
||||
import { getFactionLayers } from "./geotypes/faction";
|
||||
import { getWarLayers } from "./geotypes/war";
|
||||
import { getBattleLayers } from "./geotypes/battle";
|
||||
import { getCivilizationLayers } from "./geotypes/civilization";
|
||||
@@ -40,6 +41,7 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
|
||||
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getKingdomLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getFactionLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getWarLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LayerSpecification } from "maplibre-gl";
|
||||
import { buildPolygonGeotypeLayers } from "../shared/styleBuilders";
|
||||
|
||||
export function getFactionLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||
void pathArrowSourceId;
|
||||
void pointSourceId;
|
||||
return buildPolygonGeotypeLayers(sourceId, {
|
||||
typeId: "faction",
|
||||
fillColor: "#f97316",
|
||||
strokeColor: "#9a3412",
|
||||
fillOpacity: 0.3,
|
||||
strokeWidth: { z1: 1.6, z4: 2.3, z6: 3.1 },
|
||||
dasharray: [2, 1.5],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user