map layer management

This commit is contained in:
taDuc
2026-04-07 23:32:38 +07:00
parent 2a1b4f2f2a
commit 5ac5c4c0af
10 changed files with 514 additions and 45 deletions

13
api/config.ts Normal file
View File

@@ -0,0 +1,13 @@
const FALLBACK_API_BASE_URL = "http://localhost:3000";
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`,
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,
rasterTilesMetadata: `${API_BASE_URL}/raster-tiles/metadata/info`,
} as const;

83
api/geometries.ts Normal file
View File

@@ -0,0 +1,83 @@
import { API_ENDPOINTS } from "@/api/config";
import { requestJson } from "@/api/http";
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
export type GeometriesBBoxQuery = {
minLng: number;
minLat: number;
maxLng: number;
maxLat: number;
time?: number;
};
export type GeometryCreatePayload = {
geometry: Geometry;
time_start?: number | null;
time_end?: number | null;
kind?: string | null;
};
export type GeometryUpdatePayload = {
geometry: Geometry;
time_start?: number | null;
time_end?: number | null;
};
export type GeometryCreateResponse = {
id: string;
};
export type BatchSaveResponse = {
success: boolean;
applied: number;
};
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({
minLng: String(params.minLng),
minLat: String(params.minLat),
maxLng: String(params.maxLng),
maxLat: String(params.maxLat),
});
if (params.time !== undefined) {
query.set("time", String(params.time));
}
return query.toString();
}
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
return requestJson<FeatureCollection>(url);
}
export async function saveGeometryBatchChanges(changes: Change[]): Promise<BatchSaveResponse> {
return requestJson<BatchSaveResponse>(API_ENDPOINTS.geometriesBatch, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
}
export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> {
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function updateGeometry(id: string | number, payload: GeometryUpdatePayload): Promise<{ success: boolean }> {
return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function deleteGeometry(id: string | number): Promise<{ success: boolean }> {
return requestJson<{ success: boolean }>(`${API_ENDPOINTS.geometries}/${id}`, {
method: "DELETE",
});
}

22
api/http.ts Normal file
View File

@@ -0,0 +1,22 @@
export class ApiError extends Error {
status: number;
body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
export async function requestJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const res = await fetch(input, init);
if (!res.ok) {
const text = await res.text();
throw new ApiError(`Request failed with status ${res.status}`, res.status, text);
}
return (await res.json()) as T;
}

20
api/tiles.ts Normal file
View File

@@ -0,0 +1,20 @@
import { API_ENDPOINTS } from "@/api/config";
import { requestJson } from "@/api/http";
export type TileMetadata = Record<string, string>;
export function getVectorTileTemplateUrl(): string {
return API_ENDPOINTS.vectorTiles;
}
export function getRasterTileTemplateUrl(): string {
return API_ENDPOINTS.rasterTiles;
}
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
}
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
}

View File

@@ -3,10 +3,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Map from "@/components/Map"; import Map from "@/components/Map";
import Editor from "@/components/Editor"; import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges } from "@/api/geometries";
import { import {
FeatureCollection, FeatureCollection,
useEditorState, useEditorState,
} from "@/lib/useEditorState"; } from "@/lib/useEditorState";
import {
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
@@ -14,22 +23,21 @@ export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle"); const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC); const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })
);
const editor = useEditorState(initialData); const editor = useEditorState(initialData);
useEffect(() => { useEffect(() => {
async function loadInitial() { async function loadInitial() {
try { try {
const params = new URLSearchParams({ const data = await fetchGeometriesByBBox({
minLng: "-180", minLng: -180,
minLat: "-90", minLat: -90,
maxLng: "180", maxLng: 180,
maxLat: "90", maxLat: 90,
}); });
const res = await fetch(`http://localhost:3000/geometries?${params.toString()}`);
if (!res.ok) return;
const data = await res.json();
setInitialData(data); setInitialData(data);
} catch (err) { } catch (err) {
console.error("Load initial data failed", err); console.error("Load initial data failed", err);
@@ -44,26 +52,36 @@ export default function Page() {
if (!payload.length) return; if (!payload.length) return;
setIsSaving(true); setIsSaving(true);
try { try {
const res = await fetch("http://localhost:3000/geometries/batch", { await saveGeometryBatchChanges(payload);
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes: payload }),
});
if (!res.ok) {
const text = await res.text();
console.error("Save failed", text);
return;
}
editor.clearChanges(); editor.clearChanges();
} catch (err) { } catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
return;
}
console.error("Save error", err); console.error("Save error", err);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
setBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAllBackgroundLayers = () => {
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY });
};
const handleHideAllBackgroundLayers = () => {
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
};
return ( return (
<div style={{ display: "flex" }}> <div style={{ display: "flex", minHeight: "100vh" }}>
<Editor <Editor
mode={mode} mode={mode}
setMode={setMode} setMode={setMode}
@@ -80,6 +98,14 @@ export default function Page() {
onCreateFeature={editor.createFeature} onCreateFeature={editor.createFeature}
onDeleteFeature={editor.deleteFeature} onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
/>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,90 @@
"use client";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
} from "@/lib/backgroundLayers";
type Props = {
visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
};
export default function BackgroundLayersPanel({
visibility,
onToggleLayer,
onShowAll,
onHideAll,
}: Props) {
return (
<aside
style={{
width: "240px",
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
padding: "12px",
height: "100vh",
overflowY: "auto",
}}
>
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
<button
onClick={onShowAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#374151",
color: "#f9fafb",
}}
>
Bật hết
</button>
<button
onClick={onHideAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#1f2937",
color: "#f9fafb",
}}
>
Tắt hết
</button>
</div>
<div style={{ display: "grid", gap: "8px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => (
<label
key={layer.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={visibility[layer.id]}
onChange={() => onToggleLayer(layer.id)}
/>
<span>{layer.label}</span>
</label>
))}
</div>
</aside>
);
}

View File

@@ -4,24 +4,35 @@ import { useEffect, useRef, useCallback } from "react";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
import { initDrawing } from "@/lib/drawingEngine"; import { initDrawing } from "@/lib/drawingEngine";
import { initSelect } from "@/lib/selectingEngine"; import { initSelect } from "@/lib/selectingEngine";
import { initPoint } from "@/lib/pointEngine"; import { initPoint } from "@/lib/pointEngine";
import { createEditingEngine } from "@/lib/editingEngine"; import { createEditingEngine } from "@/lib/editingEngine";
import { FeatureCollection, Geometry } from "@/lib/useEditorState"; import { FeatureCollection, Geometry } from "@/lib/useEditorState";
import { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility } from "@/lib/backgroundLayers";
type MapProps = { type MapProps = {
mode: "idle" | "draw" | "select" | "add-point"; mode: "idle" | "draw" | "select" | "add-point";
draft: FeatureCollection; draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
onCreateFeature: (feature: FeatureCollection["features"][number]) => void; onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature: (id: string | number) => void; onDeleteFeature: (id: string | number) => void;
onUpdateFeature: (id: string | number, geometry: Geometry) => void; onUpdateFeature: (id: string | number, geometry: Geometry) => void;
}; };
export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onUpdateFeature }: MapProps) { export default function Map({
mode,
draft,
backgroundVisibility,
onCreateFeature,
onDeleteFeature,
onUpdateFeature,
}: MapProps) {
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const onCreateRef = useRef(onCreateFeature); const onCreateRef = useRef(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature); const onDeleteRef = useRef(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature); const onUpdateRef = useRef(onUpdateFeature);
@@ -36,6 +47,13 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
draftRef.current = draft; draftRef.current = draft;
}, [draft]); }, [draft]);
useEffect(() => {
backgroundVisibilityRef.current = backgroundVisibility;
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [backgroundVisibility]);
useEffect(() => { useEffect(() => {
onCreateRef.current = onCreateFeature; onCreateRef.current = onCreateFeature;
}, [onCreateFeature]); }, [onCreateFeature]);
@@ -86,9 +104,16 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
style: { style: {
version: 8, version: 8,
sources: { sources: {
rasterBase: {
type: "raster",
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
},
base: { base: {
type: "vector", type: "vector",
tiles: ["http://localhost:3000/tiles/{z}/{x}/{y}"], tiles: [getVectorTileTemplateUrl()],
minzoom: 0, minzoom: 0,
maxzoom: 6, maxzoom: 6,
}, },
@@ -101,6 +126,33 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
"background-color": "#0b1220", "background-color": "#0b1220",
}, },
}, },
{
id: "raster-base-layer",
type: "raster",
source: "rasterBase",
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear",
},
},
{
id: "graticules-line",
type: "line",
source: "base",
"source-layer": "graticules",
paint: {
"line-color": "#334155",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.3,
4, 0.6,
6, 0.8,
],
"line-opacity": 0.55,
},
},
{ {
id: "land", id: "land",
type: "fill", type: "fill",
@@ -108,6 +160,92 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
"source-layer": "land", "source-layer": "land",
paint: { paint: {
"fill-color": "#1e293b", "fill-color": "#1e293b",
"fill-opacity": 0.25,
},
},
{
id: "bg-countries-fill",
type: "fill",
source: "base",
"source-layer": "countries",
paint: {
"fill-color": "#334155",
"fill-opacity": 0.28,
},
},
{
id: "bg-country-borders-line",
type: "line",
source: "base",
"source-layer": "country_borders",
paint: {
"line-color": "#cbd5e1",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.2,
4, 0.5,
6, 1.1,
],
"line-opacity": 0.85,
},
},
{
id: "regions-line",
type: "line",
source: "base",
"source-layer": "regions",
paint: {
"line-color": "#475569",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.2,
4, 0.6,
6, 1,
],
"line-opacity": 0.6,
},
},
{
id: "lakes-fill",
type: "fill",
source: "base",
"source-layer": "lakes",
paint: {
"fill-color": "#1d4ed8",
"fill-opacity": 0.45,
},
},
{
id: "rivers-line",
type: "line",
source: "base",
"source-layer": "rivers",
paint: {
"line-color": "#38bdf8",
"line-width": [
"interpolate",
["linear"],
["zoom"],
0, 0.25,
4, 0.8,
6, 1.5,
],
"line-opacity": 0.85,
},
},
{
id: "geolines-line",
type: "line",
source: "base",
"source-layer": "geolines",
paint: {
"line-color": "#94a3b8",
"line-width": 1.2,
"line-opacity": 0.8,
}, },
}, },
], ],
@@ -119,6 +257,10 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
mapRef.current = map; mapRef.current = map;
map.on("load", async () => { map.on("load", async () => {
const placesMinZoom = 5;
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
// preview (drawing) // preview (drawing)
map.addSource("draw-preview", { map.addSource("draw-preview", {
type: "geojson", type: "geojson",
@@ -224,26 +366,45 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
}, },
}); });
// load icon từ /public // fallback layer so points are still visible even if icon cannot be loaded
map.loadImage("/point.png", (err, image) => { map.addLayer({
if (err) throw err; id: "places-circle",
type: "circle",
if (!map.hasImage("point-icon")) { source: "places",
map.addImage("point-icon", image); minzoom: placesMinZoom,
} paint: {
"circle-color": "#ef4444",
"circle-radius": 3,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
},
}); });
// layer // 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({ map.addLayer({
id: "places-symbol", id: "places-symbol",
type: "symbol", type: "symbol",
source: "places", source: "places",
minzoom: placesMinZoom,
layout: { layout: {
"icon-image": "point-icon", "icon-image": "point-icon",
"icon-size": 0.5, "icon-size": 0.25,
"icon-anchor": "bottom", "icon-anchor": "bottom",
}, },
}); });
}
map.setLayoutProperty("places-circle", "visibility", "none");
} catch (err) {
console.warn("Failed to load point icon, using circle fallback.", err);
}
// init drawing // init drawing
const cleanup = initDrawing( const cleanup = initDrawing(
@@ -313,6 +474,20 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
return <div id="map" style={{ flex: 1, height: "100vh" }} />; return <div id="map" style={{ flex: 1, height: "100vh" }} />;
} }
function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
) {
for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (!map.getLayer(layer.id)) continue;
map.setLayoutProperty(
layer.id,
"visibility",
visibility[layer.id] ? "visible" : "none"
);
}
}
function splitDraftFeatures(fc: FeatureCollection) { function splitDraftFeatures(fc: FeatureCollection) {
const polygons = { const polygons = {
type: "FeatureCollection", type: "FeatureCollection",

27
lib/backgroundLayers.ts Normal file
View File

@@ -0,0 +1,27 @@
export const BACKGROUND_LAYER_OPTIONS = [
{ id: "raster-base-layer", label: "Raster" },
{ id: "graticules-line", label: "Graticules" },
{ id: "land", label: "Land" },
{ id: "bg-countries-fill", label: "Countries" },
{ id: "bg-country-borders-line", label: "Country Borders" },
{ id: "regions-line", label: "Regions" },
{ id: "lakes-fill", label: "Lakes" },
{ id: "rivers-line", label: "Rivers" },
{ id: "geolines-line", label: "Geolines" },
] as const;
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
acc[option.id] = value;
return acc;
}, {} as BackgroundLayerVisibility);
}
export const DEFAULT_BACKGROUND_LAYER_VISIBILITY =
buildBackgroundLayerVisibility(true);
export const HIDDEN_BACKGROUND_LAYER_VISIBILITY =
buildBackgroundLayerVisibility(false);

View File

@@ -35,6 +35,7 @@ export function initDrawing(
features: [ features: [
{ {
type: "Feature", type: "Feature",
properties: {},
geometry: { geometry: {
type: "Polygon", type: "Polygon",
coordinates: [closed], coordinates: [closed],
@@ -47,14 +48,17 @@ export function initDrawing(
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw") return; if (getMode() !== "draw") return;
coords.push([e.lngLat.lng, e.lngLat.lat]); coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
update(coords); update(coords);
} }
function onMove(e: maplibregl.MapLayerMouseEvent) { function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw" || coords.length === 0) return; if (getMode() !== "draw" || coords.length === 0) return;
const preview = [...coords, [e.lngLat.lng, e.lngLat.lat]]; const preview: [number, number][] = [
...coords,
[e.lngLat.lng, e.lngLat.lat] as [number, number],
];
update(preview); update(preview);
} }
@@ -64,7 +68,7 @@ export function initDrawing(
function finishDrawing() { function finishDrawing() {
if (getMode() !== "draw" || coords.length < 3) return; if (getMode() !== "draw" || coords.length < 3) return;
const geometry = { const geometry: Geometry = {
type: "Polygon", type: "Polygon",
coordinates: [closePolygon(coords)], coordinates: [closePolygon(coords)],
}; };
@@ -73,7 +77,7 @@ export function initDrawing(
coords = []; coords = [];
map.getSource("draw-preview").setData({ (map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection", type: "FeatureCollection",
features: [], features: [],
}); });

View File

@@ -14,7 +14,7 @@ export function initPoint(
function onClick(e: maplibregl.MapLayerMouseEvent) { function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return; if (getMode() !== "add-point") return;
const geometry = { const geometry: Geometry = {
type: "Point", type: "Point",
coordinates: [e.lngLat.lng, e.lngLat.lat], coordinates: [e.lngLat.lng, e.lngLat.lat],
}; };
@@ -23,8 +23,14 @@ export function initPoint(
} }
function onMove() { function onMove() {
if (getMode() !== "add-point") return; const canvas = map.getCanvas();
map.getCanvas().style.cursor = "crosshair"; if (getMode() === "add-point") {
canvas.style.cursor = "crosshair";
return;
}
if (canvas.style.cursor === "crosshair") {
canvas.style.cursor = "";
}
} }
map.on("click", onClick); map.on("click", onClick);
@@ -33,5 +39,8 @@ export function initPoint(
return () => { return () => {
map.off("click", onClick); map.off("click", onClick);
map.off("mousemove", onMove); map.off("mousemove", onMove);
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
}; };
} }