147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import L from 'leaflet';
|
|
|
|
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
|
import {
|
|
MAP_TILE_URL,
|
|
MAP_TILE_SUBDOMAINS,
|
|
MAP_TILE_CACHE_NAMESPACE,
|
|
MAP_TILE_ATTRIBUTION,
|
|
} from '@/lib/mapTiles';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface LatLng {
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
interface SpotLocationPickerProps {
|
|
value: LatLng | null;
|
|
onChange: (coords: LatLng) => void;
|
|
className?: string;
|
|
/** Start here when there is no pin yet (usually the last map view). */
|
|
defaultCenter?: [number, number];
|
|
defaultZoom?: number;
|
|
}
|
|
|
|
/** 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, defaultCenter, defaultZoom }: 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]
|
|
: (defaultCenter ?? DEFAULT_CENTER);
|
|
const map = L.map(el, {
|
|
center,
|
|
zoom: initial ? 15 : (defaultZoom ?? (defaultCenter ? 14 : 12)),
|
|
zoomControl: true,
|
|
attributionControl: true,
|
|
});
|
|
|
|
createCachedTileLayer(MAP_TILE_URL, {
|
|
attribution: MAP_TILE_ATTRIBUTION,
|
|
subdomains: MAP_TILE_SUBDOMAINS,
|
|
minZoom: 2,
|
|
maxZoom: 19,
|
|
noWrap: true,
|
|
className: 'skatespots-dark-tiles',
|
|
}, MAP_TILE_CACHE_NAMESPACE).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;
|
|
};
|
|
// The map is created once; defaultCenter/defaultZoom are only the initial
|
|
// seed. Later value changes are handled by the marker-sync effect below.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// 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)}
|
|
/>
|
|
);
|
|
}
|