orchard
mirrorEvery 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
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}