Skip to content

Commit bbcdac0

Browse files
authored
Merge pull request #99 from slaveofcode/develop
Promote to production: GeoJSON/GPX/KML viewer
2 parents a567f7b + 27511fb commit bbcdac0

6 files changed

Lines changed: 264 additions & 1 deletion

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@tauri-apps/plugin-http": "^2.5.9",
6868
"@tauri-apps/plugin-updater": "^2.10.1",
6969
"@tensorflow/tfjs": "^4.22.0",
70+
"@tmcw/togeojson": "^7.1.2",
7071
"@upscalerjs/esrgan-slim": "^1.0.0",
7172
"@xyflow/react": "^12.11.2",
7273
"astro": "^4.16.19",

src/islands/maps/GeoViewer.tsx

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 &amp; 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+
}

src/registry/tools.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
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';
1+
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';
22
import type { ToolDef } from '@/types/tool';
33

44
export const tools: ToolDef[] = [
@@ -619,6 +619,17 @@ export const tools: ToolDef[] = [
619619
load: () => import('@/islands/maps/MapExplorer'),
620620
status: 'beta'
621621
},
622+
{
623+
id: 'geo-viewer',
624+
name: 'GeoJSON / GPX / KML Viewer',
625+
category: 'Maps',
626+
route: '/tools/geo-viewer',
627+
keywords: ['geojson', 'gpx', 'kml', 'viewer', 'map', 'gps', 'track', 'route', 'geo', 'features'],
628+
icon: Waypoints,
629+
summary: 'View GeoJSON, GPX and KML files on a map — stays on your device',
630+
load: () => import('@/islands/maps/GeoViewer'),
631+
status: 'beta'
632+
},
622633
{
623634
id: 'file-crypt',
624635
name: 'File Encrypt / Decrypt',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { normalizeGeoJson, computeBbox, parseGeoJsonText } from './geo-parse.lib';
3+
4+
describe('normalizeGeoJson', () => {
5+
it('wraps a bare geometry into a FeatureCollection', () => {
6+
const fc = normalizeGeoJson({ type: 'Point', coordinates: [10, 20] });
7+
expect(fc?.type).toBe('FeatureCollection');
8+
expect(fc?.features).toHaveLength(1);
9+
expect(fc?.features[0].geometry).toEqual({ type: 'Point', coordinates: [10, 20] });
10+
});
11+
it('wraps a bare Feature', () => {
12+
const fc = normalizeGeoJson({ type: 'Feature', geometry: { type: 'Point', coordinates: [1, 2] }, properties: { a: 1 } });
13+
expect(fc?.features).toHaveLength(1);
14+
expect(fc?.features[0].properties).toEqual({ a: 1 });
15+
});
16+
it('passes a FeatureCollection through', () => {
17+
const input = { type: 'FeatureCollection', features: [] };
18+
expect(normalizeGeoJson(input)?.type).toBe('FeatureCollection');
19+
});
20+
it('rejects non-geojson', () => {
21+
expect(normalizeGeoJson({ hello: 'world' })).toBeNull();
22+
expect(normalizeGeoJson(null)).toBeNull();
23+
});
24+
});
25+
26+
describe('computeBbox', () => {
27+
it('spans all coordinates across geometry types', () => {
28+
const fc = {
29+
type: 'FeatureCollection' as const,
30+
features: [
31+
{ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: [0, 0] } },
32+
{ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: [[10, -5], [-3, 8]] } },
33+
],
34+
};
35+
expect(computeBbox(fc)).toEqual([-3, -5, 10, 8]);
36+
});
37+
it('returns null when there are no coordinates', () => {
38+
expect(computeBbox({ type: 'FeatureCollection', features: [] })).toBeNull();
39+
});
40+
});
41+
42+
describe('parseGeoJsonText', () => {
43+
it('parses valid text', () => {
44+
expect(parseGeoJsonText('{"type":"Point","coordinates":[1,2]}')?.features).toHaveLength(1);
45+
});
46+
it('returns null on invalid JSON', () => {
47+
expect(parseGeoJsonText('not json')).toBeNull();
48+
});
49+
});

src/tools/geo/geo-parse.lib.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { FeatureCollection, Geometry, Position } from 'geojson';
2+
3+
/** Coerce parsed JSON into a FeatureCollection (accepts geometry / Feature / FC). */
4+
export function normalizeGeoJson(input: unknown): FeatureCollection | null {
5+
if (!input || typeof input !== 'object') return null;
6+
const obj = input as { type?: string };
7+
if (obj.type === 'FeatureCollection') return input as FeatureCollection;
8+
if (obj.type === 'Feature') {
9+
return { type: 'FeatureCollection', features: [input as FeatureCollection['features'][number]] };
10+
}
11+
const GEOM = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'];
12+
if (obj.type && GEOM.includes(obj.type)) {
13+
return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: input as Geometry }] };
14+
}
15+
return null;
16+
}
17+
18+
export function parseGeoJsonText(text: string): FeatureCollection | null {
19+
try {
20+
return normalizeGeoJson(JSON.parse(text));
21+
} catch {
22+
return null;
23+
}
24+
}
25+
26+
function eachPosition(geom: Geometry | null, fn: (p: Position) => void): void {
27+
if (!geom) return;
28+
if (geom.type === 'GeometryCollection') { geom.geometries.forEach(g => eachPosition(g, fn)); return; }
29+
const walk = (c: unknown): void => {
30+
if (Array.isArray(c) && typeof c[0] === 'number') fn(c as Position);
31+
else if (Array.isArray(c)) c.forEach(walk);
32+
};
33+
walk((geom as { coordinates?: unknown }).coordinates);
34+
}
35+
36+
/** [minLng, minLat, maxLng, maxLat] over every coordinate, or null if empty. */
37+
export function computeBbox(fc: FeatureCollection): [number, number, number, number] | null {
38+
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
39+
let any = false;
40+
for (const f of fc.features) {
41+
eachPosition(f.geometry, ([lng, lat]) => {
42+
any = true;
43+
if (lng < minLng) minLng = lng;
44+
if (lat < minLat) minLat = lat;
45+
if (lng > maxLng) maxLng = lng;
46+
if (lat > maxLat) maxLat = lat;
47+
});
48+
}
49+
return any ? [minLng, minLat, maxLng, maxLat] : null;
50+
}
51+
52+
/**
53+
* Parse a dropped geo file (GeoJSON/JSON, GPX, or KML) into a FeatureCollection.
54+
* GPX/KML use the DOM XML parser + @tmcw/togeojson (browser).
55+
*/
56+
export async function parseGeoFile(text: string, filename: string): Promise<FeatureCollection | null> {
57+
const ext = filename.toLowerCase().split('.').pop();
58+
if (ext === 'geojson' || ext === 'json') return parseGeoJsonText(text);
59+
if (ext === 'gpx' || ext === 'kml') {
60+
const { gpx, kml } = await import('@tmcw/togeojson');
61+
const dom = new DOMParser().parseFromString(text, 'text/xml');
62+
const fc = ext === 'gpx' ? gpx(dom) : kml(dom);
63+
return normalizeGeoJson(fc);
64+
}
65+
// Fall back to a JSON attempt.
66+
return parseGeoJsonText(text);
67+
}

0 commit comments

Comments
 (0)