when-am-i / files / src / utils / urlState.ts
urlState.ts
34c4dd59 2026-06-18 Pavel Safronov | 1 | // Initial state from URL query params. |
| 2 | // | |
| 3 | // Doubles as a shareable-link feature and the deterministic "force a state" hook | |
| 4 | // the screenshot tooling needs (see plan.md). Example: | |
| 5 | // ?lat=42.36&lng=-71.06&year=1700&month=4&day=1&era=CE | |
| 6 | // All params are optional; a missing or malformed group is simply ignored. | |
| 7 | ||
| 8 | import type { HistoricalDate } from '../types'; | |
631012ab 2026-06-21 Pavel Safronov | 9 | import { toHistoricalDate, type PickerDate } from './historicalDate'; |
34c4dd59 2026-06-18 Pavel Safronov | 10 | |
| 11 | export interface InitialState { | |
| 12 | picked?: { lat: number; lng: number }; | |
| 13 | date?: HistoricalDate; | |
| 14 | zoom?: number; | |
| 15 | } | |
| 16 | ||
| 17 | function toFloat(value: string | null): number | null { | |
| 18 | if (value === null || value.trim() === '') return null; | |
| 19 | const n = Number(value); | |
| 20 | return Number.isFinite(n) ? n : null; | |
| 21 | } | |
| 22 | ||
| 23 | export function parseInitialState(search: string): InitialState { | |
| 24 | const params = new URLSearchParams(search); | |
| 25 | const state: InitialState = {}; | |
| 26 | ||
| 27 | const lat = toFloat(params.get('lat')); | |
| 28 | const lng = toFloat(params.get('lng')); | |
| 29 | if (lat !== null && lng !== null) state.picked = { lat, lng }; | |
| 30 | ||
| 31 | const zoom = toFloat(params.get('zoom')); | |
| 32 | if (zoom !== null) state.zoom = zoom; | |
| 33 | ||
| 34 | const year = toFloat(params.get('year')); | |
| 35 | const month = toFloat(params.get('month')); | |
| 36 | const day = toFloat(params.get('day')); | |
| 37 | if (year !== null && month !== null && day !== null) { | |
| 38 | const era = (params.get('era') ?? 'CE').toUpperCase() === 'BCE' ? 'BCE' : 'CE'; | |
| 39 | state.date = toHistoricalDate({ year, month, day, era }); | |
| 40 | } | |
| 41 | ||
| 42 | return state; | |
| 43 | } | |
631012ab 2026-06-21 Pavel Safronov | 44 | |
| 45 | /** | |
| 46 | * Serialize the current view to a query string (the inverse of | |
| 47 | * `parseInitialState`), so the URL is a shareable link to the same place, date, | |
| 48 | * and zoom. Uses the same `?lat&lng&zoom&year&month&day&era` params the | |
| 49 | * screenshot tooling reads. The pin is the "position"; zoom is included so the | |
| 50 | * framing matches. Returns a leading "?" (or "" when nothing is pinned). | |
| 51 | */ | |
| 52 | export function toSearch( | |
| 53 | picked: { lat: number; lng: number } | null, | |
| 54 | date: PickerDate, | |
| 55 | zoom: number | null, | |
| 56 | ): string { | |
| 57 | const params = new URLSearchParams(); | |
| 58 | if (picked) { | |
| 59 | // Trim to ~1 m so the link stays short without losing the spot. | |
| 60 | params.set('lat', picked.lat.toFixed(5)); | |
| 61 | params.set('lng', picked.lng.toFixed(5)); | |
| 62 | } | |
| 63 | if (zoom !== null) params.set('zoom', String(Math.round(zoom))); | |
| 64 | params.set('year', String(date.year)); | |
| 65 | params.set('month', String(date.month)); | |
| 66 | params.set('day', String(date.day)); | |
| 67 | params.set('era', date.era); | |
| 68 | return `?${params.toString()}`; | |
| 69 | } |