when-am-i / files / src / utils / shuffle.ts
shuffle.ts
| 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. |
| 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. |
| 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; |
| 28 | /** The calendar this scene lands on (so the caller can avoid repeats). */ |
| 29 | calendar: string; |
| 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 | /** |
| 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. |
| 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, |
| 97 | recent: readonly string[] = [], |
| 98 | ): ShuffleScene | null { |
| 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 }[]>(); |
| 102 | for (const cr of regions) { |
| 103 | for (const era of cr.eras) { |
| 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); |
| 108 | } |
| 109 | } |
| 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; |
| 118 | |
| 119 | const calendar = pool[Math.floor(rng() * pool.length)]; |
| 120 | const candidates = byCalendar.get(calendar)!; |
| 121 | const { geo, era } = candidates[Math.floor(rng() * candidates.length)]; |
| 122 | const { lat, lng } = randomPointInGeometry(geo, rng); |
| 123 | const date = randomDateInEra(era, rng); |
| 124 | return { lat, lng, date, calendar }; |
| 125 | } |