wrolpi/app/src/components/MapViewer.js
Roland e8dd804010
ui: thirty-five files off Semantic, leaving fifteen
Phase 2, part 4.  Baseline 50 -> 15.

Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.

Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message.  useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.

Two library gaps closed, both found by call sites needing them:

  ActionInput is a forwardRef now.  Its ref reaches the input rather than the
  wrapper, because callers use it to focus or select the field -- a ref to the
  surrounding div would do neither, which is why one call site had resorted to
  hand-copying the component's markup.

  Header takes a `color` token name.  Three separate call sites had reached for
  an inline style to colour a heading that carries meaning ("3 items to add"
  against "2 to remove"), which is how a hex ends up in markup.

Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used.  Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.

Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads.  Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
2026-08-08 18:04:01 -06:00

986 lines
40 KiB
JavaScript

import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
import {useSearchParams} from "react-router";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import {Protocol} from "pmtiles";
import layers from "protomaps-themes-base";
import mlcontour from "@acalcutt/maplibre-contour-pmtiles";
import {addMapPin, deleteMapPin, getMapFiles, getMapPins, searchMap, setMapDefaultLocation} from "../api";
import {MAP_VIEWER_URI} from "./Vars";
import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
import {SettingsContext, ThemeContext} from "../contexts/contexts";
// Terrain DEM file prefix for hillshade and contours.
const TERRAIN_PREFIX = "terrain-";
// 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);
}
// Layer groups matching the standalone viewer.
const LAYER_GROUPS = {
"Hillshade": ["hillshade"],
"Contours": ["contour-lines", "contour-labels"],
"Water": ["water"],
"Land Cover": [
"landcover", "landuse_park", "landuse_urban_green", "landuse_beach",
"landuse_aerodrome", "landuse_hospital", "landuse_industrial",
"landuse_pedestrian", "landuse_pier", "landuse_school", "landuse_zoo",
],
"Buildings": ["buildings"],
"Roads - Highway": ["roads_highway"],
"Roads - Major": ["roads_major", "roads_link"],
"Roads - Minor": ["roads_minor", "roads_other"],
"Roads - Tunnels": ["roads_tunnels"],
"Roads - Bridges": ["roads_bridges"],
"Rail & Runways": ["roads_rail", "roads_runway", "roads_taxiway", "landuse_runway"],
"Boundaries": ["boundaries"],
"Labels": ["roads_labels", "places_", "address_label", "pois", "water_label", "water_waterway_label"],
};
// Register PMTiles protocol once.
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
// Planet overview blob — provides global basemap at zoom 0-6.
const MAP_OVERVIEW_URL = "/blobs/map-overview.pmtiles";
// Layers to skip from non-primary sources (they would paint over the primary source's content).
const SKIP_SECONDARY = new Set(["background", "earth"]);
// Set up contour DEM source (must be called once before buildStyle).
let demSource = null;
let terrainUrl = null;
function initContourSource(filename) {
terrainUrl = `/media/map/${filename}`;
if (demSource) return;
demSource = new mlcontour.DemSource({
url: `pmtiles://${window.location.origin}${terrainUrl}`,
encoding: "terrarium",
maxzoom: 8,
worker: true,
});
demSource.setupMaplibre(maplibregl);
}
// sources: [{name, url}] where name is used as the MapLibre source ID and url is the pmtiles:// URL.
// hasTerrain: whether to include hillshade and contour layers.
// scaleUnit: "metric" or "imperial" — affects contour intervals and labels.
function buildStyle(sources, flavor = "light", hasTerrain = false, scaleUnit = "metric") {
const styleSources = {};
const allLayers = [];
for (let i = 0; i < sources.length; i++) {
const {name, url} = sources[i];
styleSources[name] = {type: "vector", url};
const fileLayers = layers(name, flavor);
if (i === 0) {
// Primary source: use layer IDs as-is.
allLayers.push(...fileLayers);
} else {
// Additional sources: prefix layer IDs and skip background/earth.
for (const layer of fileLayers) {
if (SKIP_SECONDARY.has(layer.id)) continue;
allLayers.push({...layer, id: `${name}:${layer.id}`});
}
}
}
// Add terrain sources and layers if available.
if (hasTerrain && demSource) {
// Read saved visibility to set initial layer state correctly.
const savedVis = getInitialVisibility();
const hillshadeVis = savedVis["Hillshade"] ? "visible" : "none";
const contourVis = savedVis["Contours"] ? "visible" : "none";
// Hillshade from raster-dem source.
styleSources["terrain-dem"] = {
type: "raster-dem",
url: `pmtiles://${terrainUrl}`,
encoding: "terrarium",
tileSize: 512,
};
// Insert hillshade early (after earth/landcover, before roads).
const hillshadeSearch = allLayers.findIndex(l => l.id === "buildings");
const hillshadeIdx = hillshadeSearch === -1 ? allLayers.length : hillshadeSearch;
allLayers.splice(hillshadeIdx, 0, {
id: "hillshade",
type: "hillshade",
source: "terrain-dem",
layout: {visibility: hillshadeVis},
paint: {
"hillshade-shadow-color": "#000000",
"hillshade-illumination-anchor": "viewport",
"hillshade-exaggeration": 0.3,
},
});
// Contour lines from on-the-fly generation.
const isImperial = scaleUnit === "imperial";
const contourUrl = demSource.contourProtocolUrl({
overzoom: 1,
contourLayer: "contours",
elevationKey: "ele",
levelKey: "level",
multiplier: isImperial ? 3.28084 : 1, // meters to feet conversion
thresholds: isImperial ? {
// [minor, major] contour intervals in feet per zoom level.
4: [1000, 5000],
5: [500, 2500],
6: [200, 1000],
7: [200, 1000],
8: [100, 500],
} : {
// [minor, major] contour intervals in meters per zoom level.
4: [500, 2500],
5: [200, 1000],
6: [100, 500],
7: [100, 500],
8: [50, 200],
},
});
const elevUnit = isImperial ? "ft" : "m";
styleSources["contours"] = {
type: "vector",
tiles: [contourUrl],
maxzoom: 8,
};
allLayers.push(
{
id: "contour-lines",
type: "line",
source: "contours",
"source-layer": "contours",
filter: ["!=", ["get", "level"], 1],
layout: {visibility: contourVis},
paint: {"line-color": "rgba(150,100,50,0.3)", "line-width": 0.5},
},
{
id: "contour-lines-major",
type: "line",
source: "contours",
"source-layer": "contours",
filter: ["==", ["get", "level"], 1],
layout: {visibility: contourVis},
paint: {"line-color": "rgba(150,100,50,0.5)", "line-width": 1},
},
{
id: "contour-labels",
type: "symbol",
source: "contours",
"source-layer": "contours",
filter: ["==", ["get", "level"], 1],
layout: {
visibility: contourVis,
"symbol-placement": "line",
"text-field": ["concat", ["get", "ele"], elevUnit],
"text-size": 10,
},
paint: {
"text-color": flavor === "dark" ? "rgba(210,180,140,0.9)" : "rgba(150,100,50,0.8)",
"text-halo-color": flavor === "dark" ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.8)",
"text-halo-width": 1,
},
},
);
}
return {
version: 8,
glyphs: "/map-assets/fonts/{fontstack}/{range}.pbf",
sprite: `${window.location.origin}/map-assets/sprites/${flavor}`,
sources: styleSources,
layers: allLayers,
};
}
// Groups that start hidden by default (used when no saved state exists).
const DEFAULT_HIDDEN = new Set(["Hillshade", "Contours"]);
const STORAGE_KEY_LAYERS = "wrolpi-map-layers";
const STORAGE_KEY_SCALE = "wrolpi-map-scale";
function getInitialVisibility() {
try {
const saved = localStorage.getItem(STORAGE_KEY_LAYERS);
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
const v = {};
for (const g of Object.keys(LAYER_GROUPS)) v[g] = !DEFAULT_HIDDEN.has(g);
return v;
}
function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
const [visibility, setVisibility] = useState(() => {
const v = getInitialVisibility();
if (visibilityRef) visibilityRef.current = v;
return v;
});
const [collapsed, setCollapsed] = useState(true);
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches
);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(max-width: 768px)");
const handler = (e) => setIsMobile(e.matches);
mq.addEventListener ? mq.addEventListener("change", handler) : mq.addListener(handler);
return () => {
mq.removeEventListener ? mq.removeEventListener("change", handler) : mq.removeListener(handler);
};
}, []);
const iconOnly = isMobile && collapsed;
const toggleGroup = useCallback((groupName, checked) => {
if (!map) return;
const prefixes = LAYER_GROUPS[groupName];
const allMapLayers = map.getStyle()?.layers || [];
for (const layer of allMapLayers) {
// Match both plain IDs and prefixed IDs (e.g., "water" and "file.pmtiles:water").
const baseId = layer.id.includes(":") ? layer.id.split(":").slice(1).join(":") : layer.id;
if (prefixes.some(p => baseId.includes(p))) {
map.setLayoutProperty(layer.id, "visibility", checked ? "visible" : "none");
}
}
setVisibility(prev => {
const next = {...prev, [groupName]: checked};
if (visibilityRef) visibilityRef.current = next;
try { localStorage.setItem(STORAGE_KEY_LAYERS, JSON.stringify(next)); } catch (e) { /* ignore */ }
return next;
});
}, [map, visibilityRef]);
// A floating control drawn over the map canvas: it is part of the app's interface,
// not map data, so it is never filtered — it stays plain, token-colored chrome in
// every theme, same as the nav bar.
const panelStyle = {
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
background: "var(--panel)",
border: "1px solid var(--border)",
color: "var(--text)",
maxHeight: "calc(100vh - 120px)",
overflowY: "auto",
minWidth: iconOnly ? 0 : 200,
fontSize: 13,
};
const headerStyle = iconOnly
? {
padding: 0, cursor: "pointer", userSelect: "none",
display: "flex", alignItems: "center", justifyContent: "center",
width: 40, height: 40,
}
: {
padding: "8px 12px", cursor: "pointer", fontWeight: 600,
display: "flex", justifyContent: "space-between", alignItems: "center",
borderBottom: collapsed ? "none" : "1px solid var(--border)", userSelect: "none",
};
return <div style={panelStyle}>
<div
style={headerStyle}
onClick={() => setCollapsed(c => !c)}
title="Layers"
aria-label="Layers"
>
{iconOnly
? <Icon name="map outline" size="large" style={{margin: 0}}/>
: <>
<span>Layers</span>
<Icon name={collapsed ? "chevron right" : "chevron down"} size="small"/>
</>}
</div>
{!collapsed && <div style={{padding: "4px 12px 8px"}}>
{Object.keys(LAYER_GROUPS).map(name =>
<div key={name} style={{padding: "2px 0"}}>
<Checkbox
label={name}
checked={visibility[name]}
onChange={(e) => toggleGroup(name, e.currentTarget.checked)}
/>
</div>
)}
<div style={{borderTop: "1px solid var(--border)", marginTop: 6, paddingTop: 6}}>
<Toggle
label={scaleUnit === "imperial" ? "Imperial" : "Metric"}
checked={scaleUnit === "imperial"}
onChange={(e) => onScaleUnitChange(e.currentTarget.checked ? "imperial" : "metric")}
/>
</div>
</div>}
</div>;
}
const PIN_COLORS = ["red", "blue", "green", "yellow", "orange", "purple"];
function AddPinDialog({lat, lon, onSubmit, onCancel}) {
const [label, setLabel] = useState("");
const [color, setColor] = useState("red");
// A floating dialog over the canvas — page chrome, not map data, so it is never
// filtered and keeps flat, bordered surfaces like the rest of the app (no shadow).
return <div style={{
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
zIndex: 1002, background: "var(--panel)", border: "1px solid var(--border)", color: "var(--text)",
padding: 16, minWidth: 250,
}}>
<div style={{fontWeight: 600, marginBottom: 8}}>Add Pin</div>
<div style={{fontSize: 12, color: "var(--muted)", marginBottom: 8}}>{lat.toFixed(4)}, {lon.toFixed(4)}</div>
<TextInput
placeholder="Label"
value={label}
onChange={e => setLabel(e.target.value)}
autoFocus
onKeyDown={e => e.key === "Enter" && label.trim() && onSubmit(label.trim(), color)}
style={{marginBottom: 8}}
/>
<div style={{display: "flex", gap: 6, marginBottom: 12}}>
{PIN_COLORS.map(c =>
<div
key={c}
onClick={() => setColor(c)}
style={{
width: 24, height: 24, borderRadius: "50%", background: c, cursor: "pointer",
border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
</div>
<div style={{display: "flex", gap: 8, justifyContent: "flex-end"}}>
<Button role='cancel' size='sm' onClick={onCancel}>Cancel</Button>
<Button
role='primary'
size='sm'
onClick={() => label.trim() && onSubmit(label.trim(), color)}
disabled={!label.trim()}
>
Add
</Button>
</div>
</div>;
}
// Coordinate regex: "45.5, -122.6" or "45.5 -122.6"
const COORD_REGEX = /^\s*(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)\s*$/;
function MapSearch({map}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [showResults, setShowResults] = useState(false);
const [searchMarker, setSearchMarker] = useState(null);
const debounceRef = useRef(null);
const containerRef = useRef(null);
// Close results when clicking outside.
useEffect(() => {
const handler = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setShowResults(false);
}
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, []);
const doSearch = useCallback((q) => {
if (!q || q.length < 2) {
setResults([]);
setShowResults(false);
return;
}
// Check for coordinate input first.
const coordMatch = q.match(COORD_REGEX);
if (coordMatch) {
const lat = parseFloat(coordMatch[1]);
const lon = parseFloat(coordMatch[2]);
if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
setResults([{name: `${lat}, ${lon}`, kind: "coordinates", lat, lon, min_zoom: 0, source: "input"}]);
setShowResults(true);
return;
}
}
// Get current map center for proximity bias.
let lat = null, lon = null;
if (map) {
const center = map.getCenter();
lat = center.lat;
lon = center.lng;
}
searchMap(q, 8, 0, lat, lon).then(data => {
setResults(data.results || []);
setShowResults((data.results || []).length > 0);
});
}, [map]);
const handleInput = useCallback((e) => {
const q = e.target.value;
setQuery(q);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(q), 300);
}, [doSearch]);
const handleSelect = useCallback((result) => {
if (!map) return;
// Remove old search marker.
if (searchMarker) {
searchMarker.remove();
}
const zoom = result.kind === "coordinates" ? 14 : Math.max(map.getZoom(), 12);
map.flyTo({center: [result.lon, result.lat], zoom});
const marker = new maplibregl.Marker({color: "#6435c9"})
.setLngLat([result.lon, result.lat])
.setPopup(new maplibregl.Popup({offset: 25}).setText(result.name))
.addTo(map);
marker.togglePopup();
setSearchMarker(marker);
setShowResults(false);
setQuery(result.name);
}, [map, searchMarker]);
const handleKeyDown = useCallback((e) => {
if (e.key === "Escape") {
setShowResults(false);
e.target.blur();
}
}, []);
const formatPopulation = (pop) => {
if (!pop) return "";
if (pop >= 1_000_000) return `${(pop / 1_000_000).toFixed(1)}M`;
if (pop >= 1_000) return `${(pop / 1_000).toFixed(0)}K`;
return `${pop}`;
};
const resultSubtext = (r) => {
if (r.kind === "coordinates") return "";
const parts = [];
if (r.region) parts.push(r.region);
const detail = r.kind_detail || r.kind || "";
if (detail && !r.region) parts.push(detail.replace(/_/g, " "));
const pop = formatPopulation(r.population);
if (pop) parts.push(`pop. ${pop}`);
return parts.join(" \u00b7 ");
};
// Floating search chrome over the canvas — page interface, not map data, so it is
// never filtered. Flat surfaces: bordered, no box-shadow, token colors only.
return <div ref={containerRef} style={{
position: "absolute", top: 10, left: "50%", transform: "translateX(-50%)",
zIndex: 5, width: 320, maxWidth: "calc(100% - 100px)",
}}>
<input
type="text"
placeholder="Search places..."
value={query}
onChange={handleInput}
onFocus={() => results.length > 0 && setShowResults(true)}
onKeyDown={handleKeyDown}
style={{
width: "100%", padding: "8px 12px", border: "1px solid var(--border)",
fontSize: 14, background: "var(--panel)", color: "var(--text)",
}}
/>
{showResults && results.length > 0 && <div style={{
background: "var(--panel)", border: "1px solid var(--border)", borderTop: "none",
maxHeight: 300, overflowY: "auto",
}}>
{results.map((r, i) => <div
key={i}
onClick={() => handleSelect(r)}
onMouseEnter={e => e.currentTarget.style.background = "var(--head)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
style={{padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid var(--border)"}}
>
<div style={{fontWeight: 500}}>{r.name}</div>
{resultSubtext(r) && <div style={{fontSize: 11, color: "var(--muted)"}}>{resultSubtext(r)}</div>}
</div>)}
</div>}
</div>;
}
export default function MapViewer() {
const mapContainer = useRef(null);
const mapRef = useRef(null);
const scaleControlRef = useRef(null);
const activeFilesRef = useRef([]);
const hasTerrainRef = useRef(false);
const layerVisibilityRef = useRef(null);
const [mapReady, setMapReady] = useState(false);
const [error, setError] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
const [addingPin, setAddingPin] = useState(null); // {lat, lon} when pin dialog is open
const markersRef = useRef([]);
const pinsVisibleRef = useRef(true);
const [scaleUnit, setScaleUnit] = useState(() => {
try { return localStorage.getItem(STORAGE_KEY_SCALE) || "metric"; } catch (e) { return "metric"; }
});
const [searchParams, setSearchParams] = useSearchParams();
const {theme} = useContext(ThemeContext);
const {settings, fetchSettings} = useContext(SettingsContext);
// Use URL params if provided, otherwise fall back to saved default location, then 0,0,z2.
const hasUrlParams = searchParams.has("lat") || searchParams.has("lon") || searchParams.has("z");
const defaultLoc = settings?.map_default_location;
const initialLat = hasUrlParams
? (parseFloat(searchParams.get("lat")) || 0)
: (parseFloat(defaultLoc?.lat) || 0);
const initialLon = hasUrlParams
? (parseFloat(searchParams.get("lon")) || 0)
: (parseFloat(defaultLoc?.lon) || 0);
const initialZoom = hasUrlParams
? (parseFloat(searchParams.get("z")) || 2)
: (parseFloat(defaultLoc?.zoom) || 2);
useEffect(() => {
if (!mapContainer.current) return;
let map;
let destroyed = false;
const init = async () => {
const mapSources = [];
// Fetch user's PMTiles files first to determine what base layer is needed.
let hasTerrain = false;
let terrainFileName = null;
let planetFile = null;
try {
const data = await getMapFiles();
if (data?.files) {
const regionFiles = [];
for (const f of data.files) {
if (f.name.startsWith(TERRAIN_PREFIX) && f.name.endsWith('.pmtiles')) {
hasTerrain = true;
terrainFileName = f.name;
} else if (isPlanetFile(f.name)) {
if (!planetFile || f.size > planetFile.size) {
planetFile = f;
}
} else {
regionFiles.push(f);
}
}
if (planetFile) {
// Planet covers everything — sole vector source, no overview needed.
mapSources.push({name: planetFile.name, url: `pmtiles:///media/map/${planetFile.name}`});
} else {
// Use regional extracts.
for (const f of regionFiles) {
mapSources.push({name: f.name, url: `pmtiles:///media/map/${f.name}`});
}
// Add planet overview blob as low-zoom base layer (if available).
try {
const resp = await fetch(MAP_OVERVIEW_URL, {method: "HEAD"});
if (resp.ok) {
mapSources.unshift({name: "map-overview", url: `pmtiles://${MAP_OVERVIEW_URL}`});
}
} catch (e) {
console.debug("Planet overview not available:", e);
}
}
}
} catch (e) {
console.error("Failed to fetch map files:", e);
}
if (destroyed) return;
activeFilesRef.current = mapSources;
hasTerrainRef.current = hasTerrain;
// Initialize contour source if terrain data is available.
if (hasTerrain) {
initContourSource(terrainFileName);
}
try {
map = new maplibregl.Map({
container: mapContainer.current,
style: buildStyle(mapSources, theme, hasTerrain, scaleUnit),
center: [initialLon, initialLat],
zoom: initialZoom,
attributionControl: true,
preserveDrawingBuffer: true,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(new maplibregl.FullscreenControl(), "top-right");
const sc = new maplibregl.ScaleControl({maxWidth: 200, unit: scaleUnit});
map.addControl(sc, "bottom-left");
scaleControlRef.current = sc;
map.addControl(new maplibregl.GeolocateControl({
positionOptions: {enableHighAccuracy: true},
trackUserLocation: true,
showUserHeading: true,
showAccuracyCircle: true,
}), "top-right");
// Pin toggle button.
const pinToggle = document.createElement("button");
pinToggle.type = "button";
pinToggle.title = "Toggle pins";
pinToggle.textContent = "📍";
pinToggle.style.cssText = "font-size:18px;width:29px;height:29px;border:none;cursor:pointer;background:white;border-radius:4px;display:flex;align-items:center;justify-content:center;";
pinToggle.addEventListener("click", () => {
pinsVisibleRef.current = !pinsVisibleRef.current;
const display = pinsVisibleRef.current ? "" : "none";
pinToggle.style.opacity = pinsVisibleRef.current ? "1" : "0.5";
for (const m of markersRef.current) {
m.getElement().style.display = display;
}
});
const pinControl = {
onAdd: () => {
const container = document.createElement("div");
container.className = "maplibregl-ctrl maplibregl-ctrl-group";
container.appendChild(pinToggle);
return container;
},
onRemove: () => {},
};
map.addControl(pinControl, "top-right");
// Right-click context menu (desktop).
map.on("contextmenu", (e) => {
if (destroyed) return;
setContextMenu({
x: e.point.x,
y: e.point.y,
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
});
map.on("click", () => setContextMenu(null));
map.on("movestart", () => setContextMenu(null));
// Long-press context menu (mobile).
let longPressTimer = null;
let touchStartPos = null;
const canvas = map.getCanvas();
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length !== 1) return;
touchStartPos = {x: e.touches[0].clientX, y: e.touches[0].clientY};
longPressTimer = setTimeout(() => {
const rect = canvas.getBoundingClientRect();
const point = {x: touchStartPos.x - rect.left, y: touchStartPos.y - rect.top};
const lngLat = map.unproject(point);
setContextMenu({x: point.x, y: point.y, lat: lngLat.lat, lon: lngLat.lng});
}, 500);
}, {passive: true});
canvas.addEventListener("touchmove", (e) => {
if (longPressTimer && touchStartPos) {
const dx = e.touches[0].clientX - touchStartPos.x;
const dy = e.touches[0].clientY - touchStartPos.y;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
});
canvas.addEventListener("touchend", () => {
clearTimeout(longPressTimer);
longPressTimer = null;
});
// Update URL on pan/zoom (only while mounted).
map.on("moveend", () => {
if (destroyed) return;
const center = map.getCenter();
const zoom = map.getZoom();
window.history.replaceState(null, '',
`${window.location.pathname}?lat=${center.lat.toFixed(4)}&lon=${center.lng.toFixed(4)}&z=${zoom.toFixed(1)}`
);
});
map.on("load", () => {
if (!destroyed) {
mapRef.current = map;
// Apply saved layer visibility on initial load.
const savedVis = getInitialVisibility();
const allMapLayers = map.getStyle()?.layers || [];
for (const [groupName, visible] of Object.entries(savedVis)) {
const prefixes = LAYER_GROUPS[groupName];
if (!prefixes) continue;
for (const layer of allMapLayers) {
const baseId = layer.id.includes(":") ? layer.id.split(":").slice(1).join(":") : layer.id;
if (prefixes.some(p => baseId.includes(p))) {
map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
}
setMapReady(true);
}
});
} catch (e) {
console.error("MapLibre failed to initialize:", e);
if (!destroyed) setError(e.message);
}
};
init();
return () => {
destroyed = true;
if (map) {
map.remove();
}
mapRef.current = null;
setMapReady(false);
// Clean up map params from URL so they don't leak to other pages.
const url = new URL(window.location);
url.searchParams.delete("lat");
url.searchParams.delete("lon");
url.searchParams.delete("z");
window.history.replaceState(null, '', url.pathname + url.search);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Rebuild style when theme changes.
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
const center = map.getCenter();
const zoom = map.getZoom();
map.setStyle(buildStyle(activeFilesRef.current, theme, hasTerrainRef.current, scaleUnit));
map.once("style.load", () => {
map.setCenter(center);
map.setZoom(zoom);
// Re-apply layer visibility state after style rebuild.
const vis = layerVisibilityRef.current;
if (vis) {
const allMapLayers = map.getStyle()?.layers || [];
for (const [groupName, visible] of Object.entries(vis)) {
const prefixes = LAYER_GROUPS[groupName];
if (!prefixes) continue;
for (const layer of allMapLayers) {
const baseId = layer.id.includes(":") ? layer.id.split(":").slice(1).join(":") : layer.id;
if (prefixes.some(p => baseId.includes(p))) {
map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
}
}
});
}, [theme, mapReady, scaleUnit]);
const handleScaleUnitChange = useCallback((unit) => {
const map = mapRef.current;
const sc = scaleControlRef.current;
if (map && sc) {
map.removeControl(sc);
const newSc = new maplibregl.ScaleControl({maxWidth: 200, unit});
map.addControl(newSc, "bottom-left");
scaleControlRef.current = newSc;
}
try { localStorage.setItem(STORAGE_KEY_SCALE, unit); } catch (e) { /* ignore */ }
setScaleUnit(unit);
}, []);
const handleCopyCoords = useCallback(() => {
if (!contextMenu) return;
// Match decimal precision to current zoom level so pasting into search doesn't over-zoom.
const map = mapRef.current;
const zoom = map ? map.getZoom() : 10;
const decimals = zoom < 4 ? 1 : zoom < 8 ? 2 : zoom < 12 ? 3 : 4;
const text = `${contextMenu.lat.toFixed(decimals)}, ${contextMenu.lon.toFixed(decimals)}`;
navigator.clipboard.writeText(text).catch(() => {
window.prompt("Copy coordinates:", text);
});
setContextMenu(null);
}, [contextMenu]);
const handleExportPng = useCallback(() => {
const map = mapRef.current;
if (!map) return;
setContextMenu(null);
// Force a fresh render, then capture.
map.once("render", function() {
const c = map.getCenter();
const z = map.getZoom();
const filename = `wrolpi-map_${c.lat.toFixed(4)}_${c.lng.toFixed(4)}_z${z.toFixed(0)}.png`;
map.getCanvas().toBlob(function(blob) {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
});
});
map.triggerRepaint();
}, []);
// --- Pins ---
const loadPins = useCallback(async () => {
const map = mapRef.current;
if (!map) return;
// Remove existing markers.
for (const m of markersRef.current) m.remove();
markersRef.current = [];
const data = await getMapPins();
if (!data?.pins) return;
data.pins.forEach((pin, index) => {
const el = document.createElement("div");
el.style.width = "24px";
el.style.height = "24px";
el.style.borderRadius = "50% 50% 50% 0";
el.style.background = pin.color || "red";
el.style.border = "2px solid white";
el.style.boxShadow = "0 1px 4px rgba(0,0,0,0.4)";
el.style.transform = "rotate(-45deg)";
el.style.cursor = "pointer";
const popup = new maplibregl.Popup({offset: 15}).setHTML(
`<div style="font-size:13px">` +
`<strong>${pin.label || "Pin"}</strong><br/>` +
`<small>${pin.lat.toFixed(4)}, ${pin.lon.toFixed(4)}</small><br/>` +
`<button onclick="window.__deleteMapPin(${pin.id})" ` +
`style="margin-top:4px;padding:2px 8px;cursor:pointer;color:red;border:1px solid red;background:none;border-radius:3px;">` +
`Delete</button></div>`
);
const marker = new maplibregl.Marker({element: el})
.setLngLat([pin.lon, pin.lat])
.setPopup(popup)
.addTo(map);
if (!pinsVisibleRef.current) {
marker.getElement().style.display = "none";
}
markersRef.current.push(marker);
});
}, []);
// Global callback for popup delete buttons.
useEffect(() => {
window.__deleteMapPin = async (pinId) => {
await deleteMapPin(pinId);
await loadPins();
};
return () => { delete window.__deleteMapPin; };
}, [loadPins]);
// Load pins when map is ready.
useEffect(() => {
if (mapReady) loadPins();
}, [mapReady, loadPins]);
const handleAddPin = useCallback(() => {
if (!contextMenu) return;
setAddingPin({lat: contextMenu.lat, lon: contextMenu.lon});
setContextMenu(null);
}, [contextMenu]);
const handleSetDefaultLocation = useCallback(async () => {
const map = mapRef.current;
if (!map) return;
const center = map.getCenter();
const zoom = map.getZoom();
await setMapDefaultLocation(center.lat, center.lng, zoom);
await fetchSettings();
setContextMenu(null);
}, [fetchSettings]);
const handleSubmitPin = useCallback(async (label, color) => {
if (!addingPin) return;
const {lat, lon} = addingPin;
setAddingPin(null); // Prevent double-submit.
await addMapPin(lat, lon, label, color);
await loadPins();
}, [addingPin, loadPins]);
if (error) {
return <Panel>
<Header as="h3">Map failed to load</Header>
<p>Your browser may not support WebGL. You can access the standalone map viewer
at <a href={MAP_VIEWER_URI}>{MAP_VIEWER_URI}</a></p>
<p>Error: {error}</p>
</Panel>;
}
return <div ref={mapContainer} style={{position: "relative", width: "100%", height: "85vh"}}>
{mapReady && <MapSearch map={mapRef.current}/>}
{mapReady && <LayerControl map={mapRef.current} scaleUnit={scaleUnit} onScaleUnitChange={handleScaleUnitChange} visibilityRef={layerVisibilityRef}/>}
{contextMenu && <div
style={{
position: "absolute",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1001,
background: "white",
borderRadius: 6,
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
padding: "4px 0",
fontSize: 13,
minWidth: 180,
}}
>
<div style={{padding: "6px 12px", color: "#666", fontSize: 12, borderBottom: "1px solid #eee"}}>
{(() => {
const map = mapRef.current;
const zoom = map ? map.getZoom() : 10;
const d = zoom < 4 ? 1 : zoom < 8 ? 2 : zoom < 12 ? 3 : 4;
return `${contextMenu.lat.toFixed(d)}, ${contextMenu.lon.toFixed(d)}`;
})()}
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleCopyCoords}
>
Copy GPS coordinates
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleExportPng}
>
Export as PNG
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleAddPin}
>
Add Pin Here
</div>
<div
style={{padding: "8px 12px", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleSetDefaultLocation}
>
Set as Default View
</div>
</div>}
{addingPin && <AddPinDialog
lat={addingPin.lat}
lon={addingPin.lon}
onSubmit={handleSubmitPin}
onCancel={() => setAddingPin(null)}
/>}
</div>;
}