From 64a80d575382f8bbf90d0f775cd69536bc2987f4 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 20:31:31 +0700 Subject: [PATCH] feat(maps): Coordinate Converter + new Maps category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the maps suite. Fully client-side (no tiles/network): convert a GPS coordinate between Decimal (DD), DMS, UTM (WGS84) and geohash, plus an OpenStreetMap link and 'use my location' (geolocation). - coord.lib (pure, tested): parseLatLng, ddToDms/dmsToDd, ddToUtm/utmToDd (USGS series), encode/decodeGeohash, formatDd. Round-trip tests across hemispheres + zones. - New 'Maps' category (emerald). Coordinate Converter island with a format picker + per-format outputs with copy buttons. 575 tests · lint clean · build green (/tools/coord-convert built). Map-based tools (Map Explorer, GeoJSON/GPX/KML viewer, Static map — MapLibre + OpenFreeMap, with dark-theme sync) come next. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/islands/maps/CoordConvert.tsx | 162 +++++++++++++++++++++++++++ src/registry/categories.ts | 2 + src/registry/tools.ts | 13 ++- src/tools/geo/coord.lib.test.ts | 87 +++++++++++++++ src/tools/geo/coord.lib.ts | 176 ++++++++++++++++++++++++++++++ src/types/tool.ts | 2 +- 6 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 src/islands/maps/CoordConvert.tsx create mode 100644 src/tools/geo/coord.lib.test.ts create mode 100644 src/tools/geo/coord.lib.ts diff --git a/src/islands/maps/CoordConvert.tsx b/src/islands/maps/CoordConvert.tsx new file mode 100644 index 0000000..d51c755 --- /dev/null +++ b/src/islands/maps/CoordConvert.tsx @@ -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('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 ( +
+
+ Input format +
+ {FORMATS.map(f => ( + + ))} + +
+
+ + {fmt === 'dd' && ( + + )} + {fmt === 'dms' && ( +
+ + +
+ )} + {fmt === 'geohash' && ( + + )} + {fmt === 'utm' && ( +
+ + + + +
+ )} + + {error && {error}} + + {outputs.length > 0 ? ( +
+ {outputs.map(o => ( +
+ {o.label} + {o.value} + +
+ ))} +
+ ) : ( +

Enter a valid coordinate to see every format.

+ )} +
+ ); +} diff --git a/src/registry/categories.ts b/src/registry/categories.ts index 1ac93df..6e499a8 100644 --- a/src/registry/categories.ts +++ b/src/registry/categories.ts @@ -8,6 +8,7 @@ export const categories: Category[] = [ 'Draw', 'Media', 'Network', + 'Maps', 'Playground' ]; @@ -19,6 +20,7 @@ export const categoryColors: Record = { Draw: 'bg-purple-500', Media: 'bg-pink-500', Network: 'bg-cyan-500', + Maps: 'bg-emerald-500', Playground: 'bg-orange-500' }; diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 808aa85..b9df746 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 } 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[] = [ @@ -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', diff --git a/src/tools/geo/coord.lib.test.ts b/src/tools/geo/coord.lib.test.ts new file mode 100644 index 0000000..0b93cdb --- /dev/null +++ b/src/tools/geo/coord.lib.test.ts @@ -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'); + }); +}); diff --git a/src/tools/geo/coord.lib.ts b/src/tools/geo/coord.lib.ts new file mode 100644 index 0000000..120538f --- /dev/null +++ b/src/tools/geo/coord.lib.ts @@ -0,0 +1,176 @@ +/** Geographic coordinate conversions — all pure, no network. */ + +export interface LatLng { lat: number; lng: number } +export interface Utm { zone: number; hemisphere: 'N' | 'S'; easting: number; northing: number } + +/** Parse "lat, lng" / "lat lng" decimal degrees. Returns null if out of range/malformed. */ +export function parseLatLng(input: string): LatLng | null { + const nums = input.trim().match(/-?\d+(?:\.\d+)?/g); + if (!nums || nums.length < 2) return null; + const lat = parseFloat(nums[0]); + const lng = parseFloat(nums[1]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return { lat, lng }; +} + +export function formatDd(lat: number, lng: number, digits = 6): string { + return `${lat.toFixed(digits)}, ${lng.toFixed(digits)}`; +} + +// --- DD ↔ DMS --- + +function oneDms(deg: number, isLat: boolean): string { + const hemi = deg >= 0 ? (isLat ? 'N' : 'E') : (isLat ? 'S' : 'W'); + const abs = Math.abs(deg); + const d = Math.floor(abs); + const mFull = (abs - d) * 60; + const m = Math.floor(mFull); + const s = (mFull - m) * 60; + return `${d}°${m}'${s.toFixed(1)}"${hemi}`; +} + +export function ddToDms(lat: number, lng: number): { lat: string; lng: string } { + return { lat: oneDms(lat, true), lng: oneDms(lng, false) }; +} + +function parseOneDms(str: string): number | null { + const nums = str.match(/\d+(?:\.\d+)?/g); + if (!nums || nums.length === 0) return null; + const d = parseFloat(nums[0]); + const m = nums[1] ? parseFloat(nums[1]) : 0; + const s = nums[2] ? parseFloat(nums[2]) : 0; + let val = d + m / 60 + s / 3600; + if (/[SW]/i.test(str)) val = -val; + return val; +} + +export function dmsToDd(latStr: string, lngStr: string): LatLng | null { + const lat = parseOneDms(latStr); + const lng = parseOneDms(lngStr); + if (lat === null || lng === null) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return { lat, lng }; +} + +// --- DD ↔ UTM (WGS84, USGS series) --- + +const A = 6378137.0; +const F = 1 / 298.257223563; +const K0 = 0.9996; +const E2 = F * (2 - F); +const EP2 = E2 / (1 - E2); +const rad = (d: number) => (d * Math.PI) / 180; +const deg = (r: number) => (r * 180) / Math.PI; + +export function ddToUtm(lat: number, lng: number): Utm { + const zone = Math.floor((lng + 180) / 6) + 1; + const lngOrigin = rad((zone - 1) * 6 - 180 + 3); + const φ = rad(lat); + const λ = rad(lng); + const N = A / Math.sqrt(1 - E2 * Math.sin(φ) ** 2); + const T = Math.tan(φ) ** 2; + const C = EP2 * Math.cos(φ) ** 2; + const AA = Math.cos(φ) * (λ - lngOrigin); + const M = + A * + ((1 - E2 / 4 - (3 * E2 ** 2) / 64 - (5 * E2 ** 3) / 256) * φ - + ((3 * E2) / 8 + (3 * E2 ** 2) / 32 + (45 * E2 ** 3) / 1024) * Math.sin(2 * φ) + + ((15 * E2 ** 2) / 256 + (45 * E2 ** 3) / 1024) * Math.sin(4 * φ) - + ((35 * E2 ** 3) / 3072) * Math.sin(6 * φ)); + const easting = + K0 * N * (AA + ((1 - T + C) * AA ** 3) / 6 + ((5 - 18 * T + T * T + 72 * C - 58 * EP2) * AA ** 5) / 120) + + 500000; + let northing = + K0 * + (M + + N * + Math.tan(φ) * + ((AA * AA) / 2 + + ((5 - T + 9 * C + 4 * C * C) * AA ** 4) / 24 + + ((61 - 58 * T + T * T + 600 * C - 330 * EP2) * AA ** 6) / 720)); + if (lat < 0) northing += 10000000; + return { zone, hemisphere: lat >= 0 ? 'N' : 'S', easting, northing }; +} + +export function utmToDd(utm: Utm): LatLng { + const x = utm.easting - 500000; + let y = utm.northing; + if (utm.hemisphere === 'S') y -= 10000000; + const lngOrigin = (utm.zone - 1) * 6 - 180 + 3; + const M = y / K0; + const mu = M / (A * (1 - E2 / 4 - (3 * E2 ** 2) / 64 - (5 * E2 ** 3) / 256)); + const e1 = (1 - Math.sqrt(1 - E2)) / (1 + Math.sqrt(1 - E2)); + const φ1 = + mu + + ((3 * e1) / 2 - (27 * e1 ** 3) / 32) * Math.sin(2 * mu) + + ((21 * e1 ** 2) / 16 - (55 * e1 ** 4) / 32) * Math.sin(4 * mu) + + ((151 * e1 ** 3) / 96) * Math.sin(6 * mu) + + ((1097 * e1 ** 4) / 512) * Math.sin(8 * mu); + const N1 = A / Math.sqrt(1 - E2 * Math.sin(φ1) ** 2); + const T1 = Math.tan(φ1) ** 2; + const C1 = EP2 * Math.cos(φ1) ** 2; + const R1 = (A * (1 - E2)) / Math.pow(1 - E2 * Math.sin(φ1) ** 2, 1.5); + const D = x / (N1 * K0); + const lat = + φ1 - + ((N1 * Math.tan(φ1)) / R1) * + ((D * D) / 2 - + ((5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * EP2) * D ** 4) / 24 + + ((61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * EP2 - 3 * C1 * C1) * D ** 6) / 720); + const lng = + rad(lngOrigin) + + (D - + ((1 + 2 * T1 + C1) * D ** 3) / 6 + + ((5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * EP2 + 24 * T1 * T1) * D ** 5) / 120) / + Math.cos(φ1); + return { lat: deg(lat), lng: deg(lng) }; +} + +// --- Geohash --- + +const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; + +export function encodeGeohash(lat: number, lng: number, precision = 9): string { + let latR = [-90, 90]; + let lngR = [-180, 180]; + let hash = ''; + let bit = 0; + let ch = 0; + let even = true; + while (hash.length < precision) { + if (even) { + const mid = (lngR[0] + lngR[1]) / 2; + if (lng >= mid) { ch |= 1 << (4 - bit); lngR = [mid, lngR[1]]; } else lngR = [lngR[0], mid]; + } else { + const mid = (latR[0] + latR[1]) / 2; + if (lat >= mid) { ch |= 1 << (4 - bit); latR = [mid, latR[1]]; } else latR = [latR[0], mid]; + } + even = !even; + if (bit < 4) bit++; + else { hash += BASE32[ch]; bit = 0; ch = 0; } + } + return hash; +} + +export function decodeGeohash(hash: string): LatLng | null { + let latR = [-90, 90]; + let lngR = [-180, 180]; + let even = true; + for (const c of hash.toLowerCase()) { + const idx = BASE32.indexOf(c); + if (idx === -1) return null; + for (let b = 4; b >= 0; b--) { + const bit = (idx >> b) & 1; + if (even) { + const mid = (lngR[0] + lngR[1]) / 2; + lngR = bit ? [mid, lngR[1]] : [lngR[0], mid]; + } else { + const mid = (latR[0] + latR[1]) / 2; + latR = bit ? [mid, latR[1]] : [latR[0], mid]; + } + even = !even; + } + } + return { lat: (latR[0] + latR[1]) / 2, lng: (lngR[0] + lngR[1]) / 2 }; +} diff --git a/src/types/tool.ts b/src/types/tool.ts index 9cd8f8f..25aab17 100644 --- a/src/types/tool.ts +++ b/src/types/tool.ts @@ -1,6 +1,6 @@ import type { LucideIcon } from 'lucide-react'; -export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Network' | 'Playground'; +export type Category = 'Dev' | 'PDF' | 'Image' | 'Files' | 'Draw' | 'Media' | 'Network' | 'Maps' | 'Playground'; export interface AssetRef { url: string;