fuzzyworld

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

shuffle.ts

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

3adbd147 2026-06-20 Pavel Safronov1// 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 Safronov9//
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 Safronov17
18import type { CalendarRegion, Era, Geometry, LinearRing } from '../types';
19import { pointInPolygon } from './geo';
20import { gregorianToJulianDay, julianDayToGregorian, parseGregorianDate } from './julianDay';
21import { fromHistoricalDate, type PickerDate } from './historicalDate';
22
23/** A random place-and-time the shuffle button drops you at. */
24export interface ShuffleScene {
25 lat: number;
26 lng: number;
27 date: PickerDate;
38f135ad 2026-06-21 Pavel Safronov28 /** The calendar this scene lands on (so the caller can avoid repeats). */
29 calendar: string;
3adbd147 2026-06-20 Pavel Safronov30}
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.
36const OPEN_PAST = parseGregorianDate('1000-01-01');
37const OPEN_FUTURE = parseGregorianDate('2025-12-31');
38
39/** Every ring across a Polygon or MultiPolygon. */
40function 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. */
45function 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 */
65function 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. */
76function 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 Safronov86 * 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 Safronov91 *
92 * `rng` is injectable for deterministic tests; it defaults to Math.random.
93 */
94export function pickRandomScene(
95 regions: CalendarRegion[],
96 rng: () => number = Math.random,
38f135ad 2026-06-21 Pavel Safronov97 recent: readonly string[] = [],
3adbd147 2026-06-20 Pavel Safronov98): ShuffleScene | null {
38f135ad 2026-06-21 Pavel Safronov99 // 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 Safronov102 for (const cr of regions) {
103 for (const era of cr.eras) {
38f135ad 2026-06-21 Pavel Safronov104 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 Safronov108 }
109 }
38f135ad 2026-06-21 Pavel Safronov110 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 Safronov118
38f135ad 2026-06-21 Pavel Safronov119 const calendar = pool[Math.floor(rng() * pool.length)];
120 const candidates = byCalendar.get(calendar)!;
3adbd147 2026-06-20 Pavel Safronov121 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 Safronov124 return { lat, lng, date, calendar };
3adbd147 2026-06-20 Pavel Safronov125}