repos

darkfurrow.com-rust

mirror archived upstream

A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.

agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite

11.1 KB · 322 lines · JavaScript Raw History
  1// almanac.js
  2//
  3// the surface of the page. handles color, motion, and navigation.
  4// all content is assembled on the server now.
  5
  6(function () {
  7  'use strict';
  8
  9  var ANIM_WORD_DELAY = 18;
 10  var ANIM_LIST_DELAY = 60;
 11
 12
 13  // --- daylight cycle ---
 14  // shifts the palette based on time of day
 15
 16  var TIMES = [
 17    { name: 'night',     start: 0,  end: 5 },
 18    { name: 'dawn',      start: 5,  end: 8 },
 19    { name: 'morning',   start: 8,  end: 12 },
 20    { name: 'afternoon', start: 12, end: 17 },
 21    { name: 'evening',   start: 17, end: 21 },
 22    { name: 'night',     start: 21, end: 24 }
 23  ];
 24
 25  function getTimeOfDay(date) {
 26    var h = date.getHours();
 27    for (var i = 0; i < TIMES.length; i++) {
 28      if (h >= TIMES[i].start && h < TIMES[i].end) return TIMES[i].name;
 29    }
 30    return 'night';
 31  }
 32
 33  function getSeasonName(date) {
 34    var m = date.getMonth() + 1;
 35    var d = date.getDate();
 36    var ranges = [
 37      ['winter',       1,1,   2,29],
 38      ['early-spring', 3,1,   4,15],
 39      ['late-spring',  4,16,  5,31],
 40      ['early-summer', 6,1,   6,30],
 41      ['midsummer',    7,1,   8,31],
 42      ['early-fall',   9,1,   10,31],
 43      ['late-fall',    11,1,  11,30],
 44      ['winter',       12,1,  12,31]
 45    ];
 46    for (var i = 0; i < ranges.length; i++) {
 47      var r = ranges[i];
 48      var afterStart = m > r[1] || (m === r[1] && d >= r[2]);
 49      var beforeEnd = m < r[3] || (m === r[3] && d <= r[4]);
 50      if (afterStart && beforeEnd) return r[0];
 51    }
 52    return 'winter';
 53  }
 54
 55  // palette per time of day. text stays in the bone/cream range so
 56  // contrast holds; the ember "heading" role drives the accent (drop
 57  // cap, roman numerals, links, mood markers) and stays warm-gold
 58  // even at night so editorial elements don't disappear.
 59  var CYCLES = {
 60    night: {
 61      bg: '#0a0806', text: '#dcd0b8', accent: '#86a07a', heading: '#d2a070',
 62      glow: 'rgba(30,25,20,0.08)', under1: 'rgba(15,12,10,0.8)',
 63      under2: 'rgba(20,18,15,0.6)', under3: 'rgba(10,10,15,0.5)'
 64    },
 65    dawn: {
 66      bg: '#13100b', text: '#e0d4be', accent: '#92a880', heading: '#e3b27a',
 67      glow: 'rgba(180,120,60,0.06)', under1: 'rgba(40,25,15,0.6)',
 68      under2: 'rgba(50,30,18,0.4)', under3: 'rgba(30,20,12,0.5)'
 69    },
 70    morning: {
 71      bg: '#14100c', text: '#e2d8c2', accent: '#92a880', heading: '#d8aa78',
 72      glow: 'rgba(140,100,50,0.04)', under1: 'rgba(26,23,20,0.7)',
 73      under2: 'rgba(42,36,32,0.5)', under3: 'rgba(26,23,20,0.4)'
 74    },
 75    afternoon: {
 76      bg: '#15110e', text: '#dccfba', accent: '#88a07a', heading: '#d3a06c',
 77      glow: 'rgba(160,100,40,0.05)', under1: 'rgba(35,28,20,0.6)',
 78      under2: 'rgba(30,25,18,0.5)', under3: 'rgba(40,30,20,0.4)'
 79    },
 80    evening: {
 81      bg: '#120f0b', text: '#dac9b4', accent: '#82987a', heading: '#dfa478',
 82      glow: 'rgba(180,100,40,0.08)', under1: 'rgba(45,25,12,0.7)',
 83      under2: 'rgba(35,18,10,0.6)', under3: 'rgba(25,15,8,0.5)'
 84    }
 85  };
 86
 87  var SEASON_COLORS = {
 88    'winter':       { bg: '#07080b', tint: '40,50,80' },
 89    'early-spring': { bg: '#0a0c08', tint: '50,70,35' },
 90    'late-spring':  { bg: '#0b0d07', tint: '55,80,30' },
 91    'early-summer': { bg: '#0d0b07', tint: '80,65,20' },
 92    'midsummer':    { bg: '#0e0a06', tint: '90,60,15' },
 93    'early-fall':   { bg: '#0d0906', tint: '85,45,20' },
 94    'late-fall':    { bg: '#0b0908', tint: '65,40,30' }
 95  };
 96
 97  var TIME_LIGHTS = {
 98    night:
 99      'linear-gradient(to bottom, rgba(100,120,180,0.15) 0%, rgba(100,120,180,0.05) 30%, transparent 70%)',
100    dawn:
101      'linear-gradient(to bottom, rgba(220,150,70,0.22) 0%, rgba(200,120,50,0.06) 40%, transparent 80%)',
102    morning:
103      'linear-gradient(to bottom, rgba(240,210,140,0.16) 0%, rgba(240,210,140,0.04) 40%, transparent 80%)',
104    afternoon:
105      'linear-gradient(to bottom, rgba(240,200,110,0.18) 0%, rgba(240,200,110,0.05) 40%, transparent 80%)',
106    evening:
107      'linear-gradient(to bottom, rgba(200,90,30,0.24) 0%, rgba(180,70,20,0.06) 40%, transparent 80%)'
108  };
109
110  function applyDaylightCycle(time, seasonName) {
111    var c = CYCLES[time] || CYCLES.morning;
112    var s = SEASON_COLORS[seasonName] || SEASON_COLORS['early-spring'];
113    var r = document.documentElement;
114
115    r.style.setProperty('--earth', s.bg);
116    r.style.setProperty('--bone', c.text);
117    r.style.setProperty('--sprout', c.accent);
118    r.style.setProperty('--ember', c.heading);
119    r.style.setProperty('--glow', c.glow);
120    r.style.setProperty('--under1', c.under1);
121    r.style.setProperty('--under2', c.under2);
122    r.style.setProperty('--under3', c.under3);
123
124    var sky = document.getElementById('sky-layer');
125    if (sky) {
126      sky.style.background =
127        'radial-gradient(ellipse at 30% 15%, rgba(' + s.tint + ',0.25) 0%, transparent 55%), ' +
128        'radial-gradient(ellipse at 70% 80%, rgba(' + s.tint + ',0.12) 0%, transparent 55%), ' +
129        'radial-gradient(ellipse at 50% 50%, rgba(' + s.tint + ',0.08) 0%, transparent 70%)';
130    }
131
132    var timeEl = document.getElementById('time-layer');
133    if (timeEl) {
134      timeEl.style.background = TIME_LIGHTS[time] || TIME_LIGHTS.morning;
135    }
136  }
137
138
139  // --- word reveal ---
140
141  function revealWords(root) {
142    if (!root) return;
143    var elements = root.querySelectorAll('h1, h2, p, li, blockquote');
144    var allItems = [];
145
146    elements.forEach(function (el) {
147      if (el.tagName === 'LI') {
148        allItems.push(el);
149        return;
150      }
151
152      var nodes = [];
153      el.childNodes.forEach(function (node) {
154        if (node.nodeType === 3) {
155          node.textContent.split(/(\s+)/).forEach(function (w) {
156            if (/^\s*$/.test(w)) {
157              nodes.push(document.createTextNode(w));
158            } else {
159              var span = document.createElement('span');
160              span.className = 'word';
161              span.textContent = w;
162              nodes.push(span);
163              allItems.push(span);
164            }
165          });
166        } else if (node.nodeType === 1) {
167          var wrapper = document.createElement(node.tagName.toLowerCase());
168          for (var a = 0; a < node.attributes.length; a++) {
169            wrapper.setAttribute(node.attributes[a].name, node.attributes[a].value);
170          }
171          var innerText = node.textContent || '';
172          innerText.split(/(\s+)/).forEach(function (w) {
173            if (/^\s*$/.test(w)) {
174              wrapper.appendChild(document.createTextNode(w));
175            } else {
176              var span = document.createElement('span');
177              span.className = 'word';
178              span.textContent = w;
179              wrapper.appendChild(span);
180              allItems.push(span);
181            }
182          });
183          nodes.push(wrapper);
184        }
185      });
186      el.textContent = '';
187      nodes.forEach(function (n) { el.appendChild(n); });
188    });
189
190    // cap total cascade so the last item lands within ~1s of the
191    // first. budget includes both word and LI advances; with ~300
192    // items the per-item step shrinks below 5ms which still reads
193    // as a sweep rather than a pop.
194    var ANIM_TOTAL_MS = 900;
195    var n = allItems.length;
196    var step = n > 1 ? ANIM_TOTAL_MS / (n - 1) : 0;
197    var nextDelay = 0;
198    allItems.forEach(function (item) {
199      item.style.animationDelay = nextDelay + 'ms';
200      nextDelay += step;
201    });
202  }
203
204
205  // --- main ---
206
207  var currentSeasonOverride = null;
208  var naturalSeason = null;
209
210  var dom = {};
211  function cacheDOM() {
212    dom.dateLine = document.querySelector('.date-line');
213    dom.seasonName = document.querySelector('.season-name');
214    dom.seasonNote = document.querySelector('.season-note');
215    dom.haikuBlock = document.querySelector('.haiku-block blockquote');
216    dom.sections = document.querySelector('.sections');
217    dom.footerStatus = document.querySelector('.footer-status');
218    dom.readout = document.querySelector('.readout');
219    dom.footer = document.querySelector('footer');
220    dom.seasonsNav = document.querySelector('.seasons-nav');
221  }
222
223  function loadContent(seasonOverride) {
224    var fadeTargets = ['.season-block', '.haiku-block', '.sections', 'footer'];
225    fadeTargets.forEach(function (sel) {
226      var el = document.querySelector(sel);
227      if (el) el.style.opacity = '0';
228    });
229
230    var url = '/api/content' + (seasonOverride ? '?season=' + encodeURIComponent(seasonOverride) : '');
231    currentSeasonOverride = seasonOverride;
232
233    fetch(url)
234      .then(function (r) { return r.json(); })
235      .then(function (data) {
236        dom.dateLine.textContent = data.date_line;
237        dom.seasonName.textContent = data.season_name;
238        dom.seasonNote.innerHTML = data.season_note;
239        dom.haikuBlock.innerHTML = data.haiku_html;
240        dom.sections.innerHTML = data.sections_html;
241        dom.footerStatus.textContent = data.footer_text;
242        dom.seasonsNav.innerHTML = data.season_nav_html;
243
244        document.body.setAttribute('data-season', data.season_key);
245        document.body.setAttribute('data-time', data.time_key);
246
247        // background cycle follows real clock; season tint follows content
248        applyDaylightCycle(getTimeOfDay(new Date()), data.season_key);
249
250        fadeTargets.forEach(function (sel) {
251          var el = document.querySelector(sel);
252          if (el) el.style.opacity = '1';
253        });
254
255        revealWords(dom.readout);
256        revealWords(dom.footer);
257        bindNavClicks();
258      })
259      .catch(function () {
260        dom.sections.innerHTML = '<p style="color:var(--ash);font-style:italic;">the pages could not be found. try again in a moment.</p>';
261        fadeTargets.forEach(function (sel) {
262          var el = document.querySelector(sel);
263          if (el) el.style.opacity = '1';
264        });
265      });
266  }
267
268  function bindNavClicks() {
269    dom.seasonsNav.querySelectorAll('a[data-season]').forEach(function (a) {
270      a.addEventListener('click', function (e) {
271        e.preventDefault();
272        var season = a.getAttribute('data-season');
273        window.scrollTo({ top: 0, behavior: 'smooth' });
274        loadContent(season === naturalSeason ? null : season);
275      });
276    });
277  }
278
279
280  // --- init ---
281
282  cacheDOM();
283
284  var now = new Date();
285  naturalSeason = getSeasonName(now);
286
287  var initSeason = document.body.getAttribute('data-season') || naturalSeason;
288  var initTime = document.body.getAttribute('data-time') || getTimeOfDay(now);
289  applyDaylightCycle(initTime, initSeason);
290
291  revealWords(dom.readout);
292  revealWords(dom.footer);
293  bindNavClicks();
294
295  // refresh on day rollover (sun, moon, and the day-seeded picks all change
296  // at midnight, not just at season boundaries); keep palette in sync with
297  // the clock
298  var currentDay = now.toDateString();
299  setInterval(function () {
300    var check = new Date();
301    var newSeason = getSeasonName(check);
302    if (newSeason !== naturalSeason) {
303      naturalSeason = newSeason;
304    }
305    if (check.toDateString() !== currentDay) {
306      currentDay = check.toDateString();
307      loadContent(currentSeasonOverride);
308      return;
309    }
310    applyDaylightCycle(getTimeOfDay(check), document.body.getAttribute('data-season') || newSeason);
311  }, 60000);
312
313  // unregister the retired service worker so returning visitors stop
314  // serving the page from a stale cache
315  if ('serviceWorker' in navigator) {
316    navigator.serviceWorker.getRegistrations().then(function (regs) {
317      regs.forEach(function (r) { r.unregister(); });
318    });
319  }
320
321})();