diff --git a/astro.config.mjs b/astro.config.mjs index 1250466..9d965b9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -113,6 +113,7 @@ export default defineConfig({ '**/ort-*.wasm', '**/transformers*.js', '**/*huggingface*.js', + '**/maplibre-gl*.js', ], runtimeCaching: [ { diff --git a/package-lock.json b/package-lock.json index a0afd3f..f8e8d3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "jsqr": "^1.4.0", "libarchive.js": "^2.0.2", "lucide-react": "^0.294.0", + "maplibre-gl": "^6.1.0", "marked": "^18.0.6", "monaco-editor": "^0.52.2", "mupdf": "^1.28.0", @@ -4337,6 +4338,92 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.35", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz", @@ -10444,6 +10531,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -11966,6 +12059,12 @@ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "license": "ISC" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -13411,6 +13510,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -13499,6 +13604,12 @@ "node": ">= 12" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13841,6 +13952,38 @@ "source-map-js": "^1.2.0" } }, + "node_modules/maplibre-gl": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.1.0.tgz", + "integrity": "sha512-vLRukjvbUai4SXW2/jKo8rPsw6YMXuA/b4fKFVSulZKFVRHkOvk4ZmNlDSVZpTYPV0/7/iTqq8ltYdyr764b7w==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -15343,6 +15486,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -15410,6 +15562,12 @@ "integrity": "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg==", "license": "AGPL-3.0-or-later" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -16037,6 +16195,18 @@ "node": "*" } }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -16383,6 +16553,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/ppu-ocv": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ppu-ocv/-/ppu-ocv-4.0.0.tgz", @@ -16702,6 +16878,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -16775,6 +16957,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -17270,6 +17458,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -18696,6 +18893,12 @@ "node": ">=14.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyspy": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", diff --git a/package.json b/package.json index c21fb1d..fa44995 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "jsqr": "^1.4.0", "libarchive.js": "^2.0.2", "lucide-react": "^0.294.0", + "maplibre-gl": "^6.1.0", "marked": "^18.0.6", "monaco-editor": "^0.52.2", "mupdf": "^1.28.0", diff --git a/src/islands/maps/MapExplorer.tsx b/src/islands/maps/MapExplorer.tsx new file mode 100644 index 0000000..0d90717 --- /dev/null +++ b/src/islands/maps/MapExplorer.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '@nanostores/react'; +import { LocateFixed, Ruler, Search, X } from 'lucide-react'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { Map as MlMap, Marker as MlMarker, GeoJSONSource } from 'maplibre-gl'; +import { themeAtom } from '@/stores/theme.store'; +import { Button } from '@/components/ui/Button'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { resolveStyle, MAP_STYLES, haversineMeters, formatDistance, type StyleChoice } from '@/tools/geo/map-styles.lib'; +import { ddToDms, ddToUtm, encodeGeohash, formatDd, type LatLng } from '@/tools/geo/coord.lib'; + +const STYLE_KEY = 'gwt.map.style'; +type SearchHit = { name: string; lat: number; lng: number }; + +export default function MapExplorer() { + const theme = useStore(themeAtom); + const containerRef = useRef(null); + const mapRef = useRef(null); + const mlRef = useRef(null); + const pinMarkerRef = useRef(null); + const measureMarkersRef = useRef([]); + const measureRef = useRef([]); + const measuringRef = useRef(false); + + const [style, setStyle] = useState('auto'); + const [pin, setPin] = useState(null); + const [measuring, setMeasuring] = useState(false); + const [measureDist, setMeasureDist] = useState(0); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { measuringRef.current = measuring; }, [measuring]); + + // Restore saved style choice. + useEffect(() => { + try { const s = localStorage.getItem(STYLE_KEY) as StyleChoice | null; if (s) setStyle(s); } catch { /* */ } + }, []); + + const ensureMeasureLayer = () => { + const map = mapRef.current; + if (!map || map.getSource('measure')) return; + map.addSource('measure', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] }, properties: {} } }); + map.addLayer({ id: 'measure-line', type: 'line', source: 'measure', paint: { 'line-color': '#7c3aed', 'line-width': 3, 'line-dasharray': [2, 1] } }); + }; + + const refreshMeasureLine = () => { + const map = mapRef.current; + const src = map?.getSource('measure') as GeoJSONSource | undefined; + src?.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: measureRef.current.map(p => [p.lng, p.lat]) }, properties: {} }); + }; + + const handleClick = (lat: number, lng: number) => { + const ml = mlRef.current; + const map = mapRef.current; + if (!ml || !map) return; + if (measuringRef.current) { + measureRef.current.push({ lat, lng }); + const marker = new ml.Marker({ color: '#7c3aed', scale: 0.7 }).setLngLat([lng, lat]).addTo(map); + measureMarkersRef.current.push(marker); + let total = 0; + for (let i = 1; i < measureRef.current.length; i++) total += haversineMeters(measureRef.current[i - 1], measureRef.current[i]); + setMeasureDist(total); + refreshMeasureLine(); + } else { + setPin({ lat, lng }); + if (!pinMarkerRef.current) pinMarkerRef.current = new ml.Marker({ color: '#dc2626' }).setLngLat([lng, lat]).addTo(map); + else pinMarkerRef.current.setLngLat([lng, lat]); + } + }; + + // Init the map once. + useEffect(() => { + let cancelled = false; + (async () => { + const ml = await import('maplibre-gl'); + if (cancelled || !containerRef.current || mapRef.current) return; + mlRef.current = ml; + const map = new ml.Map({ + container: containerRef.current, + style: resolveStyle(style, theme).url, + center: [106.8272, -6.1751], + zoom: 3, + }); + map.addControl(new ml.NavigationControl(), 'top-right'); + map.addControl(new ml.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: false }), 'top-right'); + map.on('click', e => handleClick(e.lngLat.lat, e.lngLat.lng)); + map.on('style.load', () => { ensureMeasureLayer(); refreshMeasureLine(); }); + mapRef.current = map; + })(); + return () => { cancelled = true; mapRef.current?.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Re-style on choice or site-theme change. + useEffect(() => { + mapRef.current?.setStyle(resolveStyle(style, theme).url); + }, [style, theme]); + + const pickStyle = (s: StyleChoice) => { setStyle(s); try { localStorage.setItem(STYLE_KEY, s); } catch { /* */ } }; + + const clearMeasure = () => { + measureMarkersRef.current.forEach(m => m.remove()); + measureMarkersRef.current = []; + measureRef.current = []; + setMeasureDist(0); + refreshMeasureLine(); + }; + + const toggleMeasure = () => { + if (measuring) clearMeasure(); + setMeasuring(m => !m); + }; + + const search = async () => { + if (!query.trim()) return; + setSearching(true); + try { + const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(query)}`, { headers: { Accept: 'application/json' } }); + const data = (await res.json()) as { display_name: string; lat: string; lon: string }[]; + setResults(data.map(d => ({ name: d.display_name, lat: +d.lat, lng: +d.lon }))); + } catch { setResults([]); } finally { setSearching(false); } + }; + + const goTo = (hit: SearchHit) => { + setResults([]); + setQuery(hit.name.split(',')[0]); + mapRef.current?.flyTo({ center: [hit.lng, hit.lat], zoom: 14 }); + handleClick(hit.lat, hit.lng); + }; + + const myLocation = () => { + navigator.geolocation?.getCurrentPosition(pos => { + mapRef.current?.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 15 }); + handleClick(pos.coords.latitude, pos.coords.longitude); + }); + }; + + const coordRows = pin + ? [ + { label: 'Decimal', value: formatDd(pin.lat, pin.lng) }, + { label: 'DMS', value: `${ddToDms(pin.lat, pin.lng).lat} ${ddToDms(pin.lat, pin.lng).lng}` }, + { label: 'UTM', value: (() => { const u = ddToUtm(pin.lat, pin.lng); return `${u.zone}${u.hemisphere} ${Math.round(u.easting)}E ${Math.round(u.northing)}N`; })() }, + { label: 'Geohash', value: encodeGeohash(pin.lat, pin.lng, 10) }, + ] + : []; + + return ( +
+
+
+ setQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') search(); }} + placeholder="Search a place…" + className="w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm" + /> + + {results.length > 0 && ( +
+ {results.map((r, i) => ( + + ))} +
+ )} +
+ +
+ +
+ Style + {MAP_STYLES.map(s => ( + + ))} + + {measuring && measureRef.current.length > 0 && } +
+ +
+ +

+ {measuring ? 'Click points on the map to measure distance.' : 'Click anywhere on the map to drop a pin and read its coordinates.'} + {' '}Maps © OpenFreeMap / OpenStreetMap contributors. +

+ + {measuring && measureRef.current.length > 1 && ( +

Distance: {formatDistance(measureDist)} ({measureRef.current.length} points)

+ )} + + {pin && !measuring && ( +
+ {coordRows.map(r => ( +
+ {r.label} + {r.value} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index b9df746..701a765 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -608,6 +608,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/maps/CoordConvert'), status: 'beta' }, + { + id: 'map-explorer', + name: 'Map Explorer', + category: 'Maps', + route: '/tools/map-explorer', + keywords: ['map', 'maps', 'explore', 'search', 'place', 'coordinates', 'measure', 'distance', 'openstreetmap', 'pin', 'location'], + icon: Map, + summary: 'Search places, drop a pin for coordinates, and measure distance', + load: () => import('@/islands/maps/MapExplorer'), + status: 'beta' + }, { id: 'file-crypt', name: 'File Encrypt / Decrypt', diff --git a/src/tools/geo/map-styles.lib.test.ts b/src/tools/geo/map-styles.lib.test.ts new file mode 100644 index 0000000..76adfd1 --- /dev/null +++ b/src/tools/geo/map-styles.lib.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { resolveStyle, MAP_STYLES, haversineMeters } from './map-styles.lib'; + +describe('resolveStyle', () => { + it('auto follows the site theme', () => { + expect(resolveStyle('auto', 'dark').id).toBe('dark'); + expect(resolveStyle('auto', 'light').id).toBe('liberty'); + }); + it('an explicit choice overrides the theme', () => { + expect(resolveStyle('positron', 'dark').id).toBe('positron'); + expect(resolveStyle('dark', 'light').id).toBe('dark'); + }); + it('produces an OpenFreeMap style url', () => { + expect(resolveStyle('bright', 'light').url).toBe('https://tiles.openfreemap.org/styles/bright'); + }); +}); + +describe('MAP_STYLES', () => { + it('exposes auto + the four OpenFreeMap styles', () => { + expect(MAP_STYLES.map(s => s.id)).toEqual(['auto', 'liberty', 'bright', 'positron', 'dark']); + }); +}); + +describe('haversineMeters', () => { + it('measures ~111km per degree of latitude', () => { + expect(haversineMeters({ lat: 0, lng: 0 }, { lat: 1, lng: 0 })).toBeCloseTo(111195, -2); + }); + it('is zero for the same point', () => { + expect(haversineMeters({ lat: 40, lng: -74 }, { lat: 40, lng: -74 })).toBe(0); + }); +}); diff --git a/src/tools/geo/map-styles.lib.ts b/src/tools/geo/map-styles.lib.ts new file mode 100644 index 0000000..c017903 --- /dev/null +++ b/src/tools/geo/map-styles.lib.ts @@ -0,0 +1,36 @@ +import type { LatLng } from './coord.lib'; + +/** OpenFreeMap styles (free, open, no API key). 'auto' follows the site theme. */ +export type StyleChoice = 'auto' | 'liberty' | 'bright' | 'positron' | 'dark'; +export type ConcreteStyle = Exclude; + +export const MAP_STYLES: { id: StyleChoice; label: string }[] = [ + { id: 'auto', label: 'Match site' }, + { id: 'liberty', label: 'Liberty' }, + { id: 'bright', label: 'Bright' }, + { id: 'positron', label: 'Positron' }, + { id: 'dark', label: 'Dark' }, +]; + +/** Resolve a style choice (+ current site theme) to a concrete OpenFreeMap style. */ +export function resolveStyle(choice: StyleChoice, siteTheme: 'light' | 'dark'): { id: ConcreteStyle; url: string } { + const id: ConcreteStyle = choice === 'auto' ? (siteTheme === 'dark' ? 'dark' : 'liberty') : choice; + return { id, url: `https://tiles.openfreemap.org/styles/${id}` }; +} + +/** Great-circle distance between two points, in metres. */ +export function haversineMeters(a: LatLng, b: LatLng): number { + const R = 6371008.8; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(b.lat - a.lat); + const dLng = toRad(b.lng - a.lng); + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); +} + +/** Human-readable distance. */ +export function formatDistance(meters: number): string { + return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(2)} km`; +}