SkateSpots/src/components/SpotLocationPicker.tsx

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