fuzzyworld

when-am-i / files / src / utils / urlState.ts

urlState.ts

Blame: the commit that last changed each line · file · history

34c4dd59 2026-06-18 Pavel Safronov1// 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
8import type { HistoricalDate } from '../types';
631012ab 2026-06-21 Pavel Safronov9import { toHistoricalDate, type PickerDate } from './historicalDate';
34c4dd59 2026-06-18 Pavel Safronov10
11export interface InitialState {
12 picked?: { lat: number; lng: number };
13 date?: HistoricalDate;
14 zoom?: number;
15}
16
17function 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
23export 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 Safronov44
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 */
52export 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}