draw path | draw area | localstorage layer state | add entities property parallel | timeline bar
This commit is contained in:
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