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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
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 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
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: [
|
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: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user