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

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 "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",