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 && setPreviewOpen(true)}
/>}
{isSubscribed ? 'Unsubscribe' : 'Subscribe'}
{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
? Built
: 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
setAllRegionsOpen(true)}>View All Regions
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}
label.trim() && onSave(pin.id, label.trim(), color)}>Save
Cancel
;
}
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}
setEditingId(pin.id)}/>
handleDelete(pin.id)}
/>
;
};
// --- Mobile headers and row ---
const mobileHeaders = [
{key: 'color', text: '', sortBy: i => i.color},
{key: 'label', text: 'Label', sortBy: i => i.label.toLowerCase()},
{key: 'actions', text: '', sortBy: null},
];
const mobileRowFunc = (pin) => {
return
{e.preventDefault(); handleNavigate(pin);}}>
{pin.label}
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
handleDelete(pin.id)}
/>
;
};
const emptyRow =
No pins. Right-click on the map to add one.
;
const lowerFilter = filter.toLowerCase();
const filteredPins = lowerFilter
? pins.filter(p =>
(p.label || '').toLowerCase().includes(lowerFilter) ||
`${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`.includes(lowerFilter)
)
: pins;
return
}
placeholder='Filter by label or coordinates...'
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{marginBottom: '1em', width: '100%'}}
/>
;
}
function MapPage() {
return ;
}
export function MapRoute() {
useTitle('Map');
const links = [
{text: 'Map', to: '/map', key: 'map', end: true},
{text: 'Pins', to: '/map/pins', key: 'pins'},
{text: 'Manage', to: '/map/manage', key: 'manage'},
];
return
}/>
}/>
}/>
}