heartwood every commit a ring
10.8 KB raw
// almanac.js
//
// the surface of the page. handles color, motion, and navigation.
// all content is assembled on the server now.

(function () {
  'use strict';

  var ANIM_WORD_DELAY = 18;
  var ANIM_LIST_DELAY = 60;


  // --- daylight cycle ---
  // shifts the palette based on time of day

  var TIMES = [
    { name: 'night',     start: 0,  end: 5 },
    { name: 'dawn',      start: 5,  end: 8 },
    { name: 'morning',   start: 8,  end: 12 },
    { name: 'afternoon', start: 12, end: 17 },
    { name: 'evening',   start: 17, end: 21 },
    { name: 'night',     start: 21, end: 24 }
  ];

  function getTimeOfDay(date) {
    var h = date.getHours();
    for (var i = 0; i < TIMES.length; i++) {
      if (h >= TIMES[i].start && h < TIMES[i].end) return TIMES[i].name;
    }
    return 'night';
  }

  function getSeasonName(date) {
    var m = date.getMonth() + 1;
    var d = date.getDate();
    var ranges = [
      ['winter',       1,1,   2,28],
      ['early-spring', 3,1,   4,15],
      ['late-spring',  4,16,  5,31],
      ['early-summer', 6,1,   6,30],
      ['midsummer',    7,1,   8,31],
      ['early-fall',   9,1,   10,31],
      ['late-fall',    11,1,  11,30],
      ['winter',       12,1,  12,31]
    ];
    for (var i = 0; i < ranges.length; i++) {
      var r = ranges[i];
      var afterStart = m > r[1] || (m === r[1] && d >= r[2]);
      var beforeEnd = m < r[3] || (m === r[3] && d <= r[4]);
      if (afterStart && beforeEnd) return r[0];
    }
    return 'winter';
  }

  // palette per time of day. text stays in the bone/cream range so
  // contrast holds; the ember "heading" role drives the accent (drop
  // cap, roman numerals, links, mood markers) and stays warm-gold
  // even at night so editorial elements don't disappear.
  var CYCLES = {
    night: {
      bg: '#0a0806', text: '#dcd0b8', accent: '#86a07a', heading: '#d2a070',
      glow: 'rgba(30,25,20,0.08)', under1: 'rgba(15,12,10,0.8)',
      under2: 'rgba(20,18,15,0.6)', under3: 'rgba(10,10,15,0.5)'
    },
    dawn: {
      bg: '#13100b', text: '#e0d4be', accent: '#92a880', heading: '#e3b27a',
      glow: 'rgba(180,120,60,0.06)', under1: 'rgba(40,25,15,0.6)',
      under2: 'rgba(50,30,18,0.4)', under3: 'rgba(30,20,12,0.5)'
    },
    morning: {
      bg: '#14100c', text: '#e2d8c2', accent: '#92a880', heading: '#d8aa78',
      glow: 'rgba(140,100,50,0.04)', under1: 'rgba(26,23,20,0.7)',
      under2: 'rgba(42,36,32,0.5)', under3: 'rgba(26,23,20,0.4)'
    },
    afternoon: {
      bg: '#15110e', text: '#dccfba', accent: '#88a07a', heading: '#d3a06c',
      glow: 'rgba(160,100,40,0.05)', under1: 'rgba(35,28,20,0.6)',
      under2: 'rgba(30,25,18,0.5)', under3: 'rgba(40,30,20,0.4)'
    },
    evening: {
      bg: '#120f0b', text: '#dac9b4', accent: '#82987a', heading: '#dfa478',
      glow: 'rgba(180,100,40,0.08)', under1: 'rgba(45,25,12,0.7)',
      under2: 'rgba(35,18,10,0.6)', under3: 'rgba(25,15,8,0.5)'
    }
  };

  var SEASON_COLORS = {
    'winter':       { bg: '#07080b', tint: '40,50,80' },
    'early-spring': { bg: '#0a0c08', tint: '50,70,35' },
    'late-spring':  { bg: '#0b0d07', tint: '55,80,30' },
    'early-summer': { bg: '#0d0b07', tint: '80,65,20' },
    'midsummer':    { bg: '#0e0a06', tint: '90,60,15' },
    'early-fall':   { bg: '#0d0906', tint: '85,45,20' },
    'late-fall':    { bg: '#0b0908', tint: '65,40,30' }
  };

  var TIME_LIGHTS = {
    night:
      'linear-gradient(to bottom, rgba(100,120,180,0.15) 0%, rgba(100,120,180,0.05) 30%, transparent 70%)',
    dawn:
      'linear-gradient(to bottom, rgba(220,150,70,0.22) 0%, rgba(200,120,50,0.06) 40%, transparent 80%)',
    morning:
      'linear-gradient(to bottom, rgba(240,210,140,0.16) 0%, rgba(240,210,140,0.04) 40%, transparent 80%)',
    afternoon:
      'linear-gradient(to bottom, rgba(240,200,110,0.18) 0%, rgba(240,200,110,0.05) 40%, transparent 80%)',
    evening:
      'linear-gradient(to bottom, rgba(200,90,30,0.24) 0%, rgba(180,70,20,0.06) 40%, transparent 80%)'
  };

  function applyDaylightCycle(time, seasonName) {
    var c = CYCLES[time] || CYCLES.morning;
    var s = SEASON_COLORS[seasonName] || SEASON_COLORS['early-spring'];
    var r = document.documentElement;

    r.style.setProperty('--earth', s.bg);
    r.style.setProperty('--bone', c.text);
    r.style.setProperty('--sprout', c.accent);
    r.style.setProperty('--ember', c.heading);
    r.style.setProperty('--glow', c.glow);
    r.style.setProperty('--under1', c.under1);
    r.style.setProperty('--under2', c.under2);
    r.style.setProperty('--under3', c.under3);

    var sky = document.getElementById('sky-layer');
    if (sky) {
      sky.style.background =
        'radial-gradient(ellipse at 30% 15%, rgba(' + s.tint + ',0.25) 0%, transparent 55%), ' +
        'radial-gradient(ellipse at 70% 80%, rgba(' + s.tint + ',0.12) 0%, transparent 55%), ' +
        'radial-gradient(ellipse at 50% 50%, rgba(' + s.tint + ',0.08) 0%, transparent 70%)';
    }

    var timeEl = document.getElementById('time-layer');
    if (timeEl) {
      timeEl.style.background = TIME_LIGHTS[time] || TIME_LIGHTS.morning;
    }
  }


  // --- word reveal ---

  function revealWords(root) {
    if (!root) return;
    var elements = root.querySelectorAll('h1, h2, p, li, blockquote');
    var allItems = [];

    elements.forEach(function (el) {
      if (el.tagName === 'LI') {
        allItems.push(el);
        return;
      }

      var nodes = [];
      el.childNodes.forEach(function (node) {
        if (node.nodeType === 3) {
          node.textContent.split(/(\s+)/).forEach(function (w) {
            if (/^\s*$/.test(w)) {
              nodes.push(document.createTextNode(w));
            } else {
              var span = document.createElement('span');
              span.className = 'word';
              span.textContent = w;
              nodes.push(span);
              allItems.push(span);
            }
          });
        } else if (node.nodeType === 1) {
          var wrapper = document.createElement(node.tagName.toLowerCase());
          for (var a = 0; a < node.attributes.length; a++) {
            wrapper.setAttribute(node.attributes[a].name, node.attributes[a].value);
          }
          var innerText = node.textContent || '';
          innerText.split(/(\s+)/).forEach(function (w) {
            if (/^\s*$/.test(w)) {
              wrapper.appendChild(document.createTextNode(w));
            } else {
              var span = document.createElement('span');
              span.className = 'word';
              span.textContent = w;
              wrapper.appendChild(span);
              allItems.push(span);
            }
          });
          nodes.push(wrapper);
        }
      });
      el.textContent = '';
      nodes.forEach(function (n) { el.appendChild(n); });
    });

    // cap total cascade so the last item lands within ~1s of the
    // first. budget includes both word and LI advances; with ~300
    // items the per-item step shrinks below 5ms which still reads
    // as a sweep rather than a pop.
    var ANIM_TOTAL_MS = 900;
    var n = allItems.length;
    var step = n > 1 ? ANIM_TOTAL_MS / (n - 1) : 0;
    var nextDelay = 0;
    allItems.forEach(function (item) {
      item.style.animationDelay = nextDelay + 'ms';
      nextDelay += step;
    });
  }


  // --- main ---

  var currentSeasonOverride = null;
  var naturalSeason = null;

  var dom = {};
  function cacheDOM() {
    dom.dateLine = document.querySelector('.date-line');
    dom.seasonName = document.querySelector('.season-name');
    dom.seasonNote = document.querySelector('.season-note');
    dom.haikuBlock = document.querySelector('.haiku-block blockquote');
    dom.sections = document.querySelector('.sections');
    dom.footerStatus = document.querySelector('.footer-status');
    dom.readout = document.querySelector('.readout');
    dom.footer = document.querySelector('footer');
    dom.seasonsNav = document.querySelector('.seasons-nav');
  }

  function loadContent(seasonOverride) {
    var fadeTargets = ['.season-block', '.haiku-block', '.sections', 'footer'];
    fadeTargets.forEach(function (sel) {
      var el = document.querySelector(sel);
      if (el) el.style.opacity = '0';
    });

    var url = '/api/content' + (seasonOverride ? '?season=' + encodeURIComponent(seasonOverride) : '');
    currentSeasonOverride = seasonOverride;

    fetch(url)
      .then(function (r) { return r.json(); })
      .then(function (data) {
        dom.dateLine.textContent = data.date_line;
        dom.seasonName.textContent = data.season_name;
        dom.seasonNote.innerHTML = data.season_note;
        dom.haikuBlock.innerHTML = data.haiku_html;
        dom.sections.innerHTML = data.sections_html;
        dom.footerStatus.textContent = data.footer_text;
        dom.seasonsNav.innerHTML = data.season_nav_html;

        document.body.setAttribute('data-season', data.season_key);
        document.body.setAttribute('data-time', data.time_key);

        // background cycle follows real clock; season tint follows content
        applyDaylightCycle(getTimeOfDay(new Date()), data.season_key);

        fadeTargets.forEach(function (sel) {
          var el = document.querySelector(sel);
          if (el) el.style.opacity = '1';
        });

        revealWords(dom.readout);
        revealWords(dom.footer);
        bindNavClicks();
      })
      .catch(function () {
        dom.sections.innerHTML = '<p style="color:var(--ash);font-style:italic;">the pages could not be found. try again in a moment.</p>';
        fadeTargets.forEach(function (sel) {
          var el = document.querySelector(sel);
          if (el) el.style.opacity = '1';
        });
      });
  }

  function bindNavClicks() {
    dom.seasonsNav.querySelectorAll('a[data-season]').forEach(function (a) {
      a.addEventListener('click', function () {
        var season = a.getAttribute('data-season');
        window.scrollTo({ top: 0, behavior: 'smooth' });
        loadContent(season === naturalSeason ? null : season);
      });
    });
  }


  // --- init ---

  cacheDOM();

  var now = new Date();
  naturalSeason = getSeasonName(now);

  var initSeason = document.body.getAttribute('data-season') || naturalSeason;
  var initTime = document.body.getAttribute('data-time') || getTimeOfDay(now);
  applyDaylightCycle(initTime, initSeason);

  revealWords(dom.readout);
  revealWords(dom.footer);
  bindNavClicks();

  // refresh on season rollover; keep palette in sync with the clock
  setInterval(function () {
    var check = new Date();
    var newSeason = getSeasonName(check);
    if (newSeason !== naturalSeason) {
      naturalSeason = newSeason;
      if (!currentSeasonOverride) {
        loadContent(null);
        return;
      }
    }
    applyDaylightCycle(getTimeOfDay(check), document.body.getAttribute('data-season') || newSeason);
  }, 60000);

  // register service worker for offline access
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/static/sw.js');
  }

})();