import React, {useEffect, useState} from "react"; import { APIButton, ErrorMessage, HandPointMessage, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage } from "./Common"; import {Route, Routes, useNavigate} from "react-router"; import { deleteMapFile, deleteMapPin, fetchMapSubscriptions, getMapFiles, getMapPins, mapSubscribe, mapUnsubscribe, rebuildMapSearchIndex, updateMapPin } from "../api"; import {Button, Divider, Header, Icon, Modal, Table, TextInput} from "./ui"; import {SortableTable} from "./SortableTable"; import {Media, StatusContext} from "../contexts/contexts"; import {MAP_VIEWER_URI} from "./Vars"; import MapViewer from "./MapViewer"; import {AddToPlaylistButton} from "./AddToPlaylist"; import maplibregl from "maplibre-gl"; import {Protocol} from "pmtiles"; import layers from "protomaps-themes-base"; // Ensure PMTiles protocol is registered (may already be registered by MapViewer). try { maplibregl.addProtocol("pmtiles", new Protocol().tile); } catch (e) { /* already registered */ } const MAP_OVERVIEW_URL = "/blobs/map-overview.pmtiles"; // Detect full planet files: "planet-20260329.pmtiles" or bare date like "20260329.pmtiles". function isPlanetFile(name) { if (name.startsWith("planet-")) return true; return /^\d{8}\.pmtiles$/.test(name); } // Resolve the best available base map source for preview maps. // Checks for a user's planet file first, falls back to the overview blob. async function getPreviewMapSource() { try { const data = await getMapFiles(); if (data?.files) { for (const f of data.files) { if (isPlanetFile(f.name)) { return {type: 'vector', url: `pmtiles:///media/map/${f.name}`}; } } } } catch (e) { /* ignore */ } return {type: 'vector', url: `pmtiles://${MAP_OVERVIEW_URL}`}; } function RegionPreviewModal({bbox, name, open, onClose}) { const mapContainer = React.useRef(null); React.useEffect(() => { if (!open || !mapContainer.current || !bbox) return; const [minLon, minLat, maxLon, maxLat] = bbox.split(',').map(Number); const centerLon = (minLon + maxLon) / 2; const centerLat = (minLat + maxLat) / 2; let map; getPreviewMapSource().then(source => { if (!mapContainer.current) return; map = new maplibregl.Map({ container: mapContainer.current, style: { version: 8, sources: {'basemap': source}, layers: layers('basemap', 'light'), glyphs: '/map-assets/fonts/{fontstack}/{range}.pbf', sprite: `${window.location.origin}/map-assets/sprites/light`, }, center: [centerLon, centerLat], zoom: 1, attributionControl: false, }); map.on('load', () => { map.addSource('bbox', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[ [minLon, minLat], [maxLon, minLat], [maxLon, maxLat], [minLon, maxLat], [minLon, minLat], ]], }, }, }); map.addLayer({ id: 'bbox-fill', type: 'fill', source: 'bbox', paint: {'fill-color': '#6435c9', 'fill-opacity': 0.2}, }); map.addLayer({ id: 'bbox-outline', type: 'line', source: 'bbox', paint: {'line-color': '#6435c9', 'line-width': 2}, }); map.fitBounds([[minLon, minLat], [maxLon, maxLat]], {padding: 40}); }); }); return () => { if (map) map.remove(); }; }, [open, bbox]); return {/* No `.media` here: the bbox highlight is drawn as a map layer on the canvas itself (see the `bbox` source/layers above), so the automatic `canvas` element-type filter already covers it. There is no separate DOM overlay to wrap. */}
; } // Random-ish distinct colors for region boxes. These are map-data paint colors (drawn // on the WebGL canvas itself), not UI chrome, so they are not theme tokens. const REGION_COLORS = [ '#6435c9', '#e03997', '#00b5ad', '#2185d0', '#f2711c', '#b5cc18', '#a333c8', '#21ba45', '#db2828', '#fbbd08', '#a5673f', '#767676', ]; function AllRegionsPreviewModal({catalog, open, onClose}) { const mapContainer = React.useRef(null); React.useEffect(() => { if (!open || !mapContainer.current) return; let map; getPreviewMapSource().then(source => { if (!mapContainer.current) return; map = new maplibregl.Map({ container: mapContainer.current, style: { version: 8, sources: {'basemap': source}, layers: layers('basemap', 'light'), glyphs: '/map-assets/fonts/{fontstack}/{range}.pbf', sprite: `${window.location.origin}/map-assets/sprites/light`, }, center: [0, 20], zoom: 1, attributionControl: false, }); map.on('load', () => { const regions = catalog.filter(r => r.bbox); regions.forEach((region, i) => { const [minLon, minLat, maxLon, maxLat] = region.bbox.split(',').map(Number); const color = REGION_COLORS[i % REGION_COLORS.length]; const id = `region-${i}`; map.addSource(id, { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [[ [minLon, minLat], [maxLon, minLat], [maxLon, maxLat], [minLon, maxLat], [minLon, minLat], ]], }, properties: {name: region.name}, }, }); map.addLayer({ id: `${id}-fill`, type: 'fill', source: id, paint: {'fill-color': color, 'fill-opacity': 0.15}, }); map.addLayer({ id: `${id}-outline`, type: 'line', source: id, paint: {'line-color': color, 'line-width': 1.5}, }); map.addLayer({ id: `${id}-label`, type: 'symbol', source: id, layout: { 'text-field': ['get', 'name'], 'text-size': 11, 'text-allow-overlap': true, }, paint: { 'text-color': color, 'text-halo-color': 'white', 'text-halo-width': 1, }, }); }); }); }); return () => { if (map) map.remove(); }; }, [open, catalog]); return {/* No `.media` here either — same reasoning as RegionPreviewModal. */}
; } function MapCatalogRow({item, subscribedRegions, fetchData}) { const {name, region, size_estimate, bbox} = item; const isSubscribed = subscribedRegions.has(region); const [previewOpen, setPreviewOpen] = useState(false); const handleButton = async () => { if (isSubscribed) { await mapUnsubscribe(region); } else { await mapSubscribe(name, region); } if (fetchData) { await fetchData(); } }; return {name} {humanFileSize(size_estimate)} {bbox && {previewOpen && setPreviewOpen(false)} />} ; } function ManageMap() { const [files, setFiles] = useState(null); const [catalog, setCatalog] = useState([]); const [subscriptions, setSubscriptions] = useState([]); const [allRegionsOpen, setAllRegionsOpen] = useState(false); const {status} = React.useContext(StatusContext); const searchBuilding = status?.flags?.map_search_building; const fetchFiles = async () => { try { const data = await getMapFiles(); if (data) { setFiles(data.files || []); } } catch (e) { console.error(e); setFiles(undefined); } }; const fetchSubscriptionData = async () => { try { const data = await fetchMapSubscriptions(); if (data) { setCatalog(data.catalog || []); setSubscriptions(data.subscriptions || []); } } catch (e) { console.error(e); } }; useEffect(() => { fetchFiles(); fetchSubscriptionData(); const interval = setInterval(fetchFiles, 30000); return () => clearInterval(interval); }, []); const handleDelete = async (filename) => { await deleteMapFile(filename); await fetchFiles(); }; // --- Files table --- const fileHeaders = [ {key: 'name', text: 'File', sortBy: i => i.name.toLowerCase()}, {key: 'size', text: 'Size', sortBy: i => i.size || 0}, {key: 'search', text: 'Search', sortBy: null}, {key: 'actions', text: '', sortBy: null}, ]; const handleBuildSearch = async (filename) => { await rebuildMapSearchIndex(filename); await fetchFiles(); }; const fileRowFunc = (file) => { return {file.name} {humanFileSize(file.size)} {file.name.startsWith('terrain') ? null : file.has_search_index ? : handleBuildSearch(file.name)}> {searchBuilding ? 'Building...' : 'Build'} } handleDelete(file.name)} > Delete ; }; const emptyRow = No PMTiles map files were found in map/ ; // --- Catalog table --- const catalogHeaders = [ {key: 'name', text: 'Region', sortBy: i => i.name.toLowerCase()}, {key: 'size', text: 'Estimated Size', sortBy: i => i.size_estimate || 0}, {key: 'actions', text: '', sortBy: null}, ]; const catalogRowFunc = (item) => { return s.region))} fetchData={fetchSubscriptionData} />; }; if (files === undefined) { return Could not fetch map files ; } return
Map Files
Map Subscriptions
setAllRegionsOpen(false)}/>
How to get map files

Subscribe to a map region to automatically download it. Subscriptions will periodically check for updated maps. Downloads may take a long time depending on the region size and your internet speed. You can monitor progress on the Downloads page.

You can also manually extract a region using the pmtiles command-line tool and place the resulting file into the map/ directory:

  pmtiles extract https://build.protomaps.com/YYYYMMDD.pmtiles region.pmtiles \{'\n'}    --bbox="-125.0,24.4,-66.9,49.4"

Find the latest build date at  maps.protomaps.com/builds

You can also view the map at {MAP_VIEWER_URI}

} const PIN_COLORS = ["red", "blue", "green", "yellow", "orange", "purple"]; function PinEditRow({pin, onSave, onCancel}) { const [label, setLabel] = useState(pin.label); const [color, setColor] = useState(pin.color); return
{PIN_COLORS.map(c =>
setColor(c)} style={{ width: 20, height: 20, borderRadius: "50%", background: c, cursor: "pointer", border: color === c ? "3px solid var(--text)" : "2px solid var(--border)", }} /> )}
setLabel(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && label.trim() && onSave(pin.id, label.trim(), color)} /> {pin.lat.toFixed(4)}, {pin.lon.toFixed(4)} {pin.created} ; } function MapPins() { const [pins, setPins] = useState([]); const [filter, setFilter] = useState(''); const [editingId, setEditingId] = useState(null); const navigate = useNavigate(); const fetchPins = async () => { try { const data = await getMapPins(); if (data) setPins(data.pins || []); } catch (e) { console.error(e); } }; useEffect(() => { fetchPins(); }, []); const handleDelete = async (pinId) => { await deleteMapPin(pinId); setEditingId(null); await fetchPins(); }; const handleSave = async (pinId, label, color) => { await updateMapPin(pinId, label, color); setEditingId(null); await fetchPins(); }; const handleNavigate = (pin) => { navigate(`/map?lat=${pin.lat}&lon=${pin.lon}&z=14`); }; // --- Desktop headers and row --- const fullHeaders = [ {key: 'color', text: '', sortBy: i => i.color}, {key: 'label', text: 'Label', sortBy: i => i.label.toLowerCase()}, {key: 'coords', text: 'Coordinates', sortBy: i => i.lat}, {key: 'created', text: 'Created', sortBy: i => i.created}, {key: 'actions', text: '', sortBy: null}, ]; const fullRowFunc = (pin) => { if (editingId === pin.id) { return setEditingId(null)} />; } return
{pin.label} {e.preventDefault(); handleNavigate(pin);}}> {pin.lat.toFixed(4)}, {pin.lon.toFixed(4)} {pin.created}