when-am-i / commit
feat: add Shuffle button for a random non-default-calendar scene
README.md | 2 + src/App.tsx | 30 +++++++++++++- src/utils/shuffle.test.ts | 60 ++++++++++++++++++++++++++++ src/utils/shuffle.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 84b362b..4149632 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Imagine you're a time traveler who ends up in a random place and time in history - Interactive world map for selecting a location - Date picker for choosing any year in history +- **Shuffle** button that drops you at a random place and date on a non-default + (non-Gregorian) calendar — Islamic, Persian, Julian, or a lunar reckoning - Displays the local calendar year and system for the chosen place and time - Shows **all** valid answers at once — different people at the same place and time reckoned dates differently (e.g. colonial Julian alongside an Indigenous lunar calendar) diff --git a/src/App.tsx b/src/App.tsx index 325acad..0a6d260 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { type PickerDate, } from './utils/historicalDate'; import { parseInitialState } from './utils/urlState'; +import { pickRandomScene } from './utils/shuffle'; import { reportMailtoHref, setReportContext, @@ -101,6 +102,14 @@ function App() { // Surface async errors caught by the global handlers as a dismissible banner. useEffect(() => subscribeReportErrors(setRuntimeError), []); + // Shuffle: jump to a random place-and-time on a non-default calendar. + const shuffle = () => { + const scene = pickRandomScene(regions); + if (!scene) return; + setPicked({ lat: scene.lat, lng: scene.lng }); + setPickerDate(scene.date); + }; + return ( <div style={{ maxWidth: 880, margin: '0 auto', padding: '24px 20px 48px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> @@ -110,9 +119,28 @@ function App() { <p style={{ marginTop: 12 }}> You’re a time traveler who lands somewhere in history. You stop a local and ask: <em>“When am I?”</em> Their answer depends on where - you are — and who you ask. Pick a date and a spot on the map to begin. + you are — and who you ask. Pick a date and a spot on the map to begin + — or hit <em>Shuffle</em> to be dropped somewhere on a calendar + that isn’t today’s. </p> + <button + type="button" + onClick={shuffle} + style={{ + margin: '4px 0 8px', + padding: '8px 16px', + fontSize: '0.95em', + fontWeight: 600, + cursor: 'pointer', + border: '1px solid #bbb', + borderRadius: 6, + background: '#f6f6f6', + }} + > + 🎲 Shuffle + </button> + {runtimeError != null && ( <div role="alert" diff --git a/src/utils/shuffle.test.ts b/src/utils/shuffle.test.ts new file mode 100644 index 0000000..6e3ccd6 --- /dev/null +++ b/src/utils/shuffle.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { pickRandomScene } from './shuffle'; +import { pointInPolygon } from './geo'; +import { findEras } from './eras'; +import { toHistoricalDate } from './historicalDate'; +import type { CalendarRegion, Geometry } from '../types'; + +// A 10×10 square from (0,0) to (10,10) in [lng, lat]. +const square: Geometry = { + type: 'Polygon', + coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], +}; + +const regions: CalendarRegion[] = [ + { + region: { name: 'Square', geo: square }, + eras: [ + { name: 'Julian era', start: '1600-01-01', end: '1700-01-01', calendar: 'Julian' }, + // A Gregorian era must never be chosen — it is the default shuffle avoids. + { name: 'Modern', start: '1700-01-01', end: null, calendar: 'Gregorian' }, + ], + }, +]; + +// A deterministic rng cycling through fixed values. +function seq(values: number[]): () => number { + let i = 0; + return () => values[i++ % values.length]; +} + +describe('pickRandomScene', () => { + it('returns null when the dataset has only Gregorian (default) eras', () => { + const onlyGregorian: CalendarRegion[] = [ + { region: { name: 'G', geo: square }, eras: [{ name: 'M', start: null, end: null, calendar: 'Gregorian' }] }, + ]; + expect(pickRandomScene(onlyGregorian, seq([0.5]))).toBeNull(); + }); + + it('lands on a non-Gregorian era, inside the region, within the era span', () => { + const scene = pickRandomScene(regions, seq([0.3])); + expect(scene).not.toBeNull(); + if (!scene) return; + + // The point is inside the region. + expect(pointInPolygon(scene.lat, scene.lng, square)).toBe(true); + + // The date resolves to a non-Gregorian era covering it. + const eras = findEras(toHistoricalDate(scene.date), regions[0].eras); + expect(eras.map((e) => e.calendar)).toContain('Julian'); + expect(eras.map((e) => e.calendar)).not.toContain('Gregorian'); + }); + + it('produces dates strictly before a bounded era end (half-open)', () => { + // rng near 1 should still keep the day before the end boundary. + const scene = pickRandomScene(regions, seq([0, 0.5, 0.5, 0.999999])); + expect(scene).not.toBeNull(); + if (!scene) return; + expect(scene.date.year).toBeLessThan(1700); + }); +}); diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts new file mode 100644 index 0000000..fe6c1d4 --- /dev/null +++ b/src/utils/shuffle.ts @@ -0,0 +1,100 @@ +// Shuffle: drop the time traveler somewhere — and somewhen — the dataset gives +// a genuinely different answer. +// +// The app's *default* answer is the modern global Gregorian calendar (see +// resolve.ts). "Shuffle" is the opposite: it picks a random point inside a +// region and a random date within one of that region's non-Gregorian eras, so +// each press surfaces one of the historic reckonings (Islamic, Persian, Julian, +// a lunar calendar) rather than the everyday Gregorian default. + +import type { CalendarRegion, Era, Geometry, LinearRing } from '../types'; +import { pointInPolygon } from './geo'; +import { gregorianToJulianDay, julianDayToGregorian, parseGregorianDate } from './julianDay'; +import { fromHistoricalDate, type PickerDate } from './historicalDate'; + +/** A random place-and-time the shuffle button drops you at. */ +export interface ShuffleScene { + lat: number; + lng: number; + date: PickerDate; +} + +// Eras open at one end (null start / null end) still need a finite window to +// sample a date from, so we clamp the open side to these proleptic-Gregorian +// bounds — a reasonable historic span without spilling into the modern global +// Gregorian default that shuffle is meant to avoid. +const OPEN_PAST = parseGregorianDate('1000-01-01'); +const OPEN_FUTURE = parseGregorianDate('2025-12-31'); + +/** Every ring across a Polygon or MultiPolygon. */ +function allRings(geo: Geometry): LinearRing[] { + return geo.type === 'Polygon' ? geo.coordinates : geo.coordinates.flat(); +} + +/** Bounding box (in lat/lng) of a geometry's coordinates. */ +function boundingBox(geo: Geometry) { + let minLat = Infinity; + let maxLat = -Infinity; + let minLng = Infinity; + let maxLng = -Infinity; + for (const ring of allRings(geo)) { + for (const [lng, lat] of ring) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + } + } + return { minLat, maxLat, minLng, maxLng }; +} + +/** + * A random point inside the geometry, by rejection sampling its bounding box. + * Falls back to the bbox centre if no hit is found (degenerate geometry). + */ +function randomPointInGeometry(geo: Geometry, rng: () => number): { lat: number; lng: number } { + const { minLat, maxLat, minLng, maxLng } = boundingBox(geo); + for (let i = 0; i < 1000; i++) { + const lat = minLat + rng() * (maxLat - minLat); + const lng = minLng + rng() * (maxLng - minLng); + if (pointInPolygon(lat, lng, geo)) return { lat, lng }; + } + return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 }; +} + +/** A random date inside an era's half-open [start, end) span. */ +function randomDateInEra(era: Era, rng: () => number): PickerDate { + const startJDN = gregorianToJulianDay(era.start ? parseGregorianDate(era.start) : OPEN_PAST); + const endJDN = gregorianToJulianDay(era.end ? parseGregorianDate(era.end) : OPEN_FUTURE); + // Half-open interval: keep the day strictly before the end boundary. + const span = Math.max(1, endJDN - startJDN); + const jdn = startJDN + Math.floor(rng() * span); + return fromHistoricalDate(julianDayToGregorian(jdn)); +} + +/** + * Pick a random scene that lands on a non-default (non-Gregorian) calendar: + * a random point inside some region and a random date within one of that + * region's non-Gregorian eras. Returns null if the dataset has no such era. + * + * `rng` is injectable for deterministic tests; it defaults to Math.random. + */ +export function pickRandomScene( + regions: CalendarRegion[], + rng: () => number = Math.random, +): ShuffleScene | null { + const candidates: { geo: Geometry; era: Era }[] = []; + for (const cr of regions) { + for (const era of cr.eras) { + if (era.calendar !== 'Gregorian') { + candidates.push({ geo: cr.region.geo, era }); + } + } + } + if (candidates.length === 0) return null; + + const { geo, era } = candidates[Math.floor(rng() * candidates.length)]; + const { lat, lng } = randomPointInGeometry(geo, rng); + const date = randomDateInEra(era, rng); + return { lat, lng, date }; +}