153 lines
5.4 KiB
JavaScript
153 lines
5.4 KiB
JavaScript
/* ============================================================
|
||
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: 1–2.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 ~12–35 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();
|
||
|
||
})();
|