- Map: min zoom = smallest integer zoom whose 256*2^z world covers the viewport (no grey bands, no duplicated continents). Horizontal wrap is restored (worldCopyJump, no longitude maxBounds) so Hawaii/Pacific are reachable by dragging; vertical panning stays clamped. - Flags: real SVGs for Catalonia, Basque Country, Galicia and England (no Unicode region flags exist; English is not the UK). Portuguese now has Brasil (pt) and Portugal (pt-PT). - pt-PT shares the pt dictionary with a European drift for the visible chrome (Guardar, Palavra-passe, Autocarro, Utilizador, ...). - check-i18n covers pt-PT: 24 codes at 100%; tsc/tests/build green.
133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import L from 'leaflet';
|
|
|
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface LatLng {
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
interface SpotLocationPickerProps {
|
|
value: LatLng | null;
|
|
onChange: (coords: LatLng) => void;
|
|
className?: string;
|
|
}
|
|
|
|
/** Fallback center before the user has a location (NYC, same as the map default). */
|
|
const DEFAULT_CENTER: [number, number] = [40.7128, -74.006];
|
|
|
|
/**
|
|
* A small, tappable Leaflet map for choosing a spot's location.
|
|
*
|
|
* Tap anywhere to drop the pin, or drag the pin to fine-tune. This replaces the
|
|
* raw latitude/longitude text boxes as the PRIMARY way to set a location — those
|
|
* move behind an "enter coordinates manually" toggle for power users. Uses the
|
|
* same cached tile layer as the main map, so the basemap still draws offline.
|
|
*/
|
|
export function SpotLocationPicker({ value, onChange, className }: SpotLocationPickerProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
const markerRef = useRef<L.Marker | null>(null);
|
|
const onChangeRef = useRef(onChange);
|
|
// Captured once so the init effect doesn't depend on `value` (which changes
|
|
// constantly) — the marker-sync effect below handles later updates.
|
|
const initialValueRef = useRef(value);
|
|
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange;
|
|
}, [onChange]);
|
|
|
|
// Create the map once.
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el || mapRef.current) return;
|
|
|
|
const initial = initialValueRef.current;
|
|
const center: [number, number] = initial ? [initial.lat, initial.lng] : DEFAULT_CENTER;
|
|
const map = L.map(el, {
|
|
center,
|
|
zoom: initial ? 15 : 12,
|
|
zoomControl: true,
|
|
attributionControl: true,
|
|
});
|
|
|
|
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '',
|
|
subdomains: 'abc',
|
|
minZoom: 2,
|
|
maxZoom: 19,
|
|
noWrap: true,
|
|
className: 'skatespots-dark-tiles',
|
|
}).addTo(map);
|
|
|
|
map.on('click', (e: L.LeafletMouseEvent) => {
|
|
onChangeRef.current({ lat: e.latlng.lat, lng: e.latlng.lng });
|
|
});
|
|
|
|
mapRef.current = map;
|
|
|
|
const ro = new ResizeObserver(() => map.invalidateSize());
|
|
ro.observe(el);
|
|
// Leaflet can mis-measure inside a freshly-laid-out page — nudge it once.
|
|
const t = window.setTimeout(() => map.invalidateSize(), 0);
|
|
|
|
return () => {
|
|
window.clearTimeout(t);
|
|
ro.disconnect();
|
|
map.remove();
|
|
mapRef.current = null;
|
|
markerRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
// Draw / move the draggable pin to match `value`.
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
|
|
if (!value) {
|
|
if (markerRef.current) {
|
|
markerRef.current.remove();
|
|
markerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const latlng: [number, number] = [value.lat, value.lng];
|
|
if (markerRef.current) {
|
|
markerRef.current.setLatLng(latlng);
|
|
return;
|
|
}
|
|
|
|
const marker = L.marker(latlng, {
|
|
draggable: true,
|
|
icon: L.divIcon({
|
|
className: '',
|
|
html:
|
|
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
|
|
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
|
|
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
|
|
'</div>',
|
|
iconSize: [22, 34],
|
|
iconAnchor: [11, 34],
|
|
}),
|
|
}).addTo(map);
|
|
|
|
marker.on('dragend', () => {
|
|
const p = marker.getLatLng();
|
|
onChangeRef.current({ lat: p.lat, lng: p.lng });
|
|
});
|
|
|
|
markerRef.current = marker;
|
|
map.setView(latlng, Math.max(map.getZoom(), 15), { animate: true });
|
|
}, [value]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn('h-64 w-full overflow-hidden rounded-xl border-2 border-white/10', className)}
|
|
/>
|
|
);
|
|
}
|