Files
codex-obscura-hugo-theme/static/js/main.js
2026-03-24 20:30:43 -04:00

153 lines
5.4 KiB
JavaScript
Raw 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.
/* ============================================================
CODEX OBSCURA — main.js
Dust motes drifting in candlelight.
============================================================ */
(function () {
'use strict';
document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('js');
/* ── Uptime Counter ──────────────────────────────────────── */
const uptimeEl = document.getElementById('js-uptime');
if (uptimeEl) {
const startTime = Date.now();
function updateUptime() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const h = String(Math.floor(elapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
uptimeEl.textContent = h + ':' + m + ':' + s;
}
setInterval(updateUptime, 1000);
updateUptime();
}
/* ── Keyboard shortcuts ──────────────────────────────────── */
document.addEventListener('keydown', function (e) {
if (e.key === '/' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
const search = document.getElementById('search-input');
if (search) { e.preventDefault(); search.focus(); }
}
if (e.key === 'Escape' && document.activeElement) {
document.activeElement.blur();
}
});
/* ── Dust Motes ──────────────────────────────────────────── */
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const canvas = document.createElement('canvas');
canvas.style.cssText = [
'position:absolute', 'top:0', 'left:0',
'width:100%', 'height:100%',
'pointer-events:none', 'z-index:2'
].join(';');
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
let W = document.body.scrollWidth;
let H = document.body.scrollHeight;
canvas.width = W;
canvas.height = H;
window.addEventListener('resize', function () {
W = canvas.width = document.body.scrollWidth;
H = canvas.height = document.body.scrollHeight;
});
// Warm amber/parchment dust colours
const COLORS = [
'212, 162, 40', // amber
'201, 176, 122', // parchment warm
'196, 96, 28', // orange
'138, 96, 48', // umber
'232, 213, 163', // parchment light
' 78, 120, 48', // forest
];
const MOTE_COUNT = 500;
function rand(min, max) { return min + Math.random() * (max - min); }
function makeMote(scatterY) {
// Each mote has its own independent fade cycle driven by a sine wave
// so they breathe in and out at different rates rather than all
// appearing and disappearing together.
const peakAlpha = rand(0.06, 0.22);
return {
x: rand(0, W),
y: scatterY !== undefined ? scatterY : rand(0, H),
// very slow drift — tiny random velocity
vx: rand(-0.12, 0.12),
vy: rand(-0.08, 0.06), // slight upward bias like warm air
// size: 12.5px radius, soft and small
r: rand(1.2, 2.4),
color: COLORS[Math.floor(Math.random() * COLORS.length)],
peakAlpha,
// sine-wave phase and speed for opacity breathing
phase: rand(0, Math.PI * 2),
phaseSpeed: rand(0.003, 0.009), // full breath every ~1235 seconds
// gentle Brownian wobble
wobbleX: rand(0, Math.PI * 2),
wobbleY: rand(0, Math.PI * 2),
wobbleSpeedX: rand(0.002, 0.007),
wobbleSpeedY: rand(0.002, 0.006),
wobbleAmp: rand(0.04, 0.18), // pixels of wobble per frame
};
}
// Seed motes scattered across the whole viewport
let motes = Array.from({ length: MOTE_COUNT }, function () { return makeMote(rand(0, H)); });
function animate() {
ctx.clearRect(0, 0, W, H);
const now = performance.now() * 0.001; // seconds, for wobble
for (let i = 0; i < motes.length; i++) {
const m = motes[i];
// Advance fade phase
m.phase += m.phaseSpeed;
// Opacity: sine wave between 0 and peakAlpha, always non-negative
const alpha = m.peakAlpha * (0.5 + 0.5 * Math.sin(m.phase));
// Drift position
m.x += m.vx + Math.sin(m.wobbleX) * m.wobbleAmp;
m.y += m.vy + Math.cos(m.wobbleY) * m.wobbleAmp * 0.6;
// Advance wobble phases independently of main phase
m.wobbleX += m.wobbleSpeedX;
m.wobbleY += m.wobbleSpeedY;
// Wrap around edges with a small buffer so motes never pop in visibly
const buf = 4;
if (m.x < -buf) m.x = W + buf;
if (m.x > W + buf) m.x = -buf;
if (m.y < -buf) m.y = H + buf;
if (m.y > H + buf) m.y = -buf;
// Draw as a soft radial-gradient circle (blurred disc, not a hard pixel)
const grad = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, m.r * 2.5);
grad.addColorStop(0, 'rgba(' + m.color + ', ' + alpha + ')');
grad.addColorStop(0.5, 'rgba(' + m.color + ', ' + (alpha * 0.4) + ')');
grad.addColorStop(1, 'rgba(' + m.color + ', 0)');
ctx.beginPath();
ctx.arc(m.x, m.y, m.r * 2.5, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
requestAnimationFrame(animate);
}
animate();
})();