From 5ac5c4c0af6d9fe94da2ff1adebdc55d1d15ef90 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 7 Apr 2026 23:32:38 +0700 Subject: [PATCH] map layer management --- api/config.ts | 13 ++ api/geometries.ts | 83 +++++++++++ api/http.ts | 22 +++ api/tiles.ts | 20 +++ app/page.tsx | 66 ++++++--- components/BackgroundLayersPanel.tsx | 90 ++++++++++++ components/Map.tsx | 211 ++++++++++++++++++++++++--- lib/backgroundLayers.ts | 27 ++++ lib/drawingEngine.ts | 12 +- lib/pointEngine.ts | 15 +- 10 files changed, 514 insertions(+), 45 deletions(-) create mode 100644 api/config.ts create mode 100644 api/geometries.ts create mode 100644 api/http.ts create mode 100644 api/tiles.ts create mode 100644 components/BackgroundLayersPanel.tsx create mode 100644 lib/backgroundLayers.ts diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..605dcc2 --- /dev/null +++ b/api/config.ts @@ -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; diff --git a/api/geometries.ts b/api/geometries.ts new file mode 100644 index 0000000..22e1167 --- /dev/null +++ b/api/geometries.ts @@ -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 { + const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`; + return requestJson(url); +} + +export async function saveGeometryBatchChanges(changes: Change[]): Promise { + return requestJson(API_ENDPOINTS.geometriesBatch, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ changes }), + }); +} + +export async function createGeometry(payload: GeometryCreatePayload): Promise { + return requestJson(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", + }); +} diff --git a/api/http.ts b/api/http.ts new file mode 100644 index 0000000..ecb6650 --- /dev/null +++ b/api/http.ts @@ -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(input: RequestInfo | URL, init?: RequestInit): Promise { + 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; +} diff --git a/api/tiles.ts b/api/tiles.ts new file mode 100644 index 0000000..02b9085 --- /dev/null +++ b/api/tiles.ts @@ -0,0 +1,20 @@ +import { API_ENDPOINTS } from "@/api/config"; +import { requestJson } from "@/api/http"; + +export type TileMetadata = Record; + +export function getVectorTileTemplateUrl(): string { + return API_ENDPOINTS.vectorTiles; +} + +export function getRasterTileTemplateUrl(): string { + return API_ENDPOINTS.rasterTiles; +} + +export async function fetchVectorTilesMetadata(): Promise { + return requestJson(API_ENDPOINTS.vectorTilesMetadata); +} + +export async function fetchRasterTilesMetadata(): Promise { + return requestJson(API_ENDPOINTS.rasterTilesMetadata); +} diff --git a/app/page.tsx b/app/page.tsx index 4543a26..f27a542 100644 --- a/app/page.tsx +++ b/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(EMPTY_FC); const [isSaving, setIsSaving] = useState(false); + const [backgroundVisibility, setBackgroundVisibility] = useState( + () => ({ ...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 ( -
+
+ +
); diff --git a/components/BackgroundLayersPanel.tsx b/components/BackgroundLayersPanel.tsx new file mode 100644 index 0000000..7f0c0a4 --- /dev/null +++ b/components/BackgroundLayersPanel.tsx @@ -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 ( + + ); +} diff --git a/components/Map.tsx b/components/Map.tsx index 44f2b17..8f1a852 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -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(null); const modeRef = useRef(mode); const draftRef = useRef(draft); + const backgroundVisibilityRef = useRef(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
; } +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", diff --git a/lib/backgroundLayers.ts b/lib/backgroundLayers.ts new file mode 100644 index 0000000..9bfbf32 --- /dev/null +++ b/lib/backgroundLayers.ts @@ -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; + +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); diff --git a/lib/drawingEngine.ts b/lib/drawingEngine.ts index 4834341..15f0ae2 100644 --- a/lib/drawingEngine.ts +++ b/lib/drawingEngine.ts @@ -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: [], }); diff --git a/lib/pointEngine.ts b/lib/pointEngine.ts index 001e6a3..7165e5c 100644 --- a/lib/pointEngine.ts +++ b/lib/pointEngine.ts @@ -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 = ""; + } }; }