when-am-i / files / src / utils / shuffle.ts
shuffle.ts
3adbd147 2026-06-20 Pavel Safronov | 1 | // Shuffle: drop the time traveler somewhere — and somewhen — the dataset gives |
| 2 | // a genuinely different answer. | |
| 3 | // | |
| 4 | // The app's *default* answer is the modern global Gregorian calendar (see | |
| 5 | // resolve.ts). "Shuffle" is the opposite: it picks a random point inside a | |
| 6 | // region and a random date within one of that region's non-Gregorian eras, so | |
| 7 | // each press surfaces one of the historic reckonings (Islamic, Persian, Julian, | |
| 8 | // a lunar calendar) rather than the everyday Gregorian default. | |
38f135ad 2026-06-21 Pavel Safronov | 9 | // |
| 10 | // Two fairness rules (so the variety actually feels varied): | |
| 11 | // 1. Each *calendar* is equally likely, regardless of how many regions/eras | |
| 12 | // use it — otherwise a calendar shared by many regions (e.g. Julian) would | |
| 13 | // dominate. We pick a calendar first, then a region/era within it. | |
| 14 | // 2. Calendars shown recently are skipped (the caller passes the recent list), | |
| 15 | // so consecutive presses don't repeat — unless every calendar is recent, in | |
| 16 | // which case we fall back to the full set rather than return nothing. | |
3adbd147 2026-06-20 Pavel Safronov | 17 | |
| 18 | import type { CalendarRegion, Era, Geometry, LinearRing } from '../types'; | |
| 19 | import { pointInPolygon } from './geo'; | |
| 20 | import { gregorianToJulianDay, julianDayToGregorian, parseGregorianDate } from './julianDay'; | |
| 21 | import { fromHistoricalDate, type PickerDate } from './historicalDate'; | |
| 22 | ||
| 23 | /** A random place-and-time the shuffle button drops you at. */ | |
| 24 | export interface ShuffleScene { | |
| 25 | lat: number; | |
| 26 | lng: number; | |
| 27 | date: PickerDate; | |
38f135ad 2026-06-21 Pavel Safronov | 28 | /** The calendar this scene lands on (so the caller can avoid repeats). */ |
| 29 | calendar: string; | |
3adbd147 2026-06-20 Pavel Safronov | 30 | } |
| 31 | ||
| 32 | // Eras open at one end (null start / null end) still need a finite window to | |
| 33 | // sample a date from, so we clamp the open side to these proleptic-Gregorian | |
| 34 | // bounds — a reasonable historic span without spilling into the modern global | |
| 35 | // Gregorian default that shuffle is meant to avoid. | |
| 36 | const OPEN_PAST = parseGregorianDate('1000-01-01'); | |
| 37 | const OPEN_FUTURE = parseGregorianDate('2025-12-31'); | |
| 38 | ||
| 39 | /** Every ring across a Polygon or MultiPolygon. */ | |
| 40 | function allRings(geo: Geometry): LinearRing[] { | |
| 41 | return geo.type === 'Polygon' ? geo.coordinates : geo.coordinates.flat(); | |
| 42 | } | |
| 43 | ||
| 44 | /** Bounding box (in lat/lng) of a geometry's coordinates. */ | |
| 45 | function boundingBox(geo: Geometry) { | |
| 46 | let minLat = Infinity; | |
| 47 | let maxLat = -Infinity; | |
| 48 | let minLng = Infinity; | |
| 49 | let maxLng = -Infinity; | |
| 50 | for (const ring of allRings(geo)) { | |
| 51 | for (const [lng, lat] of ring) { | |
| 52 | if (lat < minLat) minLat = lat; | |
| 53 | if (lat > maxLat) maxLat = lat; | |
| 54 | if (lng < minLng) minLng = lng; | |
| 55 | if (lng > maxLng) maxLng = lng; | |
| 56 | } | |
| 57 | } | |
| 58 | return { minLat, maxLat, minLng, maxLng }; | |
| 59 | } | |
| 60 | ||
| 61 | /** | |
| 62 | * A random point inside the geometry, by rejection sampling its bounding box. | |
| 63 | * Falls back to the bbox centre if no hit is found (degenerate geometry). | |
| 64 | */ | |
| 65 | function randomPointInGeometry(geo: Geometry, rng: () => number): { lat: number; lng: number } { | |
| 66 | const { minLat, maxLat, minLng, maxLng } = boundingBox(geo); | |
| 67 | for (let i = 0; i < 1000; i++) { | |
| 68 | const lat = minLat + rng() * (maxLat - minLat); | |
| 69 | const lng = minLng + rng() * (maxLng - minLng); | |
| 70 | if (pointInPolygon(lat, lng, geo)) return { lat, lng }; | |
| 71 | } | |
| 72 | return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 }; | |
| 73 | } | |
| 74 | ||
| 75 | /** A random date inside an era's half-open [start, end) span. */ | |
| 76 | function randomDateInEra(era: Era, rng: () => number): PickerDate { | |
| 77 | const startJDN = gregorianToJulianDay(era.start ? parseGregorianDate(era.start) : OPEN_PAST); | |
| 78 | const endJDN = gregorianToJulianDay(era.end ? parseGregorianDate(era.end) : OPEN_FUTURE); | |
| 79 | // Half-open interval: keep the day strictly before the end boundary. | |
| 80 | const span = Math.max(1, endJDN - startJDN); | |
| 81 | const jdn = startJDN + Math.floor(rng() * span); | |
| 82 | return fromHistoricalDate(julianDayToGregorian(jdn)); | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
38f135ad 2026-06-21 Pavel Safronov | 86 | * Pick a random scene that lands on a non-default (non-Gregorian) calendar. |
| 87 | * Each distinct calendar is equally likely (not weighted by how many regions/ | |
| 88 | * eras use it); calendars in `recent` are skipped so presses don't repeat, with | |
| 89 | * a fallback to the full set when every calendar is recent. Returns null if the | |
| 90 | * dataset has no non-Gregorian era at all. | |
3adbd147 2026-06-20 Pavel Safronov | 91 | * |
| 92 | * `rng` is injectable for deterministic tests; it defaults to Math.random. | |
| 93 | */ | |
| 94 | export function pickRandomScene( | |
| 95 | regions: CalendarRegion[], | |
| 96 | rng: () => number = Math.random, | |
38f135ad 2026-06-21 Pavel Safronov | 97 | recent: readonly string[] = [], |
3adbd147 2026-06-20 Pavel Safronov | 98 | ): ShuffleScene | null { |
38f135ad 2026-06-21 Pavel Safronov | 99 | // Group every non-Gregorian (region, era) candidate by its calendar, so we |
| 100 | // can give each calendar equal weight (rule 1). | |
| 101 | const byCalendar = new Map<string, { geo: Geometry; era: Era }[]>(); | |
3adbd147 2026-06-20 Pavel Safronov | 102 | for (const cr of regions) { |
| 103 | for (const era of cr.eras) { | |
38f135ad 2026-06-21 Pavel Safronov | 104 | if (era.calendar === 'Gregorian') continue; |
| 105 | const list = byCalendar.get(era.calendar) ?? []; | |
| 106 | list.push({ geo: cr.region.geo, era }); | |
| 107 | byCalendar.set(era.calendar, list); | |
3adbd147 2026-06-20 Pavel Safronov | 108 | } |
| 109 | } | |
38f135ad 2026-06-21 Pavel Safronov | 110 | const calendars = [...byCalendar.keys()]; |
| 111 | if (calendars.length === 0) return null; | |
| 112 | ||
| 113 | // Prefer calendars not shown recently (rule 2); fall back to all of them if | |
| 114 | // the recent window covers everything, so we always return a scene. | |
| 115 | const recentSet = new Set(recent); | |
| 116 | const eligible = calendars.filter((c) => !recentSet.has(c)); | |
| 117 | const pool = eligible.length > 0 ? eligible : calendars; | |
3adbd147 2026-06-20 Pavel Safronov | 118 | |
38f135ad 2026-06-21 Pavel Safronov | 119 | const calendar = pool[Math.floor(rng() * pool.length)]; |
| 120 | const candidates = byCalendar.get(calendar)!; | |
3adbd147 2026-06-20 Pavel Safronov | 121 | const { geo, era } = candidates[Math.floor(rng() * candidates.length)]; |
| 122 | const { lat, lng } = randomPointInGeometry(geo, rng); | |
| 123 | const date = randomDateInEra(era, rng); | |
38f135ad 2026-06-21 Pavel Safronov | 124 | return { lat, lng, date, calendar }; |
3adbd147 2026-06-20 Pavel Safronov | 125 | } |