map layer management
This commit is contained in:
13
api/config.ts
Normal file
13
api/config.ts
Normal 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
83
api/geometries.ts
Normal 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
22
api/http.ts
Normal 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
20
api/tiles.ts
Normal 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);
|
||||
}
|
||||
66
app/page.tsx
66
app/page.tsx
@@ -3,10 +3,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Map from "@/components/Map";
|
||||
import Editor from "@/components/Editor";
|
||||
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges } from "@/api/geometries";
|
||||
import {
|
||||
FeatureCollection,
|
||||
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: [] };
|
||||
|
||||
@@ -14,22 +23,21 @@ export default function Page() {
|
||||
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle");
|
||||
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadInitial() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
minLng: "-180",
|
||||
minLat: "-90",
|
||||
maxLng: "180",
|
||||
maxLat: "90",
|
||||
const data = await fetchGeometriesByBBox({
|
||||
minLng: -180,
|
||||
minLat: -90,
|
||||
maxLng: 180,
|
||||
maxLat: 90,
|
||||
});
|
||||
|
||||
const res = await fetch(`http://localhost:3000/geometries?${params.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setInitialData(data);
|
||||
} catch (err) {
|
||||
console.error("Load initial data failed", err);
|
||||
@@ -44,26 +52,36 @@ export default function Page() {
|
||||
if (!payload.length) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/geometries/batch", {
|
||||
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;
|
||||
}
|
||||
await saveGeometryBatchChanges(payload);
|
||||
editor.clearChanges();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Save failed", err.body);
|
||||
return;
|
||||
}
|
||||
console.error("Save error", err);
|
||||
} finally {
|
||||
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 (
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Editor
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
@@ -80,6 +98,14 @@ export default function Page() {
|
||||
onCreateFeature={editor.createFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
onToggleLayer={handleToggleBackgroundLayer}
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
90
components/BackgroundLayersPanel.tsx
Normal file
90
components/BackgroundLayersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,24 +4,35 @@ import { useEffect, useRef, useCallback } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import { getRasterTileTemplateUrl, getVectorTileTemplateUrl } from "@/api/tiles";
|
||||
import { initDrawing } from "@/lib/drawingEngine";
|
||||
import { initSelect } from "@/lib/selectingEngine";
|
||||
import { initPoint } from "@/lib/pointEngine";
|
||||
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";
|
||||
draft: FeatureCollection;
|
||||
backgroundVisibility: BackgroundLayerVisibility;
|
||||
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
||||
onDeleteFeature: (id: string | number) => 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 modeRef = useRef<MapProps["mode"]>(mode);
|
||||
const draftRef = useRef<FeatureCollection>(draft);
|
||||
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
|
||||
const onCreateRef = useRef(onCreateFeature);
|
||||
const onDeleteRef = useRef(onDeleteFeature);
|
||||
const onUpdateRef = useRef(onUpdateFeature);
|
||||
@@ -36,6 +47,13 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
backgroundVisibilityRef.current = backgroundVisibility;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibility);
|
||||
}, [backgroundVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
onCreateRef.current = onCreateFeature;
|
||||
}, [onCreateFeature]);
|
||||
@@ -86,9 +104,16 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
rasterBase: {
|
||||
type: "raster",
|
||||
tiles: [getRasterTileTemplateUrl()],
|
||||
tileSize: 256,
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
base: {
|
||||
type: "vector",
|
||||
tiles: ["http://localhost:3000/tiles/{z}/{x}/{y}"],
|
||||
tiles: [getVectorTileTemplateUrl()],
|
||||
minzoom: 0,
|
||||
maxzoom: 6,
|
||||
},
|
||||
@@ -101,6 +126,33 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
"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",
|
||||
type: "fill",
|
||||
@@ -108,6 +160,92 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
"source-layer": "land",
|
||||
paint: {
|
||||
"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;
|
||||
|
||||
map.on("load", async () => {
|
||||
const placesMinZoom = 5;
|
||||
|
||||
applyBackgroundLayerVisibility(map, backgroundVisibilityRef.current);
|
||||
|
||||
// preview (drawing)
|
||||
map.addSource("draw-preview", {
|
||||
type: "geojson",
|
||||
@@ -224,27 +366,46 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
},
|
||||
});
|
||||
|
||||
// load icon từ /public
|
||||
map.loadImage("/point.png", (err, image) => {
|
||||
if (err) throw err;
|
||||
|
||||
if (!map.hasImage("point-icon")) {
|
||||
map.addImage("point-icon", image);
|
||||
}
|
||||
});
|
||||
|
||||
// layer
|
||||
// fallback layer so points are still visible even if icon cannot be loaded
|
||||
map.addLayer({
|
||||
id: "places-symbol",
|
||||
type: "symbol",
|
||||
id: "places-circle",
|
||||
type: "circle",
|
||||
source: "places",
|
||||
layout: {
|
||||
"icon-image": "point-icon",
|
||||
"icon-size": 0.5,
|
||||
"icon-anchor": "bottom",
|
||||
minzoom: placesMinZoom,
|
||||
paint: {
|
||||
"circle-color": "#ef4444",
|
||||
"circle-radius": 3,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// init drawing
|
||||
const cleanup = initDrawing(
|
||||
map,
|
||||
@@ -313,6 +474,20 @@ export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onU
|
||||
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) {
|
||||
const polygons = {
|
||||
type: "FeatureCollection",
|
||||
|
||||
27
lib/backgroundLayers.ts
Normal file
27
lib/backgroundLayers.ts
Normal 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);
|
||||
@@ -35,6 +35,7 @@ export function initDrawing(
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [closed],
|
||||
@@ -47,14 +48,17 @@ export function initDrawing(
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
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);
|
||||
}
|
||||
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -64,7 +68,7 @@ export function initDrawing(
|
||||
function finishDrawing() {
|
||||
if (getMode() !== "draw" || coords.length < 3) return;
|
||||
|
||||
const geometry = {
|
||||
const geometry: Geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [closePolygon(coords)],
|
||||
};
|
||||
@@ -73,7 +77,7 @@ export function initDrawing(
|
||||
|
||||
coords = [];
|
||||
|
||||
map.getSource("draw-preview").setData({
|
||||
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export function initPoint(
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
const geometry = {
|
||||
const geometry: Geometry = {
|
||||
type: "Point",
|
||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||
};
|
||||
@@ -23,8 +23,14 @@ export function initPoint(
|
||||
}
|
||||
|
||||
function onMove() {
|
||||
if (getMode() !== "add-point") return;
|
||||
map.getCanvas().style.cursor = "crosshair";
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() === "add-point") {
|
||||
canvas.style.cursor = "crosshair";
|
||||
return;
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
}
|
||||
|
||||
map.on("click", onClick);
|
||||
@@ -33,5 +39,8 @@ export function initPoint(
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user