| 1 | package sky |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/binary" |
| 6 | "fmt" |
| 7 | "math" |
| 8 | "math/rand/v2" |
| 9 | "strings" |
| 10 | |
| 11 | "amadan.net/oficina/estilo.git/theme" |
| 12 | ) |
| 13 | |
| 14 | // The sky is drawn on a 1600x640 canvas and covers its box, cropping |
| 15 | // rather than stretching (xMidYMid slice): on a phone a squashed canvas |
| 16 | // turns clouds into streaks and filaments into zigzags, where a crop just |
| 17 | // shows fewer apps' colours, at their true shapes. |
| 18 | const ( |
| 19 | width = 1600 |
| 20 | height = 640 |
| 21 | ) |
| 22 | |
| 23 | // rng is the generator for one stream of one sky. The same ID and stream |
| 24 | // always give the same numbers, on every machine and every Go release |
| 25 | // (ChaCha8 is specified, not an implementation detail). The drawing built |
| 26 | // from them is stable for a given binary; across architectures a |
| 27 | // coordinate can differ in its last place (Go may fuse a multiply-add on |
| 28 | // arm64 and not on amd64), which a browser cannot see. |
| 29 | func rng(id, stream string) *rand.Rand { |
| 30 | sum := sha256.Sum256([]byte("estilo-sky/v1:" + id + ":" + stream)) |
| 31 | var seed [32]byte |
| 32 | copy(seed[:], sum[:]) |
| 33 | return rand.New(rand.NewChaCha8(seed)) |
| 34 | } |
| 35 | |
| 36 | func uniform(r *rand.Rand, lo, hi float64) float64 { return lo + r.Float64()*(hi-lo) } |
| 37 | |
| 38 | // anchor is where one app's colour gathers in the sky. |
| 39 | type anchor struct { |
| 40 | hue, x, y, scale, glow float64 |
| 41 | } |
| 42 | |
| 43 | // anchors spreads every app on the ring across the sky, in ring order so |
| 44 | // neighbours blend into neighbours, from a starting app and in a direction |
| 45 | // the seed chooses; spacing, height and size are jittered. Oficina itself |
| 46 | // is not an anchor: the sky is the apps, together. |
| 47 | func anchors(id string) []anchor { |
| 48 | r := rng(id, "anchors") |
| 49 | var hues []float64 |
| 50 | for _, a := range theme.Apps() { |
| 51 | if a.Slug != "oficina" { |
| 52 | hues = append(hues, a.Hue) |
| 53 | } |
| 54 | } |
| 55 | n := len(hues) |
| 56 | start := r.IntN(n) |
| 57 | hues = append(hues[start:], hues[:start]...) |
| 58 | if r.IntN(2) == 0 { |
| 59 | for i, j := 0, n-1; i < j; i, j = i+1, j-1 { |
| 60 | hues[i], hues[j] = hues[j], hues[i] |
| 61 | } |
| 62 | } |
| 63 | spread := 70 * 7 / float64(n) |
| 64 | out := make([]anchor, n) |
| 65 | for i, h := range hues { |
| 66 | out[i] = anchor{ |
| 67 | hue: h, |
| 68 | x: (float64(i)+.5)/float64(n)*width + uniform(r, -spread, spread), |
| 69 | y: height*(.36+.2*math.Sin(float64(i)*1.3+uniform(r, 0, 6.3))) + uniform(r, -45, 45), |
| 70 | scale: uniform(r, .7, 1.3), |
| 71 | // Some apps glow brighter than others in any one sky; an even |
| 72 | // brightness reads as a rainbow band, not a nebula. |
| 73 | glow: uniform(r, .5, 1), |
| 74 | } |
| 75 | } |
| 76 | return out |
| 77 | } |
| 78 | |
| 79 | // Tone is the scheme a layer is drawn for. |
| 80 | type Tone string |
| 81 | |
| 82 | const ( |
| 83 | Light Tone = "light" |
| 84 | Dark Tone = "dark" |
| 85 | ) |
| 86 | |
| 87 | // palette is the nebula's colour at each scheme: pale on a light page, |
| 88 | // deeper and brighter on a dark one (the lab's "as now", Paul 2026-09-29). |
| 89 | func palette(t Tone) (L, C float64) { |
| 90 | if t == Dark { |
| 91 | return .72, .13 |
| 92 | } |
| 93 | return .80, .12 |
| 94 | } |
| 95 | |
| 96 | func svg(body, defs string) string { |
| 97 | return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" preserveAspectRatio="xMidYMid slice"><defs>%s</defs>%s</svg>`, |
| 98 | width, height, width, height, defs, body) |
| 99 | } |
| 100 | |
| 101 | // grain is a fine photographic texture laid over the clouds and clipped to |
| 102 | // them: noise, taken to grey and down to a third of its contrast, blended |
| 103 | // in with overlay so it only nudges each pixel. Its region is the whole |
| 104 | // canvas in user space; a bounding-box region would clip the clouds' blur |
| 105 | // to their unblurred outline, a hard edge across the sky. |
| 106 | const grain = `<filter id="g" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">` + |
| 107 | `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>` + |
| 108 | `<feColorMatrix in="n" type="matrix" values=".33 .33 .33 0 0 .33 .33 .33 0 0 .33 .33 .33 0 0 0 0 0 0 1" result="k"/>` + |
| 109 | `<feComponentTransfer in="k" result="c"><feFuncR type="linear" slope=".34" intercept=".33"/><feFuncG type="linear" slope=".34" intercept=".33"/><feFuncB type="linear" slope=".34" intercept=".33"/></feComponentTransfer>` + |
| 110 | `<feComposite in="c" in2="SourceGraphic" operator="in" result="m"/><feBlend in="m" in2="SourceGraphic" mode="overlay"/></filter>` |
| 111 | |
| 112 | // blob is one soft shape of the sky: an ellipse of one colour, blurred. |
| 113 | // The SVG layers draw blobs; Placeholder paints the same blobs, roughly, |
| 114 | // into a few pixels. |
| 115 | type blob struct { |
| 116 | cx, cy, rx, ry, rot, opacity, blur float64 |
| 117 | fill string |
| 118 | } |
| 119 | |
| 120 | // cloudBlobs is the near layer: two or three clouds of each app's colour |
| 121 | // around its anchor. |
| 122 | func cloudBlobs(id string, t Tone) []blob { |
| 123 | r := rng(id, "clouds") |
| 124 | L, C := palette(t) |
| 125 | var out []blob |
| 126 | for _, a := range anchors(id) { |
| 127 | for range 1 + r.IntN(3) { |
| 128 | cx, cy := a.x+uniform(r, -160, 160), a.y+uniform(r, -85, 70) |
| 129 | rx, ry := uniform(r, 100, 220)*a.scale, uniform(r, 55, 130)*a.scale |
| 130 | fill := theme.Hex(L, C, a.hue+uniform(r, -8, 8)) |
| 131 | op := uniform(r, .45, .85) * a.glow |
| 132 | rot := uniform(r, -28, 28) |
| 133 | out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, rot: rot, opacity: op, blur: 70, fill: fill}) |
| 134 | } |
| 135 | } |
| 136 | return out |
| 137 | } |
| 138 | |
| 139 | // hazeBlobs is the far layer: one wide, faint glow per app. |
| 140 | func hazeBlobs(id string, t Tone) []blob { |
| 141 | r := rng(id, "haze") |
| 142 | L, C := palette(t) |
| 143 | var out []blob |
| 144 | for _, a := range anchors(id) { |
| 145 | cx, cy := a.x+uniform(r, -80, 80), a.y+uniform(r, -40, 60) |
| 146 | rx, ry := uniform(r, 330, 480), uniform(r, 140, 220) |
| 147 | fill := theme.Hex(L-.02, C, a.hue) |
| 148 | op := uniform(r, .25, .45) * a.glow |
| 149 | out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, opacity: op, blur: 110, fill: fill}) |
| 150 | } |
| 151 | return out |
| 152 | } |
| 153 | |
| 154 | // Clouds is the near layer, heavily blurred, with grain. |
| 155 | func Clouds(id string, t Tone) string { |
| 156 | var b strings.Builder |
| 157 | for _, c := range cloudBlobs(id, t) { |
| 158 | fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`, |
| 159 | c.cx, c.cy, c.rx, c.ry, c.fill, c.opacity, c.rot, c.cx, c.cy) |
| 160 | } |
| 161 | blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter>` |
| 162 | return svg(`<g filter="url(#g)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grain) |
| 163 | } |
| 164 | |
| 165 | // grainOnly is the clouds' grain as a texture of its own: faint white |
| 166 | // specks where the noise is above its middle and faint black ones where it |
| 167 | // is below, kept only where the clouds are (their alpha). Drawn with |
| 168 | // ordinary blending, so it barely moves the average brightness (a fraction |
| 169 | // of a level; the dark specks show more on a light sky, the light ones on |
| 170 | // a dark one) — an overlay blend showed its grey wherever the sky beneath |
| 171 | // was thin, and lifted the whole dark sky. grainAmp is each speck's most. |
| 172 | const grainAmp = 0.34 |
| 173 | |
| 174 | var grainOnly = fmt.Sprintf(`<filter id="n" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">`+ |
| 175 | `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>`+ |
| 176 | `<feColorMatrix in="n" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .33 .33 .33 0 0" result="w"/>`+ |
| 177 | `<feComponentTransfer in="w" result="lit"><feFuncA type="linear" slope="%[1]g" intercept="%[2]g"/></feComponentTransfer>`+ |
| 178 | `<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .33 .33 .33 0 0" result="k"/>`+ |
| 179 | `<feComponentTransfer in="k" result="shade"><feFuncA type="linear" slope="%[3]g" intercept="%[4]g"/></feComponentTransfer>`+ |
| 180 | `<feMerge result="specks"><feMergeNode in="lit"/><feMergeNode in="shade"/></feMerge>`+ |
| 181 | `<feComposite in="specks" in2="SourceGraphic" operator="in"/></filter>`, |
| 182 | 2*grainAmp, -grainAmp, -2*grainAmp, grainAmp) |
| 183 | |
| 184 | // Grain is the clouds' texture as a layer of its own. When the placeholder |
| 185 | // took over the sky's colour, the clouds layer that carried the grain went |
| 186 | // faint and then unlinked (impeccable's detector, and a zoomed look, |
| 187 | // 2026-09-29); on its own layer the grain keeps the strength Paul chose. One file for |
| 188 | // both schemes: it is the clouds' shape, not their colour. |
| 189 | func Grain(id string) string { |
| 190 | var b strings.Builder |
| 191 | for _, c := range cloudBlobs(id, Light) { |
| 192 | fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="#000" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`, |
| 193 | c.cx, c.cy, c.rx, c.ry, c.opacity, c.rot, c.cx, c.cy) |
| 194 | } |
| 195 | blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter>` |
| 196 | return svg(`<g filter="url(#n)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grainOnly) |
| 197 | } |
| 198 | |
| 199 | // Haze is the far layer, much softer. |
| 200 | func Haze(id string, t Tone) string { |
| 201 | var b strings.Builder |
| 202 | for _, h := range hazeBlobs(id, t) { |
| 203 | fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f"/>`, |
| 204 | h.cx, h.cy, h.rx, h.ry, h.fill, h.opacity) |
| 205 | } |
| 206 | blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="110"/></filter>` |
| 207 | return svg(`<g filter="url(#b)">`+b.String()+`</g>`, blur) |
| 208 | } |
| 209 | |
| 210 | // Filaments are fine wisps: a few bundles of near-parallel strands that |
| 211 | // drift apart and together along their length and fade in and out at |
| 212 | // their ends. White, so they read as silk on a light page and as glowing |
| 213 | // gas on a dark one, from one file. |
| 214 | func Filaments(id string) string { |
| 215 | r := rng(id, "filaments") |
| 216 | var lines, grads strings.Builder |
| 217 | for bundle := range 4 { |
| 218 | y0, amp, lam, ph := height*uniform(r, .2, .6), uniform(r, 20, 60), uniform(r, 260, 520), uniform(r, 0, 6.3) |
| 219 | x0, x1 := uniform(r, -100, width*.4), uniform(r, width*.6, width+100) |
| 220 | for k := range 2 + r.IntN(2) { |
| 221 | off, sp := uniform(r, -10, 10), uniform(r, 6, 16) |
| 222 | var pts []string |
| 223 | for x := x0; x <= x1; x += 20 { |
| 224 | y := y0 + amp*math.Sin(x/lam+ph) + (off+sp*float64(k))*(.6+.4*math.Sin(x/190+float64(k))) |
| 225 | pts = append(pts, fmt.Sprintf("%.0f,%.1f", x, y)) |
| 226 | } |
| 227 | fmt.Fprintf(&lines, `<polyline points="%s" stroke="url(#f%d)" stroke-width="%.1f" opacity="%.2f"/>`, |
| 228 | strings.Join(pts, " "), bundle, uniform(r, .8, 1.8), uniform(r, .2, .5)) |
| 229 | } |
| 230 | fmt.Fprintf(&grads, `<linearGradient id="f%d" gradientUnits="userSpaceOnUse" x1="%.0f" x2="%.0f" y1="0" y2="0">`+ |
| 231 | `<stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient>`, |
| 232 | bundle, x0, x1) |
| 233 | } |
| 234 | blur := `<filter id="b" x="-5%" y="-20%" width="110%" height="140%"><feGaussianBlur stdDeviation="2.4"/></filter>` |
| 235 | return svg(`<g fill="none" filter="url(#b)">`+lines.String()+`</g>`, grads.String()+blur) |
| 236 | } |
| 237 | |
| 238 | // ID is the public name of the sky for seed: a digest, so a team's sky |
| 239 | // URL says nothing about the team, and a closed shape (16 lowercase hex |
| 240 | // digits) that the handler can check before it draws anything. |
| 241 | func ID(seed string) string { |
| 242 | sum := sha256.Sum256([]byte("estilo-sky:" + seed)) |
| 243 | return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8])) |
| 244 | } |
| 245 | |