Canvas-based seasonal weather effects + demo page

Server picks variant from Lisbon-local date (rain/petals/jacaranda/motes/leaves)
and renders behind content via a canvas particle layer. Each variant has a
hand-drawn silhouette so shapes are recognizable. /weather demo route exposes
variant + intensity + phase pickers, locking the time-of-day phase override.
This commit is contained in:
prosolis
2026-05-25 09:20:35 -07:00
parent 5ea64c1d32
commit 24103394ae
7 changed files with 413 additions and 3 deletions

View File

@@ -42,6 +42,8 @@ type pageData struct {
SiteTitle string SiteTitle string
Channels []Channel Channels []Channel
Active string // slug of active channel, "" for landing Active string // slug of active channel, "" for landing
Weather Weather
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
} }
type channelPage struct { type channelPage struct {
@@ -75,6 +77,7 @@ func (s *Server) base() pageData {
return pageData{ return pageData{
SiteTitle: s.cfg.SiteTitle, SiteTitle: s.cfg.SiteTitle,
Channels: channels, Channels: channels,
Weather: currentWeather(time.Now()),
} }
} }
@@ -172,6 +175,66 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
s.render(w, "channel", data) s.render(w, "channel", data)
} }
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
type weatherDemoPage struct {
pageData
Variants []string
Intensities []string
Phases []string
Variant string
Intensity string
PhaseSel string
}
func pickOr(v string, allowed []string, fallback string) string {
for _, a := range allowed {
if v == a {
return v
}
}
return fallback
}
func seasonForVariant(v string) string {
switch v {
case "rain":
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes":
return "summer"
case "leaves":
return "autumn"
}
return ""
}
func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
phase := pickOr(q.Get("phase"), demoPhases, "day")
base := s.base()
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
base.Phase = phase
s.render(w, "weather", weatherDemoPage{
pageData: base,
Variants: demoVariants,
Intensities: demoIntensities,
Phases: demoPhases,
Variant: variant,
Intensity: intensity,
PhaseSel: phase,
})
}
func (s *Server) render(w http.ResponseWriter, page string, data any) { func (s *Server) render(w http.ResponseWriter, page string, data any) {
tpl, ok := s.tpls[page] tpl, ok := s.tpls[page]
if !ok { if !ok {

39
internal/web/season.go Normal file
View File

@@ -0,0 +1,39 @@
package web
import "time"
// Weather is the seasonal effect rendered behind the page. Tuned for Lisbon:
// winter is rainy (not snowy), May gets its own jacaranda variant since the
// city turns purple for a few weeks each year, and September stays in summer
// because Portugal's dry spell runs all the way through it.
type Weather struct {
Season string // "winter" | "spring" | "summer" | "autumn"
Variant string // "rain" | "petals" | "jacaranda" | "motes" | "leaves"
Intensity string // "light" | "medium" | "heavy"
}
var lisbon = func() *time.Location {
loc, err := time.LoadLocation("Europe/Lisbon")
if err != nil {
return time.UTC
}
return loc
}()
func currentWeather(now time.Time) Weather {
t := now.In(lisbon)
var season, variant string
switch t.Month() {
case time.December, time.January, time.February:
season, variant = "winter", "rain"
case time.March, time.April:
season, variant = "spring", "petals"
case time.May:
season, variant = "spring", "jacaranda"
case time.June, time.July, time.August, time.September:
season, variant = "summer", "motes"
default: // Oct, Nov
season, variant = "autumn", "leaves"
}
return Weather{Season: season, Variant: variant, Intensity: "heavy"}
}

View File

@@ -48,7 +48,7 @@ type Server struct {
// gets its own template set sharing layout.html + _card.html, which avoids // gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages. // `main` define collisions between pages.
func New(cfg config.WebConfig) (*Server, error) { func New(cfg config.WebConfig) (*Server, error) {
pages := []string{"index", "channel"} pages := []string{"index", "channel", "weather"}
tpls := make(map[string]*template.Template, len(pages)) tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages { for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS, t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
@@ -72,6 +72,7 @@ func New(cfg config.WebConfig) (*Server, error) {
mux.HandleFunc("GET /img/{name}", s.handleImg) mux.HandleFunc("GET /img/{name}", s.handleImg)
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
for _, ch := range channels { for _, ch := range channels {
ch := ch ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,246 @@
// Canvas-based seasonal weather. The server picks variant + intensity from
// the Lisbon-local date; this script reads those off <html data-*> and
// renders the appropriate particles. Each variant has a hand-drawn shape
// so silhouettes are recognizable instead of a generic blob.
(function () {
var root = document.documentElement;
var canvas = document.getElementById("pete-weather");
if (!canvas) return;
var variant = root.dataset.weather;
if (!variant) return;
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
var ctx = canvas.getContext("2d");
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener("resize", resize);
var counts = { rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38 };
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
var N = Math.round((counts[variant] || 0) * (mults[root.dataset.intensity] || 1));
function rand(a, b) { return a + Math.random() * (b - a); }
function spawn(initial) {
var p = {};
p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain") {
p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55);
} else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120);
p.swayAmp = rand(20, 50);
p.swayFreq = rand(0.4, 0.9);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-1.2, 1.2);
p.size = rand(8, 16) * p.z;
p.alpha = rand(0.75, 1.0);
if (variant === "jacaranda") {
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
p.sat = rand(55, 75);
p.light = rand(70, 82);
} else {
p.hue = rand(335, 355); // pink (almond/cherry)
p.sat = rand(60, 80);
p.light = rand(78, 88);
}
} else if (variant === "motes") {
p.vx = rand(-15, 25);
p.vy = rand(8, 25);
p.size = rand(1.2, 2.8) * p.z;
p.alpha = rand(0.35, 0.7);
p.twinklePhase = rand(0, Math.PI * 2);
p.twinkleFreq = rand(0.5, 1.5);
} else if (variant === "leaves") {
p.vy = rand(70, 130);
p.swayAmp = rand(30, 70);
p.swayFreq = rand(0.3, 0.6);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-2.0, 2.0);
p.size = rand(10, 18) * p.z;
p.alpha = rand(0.8, 1.0);
p.hue = rand(18, 42); // orange→amber→brown
p.light = rand(38, 55);
}
return p;
}
var particles = [];
for (var i = 0; i < N; i++) particles.push(spawn(true));
var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; }
function drawRain(p) {
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
// more saturated blue plus a slightly thicker line to stay visible. Night
// keeps a paler tone so streaks read against the dark palette.
if (night) {
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
ctx.lineWidth = 1.3;
}
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
ctx.stroke();
}
function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
for (var i = 0; i < 5; i++) {
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
var cx = Math.cos(a) * s * 0.32;
var cy = Math.sin(a) * s * 0.32;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
ctx.beginPath();
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawLeaf(p) {
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
ctx.fillStyle = g;
// Almond/lozenge leaf — pointed at both ends.
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
ctx.closePath();
ctx.fill();
// Midrib vein.
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-s * 0.5, 0);
ctx.lineTo(s * 0.5, 0);
ctx.stroke();
// Small stem nub at the base.
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 1.0;
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.lineTo(-s * 0.7, -s * 0.04);
ctx.stroke();
ctx.restore();
}
function drawMote(p, t) {
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
// use a deeper saturated honey so the motes stand out against warm cream
// and peach backgrounds; alpha also gets a bump.
var color, a;
if (night) {
color = "255,240,180";
a = p.alpha * (0.45 + 0.55 * twinkle);
} else {
color = "130,80,180";
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
}
// Soft glow halo.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
g.addColorStop(1, "rgba(" + color + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
ctx.fill();
// Bright core.
ctx.fillStyle = "rgba(" + color + "," + a + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
var last = performance.now();
var raf = 0;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
ctx.clearRect(0, 0, W, H);
refreshNight();
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) {
particles[i] = spawn(false);
particles[i].x = rand(0, W + 60);
continue;
}
drawRain(p);
} else if (variant === "motes") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
particles[i] = spawn(false);
continue;
}
drawMote(p, t);
} else {
// Drifting variants: petals, jacaranda, leaves.
p.y += p.vy * dt;
p.rot += p.vrot * dt;
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
var origX = p.x;
p.x = origX + sway;
if (p.y > H + 30) {
particles[i] = spawn(false);
continue;
}
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
else drawLeaf(p);
p.x = origX;
}
}
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
cancelAnimationFrame(raf);
raf = 0;
} else if (!raf) {
last = performance.now();
raf = requestAnimationFrame(step);
}
});
})();

View File

@@ -1,5 +1,5 @@
{{define "layout"}}<!doctype html> {{define "layout"}}<!doctype html>
<html lang="en" data-phase="day"> <html lang="en" data-phase="{{if .Phase}}{{.Phase}}{{else}}day{{end}}"{{if .Phase}} data-phase-lock{{end}} data-season="{{.Weather.Season}}" data-weather="{{.Weather.Variant}}" data-intensity="{{.Weather.Intensity}}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -19,6 +19,7 @@
return "night"; return "night";
} }
function apply() { function apply() {
if (document.documentElement.hasAttribute("data-phase-lock")) return;
document.documentElement.dataset.phase = phase(new Date().getHours()); document.documentElement.dataset.phase = phase(new Date().getHours());
} }
apply(); apply();
@@ -28,6 +29,7 @@
</head> </head>
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)] transition-colors duration-1000"> <body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)] transition-colors duration-1000">
<div class="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--bg-grad)] transition-colors duration-1000"></div> <div class="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--bg-grad)] transition-colors duration-1000"></div>
<canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas>
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12"> <header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
@@ -76,5 +78,7 @@
setInterval(tick, 30000); setInterval(tick, 30000);
})(); })();
</script> </script>
<script src="/static/js/weather.js" defer></script>
</body> </body>
</html>{{end}} </html>{{end}}

View File

@@ -0,0 +1,57 @@
{{define "title"}}Weather demo · {{.SiteTitle}}{{end}}
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
<div class="space-y-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
{{range $v := .Variants}}
<a href="?variant={{$v}}&amp;intensity={{$.Intensity}}&amp;phase={{$.PhaseSel}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
{{range $i := .Intensities}}
<a href="?variant={{$.Variant}}&amp;intensity={{$i}}&amp;phase={{$.PhaseSel}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
{{range $p := .Phases}}
<a href="?variant={{$.Variant}}&amp;intensity={{$.Intensity}}&amp;phase={{$p}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
{{end}}
</div>
</div>
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
</div>
</section>
<section class="rounded-3xl bg-[color:var(--card)] p-10 shadow-pete border-2 border-[color:var(--ink)]/10 text-center">
<p class="font-display text-lg">Sample card</p>
<p class="mt-2 text-sm text-[color:var(--ink)]/60">Particles should drift in the margins around this block, not over the top of it.</p>
</section>
<section class="grid gap-4 sm:grid-cols-2">
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<p class="font-display">Card A</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Two cards with a gap so you can see the canvas between them.</p>
</div>
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<p class="font-display">Card B</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
</div>
</section>
</div>
{{end}}