repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.1 KB · 192 lines · JavaScript Raw History
  1// The decoration on the sign in pages: a drifting star field with faint lines
  2// between near neighbours, and the occasional shooting star.
  3//
  4// Canvas rather than DOM nodes, because this draws a couple of hundred points
  5// every frame and CSS starts dropping frames somewhere around a hundred and
  6// fifty. It is decorative, so the canvas carries aria-hidden in the template and
  7// nothing here is reachable by keyboard.
  8
  9const NEAR = 150; // px between two stars before a line is drawn between them
 10const MAX_LINKS = 3; // per star, so a dense corner does not turn into a mesh
 11
 12export function starfield(canvas) {
 13  const ctx = canvas.getContext("2d", { alpha: true });
 14  if (!ctx) return;
 15
 16  const still = window.matchMedia("(prefers-reduced-motion: reduce)");
 17
 18  let stars = [];
 19  let shooting = null;
 20  let frame = null;
 21  let w = 0;
 22  let h = 0;
 23  const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5 };
 24
 25  // Density by area rather than a fixed count, or the panel is crowded on a
 26  // phone and empty on a wide monitor.
 27  function populate() {
 28    const target = Math.min(300, Math.round((w * h) / 6200));
 29    stars = [];
 30    for (let i = 0; i < target; i++) {
 31      stars.push({
 32        x: Math.random() * w,
 33        y: Math.random() * h,
 34        // Three rough depths. Far stars are dimmer, smaller and slower, which
 35        // is the whole of the parallax.
 36        z: 0.35 + Math.random() * 0.65,
 37        r: 0.6 + Math.random() * 1.6,
 38        vx: (Math.random() - 0.5) * 0.05,
 39        vy: -0.04 - Math.random() * 0.07,
 40        tw: Math.random() * Math.PI * 2,
 41      });
 42    }
 43  }
 44
 45  function resize() {
 46    const rect = canvas.getBoundingClientRect();
 47    // Guard against a zero-sized parent, which happens while the panel is still
 48    // being laid out and would otherwise divide by zero in populate().
 49    if (rect.width < 2 || rect.height < 2) return;
 50
 51    const dpr = Math.min(window.devicePixelRatio || 1, 2);
 52    w = rect.width;
 53    h = rect.height;
 54    canvas.width = Math.round(w * dpr);
 55    canvas.height = Math.round(h * dpr);
 56    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
 57    populate();
 58  }
 59
 60  function maybeShoot() {
 61    if (shooting || Math.random() > 0.004) return;
 62    shooting = {
 63      x: Math.random() * w * 0.7,
 64      y: Math.random() * h * 0.4,
 65      len: 90 + Math.random() * 120,
 66      life: 0,
 67      span: 34 + Math.random() * 18,
 68    };
 69  }
 70
 71  function draw(animate) {
 72    ctx.clearRect(0, 0, w, h);
 73
 74    // Eased, so moving the mouse glides the field instead of snapping it.
 75    pointer.x += (pointer.tx - pointer.x) * 0.05;
 76    pointer.y += (pointer.ty - pointer.y) * 0.05;
 77    const shiftX = (pointer.x - 0.5) * 26;
 78    const shiftY = (pointer.y - 0.5) * 18;
 79
 80    const placed = stars.map((s) => {
 81      if (animate) {
 82        s.x += s.vx * s.z;
 83        s.y += s.vy * s.z;
 84        s.tw += 0.01 + s.z * 0.015;
 85        // Wrap rather than respawn, so density stays flat over time.
 86        if (s.y < -4) s.y = h + 4;
 87        if (s.x < -4) s.x = w + 4;
 88        if (s.x > w + 4) s.x = -4;
 89      }
 90      return { s, x: s.x + shiftX * s.z, y: s.y + shiftY * s.z };
 91    });
 92
 93    // Lines first, so a star always sits on top of the threads it anchors.
 94    ctx.lineWidth = 0.7;
 95    for (let i = 0; i < placed.length; i++) {
 96      let links = 0;
 97      for (let j = i + 1; j < placed.length && links < MAX_LINKS; j++) {
 98        const dx = placed[i].x - placed[j].x;
 99        const dy = placed[i].y - placed[j].y;
100        const d = Math.hypot(dx, dy);
101        if (d > NEAR) continue;
102        links++;
103        const fade = (1 - d / NEAR) * 0.55 * (0.5 + placed[i].s.z * 0.5);
104        ctx.strokeStyle = `rgba(125, 184, 140, ${fade.toFixed(3)})`;
105        ctx.beginPath();
106        ctx.moveTo(placed[i].x, placed[i].y);
107        ctx.lineTo(placed[j].x, placed[j].y);
108        ctx.stroke();
109      }
110    }
111
112    for (const p of placed) {
113      const twinkle = animate ? 0.78 + Math.sin(p.s.tw) * 0.22 : 1;
114      // Depth is halved into the alpha rather than driving it outright. A far
115      // star at its own z disappears entirely under a bright light, and radius
116      // and speed still carry the parallax on their own.
117      ctx.globalAlpha = Math.min(1, (0.5 + p.s.z * 0.5) * twinkle);
118      ctx.fillStyle = p.s.z > 0.8 ? "#e6f2e9" : "#c2d3c7";
119      ctx.beginPath();
120      ctx.arc(p.x, p.y, p.s.r * p.s.z, 0, Math.PI * 2);
121      ctx.fill();
122    }
123    ctx.globalAlpha = 1;
124
125    if (!animate) return;
126
127    maybeShoot();
128    if (shooting) {
129      shooting.life++;
130      const t = shooting.life / shooting.span;
131      const x = shooting.x + t * shooting.len * 1.7;
132      const y = shooting.y + t * shooting.len;
133      // Fades in and back out rather than popping, so it reads as a streak.
134      const alpha = Math.sin(Math.PI * t) * 0.7;
135      const grad = ctx.createLinearGradient(x, y, x - 46, y - 27);
136      grad.addColorStop(0, `rgba(219, 234, 223, ${alpha.toFixed(3)})`);
137      grad.addColorStop(1, "rgba(219, 234, 223, 0)");
138      ctx.strokeStyle = grad;
139      ctx.lineWidth = 1.1;
140      ctx.beginPath();
141      ctx.moveTo(x, y);
142      ctx.lineTo(x - 46, y - 27);
143      ctx.stroke();
144      if (shooting.life > shooting.span) shooting = null;
145    }
146  }
147
148  function loop() {
149    draw(true);
150    frame = requestAnimationFrame(loop);
151  }
152
153  function start() {
154    stop();
155    if (still.matches) {
156      // One frame and no timer: the field is there to look at, it just does not
157      // move for anybody who asked for that.
158      draw(false);
159      return;
160    }
161    frame = requestAnimationFrame(loop);
162  }
163
164  function stop() {
165    if (frame !== null) cancelAnimationFrame(frame);
166    frame = null;
167  }
168
169  const observer = new ResizeObserver(() => {
170    resize();
171    if (still.matches) draw(false);
172  });
173  observer.observe(canvas);
174
175  window.addEventListener("pointermove", (e) => {
176    pointer.tx = e.clientX / window.innerWidth;
177    pointer.ty = e.clientY / window.innerHeight;
178  });
179
180  // A background tab still runs rAF in some browsers and always burns battery
181  // in the rest, and nobody is looking at it.
182  document.addEventListener("visibilitychange", () => {
183    if (document.hidden) stop();
184    else start();
185  });
186
187  still.addEventListener("change", start);
188
189  resize();
190  start();
191}