a6b3f039d8
Pages: /, /simple, /decimal, /seth, /calendar, /astro, /convert, /timegov Features: Seth Calendar (10×36 + holidays), decimal time, moon phases, astronomy (sun/moon), bidirectional time converter, Seth date display, leap day split cell in calendar grid.
281 lines
8.9 KiB
JavaScript
281 lines
8.9 KiB
JavaScript
// Decimal time & French Republican Calendar
|
|
//
|
|
// Decimal time: 1 day = 10 decimal hours
|
|
// 1 decimal hour = 100 decimal minutes
|
|
// 1 decimal minute = 100 decimal seconds
|
|
//
|
|
// French Republican Calendar epoch: 22 September 1792 (Gregorian) = 1 Vendémiaire An I
|
|
// Each year has 12 months of 30 days + 5 (or 6 in sextile years) complementary days.
|
|
// Sextile (leap) years: the Republican leap rule approximates the Gregorian one —
|
|
// years 3, 7, 11, 15, 20 of each 20-year cycle were designated sextile in the original
|
|
// Romme proposal. For simplicity we use the widely accepted rule:
|
|
// A Republican year is sextile if the *following* Gregorian year is a Gregorian leap year.
|
|
|
|
const REPUBLICAN_EPOCH_JD = 2375840; // Julian Day Number of 1 Vendémiaire An I (22 Sep 1792)
|
|
|
|
const MONTH_NAMES = [
|
|
"Vendémiaire", "Brumaire", "Frimaire",
|
|
"Nivôse", "Pluviôse", "Ventôse",
|
|
"Germinal", "Floréal", "Prairial",
|
|
"Messidor", "Thermidor", "Fructidor"
|
|
];
|
|
|
|
const MONTH_NAMES_EN = [
|
|
"Vintage", "Mist", "Frost",
|
|
"Snowy", "Rainy", "Windy",
|
|
"Budding", "Flowery", "Meadow",
|
|
"Harvest", "Heat", "Fruit"
|
|
];
|
|
|
|
const COMPLEMENTARY_NAMES = [
|
|
"Jour de la Vertu", // 1
|
|
"Jour du Génie", // 2
|
|
"Jour du Travail", // 3
|
|
"Jour de l'Opinion", // 4
|
|
"Jour des Récompenses", // 5
|
|
"Jour de la Révolution", // 6 (sextile only)
|
|
];
|
|
|
|
const COMPLEMENTARY_NAMES_EN = [
|
|
"Day of Virtue",
|
|
"Day of Genius",
|
|
"Day of Labour",
|
|
"Day of Opinion",
|
|
"Day of Rewards",
|
|
"Day of the Revolution",
|
|
];
|
|
|
|
const WEEKDAY_NAMES = [
|
|
"Primidi", "Duodi", "Tridi", "Quartidi", "Quintidi",
|
|
"Sextidi", "Septidi", "Octidi", "Nonidi", "Décadi"
|
|
];
|
|
|
|
const WEEKDAY_NAMES_EN = [
|
|
"First day", "Second day", "Third day", "Fourth day", "Fifth day",
|
|
"Sixth day", "Seventh day", "Eighth day", "Ninth day", "Tenth day"
|
|
];
|
|
|
|
// --- Julian Day Number helpers ---
|
|
|
|
function gregorianToJD(year, month, day) {
|
|
// Algorithm from Jean Meeus, "Astronomical Algorithms"
|
|
if (month <= 2) { year -= 1; month += 12; }
|
|
const A = Math.floor(year / 100);
|
|
const B = 2 - A + Math.floor(A / 4);
|
|
return Math.floor(365.25 * (year + 4716)) +
|
|
Math.floor(30.6001 * (month + 1)) +
|
|
day + B - 1524.5;
|
|
}
|
|
|
|
function isGregorianLeap(year) {
|
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
}
|
|
|
|
function isSextile(republicanYear) {
|
|
// The year following An N is Gregorian year 1792 + N
|
|
return isGregorianLeap(1792 + republicanYear);
|
|
}
|
|
|
|
function toRepublicanDate(utcDate) {
|
|
// JD at midnight UTC for the given date
|
|
const jd = Math.floor(gregorianToJD(
|
|
utcDate.getUTCFullYear(),
|
|
utcDate.getUTCMonth() + 1,
|
|
utcDate.getUTCDate()
|
|
) + 0.5); // +0.5 because JD starts at noon; floor gives previous midnight
|
|
|
|
const daysSinceEpoch = jd - REPUBLICAN_EPOCH_JD;
|
|
|
|
if (daysSinceEpoch < 0) {
|
|
return null; // Before the Republic
|
|
}
|
|
|
|
// Walk years: each year is 365 or 366 days
|
|
let year = 1;
|
|
let remaining = daysSinceEpoch;
|
|
|
|
while (true) {
|
|
const yearLen = isSextile(year) ? 366 : 365;
|
|
if (remaining < yearLen) break;
|
|
remaining -= yearLen;
|
|
year++;
|
|
}
|
|
|
|
let month, day, weekday, complementary;
|
|
|
|
if (remaining < 360) {
|
|
// Normal months (0-indexed day within year)
|
|
month = Math.floor(remaining / 30) + 1; // 1..12
|
|
day = (remaining % 30) + 1; // 1..30
|
|
weekday = ((remaining % 10)); // 0..9 → Primidi..Décadi
|
|
complementary = null;
|
|
} else {
|
|
// Complementary days (jours complémentaires / sans-culottides)
|
|
const compIdx = remaining - 360; // 0..4 (or 0..5 sextile)
|
|
month = null;
|
|
day = compIdx + 1;
|
|
weekday = null;
|
|
complementary = COMPLEMENTARY_NAMES[compIdx] || "Jour inconnu";
|
|
}
|
|
|
|
return { year, month, day, weekday, complementary };
|
|
}
|
|
|
|
function toRomanNumeral(n) {
|
|
const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1];
|
|
const syms = ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"];
|
|
let result = "";
|
|
for (let i = 0; i < vals.length; i++) {
|
|
while (n >= vals[i]) { result += syms[i]; n -= vals[i]; }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function formatRepublicanDate(r) {
|
|
if (!r) return { full: "Before the Republic", translation: "", dayOfYear: "-", year: "-" };
|
|
|
|
let full, translation;
|
|
if (r.complementary) {
|
|
const compIdx = r.day - 1;
|
|
full = `${r.complementary}, An ${toRomanNumeral(r.year)}`;
|
|
translation = `${COMPLEMENTARY_NAMES_EN[compIdx]}, Year ${r.year}`;
|
|
} else {
|
|
const weekdayName = WEEKDAY_NAMES[r.weekday];
|
|
const weekdayNameEn = WEEKDAY_NAMES_EN[r.weekday];
|
|
const monthName = MONTH_NAMES[r.month - 1];
|
|
const monthNameEn = MONTH_NAMES_EN[r.month - 1];
|
|
full = `${weekdayName}, ${r.day} ${monthName} An ${toRomanNumeral(r.year)}`;
|
|
translation = `${weekdayNameEn}, ${r.day} ${monthNameEn}, Year ${r.year}`;
|
|
}
|
|
|
|
const dayOfYear = r.complementary
|
|
? `360 + ${r.day} (Jour complémentaire)`
|
|
: `${(r.month - 1) * 30 + r.day}`;
|
|
|
|
return { full, translation, dayOfYear, year: `An ${toRomanNumeral(r.year)}` };
|
|
}
|
|
|
|
// --- Decimal time ---
|
|
// Decimal time counts from local midnight in the selected time zone.
|
|
|
|
function getLocalParts(date, zone) {
|
|
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: zone,
|
|
hour12: false,
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
|
});
|
|
const parts = fmt.formatToParts(date).reduce((acc, p) => {
|
|
if (p.type !== "literal") acc[p.type] = parseInt(p.value, 10);
|
|
return acc;
|
|
}, {});
|
|
return parts; // { year, month, day, hour, minute, second }
|
|
}
|
|
|
|
function toDecimalTime(date, zone) {
|
|
const p = getLocalParts(date, zone);
|
|
// ms since local midnight (ignoring DST transitions within the day — good enough)
|
|
const msIntoDay =
|
|
p.hour * 3_600_000 +
|
|
p.minute * 60_000 +
|
|
p.second * 1_000 +
|
|
date.getMilliseconds();
|
|
|
|
const dayFraction = msIntoDay / 86_400_000;
|
|
const totalDecimalSeconds = dayFraction * 100_000; // 10h * 100m * 100s
|
|
|
|
const dHour = Math.floor(totalDecimalSeconds / 10_000);
|
|
const rem1 = totalDecimalSeconds % 10_000;
|
|
const dMin = Math.floor(rem1 / 100);
|
|
const dSec = rem1 % 100;
|
|
const dCenti = Math.floor((dSec % 1) * 100);
|
|
const dSecInt = Math.floor(dSec);
|
|
|
|
return {
|
|
str: `${dHour}:${String(dMin).padStart(2,"0")}:${String(dSecInt).padStart(2,"0")}.${String(dCenti).padStart(2,"0")}`
|
|
};
|
|
}
|
|
|
|
function toRepublicanDateLocal(date, zone) {
|
|
const p = getLocalParts(date, zone);
|
|
return toRepublicanDate(new Date(Date.UTC(p.year, p.month - 1, p.day)));
|
|
}
|
|
|
|
// --- DOM & sync ---
|
|
|
|
const zoneSelect = document.getElementById("zoneSelect");
|
|
const zoneLabel = document.getElementById("zoneLabel");
|
|
const dateTrans = document.getElementById("dateTrans");
|
|
const offsetLabel = document.getElementById("offsetLabel");
|
|
const decimalUtc = document.getElementById("decimalUtc");
|
|
const dayOfYear = document.getElementById("dayOfYear");
|
|
const repYear = document.getElementById("repYear");
|
|
const utcLabel = document.getElementById("utcLabel");
|
|
const dateLine = document.getElementById("dateLine");
|
|
const digital = document.getElementById("digital");
|
|
|
|
const zones = [
|
|
"America/New_York",
|
|
"America/Chicago",
|
|
"America/Denver",
|
|
"America/Los_Angeles",
|
|
"America/Anchorage",
|
|
"Pacific/Honolulu",
|
|
"UTC"
|
|
];
|
|
|
|
let selectedZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York";
|
|
if (!zones.includes(selectedZone)) selectedZone = "America/New_York";
|
|
|
|
for (const zone of zones) {
|
|
const option = document.createElement("option");
|
|
option.value = zone;
|
|
option.textContent = zone;
|
|
if (zone === selectedZone) option.selected = true;
|
|
zoneSelect.appendChild(option);
|
|
}
|
|
|
|
zoneSelect.addEventListener("change", () => {
|
|
selectedZone = zoneSelect.value;
|
|
zoneLabel.textContent = selectedZone;
|
|
});
|
|
|
|
let serverOffsetMs = 0;
|
|
|
|
async function syncWithServer() {
|
|
const start = Date.now();
|
|
try {
|
|
const response = await fetch(`/api/time?ts=${start}`, { cache: "no-store" });
|
|
const end = Date.now();
|
|
const data = await response.json();
|
|
const rtt = end - start;
|
|
serverOffsetMs = (data.epoch_ms + rtt / 2) - end;
|
|
offsetLabel.textContent = `Synchronized (${serverOffsetMs >= 0 ? "+" : ""}${serverOffsetMs.toFixed(0)} ms, RTT ${rtt} ms)`;
|
|
} catch (_) {
|
|
offsetLabel.textContent = "Sync failed";
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const now = new Date(Date.now() + serverOffsetMs);
|
|
|
|
const dt = toDecimalTime(now, selectedZone);
|
|
const rep = toRepublicanDateLocal(now, selectedZone);
|
|
const fmt = formatRepublicanDate(rep);
|
|
|
|
digital.textContent = dt.str;
|
|
dateLine.textContent = fmt.full;
|
|
dateTrans.textContent = fmt.translation;
|
|
decimalUtc.textContent = dt.str;
|
|
dayOfYear.textContent = fmt.dayOfYear;
|
|
repYear.textContent = fmt.year;
|
|
zoneLabel.textContent = selectedZone;
|
|
utcLabel.textContent = now.toISOString().replace("T", " ").replace("Z", " UTC");
|
|
|
|
requestAnimationFrame(render);
|
|
}
|
|
|
|
syncWithServer();
|
|
setInterval(syncWithServer, 20_000);
|
|
render();
|