diff --git a/src/islands/maps/StaticMap.tsx b/src/islands/maps/StaticMap.tsx new file mode 100644 index 0000000..ea4bd2c --- /dev/null +++ b/src/islands/maps/StaticMap.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '@nanostores/react'; +import { Search, LocateFixed, Download, MapPin } from 'lucide-react'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { Map as MlMap, Marker as MlMarker } from 'maplibre-gl'; +import { themeAtom } from '@/stores/theme.store'; +import { Button } from '@/components/ui/Button'; +import { downloadService } from '@/services/download'; +import { resolveStyle, MAP_STYLES, type StyleChoice } from '@/tools/geo/map-styles.lib'; + +const STYLE_KEY = 'gwt.map.style'; +type SearchHit = { name: string; lat: number; lng: number }; + +export default function StaticMap() { + const theme = useStore(themeAtom); + const containerRef = useRef(null); + const mapRef = useRef(null); + const mlRef = useRef(null); + const markerRef = useRef(null); + + const [style, setStyle] = useState('auto'); + const [pinCenter, setPinCenter] = useState(false); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { try { const s = localStorage.getItem(STYLE_KEY) as StyleChoice | null; if (s) setStyle(s); } catch { /* */ } }, []); + + 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: [2.3522, 48.8566], + zoom: 11, + preserveDrawingBuffer: true, // required to read the canvas for PNG export + }); + map.addControl(new ml.NavigationControl(), 'top-right'); + mapRef.current = map; + })(); + return () => { cancelled = true; mapRef.current?.remove(); mapRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { mapRef.current?.setStyle(resolveStyle(style, theme).url); }, [style, theme]); + const pickStyle = (s: StyleChoice) => { setStyle(s); try { localStorage.setItem(STYLE_KEY, s); } catch { /* */ } }; + + // Toggle a marker at the map centre. + useEffect(() => { + const map = mapRef.current; + const ml = mlRef.current; + if (!map || !ml) return; + if (pinCenter) { + if (!markerRef.current) markerRef.current = new ml.Marker({ color: '#dc2626' }); + const place = () => markerRef.current?.setLngLat(map.getCenter()).addTo(map); + place(); + map.on('move', place); + return () => { map.off('move', place); }; + } else { + markerRef.current?.remove(); + } + }, [pinCenter]); + + 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: 13 }); }; + const myLocation = () => navigator.geolocation?.getCurrentPosition(p => mapRef.current?.flyTo({ center: [p.coords.longitude, p.coords.latitude], zoom: 14 })); + + const downloadPng = () => { + const map = mapRef.current; + if (!map) return; + const capture = () => { + const src = map.getCanvas(); + const out = document.createElement('canvas'); + out.width = src.width; + out.height = src.height; + const ctx = out.getContext('2d'); + if (!ctx) return; + ctx.drawImage(src, 0, 0); + // Burn in the required attribution. + const text = '© OpenStreetMap contributors · OpenFreeMap'; + ctx.font = '13px sans-serif'; + const w = ctx.measureText(text).width; + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.fillRect(out.width - w - 14, out.height - 24, w + 14, 24); + ctx.fillStyle = '#111'; + ctx.fillText(text, out.width - w - 7, out.height - 7); + out.toBlob(b => { if (b) downloadService.download(b, 'map.png'); }, 'image/png'); + }; + map.once('idle', capture); + map.triggerRepaint(); + }; + + 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 => )} + +
+ +
+ +
+ + Pan & zoom to frame your map, then export the current view. Maps © OpenFreeMap / OpenStreetMap. +
+
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 0c26712..3b31f33 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, Map, Waypoints } 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, Waypoints, ImageDown } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -630,6 +630,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/maps/GeoViewer'), status: 'beta' }, + { + id: 'static-map', + name: 'Static Map Maker', + category: 'Maps', + route: '/tools/static-map', + keywords: ['static', 'map', 'image', 'png', 'export', 'screenshot', 'snapshot', 'download', 'openstreetmap'], + icon: ImageDown, + summary: 'Frame a map and export it as a PNG image', + load: () => import('@/islands/maps/StaticMap'), + status: 'beta' + }, { id: 'file-crypt', name: 'File Encrypt / Decrypt',