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 art page: a lightbox over the acrylic pours, and three generative canvas
2// pieces that play one at a time.
3
4import { constellations } from "../canvas/constellations.js";
5import { retrostars } from "../canvas/retrostars.js";
6import { slimemold } from "../canvas/slimemold.js";
7
8const FACTORIES = {
9 constellations: (cvs) => constellations(cvs, { numStars: 50 }),
10 retrostars: (cvs) => retrostars(cvs, { numStars: 50 }),
11 slimemold: (cvs) => slimemold(cvs, {}),
12};
13
14const initCanvases = () => {
15 const running = new Map();
16 let active = null;
17
18 const instanceFor = (name, cvs) => {
19 // Built on first play, since slime mold alone allocates two Float32Arrays
20 // over the whole grid plus 6000 agents.
21 if (!running.has(name)) running.set(name, FACTORIES[name](cvs));
22 return running.get(name);
23 };
24
25 const setActive = (name) => {
26 if (active && active !== name) {
27 const previous = running.get(active);
28 if (previous) previous.stop();
29 const button = document.querySelector(`[data-art-toggle="${active}"]`);
30 if (button) {
31 button.dataset.active = "false";
32 button.textContent = "▶";
33 }
34 }
35 active = name;
36 };
37
38 document.querySelectorAll("[data-art-toggle]").forEach((button) => {
39 const name = button.dataset.artToggle;
40 const cvs = document.querySelector(`[data-art-canvas="${name}"]`);
41 if (!cvs || !FACTORIES[name]) return;
42
43 const play = () => {
44 setActive(name);
45 instanceFor(name, cvs).start();
46 button.dataset.active = "true";
47 button.textContent = "⏸";
48 };
49
50 const pause = () => {
51 const instance = running.get(name);
52 if (instance) instance.stop();
53 active = null;
54 button.dataset.active = "false";
55 button.textContent = "▶";
56 };
57
58 button.addEventListener("click", () => {
59 if (button.dataset.active === "true") pause();
60 else play();
61 });
62
63 if (button.dataset.autoplay === "true") play();
64 });
65};
66
67const initLightbox = () => {
68 const overlay = document.querySelector(".art-lightboxOverlay");
69 if (!overlay) return;
70
71 const image = overlay.querySelector(".art-lightboxImage");
72 const loading = overlay.querySelector(".art-lightboxLoading");
73 const close = overlay.querySelector(".art-lightboxClose");
74
75 const hide = () => {
76 overlay.hidden = true;
77 image.classList.remove("art-show");
78 loading.classList.remove("art-hide");
79 image.removeAttribute("src");
80 document.body.style.overflowY = "scroll";
81 };
82
83 const show = (src, alt) => {
84 image.classList.remove("art-show");
85 loading.classList.remove("art-hide");
86 image.alt = alt || "";
87 image.src = src;
88 overlay.hidden = false;
89 document.body.style.overflowY = "hidden";
90
91 // A cached image can be complete before the load listener attaches, and
92 // then the event never fires and the spinner sits over a decoded picture.
93 if (image.complete && image.naturalWidth > 0) revealed();
94 };
95
96 const revealed = () => {
97 image.classList.add("art-show");
98 loading.classList.add("art-hide");
99 };
100
101 image.addEventListener("load", revealed);
102 image.addEventListener("error", revealed);
103
104 document.querySelectorAll("[data-lightbox]").forEach((item) => {
105 item.addEventListener("click", () => {
106 show(item.dataset.lightbox, item.dataset.lightboxAlt);
107 });
108 });
109
110 overlay.addEventListener("click", hide);
111 close.addEventListener("click", (e) => {
112 e.stopPropagation();
113 hide();
114 });
115 overlay
116 .querySelector(".art-lightboxImageWrapper")
117 .addEventListener("click", (e) => e.stopPropagation());
118
119 document.addEventListener("keydown", (e) => {
120 if (e.key === "Escape" && !overlay.hidden) hide();
121 });
122};
123
124const initCardFades = () => {
125 // Anything already cached is marked at once, or it sits invisible waiting for
126 // a load event that will not fire.
127 document.querySelectorAll(".art-cardImage img").forEach((img) => {
128 if (img.complete && img.naturalWidth > 0) {
129 img.classList.add("art-imgLoaded");
130 return;
131 }
132 img.addEventListener("load", () => img.classList.add("art-imgLoaded"), {
133 once: true,
134 });
135 img.addEventListener("error", () => img.classList.add("art-imgLoaded"), {
136 once: true,
137 });
138 });
139};
140
141export const initArt = () => {
142 initCardFades();
143 initLightbox();
144 initCanvases();
145};