levineuwirth.org/content/scripts/memento-mori.js

383 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
'use strict';
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
// Birthday: Monday, December 29, 2003
const BIRTHDAY = new Date(2003, 11, 29); // month is 0-indexed
// End of the 80-year span: December 29, 2083
const LIFE_END = new Date(2083, 11, 29);
const YEARS = 80;
const WEEKS_PER_YEAR = 52;
const TOTAL_WEEKS = YEARS * WEEKS_PER_YEAR; // 4160
// A tropical season: 365.25 / 4 days
const SEASON_MS = 91.25 * 24 * 60 * 60 * 1000;
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
function fmtDate(d) {
return d.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
});
}
function fmtDayShort(d) {
return d.toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
}
function currentSeasonName() {
const now = new Date();
const m = now.getMonth(); // 0-indexed
const d = now.getDate();
// Approximate meteorological seasons (northern hemisphere)
if (m < 2 || (m === 11)) return 'winter';
if (m < 5) return 'spring';
if (m < 8) return 'summer';
return 'autumn';
}
// -------------------------------------------------------------------------
// Grid
// -------------------------------------------------------------------------
function buildGrid() {
const container = document.getElementById('weeks-grid-wrapper');
if (!container) return;
const today = new Date();
today.setHours(0, 0, 0, 0);
const grid = document.createElement('div');
grid.className = 'weeks-grid';
grid.setAttribute('role', 'img');
grid.setAttribute('aria-label', 'A grid of 4,160 squares representing 80 years of life, one square per week.');
let globalWeekIndex = 1;
for (let year = 0; year < YEARS; year++) {
// Anchor the start of every row exactly to the calendar anniversary.
const yearStart = new Date(BIRTHDAY.getTime());
yearStart.setFullYear(yearStart.getFullYear() + year);
for (let weekInYear = 1; weekInYear <= WEEKS_PER_YEAR; weekInYear++) {
const start = new Date(yearStart.getTime());
start.setDate(start.getDate() + (weekInYear - 1) * 7);
const end = new Date(start.getTime());
// Stretch week 52 to the eve of the next birthday, absorbing
// the 365th/366th day so no day falls outside the grid.
if (weekInYear === WEEKS_PER_YEAR) {
const nextBday = new Date(BIRTHDAY.getTime());
nextBday.setFullYear(nextBday.getFullYear() + year + 1);
end.setTime(nextBday.getTime() - 24 * 60 * 60 * 1000);
} else {
end.setDate(end.getDate() + 6);
}
const cell = document.createElement('span');
cell.className = 'week';
if (today > end) {
cell.classList.add('week--past');
} else if (today >= start) {
cell.classList.add('week--current');
} else {
cell.classList.add('week--future');
}
if (weekInYear === 1 && year % 10 === 0) {
cell.classList.add('week--decade');
}
cell.dataset.start = fmtDate(start);
cell.dataset.end = fmtDate(end);
cell.dataset.startRaw = start.getTime();
cell.dataset.endRaw = end.getTime();
cell.dataset.year = year;
cell.dataset.weekInYear = weekInYear;
cell.dataset.index = globalWeekIndex;
grid.appendChild(cell);
globalWeekIndex++;
}
}
container.appendChild(grid);
const legend = document.createElement('p');
legend.className = 'weeks-legend';
legend.textContent =
'Each row is one year (age 079). Each cell is one week. ' +
'Decade boundaries are marked.';
container.appendChild(legend);
initTooltip(grid);
initWeekPopup(grid);
}
// -------------------------------------------------------------------------
// Tooltip
// -------------------------------------------------------------------------
function initTooltip(grid) {
const tip = document.createElement('div');
tip.className = 'weeks-tooltip';
tip.setAttribute('aria-hidden', 'true');
tip.hidden = true;
document.body.appendChild(tip);
grid.addEventListener('mouseover', e => {
const cell = e.target.closest('.week');
if (!cell) { tip.hidden = true; return; }
const year = parseInt(cell.dataset.year, 10);
const weekInYear = parseInt(cell.dataset.weekInYear, 10);
const index = parseInt(cell.dataset.index, 10);
const isPast = cell.classList.contains('week--past');
const isCurrent = cell.classList.contains('week--current');
const status = isPast ? 'elapsed' : isCurrent ? 'this week' : 'remaining';
tip.innerHTML =
`<span class="tt-age">age ${year}</span>` +
`<span class="tt-week">year ${year + 1}, week ${weekInYear}</span>` +
`<span class="tt-date">${cell.dataset.start} ${cell.dataset.end}</span>` +
`<span class="tt-meta">` +
`week ${index.toLocaleString()} of ${TOTAL_WEEKS.toLocaleString()}` +
` · ${status}` +
`</span>`;
tip.hidden = false;
});
grid.addEventListener('mouseleave', () => { tip.hidden = true; });
document.addEventListener('mousemove', e => {
if (tip.hidden) return;
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
let x = e.clientX + 14;
let y = e.clientY - th - 10;
if (y < 8) y = e.clientY + 16;
if (x + tw > window.innerWidth - 8) x = e.clientX - tw - 14;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
});
}
// -------------------------------------------------------------------------
// Week popup — dynamic day count
// -------------------------------------------------------------------------
function initWeekPopup(grid) {
const backdrop = document.createElement('div');
backdrop.className = 'week-popup-backdrop';
backdrop.hidden = true;
const card = document.createElement('div');
card.className = 'week-popup-card';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
backdrop.appendChild(card);
document.body.appendChild(backdrop);
function openPopup(cell) {
const startMs = parseInt(cell.dataset.startRaw, 10);
const endMs = parseInt(cell.dataset.endRaw, 10);
const year = parseInt(cell.dataset.year, 10);
const weekInYear = parseInt(cell.dataset.weekInYear, 10);
const index = parseInt(cell.dataset.index, 10);
const today = new Date();
today.setHours(0, 0, 0, 0);
// Dynamically calculate how many days are in this week (7, 8, or 9).
const daysInWeek = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)) + 1;
const header = document.createElement('div');
header.className = 'week-popup-header';
const title = document.createElement('div');
title.className = 'week-popup-title';
title.textContent = `Age ${year} — year ${year + 1}, week ${weekInYear}`;
const sub = document.createElement('div');
sub.className = 'week-popup-sub';
sub.textContent = `week ${index.toLocaleString()} of ${TOTAL_WEEKS.toLocaleString()}`;
const closeBtn = document.createElement('button');
closeBtn.className = 'week-popup-close';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.innerHTML = '×';
closeBtn.addEventListener('click', closePopup);
header.appendChild(title);
header.appendChild(sub);
header.appendChild(closeBtn);
const days = document.createElement('div');
days.className = 'week-popup-days';
for (let d = 0; d < daysInWeek; d++) {
const dayDate = new Date(startMs);
dayDate.setDate(dayDate.getDate() + d);
dayDate.setHours(0, 0, 0, 0);
const wday = document.createElement('div');
wday.className = 'wday';
if (dayDate < today) {
wday.classList.add('wday--past');
} else if (dayDate.getTime() === today.getTime()) {
wday.classList.add('wday--today');
} else {
wday.classList.add('wday--future');
}
const dot = document.createElement('div');
dot.className = 'wday-dot';
const name = document.createElement('div');
name.className = 'wday-name';
// Derive day name from the date itself — never misaligned.
name.textContent = dayDate.toLocaleDateString('en-US', { weekday: 'short' });
const date = document.createElement('div');
date.className = 'wday-date';
date.textContent = dayDate.toLocaleDateString('en-US', {
month: 'short', day: 'numeric'
});
const dayNum = Math.floor(
(dayDate.getTime() - BIRTHDAY.getTime()) / (24 * 60 * 60 * 1000)
) + 1;
const numEl = document.createElement('div');
numEl.className = 'wday-daynum';
numEl.textContent = 'day ' + dayNum.toLocaleString();
wday.appendChild(dot);
wday.appendChild(name);
wday.appendChild(date);
wday.appendChild(numEl);
days.appendChild(wday);
}
card.innerHTML = '';
card.appendChild(header);
card.appendChild(days);
backdrop.hidden = false;
closeBtn.focus();
}
function closePopup() {
backdrop.hidden = true;
}
grid.addEventListener('click', e => {
const cell = e.target.closest('.week');
if (!cell) return;
openPopup(cell);
});
backdrop.addEventListener('click', e => {
if (e.target === backdrop) closePopup();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !backdrop.hidden) closePopup();
});
}
// -------------------------------------------------------------------------
// Countdown
// -------------------------------------------------------------------------
function initCountdown() {
const wrapper = document.getElementById('countdown-wrapper');
if (!wrapper) return;
let unit = localStorage.getItem('mm-unit') || 'seconds';
const labelEl = document.createElement('div');
labelEl.className = 'countdown-label';
const valueEl = document.createElement('div');
valueEl.className = 'countdown-value';
const switcherEl = document.createElement('div');
switcherEl.className = 'countdown-switcher';
['seconds', 'hours', 'seasons'].forEach(u => {
const btn = document.createElement('button');
btn.className = 'countdown-btn';
btn.dataset.unit = u;
btn.textContent = u;
if (u === unit) btn.classList.add('is-active');
btn.addEventListener('click', () => {
unit = u;
localStorage.setItem('mm-unit', u);
switcherEl.querySelectorAll('.countdown-btn').forEach(b =>
b.classList.toggle('is-active', b.dataset.unit === u));
tick();
});
switcherEl.appendChild(btn);
});
wrapper.appendChild(labelEl);
wrapper.appendChild(valueEl);
wrapper.appendChild(switcherEl);
function format(ms) {
if (ms <= 0) return '0';
if (unit === 'seconds') return Math.floor(ms / 1000).toLocaleString();
if (unit === 'hours') return Math.floor(ms / (1000 * 60 * 60)).toLocaleString();
return Math.floor(ms / SEASON_MS).toLocaleString(); // seasons — whole integer
}
function label() {
if (unit === 'seconds') return 'seconds remaining';
if (unit === 'hours') return 'hours remaining';
// seasons: poetic label with current season name
return 'seasons remaining — it is ' + currentSeasonName();
}
function tick() {
const ms = Math.max(0, LIFE_END.getTime() - Date.now());
labelEl.textContent = label();
valueEl.textContent = format(ms);
}
tick();
setInterval(tick, 1000);
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function init() {
buildGrid();
initCountdown();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());