overlay.gno
13.64 Kb · 408 lines
1package nsview
2
3import (
4 "strconv"
5 "strings"
6 "time"
7
8 "gno.land/p/nt/ufmt/v0"
9 "gno.land/r/nym-mnsrehearsal001/nsdata/v1"
10)
11
12// Seasonal decoration, driven by DATA rather than by code.
13//
14// The card is generated on every read, so anything it draws can change
15// after names have been sold. That makes a holiday overlay possible —
16// snow in December, falling leaves in October — across every token at
17// once. The temptation is to write a drawSnow() into this realm and a
18// drawPumpkins() next year, which would make every new decoration a
19// redeploy of the view.
20//
21// So it is parameterised instead. This realm knows how to draw drifting
22// particles; WHICH particles, what colour, how many and until when live
23// in a global that any admin call can rewrite. Christmas is then one
24// transaction, not a deployment, and so is turning it off.
25//
26// Format, read from the global key `cardfx`:
27//
28// dir:fall;n:22;c:#ffffff;c2:#dbeafe;s:3;d:9;until:1767225600
29//
30// dir fall | rise | burst drift down, drift up, or fireworks
31// n 1..40 how many
32// c #rrggbb colour (c2, c3 optional, cycled)
33// s 1..6 pixel size
34// d 2..30 seconds for one traverse (lower = faster)
35// sv 0..3 size jitter, so they are not all identical
36// k 4..16 sparks per shell, when dir is burst
37// sh sq | leaf | petal particle shape; leaf and petal are angled
38// until unix seconds, 0 = no expiry
39//
40// `until` matters more than it looks: an overlay that expires on its own
41// cannot be forgotten in January. Everything is parsed defensively and a
42// malformed value yields NO overlay, for the same reason the sprite
43// parser does — this markup lands inside a token that other people's
44// software renders.
45const keyFX = "cardfx"
46
47func overlaySVG(now int64) string {
48 return buildOverlay(nsdata.GetGlobal(keyFX), now)
49}
50
51// A SCHEDULE, so December snows without anybody remembering to make it.
52//
53// This is free in a way the treasury swap was not. That needed somebody
54// to send a transaction, because a realm cannot act on its own. This
55// needs nobody, because the card is generated on every read and can
56// simply look at the clock — the decoration is a rendering decision, not
57// an action.
58//
59// Rules are separated by "|" and the FIRST one whose window contains
60// today wins, so put the specific ones first:
61//
62// from:12-01;to:01-06;dir:fall;n:26;c:#ffffff|from:10-24;to:11-02;dir:rise;n:22;c:#f97316
63//
64// from/to are MM-DD and RECUR every year, which is the point — set it
65// once and every December snows. A window whose start is after its end
66// wraps the new year, so 12-01 to 01-06 is what you would expect rather
67// than an empty range. A rule with no window is always eligible, so a
68// bare spec still behaves as it did.
69func pickRule(spec string, now int64) string {
70 if spec == "" {
71 return ""
72 }
73 t := time.Unix(now, 0).UTC()
74 today := int(t.Month())*100 + t.Day()
75 for _, rule := range strings.Split(spec, "|") {
76 /* A WEEKDAY RULE, for the one date that is not a date.
77 *
78 * Friday the thirteenth falls one to three times a year and never
79 * on a schedule, so it cannot be written as a MM-DD window. `on`
80 * takes a weekday and an optional day-of-month — on:fri-13 — and
81 * is checked before the window fields so a rule can use either
82 * without the two interfering.
83 *
84 * Deliberately narrow: this is not a general calendar language,
85 * it is the one shape the existing one could not express. */
86 if on := ruleField(rule, "on"); on != "" {
87 if weekdayMatch(on, t) {
88 return rule
89 }
90 continue
91 }
92 from := ruleField(rule, "from")
93 to := ruleField(rule, "to")
94 if from == "" && to == "" {
95 return rule // no window: always eligible
96 }
97 f, okF := mmdd(from)
98 e, okE := mmdd(to)
99 if !okF || !okE {
100 continue // a malformed window disables its rule, not the lot
101 }
102 if f <= e {
103 if today >= f && today <= e {
104 return rule
105 }
106 } else if today >= f || today <= e {
107 // wraps the year end, e.g. 12-01 to 01-06
108 return rule
109 }
110 }
111 return ""
112}
113
114func ruleField(rule, key string) string {
115 for _, part := range strings.Split(rule, ";") {
116 kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
117 if len(kv) == 2 && strings.TrimSpace(kv[0]) == key {
118 return strings.TrimSpace(kv[1])
119 }
120 }
121 return ""
122}
123
124// mmdd reads "12-01" into 1201. Rejects anything that is not a real
125// calendar date, so a typo cannot silently make a rule match all year.
126func mmdd(s string) (int, bool) {
127 if len(s) != 5 || s[2] != '-' {
128 return 0, false
129 }
130 m, err1 := strconv.Atoi(s[:2])
131 d, err2 := strconv.Atoi(s[3:])
132 if err1 != nil || err2 != nil || m < 1 || m > 12 || d < 1 || d > 31 {
133 return 0, false
134 }
135 return m*100 + d, true
136}
137
138func buildOverlay(spec string, now int64) string {
139 spec = pickRule(spec, now)
140 if spec == "" {
141 return ""
142 }
143 dir := "fall"
144 n := 0
145 size := 3
146 dur := 9
147 jitter := 0
148 sparks := 8
149 shape := "sq"
150 var until int64
151 cols := []string{}
152
153 for _, part := range strings.Split(spec, ";") {
154 kv := strings.SplitN(strings.TrimSpace(part), ":", 2)
155 if len(kv) != 2 {
156 continue
157 }
158 k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
159 switch k {
160 case "dir":
161 if v == "fall" || v == "rise" || v == "burst" {
162 dir = v
163 }
164 case "n":
165 n = atoiClamp(v, 0, 40)
166 case "s":
167 size = atoiClamp(v, 1, 6)
168 case "d":
169 // Floor of 2, not 3: a 3-second traverse was the fastest the
170 // card could go, and rain wants to be faster than snow by more
171 // than that allows.
172 dur = atoiClamp(v, 2, 30)
173 case "sv":
174 jitter = atoiClamp(v, 0, 3)
175 case "k":
176 sparks = atoiClamp(v, 4, 16)
177 case "sh":
178 if v == "sq" || v == "leaf" || v == "petal" {
179 shape = v
180 }
181 case "until":
182 if x, err := strconv.ParseInt(v, 10, 64); err == nil && x >= 0 {
183 until = x
184 }
185 case "c", "c2", "c3":
186 if isHexColour(v) {
187 cols = append(cols, v)
188 }
189 }
190 }
191
192 if n <= 0 || len(cols) == 0 {
193 return ""
194 }
195 // Expired decoration draws nothing, so a forgotten overlay retires
196 // itself rather than snowing in July.
197 if until > 0 && now >= until {
198 return ""
199 }
200
201 if dir == "burst" {
202 return fireworks(n, sparks, size, dur, cols)
203 }
204
205 var b strings.Builder
206 b.WriteString(`<g opacity="0.85">`)
207 for i := 0; i < n; i++ {
208 // Deterministic scatter: the same token renders identically on
209 // every read, which matters because a marketplace may cache it.
210 h := hash(i)
211 x := int(h % 392)
212 delay := int(h/7) % (dur * 10)
213 drift := int(h/13)%14 - 7
214 wobble := dur + int(h/17)%5
215 from, to := -12, 412
216 if dir == "rise" {
217 from, to = 412, -12
218 }
219 col := cols[i%len(cols)]
220 // Not all the same size. Uniform particles read as a texture;
221 // a little variation reads as depth, for two bytes of spec.
222 px := size
223 if jitter > 0 {
224 px = size + int(h/23)%(jitter+1)
225 }
226 if shape == "sq" {
227 b.WriteString(ufmt.Sprintf(
228 `<rect x="%d" y="%d" width="%d" height="%d" fill="%s">`+
229 `<animateTransform attributeName="transform" type="translate" `+
230 `values="0 0;%d %d" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
231 x, from, px, px, col,
232 drift, to-from, wobble, delay/10, delay%10))
233 continue
234 }
235 // A shaped particle needs its own rotation, and an element can
236 // carry only one transform. Animating the group and rotating the
237 // shape inside it keeps the two independent — animating both on
238 // one element would make each leaf drift along its own angle
239 // instead of downward.
240 b.WriteString(ufmt.Sprintf(
241 `<g><animateTransform attributeName="transform" type="translate" `+
242 `values="0 0;%d %d" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>%s</g>`,
243 drift, to-from, wobble, delay/10, delay%10,
244 shapeAt(shape, x, from, px, int(h/31)%360, col)))
245 }
246 b.WriteString(`</g>`)
247 return b.String()
248}
249
250// A small deterministic spread. Not random: the same index must always
251// give the same particle, or a cached render and a fresh one disagree.
252func hash(i int) int {
253 h := (i+1)*2654435761 ^ 0x9e3779b9
254 if h < 0 {
255 h = -h
256 }
257 return h
258}
259
260func atoiClamp(s string, lo, hi int) int {
261 v, err := strconv.Atoi(s)
262 if err != nil {
263 return 0
264 }
265 if v < lo {
266 return lo
267 }
268 if v > hi {
269 return hi
270 }
271 return v
272}
273
274// Fireworks: a shell climbs, vanishes, and throws sparks outward.
275//
276// The cost question is worth answering plainly, because it is the first
277// decoration that is not one rect per particle. A shell is 1 rect for
278// the climb plus k rects for the burst, so `n:5;k:10` is 55 rects — the
279// same order as the 34 the rain already draws, and a fifth of the 128
280// the domain sprite draws on every card anyway. None of it is stored:
281// the card is built during a read, so this costs no gas and no storage,
282// only the browser's time. That is the real ceiling, so the totals are
283// capped below rather than left to whoever writes the spec.
284//
285// Two phases in one loop, driven by keyTimes on a single animation each,
286// because a <set> chain or nested <g> would double the element count for
287// the same picture.
288func fireworks(shells, sparks, size, dur int, cols []string) string {
289 if shells > 6 {
290 shells = 6 // 6 x 16 sparks is already 102 rects
291 }
292 var b strings.Builder
293 b.WriteString(`<g opacity="0.9">`)
294 for i := 0; i < shells; i++ {
295 h := hash(i * 7)
296 x := 40 + int(h%340) // keep the burst off the very edge
297 burstY := 96 + int(h/11)%140 // where it stops climbing
298 lift := 400 - burstY // how far it climbs from the base
299 // Shells are staggered across the cycle so they do not all go up
300 // together, and each has its own colour from the palette.
301 begin := int(h/29) % (dur * 10)
302 // One colour per shell, not one per spark. A real firework is a
303 // single charge, and mixing colours inside one burst reads as
304 // confetti rather than pyrotechnics.
305 col := cols[i%len(cols)]
306
307 // The climb. Held at the top for the rest of the cycle rather
308 // than looping straight away, and faded out at the moment of the
309 // burst so the eye reads one event, not two objects.
310 b.WriteString(ufmt.Sprintf(
311 `<rect x="%d" y="400" width="%d" height="%d" fill="%s" opacity="0">`+
312 `<animateTransform attributeName="transform" type="translate" `+
313 `values="0 0;0 -%d;0 -%d" keyTimes="0;0.4;1" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>`+
314 `<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;0.38;0.4;1" `+
315 `dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
316 x, size, size, col, lift, lift, dur, begin/10, begin%10, dur, begin/10, begin%10))
317
318 for j := 0; j < sparks; j++ {
319 g := hash(i*97 + j*13)
320 d := j * 16 / sparks // one of the 16 fixed directions
321 r := 34 + int(g%34) // how far this spark travels
322 dx := r * dirCos[d] / 100
323 dy := r * dirSin[d] / 100
324 // Sparks fall as they fly, which is the whole difference
325 // between a firework and a starburst.
326 dy += r / 3
327 px := size
328 if int(g/7)%3 == 0 {
329 px = size + 1 // a few brighter, bigger sparks per shell
330 }
331 b.WriteString(ufmt.Sprintf(
332 `<rect x="%d" y="%d" width="%d" height="%d" fill="%s" opacity="0">`+
333 `<animateTransform attributeName="transform" type="translate" `+
334 `values="0 0;0 0;%d %d" keyTimes="0;0.4;1" dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/>`+
335 `<animate attributeName="opacity" values="0;0;1;0" keyTimes="0;0.4;0.52;1" `+
336 `dur="%ds" begin="-%d.%ds" repeatCount="indefinite"/></rect>`,
337 x, burstY, px, px, col,
338 dx, dy, dur, begin/10, begin%10, dur, begin/10, begin%10))
339 }
340 }
341 b.WriteString(`</g>`)
342 return b.String()
343}
344
345// Sixteen directions on a circle, as hundredths. A table rather than
346// math.Sin: the same integers must come out on every node, and this is
347// the whole of the trigonometry the card needs.
348var (
349 dirCos = [16]int{100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38, 0, 38, 71, 92}
350 dirSin = [16]int{0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38}
351)
352
353// shapeAt draws one non-square particle at a position, turned to its own
354// angle. Leaves and petals are two curves each rather than a polygon: a
355// leaf is pointed at both ends, a petal is round at one, and that single
356// difference is the whole of what distinguishes October from April.
357//
358// The numbers are derived from the particle size so `s` still means what
359// it means everywhere else, and both shapes stay legible at the size a
360// card is actually looked at.
361func shapeAt(kind string, x, y, px, ang int, col string) string {
362 w, hh := px, px*2
363 switch kind {
364 case "leaf":
365 // Wider than a petal at the same size. A lens with its control
366 // points at the particle width is a sliver, not a leaf.
367 w = px * 3 / 2
368 return ufmt.Sprintf(
369 `<path d="M0 -%dQ%d 0 0 %dQ-%d 0 0 -%dZ" fill="%s" transform="translate(%d,%d) rotate(%d)"/>`,
370 hh, w, hh, w, hh, col, x, y, ang)
371 case "petal":
372 return ufmt.Sprintf(
373 `<ellipse rx="%d" ry="%d" fill="%s" transform="translate(%d,%d) rotate(%d)"/>`,
374 w, hh, col, x, y, ang)
375 }
376 return ufmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="%s"/>`, x, y, px, px, col)
377}
378
379// weekdayMatch reads "fri" or "fri-13": a weekday, optionally pinned to a
380// day of the month. Anything it does not understand matches nothing, so a
381// typo disables its own rule rather than firing every day.
382func weekdayMatch(spec string, t time.Time) bool {
383 day := ""
384 dom := 0
385 if at := strings.Index(spec, "-"); at > 0 {
386 day = spec[:at]
387 n, err := strconv.Atoi(spec[at+1:])
388 if err != nil || n < 1 || n > 31 {
389 return false
390 }
391 dom = n
392 } else {
393 day = spec
394 }
395 names := map[string]time.Weekday{
396 "sun": time.Sunday, "mon": time.Monday, "tue": time.Tuesday,
397 "wed": time.Wednesday, "thu": time.Thursday, "fri": time.Friday,
398 "sat": time.Saturday,
399 }
400 want, ok := names[strings.ToLower(day)]
401 if !ok {
402 return false
403 }
404 if t.Weekday() != want {
405 return false
406 }
407 return dom == 0 || t.Day() == dom
408}