|
| 1 | +import { useEffect, useRef, useState } from 'react'; |
| 2 | +import { useStore } from '@nanostores/react'; |
| 3 | +import 'maplibre-gl/dist/maplibre-gl.css'; |
| 4 | +import type { Map as MlMap, GeoJSONSource } from 'maplibre-gl'; |
| 5 | +import type { FeatureCollection } from 'geojson'; |
| 6 | +import { themeAtom } from '@/stores/theme.store'; |
| 7 | +import { Dropzone } from '@/components/ui/Dropzone'; |
| 8 | +import { Button } from '@/components/ui/Button'; |
| 9 | +import { Alert } from '@/components/ui/Alert'; |
| 10 | +import { resolveStyle, MAP_STYLES, type StyleChoice } from '@/tools/geo/map-styles.lib'; |
| 11 | +import { parseGeoFile, computeBbox } from '@/tools/geo/geo-parse.lib'; |
| 12 | + |
| 13 | +const STYLE_KEY = 'gwt.map.style'; |
| 14 | +const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] }; |
| 15 | + |
| 16 | +export default function GeoViewer() { |
| 17 | + const theme = useStore(themeAtom); |
| 18 | + const containerRef = useRef<HTMLDivElement>(null); |
| 19 | + const mapRef = useRef<MlMap | null>(null); |
| 20 | + const mlRef = useRef<typeof import('maplibre-gl') | null>(null); |
| 21 | + const fcRef = useRef<FeatureCollection>(EMPTY); |
| 22 | + |
| 23 | + const [style, setStyle] = useState<StyleChoice>('auto'); |
| 24 | + const [count, setCount] = useState(0); |
| 25 | + const [error, setError] = useState(''); |
| 26 | + const [selected, setSelected] = useState<Record<string, unknown> | null>(null); |
| 27 | + |
| 28 | + useEffect(() => { try { const s = localStorage.getItem(STYLE_KEY) as StyleChoice | null; if (s) setStyle(s); } catch { /* */ } }, []); |
| 29 | + |
| 30 | + const renderGeo = () => { |
| 31 | + const map = mapRef.current; |
| 32 | + if (!map || !map.isStyleLoaded()) return; |
| 33 | + const existing = map.getSource('geo') as GeoJSONSource | undefined; |
| 34 | + if (existing) { existing.setData(fcRef.current); return; } |
| 35 | + map.addSource('geo', { type: 'geojson', data: fcRef.current }); |
| 36 | + map.addLayer({ id: 'geo-fill', type: 'fill', source: 'geo', paint: { 'fill-color': '#7c3aed', 'fill-opacity': 0.2 } }); |
| 37 | + map.addLayer({ id: 'geo-line', type: 'line', source: 'geo', paint: { 'line-color': '#7c3aed', 'line-width': 2.5 } }); |
| 38 | + map.addLayer({ id: 'geo-point', type: 'circle', source: 'geo', paint: { 'circle-radius': 5, 'circle-color': '#dc2626', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5 } }); |
| 39 | + }; |
| 40 | + |
| 41 | + const fit = () => { |
| 42 | + const bbox = computeBbox(fcRef.current); |
| 43 | + if (bbox && mapRef.current) mapRef.current.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 40, maxZoom: 16, duration: 600 }); |
| 44 | + }; |
| 45 | + |
| 46 | + useEffect(() => { |
| 47 | + let cancelled = false; |
| 48 | + (async () => { |
| 49 | + const ml = await import('maplibre-gl'); |
| 50 | + if (cancelled || !containerRef.current || mapRef.current) return; |
| 51 | + mlRef.current = ml; |
| 52 | + const map = new ml.Map({ container: containerRef.current, style: resolveStyle(style, theme).url, center: [0, 20], zoom: 1.3 }); |
| 53 | + map.addControl(new ml.NavigationControl(), 'top-right'); |
| 54 | + map.on('style.load', () => { renderGeo(); }); |
| 55 | + map.on('click', e => { |
| 56 | + const feats = map.queryRenderedFeatures(e.point, { layers: ['geo-fill', 'geo-line', 'geo-point'] }); |
| 57 | + setSelected(feats[0]?.properties ?? null); |
| 58 | + }); |
| 59 | + mapRef.current = map; |
| 60 | + })(); |
| 61 | + return () => { cancelled = true; mapRef.current?.remove(); mapRef.current = null; }; |
| 62 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 63 | + }, []); |
| 64 | + |
| 65 | + useEffect(() => { mapRef.current?.setStyle(resolveStyle(style, theme).url); }, [style, theme]); |
| 66 | + const pickStyle = (s: StyleChoice) => { setStyle(s); try { localStorage.setItem(STYLE_KEY, s); } catch { /* */ } }; |
| 67 | + |
| 68 | + const onDrop = async (files: File[]) => { |
| 69 | + const file = files[0]; |
| 70 | + if (!file) return; |
| 71 | + setError(''); |
| 72 | + setSelected(null); |
| 73 | + try { |
| 74 | + const fc = await parseGeoFile(await file.text(), file.name); |
| 75 | + if (!fc || fc.features.length === 0) { setError('No map features found in this file (expected GeoJSON, GPX or KML).'); return; } |
| 76 | + fcRef.current = fc; |
| 77 | + setCount(fc.features.length); |
| 78 | + renderGeo(); |
| 79 | + fit(); |
| 80 | + } catch { |
| 81 | + setError('Could not read this file.'); |
| 82 | + } |
| 83 | + }; |
| 84 | + |
| 85 | + return ( |
| 86 | + <div className="space-y-3"> |
| 87 | + <Dropzone onDrop={onDrop} accept=".geojson,.json,.gpx,.kml,application/geo+json,application/gpx+xml,application/vnd.google-earth.kml+xml" multiple={false}> |
| 88 | + <div className="space-y-1"> |
| 89 | + <p className="text-lg font-bold">Drop a GeoJSON, GPX or KML file</p> |
| 90 | + <p className="text-sm text-muted-foreground">View tracks & features on a map · the file stays on your device</p> |
| 91 | + </div> |
| 92 | + </Dropzone> |
| 93 | + |
| 94 | + <div className="flex flex-wrap items-center gap-2"> |
| 95 | + <span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Style</span> |
| 96 | + {MAP_STYLES.map(s => ( |
| 97 | + <Button key={s.id} variant={style === s.id ? 'primary' : 'secondary'} aria-pressed={style === s.id} onClick={() => pickStyle(s.id)}>{s.label}</Button> |
| 98 | + ))} |
| 99 | + {count > 0 && <Button variant="ghost" onClick={fit}>Fit to data</Button>} |
| 100 | + </div> |
| 101 | + |
| 102 | + {error && <Alert variant="error">{error}</Alert>} |
| 103 | + |
| 104 | + <div ref={containerRef} className="h-[60vh] w-full border-2 border-border" /> |
| 105 | + |
| 106 | + <p className="text-xs text-muted-foreground"> |
| 107 | + {count > 0 ? `${count} feature${count === 1 ? '' : 's'} · click one to see its properties. ` : ''} |
| 108 | + Maps © OpenFreeMap / OpenStreetMap contributors. |
| 109 | + </p> |
| 110 | + |
| 111 | + {selected && ( |
| 112 | + <div className="space-y-1 border-2 border-border p-3"> |
| 113 | + <p className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Feature properties</p> |
| 114 | + {Object.keys(selected).length === 0 && <p className="text-sm text-muted-foreground">(no properties)</p>} |
| 115 | + {Object.entries(selected).map(([k, v]) => ( |
| 116 | + <div key={k} className="flex flex-wrap gap-2 text-sm"> |
| 117 | + <span className="font-bold">{k}</span> |
| 118 | + <span className="min-w-0 break-all text-muted-foreground">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</span> |
| 119 | + </div> |
| 120 | + ))} |
| 121 | + </div> |
| 122 | + )} |
| 123 | + </div> |
| 124 | + ); |
| 125 | +} |
0 commit comments