add vector mbtiles

This commit is contained in:
parent c3fe22fb4a
commit 60fa1eec06
10 changed files with 628 additions and 18 deletions

View file

@ -146,6 +146,18 @@ body, #root {
overflow: hidden;
}
.maplibre {
height: 70vh;
border-radius: 12px;
overflow: hidden;
}
.map-switch {
display: flex;
gap: 10px;
padding: 12px;
}
.empty {
padding: 24px;
text-align: center;

View file

@ -3,6 +3,7 @@ import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet
import L, { LatLngExpression } from "leaflet";
import "./App.css";
import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css";
type FixMessage = {
type: "fix";
@ -27,6 +28,8 @@ type IncomingMessage = FixMessage | StatusMessage;
type AppConfig = {
tiles_url: string;
tiles_url_tms?: string;
vector_tiles_url?: string;
vector_metadata_url?: string;
ws_url: string;
gnss_source: string;
};
@ -73,6 +76,7 @@ export default function App() {
const [status, setStatus] = useState<string>("connecting");
const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
const [selectedFileName, setSelectedFileName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sliceParams, setSliceParams] = useState({
@ -85,6 +89,8 @@ export default function App() {
source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz",
});
const [sliceStatus, setSliceStatus] = useState<string>("idle");
const maplibreContainerRef = useRef<HTMLDivElement | null>(null);
const maplibreRef = useRef<any>(null);
const uploadFileBlob = async (file: File) => {
setSliceStatus("uploading");
@ -100,6 +106,11 @@ export default function App() {
setSelectedFileName(res.filename);
setSliceStatus("idle");
sendFrontLog(`uploaded source ${res.filename}`, "info");
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
activateVectorMbtiles(res.path).catch((err) => {
sendFrontLog(`vector activate failed: ${err}`, "warn");
});
}
})
.catch((err) => {
setSliceStatus("error");
@ -201,6 +212,181 @@ export default function App() {
: config.tiles_url || "/tiles/{z}/{x}/{y}.png"
}`;
const vectorTileUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`;
const vectorMetaUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`;
const activateVectorMbtiles = async (path: string) => {
const res = await fetch(`${backendBase}/tiles/vector/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`vector mbtiles activated: ${path}`, "info");
setMapMode("vector");
};
useEffect(() => {
if (mapMode !== "vector") return;
if (!maplibreContainerRef.current) return;
if (!vectorMetaUrl || !vectorTileUrl) return;
let canceled = false;
(async () => {
const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" });
if (!metaRes.ok) return;
const meta = await metaRes.json();
const vectorLayersRaw = meta.vector_layers || meta["json"] || null;
let sourceLayers: string[] = [];
try {
const parsed = typeof vectorLayersRaw === "string" ? JSON.parse(vectorLayersRaw) : vectorLayersRaw;
const layers = parsed?.vector_layers ?? parsed?.layers ?? parsed ?? [];
if (Array.isArray(layers)) {
sourceLayers = layers.map((l: any) => l.id).filter(Boolean);
}
} catch {
sourceLayers = [];
}
const { default: maplibregl } = await import("maplibre-gl");
if (canceled) return;
if (maplibreRef.current) {
try {
maplibreRef.current.remove();
} catch {}
maplibreRef.current = null;
}
const style: any = {
version: 8,
sources: {
osm: {
type: "vector",
tiles: [vectorTileUrl],
minzoom: Number(meta.minzoom ?? 0),
maxzoom: Number(meta.maxzoom ?? 14),
},
track: {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
},
},
layers: [
{ id: "bg", type: "background", paint: { "background-color": "#050816" } },
],
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
};
for (const id of sourceLayers) {
style.layers.push({
id: `fill-${id}`,
type: "fill",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#1f2937", "fill-opacity": 0.35 },
});
style.layers.push({
id: `linepoly-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "line-color": "#334155", "line-width": 1 },
});
style.layers.push({
id: `line-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "LineString"],
paint: { "line-color": "#475569", "line-width": 1.2 },
});
style.layers.push({
id: `point-${id}`,
type: "circle",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Point"],
paint: { "circle-radius": 2, "circle-color": "#94a3b8", "circle-opacity": 0.8 },
});
}
style.layers.push(
{
id: "track-line",
type: "line",
source: "track",
paint: { "line-color": "#7dd3fc", "line-width": 4, "line-opacity": 0.9 },
},
{
id: "track-point",
type: "circle",
source: "track",
paint: { "circle-radius": 6, "circle-color": "#a78bfa", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 },
},
);
const map = new maplibregl.Map({
container: maplibreContainerRef.current!,
style,
center: [37.618423, 55.751244],
zoom: 11,
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right");
map.on("load", () => {
const bounds = meta.bounds;
if (typeof bounds === "string") {
const parts = bounds.split(",").map((p: string) => Number(p.trim()));
if (parts.length === 4 && parts.every((n: number) => Number.isFinite(n))) {
map.fitBounds(
[
[parts[0], parts[1]],
[parts[2], parts[3]],
],
{ padding: 30, duration: 0 },
);
}
}
});
maplibreRef.current = map;
})().catch((err) => {
sendFrontLog(`maplibre init error: ${err}`, "error");
});
return () => {
canceled = true;
};
}, [mapMode, vectorMetaUrl, vectorTileUrl]);
useEffect(() => {
if (mapMode !== "vector") return;
const map = maplibreRef.current;
if (!map) return;
const geojson = {
type: "FeatureCollection",
features: [
positions.length
? { type: "Feature", geometry: { type: "LineString", coordinates: positions.map((p: any) => [p[1], p[0]]) } }
: null,
lastPoint ? { type: "Feature", geometry: { type: "Point", coordinates: [lastPoint.lon, lastPoint.lat] } } : null,
].filter(Boolean),
};
try {
const src = map.getSource("track");
if (src && src.setData) {
src.setData(geojson);
}
} catch {}
}, [mapMode, positions, lastPoint]);
return (
<div className="layout">
<header className="topbar">
@ -236,19 +422,39 @@ export default function App() {
</section>
<section className="panel map-panel">
{tileUrl ? (
<MapContainer center={defaultCenter} zoom={13} scrollWheelZoom className="map">
<TileLayer url={tileUrl} attribution="Local tiles" tms={sliceParams.scheme === "tms"} />
{positions.length > 0 && (
<>
<Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 5, opacity: 0.9 }} />
<Marker position={positions[positions.length - 1]} icon={markerIcon} />
</>
)}
<FlyTo point={lastPoint} />
</MapContainer>
<div className="map-switch">
<button
type="button"
className={`btn ${mapMode === "raster" ? "primary" : "ghost"}`}
onClick={() => setMapMode("raster")}
>
Raster
</button>
<button
type="button"
className={`btn ${mapMode === "vector" ? "primary" : "ghost"}`}
onClick={() => setMapMode("vector")}
>
Vector (MBTiles)
</button>
</div>
{mapMode === "raster" ? (
tileUrl ? (
<MapContainer center={defaultCenter} zoom={13} scrollWheelZoom className="map">
<TileLayer url={tileUrl} attribution="Local tiles" tms={sliceParams.scheme === "tms"} />
{positions.length > 0 && (
<>
<Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 5, opacity: 0.9 }} />
<Marker position={positions[positions.length - 1]} icon={markerIcon} />
</>
)}
<FlyTo point={lastPoint} />
</MapContainer>
) : (
<div className="empty">Нет конфигурации тайлов</div>
)
) : (
<div className="empty">Нет конфигурации тайлов</div>
<div className="maplibre" ref={maplibreContainerRef} />
)}
</section>