Theoma calendar, basic implementation

This commit is contained in:
2026-01-01 13:51:41 -08:00
commit aee0c66c05
9 changed files with 2472 additions and 0 deletions

268
src/index.tsx Normal file
View File

@@ -0,0 +1,268 @@
import { render } from "preact";
import { useState } from "preact/hooks";
type Calendar = {
name: string;
periods: Period[];
epoch: RelativeDay;
};
type Period = {
name: string;
duration: number;
};
type RelativeDay = {
cycle: number;
period: string;
index: number;
};
type AbsoluteDay = number;
const StockCalendars: Calendar[] = [
{
name: "Week",
periods: [
{ name: "Niesday", duration: 1 },
{ name: "Tinsday", duration: 1 },
{ name: "Gwensday", duration: 1 },
{ name: "Varsday", duration: 1 },
{ name: "Ryesday", duration: 1 },
{ name: "Pretsday", duration: 1 },
{ name: "Skansday", duration: 1 },
],
epoch: {
cycle: 0,
period: "Niesday",
index: 1,
},
},
{
name: "Kelkaithian",
periods: [
{ name: "Frigidae", duration: 35 },
{ name: "Vesvor", duration: 35 },
{ name: "Inesqua", duration: 35 },
{ name: "Spring Celebration", duration: 7 },
{ name: "Kinish", duration: 35 },
{ name: "Chyran", duration: 35 },
{ name: "Kelit", duration: 35 },
{ name: "Emrak", duration: 35 },
{ name: "Rynawa", duration: 35 },
{ name: "Tanith", duration: 35 },
{ name: "Autumn Celebration", duration: 7 },
{ name: "Skelundy", duration: 35 },
],
epoch: {
cycle: 0,
period: "Frigidae",
index: 1,
},
},
{
name: "Tachamundi",
periods: [
{ name: "Firstmonth", duration: 28 },
{ name: "Secondmonth", duration: 28 },
{ name: "Thirdmonth", duration: 28 },
{ name: "Fourthmonth", duration: 28 },
{ name: "Fifthmonth", duration: 28 },
{ name: "Sixthmonth", duration: 28 },
{ name: "Seventhmonth", duration: 28 },
{ name: "Eighthmonth", duration: 28 },
{ name: "Ninthmonth", duration: 28 },
{ name: "Tenthmonth", duration: 28 },
{ name: "Eleventhmonth", duration: 28 },
{ name: "Twelfthmonth", duration: 28 },
{ name: "Thirteenthmonth", duration: 28 },
],
epoch: {
cycle: 0,
period: "Firstmonth",
index: 1,
},
},
{
name: "Seasonal",
periods: [
{ name: "Winter", duration: 91 },
{ name: "Spring", duration: 91 },
{ name: "Summer", duration: 91 },
{ name: "Autumn", duration: 91 },
],
epoch: {
cycle: 0,
period: "Winter",
index: 14,
},
},
];
function computeDayOffset(calendar: Calendar, period: string, index: number) {
let cumulativeOffset = 0;
for (let candidate of calendar.periods) {
if (candidate.name != period) {
cumulativeOffset += candidate.duration;
} else {
// correct invalid dates
if (index < 1) {
index = 1;
}
if (index > candidate.duration) {
index = candidate.duration;
}
cumulativeOffset += index - 1; // days are 1-indexed
return cumulativeOffset;
}
}
throw new Error(`could not compute day offset for ${period} ${index}`);
}
function epochOffset(calendar: Calendar) {
let epochDayOffset = computeDayOffset(
calendar,
calendar.epoch.period,
calendar.epoch.index,
);
let duration = calendarDuration(calendar);
let fullOffset = epochDayOffset + calendar.epoch.cycle * duration;
return fullOffset;
}
function calendarDuration(calendar: Calendar): number {
let totalLength = 0;
for (let period of calendar.periods) {
totalLength += period.duration;
}
return totalLength;
}
function divmod(i: number, base: number): [number, number] {
// a polite modulus similar to Python's
return [Math.floor(i / base), ((i % base) + base) % base];
}
function relativize(calendar: Calendar, absolute: AbsoluteDay): RelativeDay {
let duration = calendarDuration(calendar);
absolute += epochOffset(calendar);
let [cycle, cycleDay] = divmod(absolute, duration);
let period = null;
let index = null;
for (let candidate of calendar.periods) {
if (cycleDay < candidate.duration) {
period = candidate.name;
index = cycleDay + 1; // 1-indexed
break;
} else {
cycleDay -= candidate.duration;
}
}
if (period == null) {
throw new Error(`could not resolve ${absolute}`);
}
return { cycle, period, index };
}
function absolutize(calendar: Calendar, instant: RelativeDay): AbsoluteDay {
let duration = calendarDuration(calendar);
let cycleDay = computeDayOffset(calendar, instant.period, instant.index);
return cycleDay + instant.cycle * duration - epochOffset(calendar);
}
export function App() {
let [absoluteDay, setAbsoluteDay] = useState(0);
let widgets = [];
for (let calendar of StockCalendars) {
widgets.push(
<CalendarWidget
key={calendar.name}
value={calendar}
absoluteDay={absoluteDay}
setAbsoluteDay={setAbsoluteDay}
/>,
);
}
return <>{widgets}</>;
}
export function CalendarWidget({
value,
absoluteDay,
setAbsoluteDay,
}: {
value: Calendar;
absoluteDay: number;
setAbsoluteDay: (number) => void;
}) {
let rel = relativize(value, absoluteDay);
let abs = absolutize(value, rel);
if (abs != absoluteDay) {
throw new Error(`roundtrip failed: ${absoluteDay} ${rel} ${abs}`);
}
let cycleSelector = (
<input
type="text"
onChange={(t) => {
let cycle = parseInt(t.currentTarget.value);
setAbsoluteDay(absolutize(value, { ...rel, cycle }));
}}
value={rel.cycle}
></input>
);
let periodSelector = (
<select
onChange={(t) => {
let selection = t.currentTarget.value;
setAbsoluteDay(absolutize(value, { ...rel, period: selection }));
}}
>
{value.periods.map((i) => (
<option key={i.name} selected={i.name == rel.period}>
{i.name}
</option>
))}
</select>
);
let relevantPeriod = value.periods.find((p) => p.name == rel.period);
let possibleIndexes = [...Array(relevantPeriod.duration).keys()].map(
(i) => i + 1,
);
let indexSelector = (
<select
onChange={(t) => {
let selection = parseInt(t.currentTarget.value);
setAbsoluteDay(absolutize(value, { ...rel, index: selection }));
}}
>
{possibleIndexes.map((i: number) => (
<option key={i} selected={i == rel.index} value={i}>
{i}
</option>
))}
</select>
);
if (possibleIndexes.length == 1) {
indexSelector = <></>;
}
return (
<div>
<p>{value.name}</p>
<p>
{cycleSelector} {periodSelector} {indexSelector}
</p>
</div>
);
}
render(<App />, document.getElementById("app"));

0
src/style.css Normal file
View File