From 0395ba1ac356084c1c71ebb333d14e898f3ca53e Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 20:51:43 +0700 Subject: [PATCH] feat(maps): GeoJSON / GPX / KML viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third maps tool. Drop a GeoJSON/GPX/KML file → view features & GPS tracks on a MapLibre + OpenFreeMap map, click a feature to see its properties. The file never leaves the device. - geo-parse.lib (tested): normalizeGeoJson (geometry/Feature/FC), computeBbox, parseGeoJsonText, parseGeoFile (GPX/KML via @tmcw/togeojson + DOMParser). - Island reuses the map-styles picker (all OpenFreeMap styles, Match-site default, theme-synced) + fill/line/circle layers, fit-to-bounds, feature property panel. 591 tests · lint clean · build green (/tools/geo-viewer built). Map render is build + manual smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 10 +++ package.json | 1 + src/islands/maps/GeoViewer.tsx | 125 ++++++++++++++++++++++++++++ src/registry/tools.ts | 13 ++- src/tools/geo/geo-parse.lib.test.ts | 49 +++++++++++ src/tools/geo/geo-parse.lib.ts | 67 +++++++++++++++ 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/islands/maps/GeoViewer.tsx create mode 100644 src/tools/geo/geo-parse.lib.test.ts create mode 100644 src/tools/geo/geo-parse.lib.ts diff --git a/package-lock.json b/package-lock.json index f8e8d3c..44fe3d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-updater": "^2.10.1", "@tensorflow/tfjs": "^4.22.0", + "@tmcw/togeojson": "^7.1.2", "@upscalerjs/esrgan-slim": "^1.0.0", "@xyflow/react": "^12.11.2", "astro": "^4.16.19", @@ -7096,6 +7097,15 @@ "react-dom": "^18.0.0" } }, + "node_modules/@tmcw/togeojson": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@tmcw/togeojson/-/togeojson-7.1.2.tgz", + "integrity": "sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A==", + "license": "BSD-2-Clause", + "engines": { + "node": "*" + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", diff --git a/package.json b/package.json index fa44995..68753ec 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-updater": "^2.10.1", "@tensorflow/tfjs": "^4.22.0", + "@tmcw/togeojson": "^7.1.2", "@upscalerjs/esrgan-slim": "^1.0.0", "@xyflow/react": "^12.11.2", "astro": "^4.16.19", diff --git a/src/islands/maps/GeoViewer.tsx b/src/islands/maps/GeoViewer.tsx new file mode 100644 index 0000000..48b536e --- /dev/null +++ b/src/islands/maps/GeoViewer.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '@nanostores/react'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { Map as MlMap, GeoJSONSource } from 'maplibre-gl'; +import type { FeatureCollection } from 'geojson'; +import { themeAtom } from '@/stores/theme.store'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { resolveStyle, MAP_STYLES, type StyleChoice } from '@/tools/geo/map-styles.lib'; +import { parseGeoFile, computeBbox } from '@/tools/geo/geo-parse.lib'; + +const STYLE_KEY = 'gwt.map.style'; +const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +export default function GeoViewer() { + const theme = useStore(themeAtom); + const containerRef = useRef(null); + const mapRef = useRef(null); + const mlRef = useRef(null); + const fcRef = useRef(EMPTY); + + const [style, setStyle] = useState('auto'); + const [count, setCount] = useState(0); + const [error, setError] = useState(''); + const [selected, setSelected] = useState | null>(null); + + useEffect(() => { try { const s = localStorage.getItem(STYLE_KEY) as StyleChoice | null; if (s) setStyle(s); } catch { /* */ } }, []); + + const renderGeo = () => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + const existing = map.getSource('geo') as GeoJSONSource | undefined; + if (existing) { existing.setData(fcRef.current); return; } + map.addSource('geo', { type: 'geojson', data: fcRef.current }); + map.addLayer({ id: 'geo-fill', type: 'fill', source: 'geo', paint: { 'fill-color': '#7c3aed', 'fill-opacity': 0.2 } }); + map.addLayer({ id: 'geo-line', type: 'line', source: 'geo', paint: { 'line-color': '#7c3aed', 'line-width': 2.5 } }); + 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 } }); + }; + + const fit = () => { + const bbox = computeBbox(fcRef.current); + if (bbox && mapRef.current) mapRef.current.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 40, maxZoom: 16, duration: 600 }); + }; + + 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: [0, 20], zoom: 1.3 }); + map.addControl(new ml.NavigationControl(), 'top-right'); + map.on('style.load', () => { renderGeo(); }); + map.on('click', e => { + const feats = map.queryRenderedFeatures(e.point, { layers: ['geo-fill', 'geo-line', 'geo-point'] }); + setSelected(feats[0]?.properties ?? null); + }); + 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 { /* */ } }; + + const onDrop = async (files: File[]) => { + const file = files[0]; + if (!file) return; + setError(''); + setSelected(null); + try { + const fc = await parseGeoFile(await file.text(), file.name); + if (!fc || fc.features.length === 0) { setError('No map features found in this file (expected GeoJSON, GPX or KML).'); return; } + fcRef.current = fc; + setCount(fc.features.length); + renderGeo(); + fit(); + } catch { + setError('Could not read this file.'); + } + }; + + return ( +
+ +
+

Drop a GeoJSON, GPX or KML file

+

View tracks & features on a map · the file stays on your device

+
+
+ +
+ Style + {MAP_STYLES.map(s => ( + + ))} + {count > 0 && } +
+ + {error && {error}} + +
+ +

+ {count > 0 ? `${count} feature${count === 1 ? '' : 's'} · click one to see its properties. ` : ''} + Maps © OpenFreeMap / OpenStreetMap contributors. +

+ + {selected && ( +
+

Feature properties

+ {Object.keys(selected).length === 0 &&

(no properties)

} + {Object.entries(selected).map(([k, v]) => ( +
+ {k} + {typeof v === 'object' ? JSON.stringify(v) : String(v)} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 701a765..0c26712 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 } 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 } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -619,6 +619,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/maps/MapExplorer'), status: 'beta' }, + { + id: 'geo-viewer', + name: 'GeoJSON / GPX / KML Viewer', + category: 'Maps', + route: '/tools/geo-viewer', + keywords: ['geojson', 'gpx', 'kml', 'viewer', 'map', 'gps', 'track', 'route', 'geo', 'features'], + icon: Waypoints, + summary: 'View GeoJSON, GPX and KML files on a map — stays on your device', + load: () => import('@/islands/maps/GeoViewer'), + status: 'beta' + }, { id: 'file-crypt', name: 'File Encrypt / Decrypt', diff --git a/src/tools/geo/geo-parse.lib.test.ts b/src/tools/geo/geo-parse.lib.test.ts new file mode 100644 index 0000000..7daa360 --- /dev/null +++ b/src/tools/geo/geo-parse.lib.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeGeoJson, computeBbox, parseGeoJsonText } from './geo-parse.lib'; + +describe('normalizeGeoJson', () => { + it('wraps a bare geometry into a FeatureCollection', () => { + const fc = normalizeGeoJson({ type: 'Point', coordinates: [10, 20] }); + expect(fc?.type).toBe('FeatureCollection'); + expect(fc?.features).toHaveLength(1); + expect(fc?.features[0].geometry).toEqual({ type: 'Point', coordinates: [10, 20] }); + }); + it('wraps a bare Feature', () => { + const fc = normalizeGeoJson({ type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: { a: 1 } }); + expect(fc?.features).toHaveLength(1); + expect(fc?.features[0].properties).toEqual({ a: 1 }); + }); + it('passes a FeatureCollection through', () => { + const input = { type: 'FeatureCollection', features: [] }; + expect(normalizeGeoJson(input)?.type).toBe('FeatureCollection'); + }); + it('rejects non-geojson', () => { + expect(normalizeGeoJson({ hello: 'world' })).toBeNull(); + expect(normalizeGeoJson(null)).toBeNull(); + }); +}); + +describe('computeBbox', () => { + it('spans all coordinates across geometry types', () => { + const fc = { + type: 'FeatureCollection' as const, + features: [ + { type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [0, 0] } }, + { type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: [[10, -5], [-3, 8]] } }, + ], + }; + expect(computeBbox(fc)).toEqual([-3, -5, 10, 8]); + }); + it('returns null when there are no coordinates', () => { + expect(computeBbox({ type: 'FeatureCollection', features: [] })).toBeNull(); + }); +}); + +describe('parseGeoJsonText', () => { + it('parses valid text', () => { + expect(parseGeoJsonText('{"type":"Point","coordinates":[1,2]}')?.features).toHaveLength(1); + }); + it('returns null on invalid JSON', () => { + expect(parseGeoJsonText('not json')).toBeNull(); + }); +}); diff --git a/src/tools/geo/geo-parse.lib.ts b/src/tools/geo/geo-parse.lib.ts new file mode 100644 index 0000000..af1efca --- /dev/null +++ b/src/tools/geo/geo-parse.lib.ts @@ -0,0 +1,67 @@ +import type { FeatureCollection, Geometry, Position } from 'geojson'; + +/** Coerce parsed JSON into a FeatureCollection (accepts geometry / Feature / FC). */ +export function normalizeGeoJson(input: unknown): FeatureCollection | null { + if (!input || typeof input !== 'object') return null; + const obj = input as { type?: string }; + if (obj.type === 'FeatureCollection') return input as FeatureCollection; + if (obj.type === 'Feature') { + return { type: 'FeatureCollection', features: [input as FeatureCollection['features'][number]] }; + } + const GEOM = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection']; + if (obj.type && GEOM.includes(obj.type)) { + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: input as Geometry }] }; + } + return null; +} + +export function parseGeoJsonText(text: string): FeatureCollection | null { + try { + return normalizeGeoJson(JSON.parse(text)); + } catch { + return null; + } +} + +function eachPosition(geom: Geometry | null, fn: (p: Position) => void): void { + if (!geom) return; + if (geom.type === 'GeometryCollection') { geom.geometries.forEach(g => eachPosition(g, fn)); return; } + const walk = (c: unknown): void => { + if (Array.isArray(c) && typeof c[0] === 'number') fn(c as Position); + else if (Array.isArray(c)) c.forEach(walk); + }; + walk((geom as { coordinates?: unknown }).coordinates); +} + +/** [minLng, minLat, maxLng, maxLat] over every coordinate, or null if empty. */ +export function computeBbox(fc: FeatureCollection): [number, number, number, number] | null { + let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity; + let any = false; + for (const f of fc.features) { + eachPosition(f.geometry, ([lng, lat]) => { + any = true; + if (lng < minLng) minLng = lng; + if (lat < minLat) minLat = lat; + if (lng > maxLng) maxLng = lng; + if (lat > maxLat) maxLat = lat; + }); + } + return any ? [minLng, minLat, maxLng, maxLat] : null; +} + +/** + * Parse a dropped geo file (GeoJSON/JSON, GPX, or KML) into a FeatureCollection. + * GPX/KML use the DOM XML parser + @tmcw/togeojson (browser). + */ +export async function parseGeoFile(text: string, filename: string): Promise { + const ext = filename.toLowerCase().split('.').pop(); + if (ext === 'geojson' || ext === 'json') return parseGeoJsonText(text); + if (ext === 'gpx' || ext === 'kml') { + const { gpx, kml } = await import('@tmcw/togeojson'); + const dom = new DOMParser().parseFromString(text, 'text/xml'); + const fc = ext === 'gpx' ? gpx(dom) : kml(dom); + return normalizeGeoJson(fc); + } + // Fall back to a JSON attempt. + return parseGeoJsonText(text); +}