when-am-i / files / src / utils / urlState.ts
urlState.ts
| 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'; |
| 9 | import { toHistoricalDate, type PickerDate } from './historicalDate'; |
| 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 | } |
| 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 | } |