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
162 changes: 162 additions & 0 deletions src/islands/maps/CoordConvert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { LocateFixed } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { CopyButton } from '@/components/ui/CopyButton';
import {
parseLatLng,
formatDd,
ddToDms,
dmsToDd,
ddToUtm,
utmToDd,
encodeGeohash,
decodeGeohash,
type LatLng,
} from '@/tools/geo/coord.lib';

type Fmt = 'dd' | 'dms' | 'geohash' | 'utm';

const FORMATS: { value: Fmt; label: string }[] = [
{ value: 'dd', label: 'Decimal (DD)' },
{ value: 'dms', label: 'DMS' },
{ value: 'geohash', label: 'Geohash' },
{ value: 'utm', label: 'UTM' },
];

export default function CoordConvert() {
const [fmt, setFmt] = useState<Fmt>('dd');
const [dd, setDd] = useState('-6.2088, 106.8456');
const [dmsLat, setDmsLat] = useState('');
const [dmsLng, setDmsLng] = useState('');
const [geohash, setGeohash] = useState('');
const [utmZone, setUtmZone] = useState('');
const [utmHemi, setUtmHemi] = useState<'N' | 'S'>('N');
const [utmE, setUtmE] = useState('');
const [utmN, setUtmN] = useState('');
const [error, setError] = useState('');
const [locating, setLocating] = useState(false);

const point: LatLng | null = (() => {
if (fmt === 'dd') return parseLatLng(dd);
if (fmt === 'dms') return dmsLat && dmsLng ? dmsToDd(dmsLat, dmsLng) : null;
if (fmt === 'geohash') return geohash ? decodeGeohash(geohash) : null;
if (fmt === 'utm') {
const zone = parseInt(utmZone, 10);
const e = parseFloat(utmE);
const n = parseFloat(utmN);
if (!zone || !Number.isFinite(e) || !Number.isFinite(n)) return null;
return utmToDd({ zone, hemisphere: utmHemi, easting: e, northing: n });
}
return null;
})();

const useMyLocation = () => {
if (!navigator.geolocation) { setError('Geolocation isn’t available in this browser.'); return; }
setLocating(true);
setError('');
navigator.geolocation.getCurrentPosition(
pos => { setFmt('dd'); setDd(formatDd(pos.coords.latitude, pos.coords.longitude)); setLocating(false); },
() => { setError('Couldn’t get your location (permission denied or unavailable).'); setLocating(false); },
{ enableHighAccuracy: true, timeout: 10000 },
);
};

const outputs = point
? (() => {
const dms = ddToDms(point.lat, point.lng);
const utm = ddToUtm(point.lat, point.lng);
return [
{ label: 'Decimal (DD)', value: formatDd(point.lat, point.lng) },
{ label: 'DMS', value: `${dms.lat} ${dms.lng}` },
{ label: 'UTM', value: `${utm.zone}${utm.hemisphere} ${Math.round(utm.easting)}E ${Math.round(utm.northing)}N` },
{ label: 'Geohash', value: encodeGeohash(point.lat, point.lng, 10) },
{ label: 'Map link', value: `https://www.openstreetmap.org/?mlat=${point.lat}&mlon=${point.lng}#map=15/${point.lat}/${point.lng}` },
];
})()
: [];

const inputCls = 'w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm';

return (
<div className="space-y-4">
<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Input format</span>
<div className="flex flex-wrap gap-2">
{FORMATS.map(f => (
<Button key={f.value} variant={fmt === f.value ? 'primary' : 'secondary'} aria-pressed={fmt === f.value} onClick={() => { setFmt(f.value); setError(''); }}>
{f.label}
</Button>
))}
<Button variant="secondary" onClick={useMyLocation} disabled={locating}>
<LocateFixed className="h-4 w-4" /> {locating ? 'Locating…' : 'My location'}
</Button>
</div>
</div>

{fmt === 'dd' && (
<label className="block space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Latitude, Longitude</span>
<input value={dd} onChange={e => setDd(e.target.value)} placeholder="-6.2088, 106.8456" className={inputCls} />
</label>
)}
{fmt === 'dms' && (
<div className="flex flex-wrap gap-3">
<label className="flex-1 space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Latitude</span>
<input value={dmsLat} onChange={e => setDmsLat(e.target.value)} placeholder={`6°12'31.7"S`} className={inputCls} />
</label>
<label className="flex-1 space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Longitude</span>
<input value={dmsLng} onChange={e => setDmsLng(e.target.value)} placeholder={`106°50'44.2"E`} className={inputCls} />
</label>
</div>
)}
{fmt === 'geohash' && (
<label className="block space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Geohash</span>
<input value={geohash} onChange={e => setGeohash(e.target.value)} placeholder="qqguwptbm5" className={inputCls} />
</label>
)}
{fmt === 'utm' && (
<div className="flex flex-wrap items-end gap-3">
<label className="w-20 space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Zone</span>
<input value={utmZone} onChange={e => setUtmZone(e.target.value)} placeholder="48" className={inputCls} />
</label>
<label className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Hemi</span>
<select value={utmHemi} onChange={e => setUtmHemi(e.target.value as 'N' | 'S')} className={inputCls}>
<option value="N">N</option>
<option value="S">S</option>
</select>
</label>
<label className="flex-1 space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Easting</span>
<input value={utmE} onChange={e => setUtmE(e.target.value)} placeholder="700000" className={inputCls} />
</label>
<label className="flex-1 space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">Northing</span>
<input value={utmN} onChange={e => setUtmN(e.target.value)} placeholder="9312000" className={inputCls} />
</label>
</div>
)}

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

{outputs.length > 0 ? (
<div className="space-y-2 border-2 border-border p-3">
{outputs.map(o => (
<div key={o.label} className="flex flex-wrap items-center gap-2">
<span className="w-28 shrink-0 text-sm font-bold uppercase tracking-wide text-muted-foreground">{o.label}</span>
<code className="min-w-0 flex-1 break-all border-2 border-border bg-muted px-2 py-1 text-sm">{o.value}</code>
<CopyButton value={o.value} />
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Enter a valid coordinate to see every format.</p>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions src/registry/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const categories: Category[] = [
'Draw',
'Media',
'Network',
'Maps',
'Playground'
];

Expand All @@ -19,6 +20,7 @@ export const categoryColors: Record<Category, string> = {
Draw: 'bg-purple-500',
Media: 'bg-pink-500',
Network: 'bg-cyan-500',
Maps: 'bg-emerald-500',
Playground: 'bg-orange-500'
};

Expand Down
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 } 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 } from 'lucide-react';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -597,6 +597,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/network/OpticalTransfer'),
status: 'beta'
},
{
id: 'coord-convert',
name: 'Coordinate Converter',
category: 'Maps',
route: '/tools/coord-convert',
keywords: ['coordinate', 'gps', 'latitude', 'longitude', 'dms', 'utm', 'geohash', 'convert', 'lat', 'lng', 'map'],
icon: Compass,
summary: 'Convert GPS coordinates between DD, DMS, UTM and geohash',
load: () => import('@/islands/maps/CoordConvert'),
status: 'beta'
},
{
id: 'file-crypt',
name: 'File Encrypt / Decrypt',
Expand Down
87 changes: 87 additions & 0 deletions src/tools/geo/coord.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import {
parseLatLng,
formatDd,
ddToDms,
dmsToDd,
ddToUtm,
utmToDd,
encodeGeohash,
decodeGeohash,
} from './coord.lib';

describe('parseLatLng', () => {
it('parses "lat, lng" decimal pairs', () => {
expect(parseLatLng('-6.2088, 106.8456')).toEqual({ lat: -6.2088, lng: 106.8456 });
expect(parseLatLng('40.7128 -74.0060')).toEqual({ lat: 40.7128, lng: -74.006 });
});
it('rejects out-of-range or malformed input', () => {
expect(parseLatLng('91, 0')).toBeNull();
expect(parseLatLng('0, 181')).toBeNull();
expect(parseLatLng('hello')).toBeNull();
expect(parseLatLng('1')).toBeNull();
});
});

describe('DD ↔ DMS', () => {
it('formats DMS with hemisphere', () => {
const dms = ddToDms(-6.2088, 106.8456);
expect(dms.lat).toMatch(/6°12'.*S/);
expect(dms.lng).toMatch(/106°50'.*E/);
});
it('round-trips DD → DMS → DD', () => {
for (const [lat, lng] of [[40.7128, -74.006], [-33.8688, 151.2093], [51.5074, -0.1278]]) {
const dms = ddToDms(lat, lng);
const back = dmsToDd(dms.lat, dms.lng)!;
expect(back.lat).toBeCloseTo(lat, 4);
expect(back.lng).toBeCloseTo(lng, 4);
}
});
it('parses varied DMS punctuation', () => {
const back = dmsToDd('40 42 46 N', '74 0 21.6 W')!;
expect(back.lat).toBeCloseTo(40.7128, 3);
expect(back.lng).toBeCloseTo(-74.006, 3);
});
});

describe('DD ↔ UTM', () => {
it('computes the right zone and round-trips', () => {
const cases: [number, number, number][] = [
[40.7128, -74.006, 18],
[-6.2088, 106.8456, 48],
[51.5074, -0.1278, 30],
];
for (const [lat, lng, zone] of cases) {
const utm = ddToUtm(lat, lng);
expect(utm.zone).toBe(zone);
expect(utm.hemisphere).toBe(lat >= 0 ? 'N' : 'S');
const back = utmToDd(utm);
expect(back.lat).toBeCloseTo(lat, 4);
expect(back.lng).toBeCloseTo(lng, 4);
}
});
});

describe('geohash', () => {
it('encodes a known point', () => {
// London ~ "gcpvj0..."
expect(encodeGeohash(51.5074, -0.1278, 6)).toMatch(/^gcpv/);
});
it('round-trips within precision tolerance', () => {
for (const [lat, lng] of [[40.7128, -74.006], [-6.2088, 106.8456]]) {
const hash = encodeGeohash(lat, lng, 9);
const back = decodeGeohash(hash)!;
expect(back.lat).toBeCloseTo(lat, 3);
expect(back.lng).toBeCloseTo(lng, 3);
}
});
it('rejects invalid characters', () => {
expect(decodeGeohash('ail')).toBeNull(); // a,i,l not in geohash alphabet
});
});

describe('formatDd', () => {
it('formats to a fixed precision', () => {
expect(formatDd(-6.208812345, 106.845612345)).toBe('-6.208812, 106.845612');
});
});
Loading
Loading