forked from pyrex/theoma-calendar
Theoma calendar, basic implementation
This commit is contained in:
268
src/index.tsx
Normal file
268
src/index.tsx
Normal 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
0
src/style.css
Normal file
Reference in New Issue
Block a user