mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Support multiple map layers. Closes #193.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet';
|
||||
import {
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
CircleMarker,
|
||||
Popup,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
Polyline,
|
||||
LayersControl,
|
||||
} from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
@@ -27,26 +36,126 @@ interface MapViewProps {
|
||||
}
|
||||
|
||||
// --- Tile layer presets ---
|
||||
const TILE_LIGHT = {
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
background: '#1a1a2e',
|
||||
};
|
||||
const TILE_DARK = {
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
background: '#0d0d0d',
|
||||
};
|
||||
// Every provider here is free and works without an API key. Attribution strings
|
||||
// follow each provider's requirements; do not remove them. If you add a new
|
||||
// provider, verify its terms of service (especially for Esri / Google-style
|
||||
// satellite tiles) before committing.
|
||||
interface TileLayerPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
attribution: string;
|
||||
background: string;
|
||||
/** Highest zoom the provider publishes tiles at. When the layer is active,
|
||||
* the map's zoom ceiling is tightened to this value via
|
||||
* `MaxZoomByActiveLayer` so the user cannot zoom into a grey void. */
|
||||
maxZoom?: number;
|
||||
}
|
||||
|
||||
function getSavedDarkMap(): boolean {
|
||||
// Global zoom bounds for the MapContainer itself. These are pinned to the
|
||||
// container so Leaflet's internal tile-range math never has to guess when
|
||||
// layers swap in/out via LayersControl. Without this, an initial-mount race
|
||||
// between MapContainer layout and LayersControl.BaseLayer addition has been
|
||||
// observed to throw "Attempted to load an infinite number of tiles".
|
||||
const MAP_MIN_ZOOM = 2;
|
||||
const MAP_MAX_ZOOM = 19;
|
||||
|
||||
const TILE_LAYERS: readonly TileLayerPreset[] = [
|
||||
{
|
||||
id: 'light',
|
||||
label: 'Light (OpenStreetMap)',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
background: '#1a1a2e',
|
||||
maxZoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
label: 'Dark (CARTO)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
background: '#0d0d0d',
|
||||
maxZoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'topographic',
|
||||
label: 'Topographic (OpenTopoMap)',
|
||||
url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||
attribution:
|
||||
'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
|
||||
background: '#a3b3bc',
|
||||
maxZoom: 17,
|
||||
},
|
||||
{
|
||||
id: 'satellite',
|
||||
label: 'Satellite (Esri)',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution:
|
||||
'Tiles © <a href="https://www.esri.com/">Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
background: '#1a1f2e',
|
||||
// Esri's tile service advertises LODs up to 23 and returns HTTP 200 for
|
||||
// every tile request, but the underlying imagery is only high-resolution
|
||||
// up to ~18 in most developed areas and shallower in rural regions. We
|
||||
// cap at 18 rather than 19 so users don't zoom into visibly-empty or
|
||||
// severely-upscaled tiles. Remote regions may still be sparse at 18.
|
||||
maxZoom: 18,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const MAP_LAYER_STORAGE_KEY = 'remoteterm-map-layer';
|
||||
const LEGACY_DARK_MAP_STORAGE_KEY = 'remoteterm-dark-map';
|
||||
|
||||
function getSavedLayerId(): string {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
const stored = localStorage.getItem(MAP_LAYER_STORAGE_KEY);
|
||||
if (stored && TILE_LAYERS.some((l) => l.id === stored)) return stored;
|
||||
// Legacy migration: boolean dark-map flag predates multi-layer support.
|
||||
const legacyDark = localStorage.getItem(LEGACY_DARK_MAP_STORAGE_KEY) === 'true';
|
||||
return legacyDark ? 'dark' : 'light';
|
||||
} catch {
|
||||
return false;
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaflet-internal companion component: listens for base-layer changes driven
|
||||
* by Leaflet's own LayersControl UI and pipes the selection back to React.
|
||||
* Kept separate so the persistence/state logic stays out of the render tree.
|
||||
*/
|
||||
function LayerChangeWatcher({ onChange }: { onChange: (name: string) => void }) {
|
||||
useMapEvents({
|
||||
baselayerchange: (event) => {
|
||||
if (event.name) onChange(event.name);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the active layer's zoom ceiling on the underlying Leaflet map.
|
||||
*
|
||||
* Leaflet's `map.getMaxZoom()` prefers `options.maxZoom` (set on MapContainer)
|
||||
* over per-layer `maxZoom`, so a per-TileLayer cap is silently ignored unless
|
||||
* we push it down to the map itself. We do that here whenever the active
|
||||
* layer changes, and clamp the current zoom if the user happened to be zoomed
|
||||
* past the new cap at the moment of the switch.
|
||||
*
|
||||
* The MapContainer's fixed `minZoom`/`maxZoom` remain the absolute hull that
|
||||
* prevents the "Attempted to load an infinite number of tiles" race during
|
||||
* initial mount (see `MAP_MIN_ZOOM`/`MAP_MAX_ZOOM` below).
|
||||
*/
|
||||
function MaxZoomByActiveLayer({ maxZoom }: { maxZoom: number }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setMaxZoom(maxZoom);
|
||||
if (map.getZoom() > maxZoom) {
|
||||
map.setZoom(maxZoom);
|
||||
}
|
||||
}, [map, maxZoom]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const MAP_RECENCY_COLORS = {
|
||||
recent: '#06b6d4',
|
||||
today: '#2563eb',
|
||||
@@ -390,18 +499,35 @@ export function MapView({
|
||||
onSelectContact,
|
||||
}: MapViewProps) {
|
||||
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
|
||||
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
|
||||
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string>(getSavedLayerId);
|
||||
const activeLayer = TILE_LAYERS.find((l) => l.id === selectedLayerId) ?? TILE_LAYERS[0];
|
||||
|
||||
// Sync with settings changes from other components
|
||||
// Sync layer selection across tabs and windows.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
|
||||
if (e.key !== MAP_LAYER_STORAGE_KEY) return;
|
||||
const next = e.newValue ?? '';
|
||||
if (TILE_LAYERS.some((l) => l.id === next)) {
|
||||
setSelectedLayerId(next);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const handleLayerChange = useCallback((layerName: string) => {
|
||||
const match = TILE_LAYERS.find((l) => l.label === layerName);
|
||||
if (!match) return;
|
||||
setSelectedLayerId(match.id);
|
||||
try {
|
||||
localStorage.setItem(MAP_LAYER_STORAGE_KEY, match.id);
|
||||
// Clear the legacy key so a future downgrade-rollback doesn't revert us.
|
||||
localStorage.removeItem(LEGACY_DARK_MAP_STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage may be disabled; selection stays in memory only.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [showPackets, setShowPackets] = useState(false);
|
||||
const [discoveryMode, setDiscoveryMode] = useState(false);
|
||||
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
|
||||
@@ -800,10 +926,28 @@ export function MapView({
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
className="h-full w-full"
|
||||
style={{ background: tile.background }}
|
||||
style={{ background: activeLayer.background }}
|
||||
>
|
||||
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} />
|
||||
<LayersControl position="topright">
|
||||
{TILE_LAYERS.map((layer) => (
|
||||
<LayersControl.BaseLayer
|
||||
key={layer.id}
|
||||
name={layer.label}
|
||||
checked={layer.id === selectedLayerId}
|
||||
>
|
||||
<TileLayer
|
||||
url={layer.url}
|
||||
attribution={layer.attribution}
|
||||
maxZoom={layer.maxZoom}
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
</LayersControl>
|
||||
<LayerChangeWatcher onChange={handleLayerChange} />
|
||||
<MaxZoomByActiveLayer maxZoom={activeLayer.maxZoom ?? MAP_MAX_ZOOM} />
|
||||
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
|
||||
|
||||
{/* Faint route lines for active packet paths */}
|
||||
|
||||
@@ -47,13 +47,6 @@ export function SettingsLocalSection({
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
const [darkMap, setDarkMap] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('remoteterm-dark-map') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
|
||||
@@ -178,24 +171,6 @@ export function SettingsLocalSection({
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -4,23 +4,40 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { MapView } from '../components/MapView';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TileLayer: () => null,
|
||||
CircleMarker: forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode; pathOptions?: { fillColor?: string } }
|
||||
>(({ children, pathOptions }, ref) => (
|
||||
<div ref={ref} data-fill-color={pathOptions?.fillColor}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
Popup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useMap: () => ({
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('react-leaflet', () => {
|
||||
const BaseLayer = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
checked?: boolean;
|
||||
}) => <div>{children}</div>;
|
||||
const LayersControlMock = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
(LayersControlMock as unknown as { BaseLayer: typeof BaseLayer }).BaseLayer = BaseLayer;
|
||||
return {
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TileLayer: () => null,
|
||||
CircleMarker: forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode; pathOptions?: { fillColor?: string } }
|
||||
>(({ children, pathOptions }, ref) => (
|
||||
<div ref={ref} data-fill-color={pathOptions?.fillColor}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
Popup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Polyline: () => null,
|
||||
LayersControl: LayersControlMock,
|
||||
useMap: () => ({
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
setMaxZoom: vi.fn(),
|
||||
setZoom: vi.fn(),
|
||||
getZoom: vi.fn(() => 2),
|
||||
}),
|
||||
useMapEvents: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
describe('MapView', () => {
|
||||
it('renders a never-heard fallback for a focused contact without last_seen', () => {
|
||||
|
||||
Reference in New Issue
Block a user