SkateSpots/src/components/SpotLocationPicker.tsx
SkateSpots 7e48884eb9 feat(map,i18n): single-copy world clamp + regional flags + pt-PT
- 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.
2026-10-26 16:09:21 +07:00

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)}
/>
);
}