Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
125 changes: 125 additions & 0 deletions src/islands/maps/GeoViewer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const mapRef = useRef<MlMap | null>(null);
const mlRef = useRef<typeof import('maplibre-gl') | null>(null);
const fcRef = useRef<FeatureCollection>(EMPTY);

const [style, setStyle] = useState<StyleChoice>('auto');
const [count, setCount] = useState(0);
const [error, setError] = useState('');
const [selected, setSelected] = useState<Record<string, unknown> | 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 (
<div className="space-y-3">
<Dropzone onDrop={onDrop} accept=".geojson,.json,.gpx,.kml,application/geo+json,application/gpx+xml,application/vnd.google-earth.kml+xml" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop a GeoJSON, GPX or KML file</p>
<p className="text-sm text-muted-foreground">View tracks &amp; features on a map · the file stays on your device</p>
</div>
</Dropzone>

<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Style</span>
{MAP_STYLES.map(s => (
<Button key={s.id} variant={style === s.id ? 'primary' : 'secondary'} aria-pressed={style === s.id} onClick={() => pickStyle(s.id)}>{s.label}</Button>
))}
{count > 0 && <Button variant="ghost" onClick={fit}>Fit to data</Button>}
</div>

{error && <Alert variant="error">{error}</Alert>}

<div ref={containerRef} className="h-[60vh] w-full border-2 border-border" />

<p className="text-xs text-muted-foreground">
{count > 0 ? `${count} feature${count === 1 ? '' : 's'} · click one to see its properties. ` : ''}
Maps © OpenFreeMap / OpenStreetMap contributors.
</p>

{selected && (
<div className="space-y-1 border-2 border-border p-3">
<p className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Feature properties</p>
{Object.keys(selected).length === 0 && <p className="text-sm text-muted-foreground">(no properties)</p>}
{Object.entries(selected).map(([k, v]) => (
<div key={k} className="flex flex-wrap gap-2 text-sm">
<span className="font-bold">{k}</span>
<span className="min-w-0 break-all text-muted-foreground">{typeof v === 'object' ? JSON.stringify(v) : String(v)}</span>
</div>
))}
</div>
)}
</div>
);
}
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand Down Expand Up @@ -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',
Expand Down
49 changes: 49 additions & 0 deletions src/tools/geo/geo-parse.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
67 changes: 67 additions & 0 deletions src/tools/geo/geo-parse.lib.ts
Original file line number Diff line number Diff line change
@@ -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<FeatureCollection | null> {
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);
}
Loading