when-am-i / commit
feat: add resolution engine (geo, era matching, calendar conversion)
src/utils/calendars.test.ts | 34 +++++++++++++ src/utils/calendars.ts | 59 +++++++++++++++++++++++ src/utils/eras.test.ts | 40 ++++++++++++++++ src/utils/eras.ts | 20 ++++++++ src/utils/geo.test.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++ src/utils/geo.ts | 42 ++++++++++++++++ src/utils/julianDay.test.ts | 58 +++++++++++++++++++++++ src/utils/julianDay.ts | 72 ++++++++++++++++++++++++++++ src/utils/resolve.test.ts | 60 +++++++++++++++++++++++ src/utils/resolve.ts | 30 ++++++++++++ 10 files changed, 528 insertions(+) diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts new file mode 100644 index 0000000..8bb4604 --- /dev/null +++ b/src/utils/calendars.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { convert } from './calendars'; + +describe('convert', () => { + it('Gregorian is the identity on the input year', () => { + const c = convert({ year: 1700, month: 3, day: 22 }, 'Gregorian'); + expect(c).toMatchObject({ system: 'Gregorian', localYearLabel: '1700', convertible: true }); + expect(c.localDateLabel).toBe('1700-03-22'); + }); + + it('is case/space-insensitive on the calendar name', () => { + expect(convert({ year: 1700, month: 1, day: 1 }, ' jUlIaN ').system).toBe('Julian'); + }); + + it('Julian converts 1582-10-15 Gregorian to 1582-10-05', () => { + const c = convert({ year: 1582, month: 10, day: 15 }, 'Julian'); + expect(c).toMatchObject({ system: 'Julian', localYearLabel: '1582', convertible: true }); + expect(c.localDateLabel).toBe('1582-10-05'); + }); + + it('labels BCE years using astronomical numbering', () => { + // Astronomical year 0 == 1 BCE. + expect(convert({ year: 0, month: 1, day: 1 }, 'Gregorian').localYearLabel).toBe('1 BC'); + }); + + it('falls back to descriptive-only for unknown calendars', () => { + const c = convert({ year: 1700, month: 4, day: 1 }, 'Algonquian lunar calendar'); + expect(c.convertible).toBe(false); + expect(c.system).toBe('Algonquian lunar calendar'); + expect(c.localYearLabel).toBe('1700'); // Gregorian reference + expect(c.note).toBeTruthy(); + expect(c.localDateLabel).toBeUndefined(); + }); +}); diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts new file mode 100644 index 0000000..70703ff --- /dev/null +++ b/src/utils/calendars.ts @@ -0,0 +1,59 @@ +// Calendar conversion registry. +// +// `convert` turns a (proleptic-Gregorian) date into the local year for a named +// calendar system. Two gates (see plan.md, Q3): a calendar is *accepted* into +// the app as a data decision; here we only decide whether it is *convertible* — +// i.e. a deterministic epoch+rule yields a numeric local year. Calendars without +// one (e.g. an observational lunar calendar) are descriptive-only: we return the +// Gregorian year for reference and convertible:false, never a wrong number. + +import type { CalendarConversion, HistoricalDate } from '../types'; +import { gregorianToJulianDay, julianDayToJulian } from './julianDay'; + +function pad2(n: number): string { + return String(Math.abs(n)).padStart(2, '0'); +} + +/** Year label in astronomical numbering: year <= 0 renders as "N BC". */ +function yearLabel(year: number): string { + return year > 0 ? String(year) : `${1 - year} BC`; +} + +function dateLabel({ year, month, day }: HistoricalDate): string { + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +type Converter = (date: HistoricalDate) => CalendarConversion; + +const CONVERTERS: Record<string, Converter> = { + gregorian: (date) => ({ + system: 'Gregorian', + localYearLabel: yearLabel(date.year), + localDateLabel: dateLabel(date), + convertible: true, + }), + julian: (date) => { + const julian = julianDayToJulian(gregorianToJulianDay(date)); + return { + system: 'Julian', + localYearLabel: yearLabel(julian.year), + localDateLabel: dateLabel(julian), + convertible: true, + }; + }, +}; + +/** Convert `date` into the named calendar's local year. */ +export function convert(date: HistoricalDate, calendarName: string): CalendarConversion { + const converter = CONVERTERS[calendarName.trim().toLowerCase()]; + if (converter) return converter(date); + + // Descriptive-only: keep the original calendar name, show the Gregorian + // year for reference, and be explicit that no numeric year exists. + return { + system: calendarName, + localYearLabel: yearLabel(date.year), + convertible: false, + note: 'This calendar has no continuous numeric year to convert to; showing the Gregorian year for reference.', + }; +} diff --git a/src/utils/eras.test.ts b/src/utils/eras.test.ts new file mode 100644 index 0000000..697c600 --- /dev/null +++ b/src/utils/eras.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { findEras } from './eras'; +import type { Era } from '../types'; + +const colonial: Era = { name: 'Colonial', start: '1750-01-01', end: null, calendar: 'Gregorian' }; +const preColonial: Era = { name: 'Pre-Colonial', start: null, end: '1750-01-01', calendar: 'Algonquian lunar calendar' }; +// Deliberately overlaps both of the above around the 1750 boundary. +const transitional: Era = { name: 'Transitional', start: '1700-01-01', end: '1800-01-01', calendar: 'Julian' }; + +const eras = [colonial, preColonial, transitional]; +const names = (date: { year: number; month: number; day: number }) => + findEras(date, eras).map((e) => e.name); + +describe('findEras', () => { + it('matches an open-past era before the boundary', () => { + expect(names({ year: 1600, month: 6, day: 1 })).toEqual(['Pre-Colonial']); + }); + + it('matches an open-present era after the boundary', () => { + expect(names({ year: 1900, month: 1, day: 1 })).toEqual(['Colonial']); + }); + + it('is half-open: the end date belongs to the next era, not the ending one', () => { + // 1750-01-01 is excluded from Pre-Colonial (its exclusive end) but included + // in Colonial (its inclusive start). Transitional (1700..1800) also covers it. + expect(names({ year: 1750, month: 1, day: 1 })).toEqual(['Colonial', 'Transitional']); + }); + + it('returns all overlapping eras at once', () => { + // 1740 is in both Pre-Colonial (< 1750) and Transitional (1700..1800). + expect(names({ year: 1740, month: 1, day: 1 })).toEqual(['Pre-Colonial', 'Transitional']); + // 1760 is in both Colonial (>= 1750) and Transitional. + expect(names({ year: 1760, month: 1, day: 1 })).toEqual(['Colonial', 'Transitional']); + }); + + it('returns nothing when no era covers the date', () => { + const bounded: Era[] = [{ name: 'Only', start: '1000-01-01', end: '1100-01-01', calendar: 'Julian' }]; + expect(findEras({ year: 1200, month: 1, day: 1 }, bounded)).toEqual([]); + }); +}); diff --git a/src/utils/eras.ts b/src/utils/eras.ts new file mode 100644 index 0000000..3108618 --- /dev/null +++ b/src/utils/eras.ts @@ -0,0 +1,20 @@ +// Era matching: which eras cover a given date. + +import type { Era, HistoricalDate } from '../types'; +import { gregorianToJulianDay, parseGregorianDate } from './julianDay'; + +/** + * All eras whose date range covers `date`, using the half-open interval + * [start, end): a null start is the open past, a null end is the open present. + * + * Eras MAY overlap in time (parallel calendars coexisting at the same place and + * time), so this returns every match, not the first one (see plan.md, Q4). + */ +export function findEras(date: HistoricalDate, eras: Era[]): Era[] { + const t = gregorianToJulianDay(date); + return eras.filter((era) => { + const afterStart = era.start === null || gregorianToJulianDay(parseGregorianDate(era.start)) <= t; + const beforeEnd = era.end === null || t < gregorianToJulianDay(parseGregorianDate(era.end)); + return afterStart && beforeEnd; + }); +} diff --git a/src/utils/geo.test.ts b/src/utils/geo.test.ts new file mode 100644 index 0000000..5298674 --- /dev/null +++ b/src/utils/geo.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { pointInPolygon, findRegions } from './geo'; +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], + ], + ], +}; + +// Same square with a 2..8 hole punched in the middle. +const squareWithHole: Geometry = { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + [ + [2, 2], + [8, 2], + [8, 8], + [2, 8], + [2, 2], + ], + ], +}; + +describe('pointInPolygon', () => { + it('detects a point inside the polygon', () => { + // pointInPolygon takes (lat, lng); the square spans lat/lng 0..10. + expect(pointInPolygon(5, 5, square)).toBe(true); + }); + + it('detects a point outside the polygon', () => { + expect(pointInPolygon(15, 5, square)).toBe(false); + expect(pointInPolygon(5, -1, square)).toBe(false); + }); + + it('treats a point inside a hole as outside', () => { + expect(pointInPolygon(5, 5, squareWithHole)).toBe(false); + expect(pointInPolygon(1, 1, squareWithHole)).toBe(true); // in the ring, outside the hole + }); + + it('handles MultiPolygon (inside any part)', () => { + const multi: Geometry = { + type: 'MultiPolygon', + coordinates: [ + square.type === 'Polygon' ? square.coordinates : [], + [ + [ + [20, 20], + [30, 20], + [30, 30], + [20, 30], + [20, 20], + ], + ], + ], + }; + expect(pointInPolygon(5, 5, multi)).toBe(true); + expect(pointInPolygon(25, 25, multi)).toBe(true); + expect(pointInPolygon(15, 15, multi)).toBe(false); + }); +}); + +describe('findRegions', () => { + const overlapping: CalendarRegion[] = [ + { region: { name: 'A', geo: square }, eras: [] }, + { + region: { + name: 'B', + geo: { + type: 'Polygon', + coordinates: [ + [ + [5, 5], + [15, 5], + [15, 15], + [5, 15], + [5, 5], + ], + ], + }, + }, + eras: [], + }, + ]; + + it('returns every region containing the point (overlap)', () => { + const hits = findRegions(7, 7, overlapping).map((r) => r.region.name); + expect(hits).toEqual(['A', 'B']); + }); + + it('returns a single region when only one contains the point', () => { + expect(findRegions(2, 2, overlapping).map((r) => r.region.name)).toEqual(['A']); + }); + + it('returns nothing when no region contains the point', () => { + expect(findRegions(100, 100, overlapping)).toEqual([]); + }); +}); diff --git a/src/utils/geo.ts b/src/utils/geo.ts new file mode 100644 index 0000000..f348676 --- /dev/null +++ b/src/utils/geo.ts @@ -0,0 +1,42 @@ +// Point-in-region geometry. GeoJSON coordinates are [lng, lat]. + +import type { CalendarRegion, Geometry, LinearRing } from '../types'; + +// Standard ray-casting test against a single ring (x = lng, y = lat). +function pointInRing(lng: number, lat: number, ring: LinearRing): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + const intersects = + yi > lat !== yj > lat && + lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersects) inside = !inside; + } + return inside; +} + +// A polygon is its outer ring (index 0) minus any holes (further rings). +function pointInPolygonRings(lng: number, lat: number, rings: LinearRing[]): boolean { + if (rings.length === 0) return false; + const [outer, ...holes] = rings; + if (!pointInRing(lng, lat, outer)) return false; + return !holes.some((hole) => pointInRing(lng, lat, hole)); +} + +/** Whether the point (lat, lng) lies inside a Polygon or MultiPolygon. */ +export function pointInPolygon(lat: number, lng: number, geometry: Geometry): boolean { + if (geometry.type === 'Polygon') { + return pointInPolygonRings(lng, lat, geometry.coordinates); + } + return geometry.coordinates.some((polygon) => pointInPolygonRings(lng, lat, polygon)); +} + +/** All regions whose geometry contains the point — regions may overlap. */ +export function findRegions( + lat: number, + lng: number, + regions: CalendarRegion[], +): CalendarRegion[] { + return regions.filter((r) => pointInPolygon(lat, lng, r.region.geo)); +} diff --git a/src/utils/julianDay.test.ts b/src/utils/julianDay.test.ts new file mode 100644 index 0000000..9385b0a --- /dev/null +++ b/src/utils/julianDay.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { + gregorianToJulianDay, + julianDayToGregorian, + julianDayToJulian, + parseGregorianDate, +} from './julianDay'; + +describe('gregorianToJulianDay', () => { + it('matches the J2000 epoch (2000-01-01 = JDN 2451545)', () => { + expect(gregorianToJulianDay({ year: 2000, month: 1, day: 1 })).toBe(2451545); + }); + + it('orders dates monotonically across the BCE/CE boundary', () => { + const bce = gregorianToJulianDay({ year: -1, month: 12, day: 31 }); // 2 BCE + const ce = gregorianToJulianDay({ year: 1, month: 1, day: 1 }); // 1 CE + expect(bce).toBeLessThan(ce); + }); +}); + +describe('julianDayToGregorian', () => { + it('round-trips with the forward conversion', () => { + for (const date of [ + { year: 2000, month: 1, day: 1 }, + { year: 1582, month: 10, day: 15 }, + { year: 1, month: 1, day: 1 }, + { year: -44, month: 3, day: 15 }, + ]) { + expect(julianDayToGregorian(gregorianToJulianDay(date))).toEqual(date); + } + }); +}); + +describe('julianDayToJulian', () => { + it('2000-01-01 Gregorian is 1999-12-19 in the Julian calendar (13-day gap)', () => { + const jdn = gregorianToJulianDay({ year: 2000, month: 1, day: 1 }); + expect(julianDayToJulian(jdn)).toEqual({ year: 1999, month: 12, day: 19 }); + }); + + it('1582-10-15 Gregorian is 1582-10-05 Julian (the 10-day reform gap)', () => { + const jdn = gregorianToJulianDay({ year: 1582, month: 10, day: 15 }); + expect(julianDayToJulian(jdn)).toEqual({ year: 1582, month: 10, day: 5 }); + }); +}); + +describe('parseGregorianDate', () => { + it('parses a normal date', () => { + expect(parseGregorianDate('1750-01-01')).toEqual({ year: 1750, month: 1, day: 1 }); + }); + + it('parses a negative (BCE) year', () => { + expect(parseGregorianDate('-0044-03-15')).toEqual({ year: -44, month: 3, day: 15 }); + }); + + it('throws on a malformed string', () => { + expect(() => parseGregorianDate('not-a-date')).toThrow(); + }); +}); diff --git a/src/utils/julianDay.ts b/src/utils/julianDay.ts new file mode 100644 index 0000000..c8dec59 --- /dev/null +++ b/src/utils/julianDay.ts @@ -0,0 +1,72 @@ +// Julian Day Number (JDN) math. +// +// The engine compares dates by converting them to a single integer ordering +// key — the Julian Day Number — so proleptic-Gregorian, Julian, BCE and +// pre-1582 dates all compare correctly (see plan.md, Q2). All dates here use +// astronomical year numbering: year 0 = 1 BCE, year -1 = 2 BCE, etc. + +import type { HistoricalDate } from '../types'; + +/** Proleptic-Gregorian date → Julian Day Number (Fliegel & Van Flandern). */ +export function gregorianToJulianDay({ year, month, day }: HistoricalDate): number { + const a = Math.floor((14 - month) / 12); + const y = year + 4800 - a; + const m = month + 12 * a - 3; + return ( + day + + Math.floor((153 * m + 2) / 5) + + 365 * y + + Math.floor(y / 4) - + Math.floor(y / 100) + + Math.floor(y / 400) - + 32045 + ); +} + +// Richards' algorithm (Explanatory Supplement to the Astronomical Almanac). +// The Gregorian and Julian variants differ only in how `f` is derived. +function fromJulianDay(jdn: number, calendar: 'gregorian' | 'julian'): HistoricalDate { + const Y = 4716; + const J = 1401; + const M0 = 2; + const N = 12; + const R = 4; + const P = 1461; + const V = 3; + const U = 5; + const S = 153; + const W = 2; + const B = 274277; + const C = -38; + + let f = jdn + J; + if (calendar === 'gregorian') { + f += Math.floor((Math.floor((4 * jdn + B) / 146097) * 3) / 4) + C; + } + const e = R * f + V; + const g = Math.floor((e % P) / R); + const h = U * g + W; + const day = Math.floor((h % S) / U) + 1; + const month = ((Math.floor(h / S) + M0) % N) + 1; + const year = Math.floor(e / P) - Y + Math.floor((N + M0 - month) / N); + return { year, month, day }; +} + +/** Julian Day Number → proleptic-Gregorian date. */ +export function julianDayToGregorian(jdn: number): HistoricalDate { + return fromJulianDay(jdn, 'gregorian'); +} + +/** Julian Day Number → Julian-calendar date. */ +export function julianDayToJulian(jdn: number): HistoricalDate { + return fromJulianDay(jdn, 'julian'); +} + +const ISO_DATE = /^(-?\d+)-(\d{2})-(\d{2})$/; + +/** Parse a proleptic-Gregorian "YYYY-MM-DD" (optionally a negative year). */ +export function parseGregorianDate(value: string): HistoricalDate { + const match = ISO_DATE.exec(value); + if (!match) throw new Error(`Invalid date "${value}" (expected YYYY-MM-DD)`); + return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; +} diff --git a/src/utils/resolve.test.ts b/src/utils/resolve.test.ts new file mode 100644 index 0000000..a5c1a95 --- /dev/null +++ b/src/utils/resolve.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { resolve } from './resolve'; +import type { CalendarRegion, Geometry } from '../types'; + +const square: Geometry = { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + ], +}; + +const regions: CalendarRegion[] = [ + { + region: { name: 'Testland', geo: square }, + eras: [ + { name: 'Modern', start: '1750-01-01', end: null, calendar: 'Gregorian', usedBy: 'settlers' }, + { name: 'Old Ways', start: null, end: '1750-01-01', calendar: 'Algonquian lunar calendar', usedBy: 'locals' }, + ], + }, +]; + +describe('resolve', () => { + it('returns no-region when the point is outside every region', () => { + expect(resolve(50, 50, { year: 1700, month: 1, day: 1 }, regions)).toEqual({ + answers: [], + missReason: 'no-region', + }); + }); + + it('returns no-era when a region matches but no era covers the date', () => { + const bounded: CalendarRegion[] = [ + { region: { name: 'X', geo: square }, eras: [{ name: 'E', start: '1000-01-01', end: '1100-01-01', calendar: 'Julian' }] }, + ]; + expect(resolve(5, 5, { year: 1700, month: 1, day: 1 }, bounded)).toEqual({ + answers: [], + missReason: 'no-era', + }); + }); + + it('returns the matching answer with conversion', () => { + const { answers, missReason } = resolve(5, 5, { year: 1900, month: 1, day: 1 }, regions); + expect(missReason).toBeUndefined(); + expect(answers).toHaveLength(1); + expect(answers[0].era.name).toBe('Modern'); + expect(answers[0].conversion).toMatchObject({ system: 'Gregorian', localYearLabel: '1900', convertible: true }); + }); + + it('gives the descriptive-only answer before the boundary', () => { + const { answers } = resolve(5, 5, { year: 1700, month: 1, day: 1 }, regions); + expect(answers).toHaveLength(1); + expect(answers[0].era.usedBy).toBe('locals'); + expect(answers[0].conversion.convertible).toBe(false); + }); +}); diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts new file mode 100644 index 0000000..16fd529 --- /dev/null +++ b/src/utils/resolve.ts @@ -0,0 +1,30 @@ +// The engine: "what year is it?" at a point and date. +// +// Composes the layers into the plural-answers model — every valid reply a local +// could give. Pure (regions are passed in) so it is easy to unit-test; the UI +// supplies the loaded calendar data. + +import type { CalendarRegion, HistoricalDate, Answer, Resolution } from '../types'; +import { findRegions } from './geo'; +import { findEras } from './eras'; +import { convert } from './calendars'; + +export function resolve( + lat: number, + lng: number, + date: HistoricalDate, + regions: CalendarRegion[], +): Resolution { + const matched = findRegions(lat, lng, regions); + if (matched.length === 0) return { answers: [], missReason: 'no-region' }; + + const answers: Answer[] = []; + for (const { region, eras } of matched) { + for (const era of findEras(date, eras)) { + answers.push({ region, era, conversion: convert(date, era.calendar) }); + } + } + + if (answers.length === 0) return { answers: [], missReason: 'no-era' }; + return { answers }; +}