art.gno
6.58 Kb · 198 lines
1package nft
2
3import (
4 "encoding/base64"
5 "strconv"
6 "time"
7
8 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
9 "gno.land/p/nym-alexiscolin000/gnoradio/svg/v0"
10 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
11)
12
13// Tickets are drawn on-chain as SVG in the GnoRadio sober style: white,
14// black and greys with a single red accent. The perforation is cut out with a
15// mask so it reads as holes on any page background. Text is escaped by
16// svg.Text. Nothing extra is stored: a ticket is drawn from its records.
17// The artist's name and visibility are read from the catalog's artist
18// records in the data realm (nft.gno, CArtists).
19
20const (
21 ink = "#0d0d0d"
22 paper = "#fafaf8"
23 grey = "#9a9a96"
24 accent = "#e5432f"
25)
26
27// removedTitle replaces the details of a concert hidden by moderation.
28const removedTitle = "Removed by moderation"
29
30// concert is what a ticket shows of its concert.
31type concert struct {
32 id, artist, capacity int
33 title, venue string
34 start, price int64
35 cancelled bool
36}
37
38// shown decodes an event record for a ticket: everything while the concert
39// and its artist are visible, a neutral placeholder otherwise. ok is false
40// when the concert is not visible.
41func shown(id int, rec string) (c concert, ok bool) {
42 if rec == "" {
43 return concert{title: removedTitle}, false
44 }
45 // store.Field reads "" past a record's end, and texts are checked again
46 // here: whatever a later release writes, the metadata is valid JSON (a
47 // record that is not a store.Rec may also make the read panic).
48 c = concert{id: id, start: text.Atoi64(store.Field(rec, EStart)), capacity: text.Atoi(store.Field(rec, ECapacity)),
49 cancelled: store.Field(rec, ECancelled) != ""}
50 artist, title, venue := text.Atoi(store.Field(rec, EArtist)), store.Field(rec, ETitle), store.Field(rec, EVenue)
51 if store.Field(rec, EHidden) != "" || !artistVisible(artist) || !text.Valid(title, 1, 64) || !text.Valid(venue, 0, 64) {
52 c.title = removedTitle
53 return c, false
54 }
55 c.artist, c.title, c.venue, c.price = artist, title, venue, text.Atoi64(store.Field(rec, EPrice))
56 return c, true
57}
58
59// fields reads ticket id's event, serial and attended mark (ok false when
60// unknown).
61func fields(id int) (ev, serial int, attended, ok bool) {
62 rec := ticket(id)
63 if rec == "" {
64 return 0, 0, false, false
65 }
66 return text.Atoi(store.Field(rec, TEvent)), text.Atoi(store.Field(rec, TSerial)), store.Field(rec, TAttended) != "", true
67}
68
69// EventSVG is the specimen ticket of a visible concert as a markdown image,
70// or "" when the concert is not visible.
71func EventSVG(id int) string {
72 c, ok := shown(id, event(id))
73 if !ok {
74 return ""
75 }
76 return draw(c, 0, false).Image("ticket for " + c.title)
77}
78
79// TicketSVG is a ticket as a markdown image, or "" if unknown.
80func TicketSVG(id int) string {
81 ev, serial, attended, ok := fields(id)
82 if !ok {
83 return ""
84 }
85 c, _ := shown(ev, event(ev))
86 return draw(c, serial, attended).Image("ticket for " + c.title)
87}
88
89// TokenURI is a ticket's GRC721 metadata as a data URI, its artwork drawn on
90// the fly. It panics for an unknown ticket.
91func TokenURI(tokenID string) string {
92 id := text.Atoi(tokenID)
93 ev, serial, attended, ok := fields(id)
94 if !ok || strconv.Itoa(id) != tokenID {
95 panic("nft: unknown ticket")
96 }
97 c, _ := shown(ev, event(ev))
98 meta := `{"name":` + text.Str("GnoRadio · "+c.title+" #"+pad3(serial)) +
99 `,"description":"GnoRadio concert ticket, drawn on-chain on gno.land","image":` + text.Str(draw(c, serial, attended).Data()) +
100 `,"attributes":[{"trait_type":"event","value":` + strconv.Itoa(c.id) +
101 `},{"trait_type":"artist","value":` + text.Str(ArtistName(c.artist)) +
102 `},{"trait_type":"attended","value":` + strconv.FormatBool(attended) + `}]}`
103 return "data:application/json;base64," + base64.StdEncoding.EncodeToString([]byte(meta))
104}
105
106func newCanvas(w, h int) *svg.Canvas {
107 return svg.New(w, h, `text{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-weight:600}.m{font-weight:500}.l{font-weight:600;letter-spacing:.08em}.n{font-family:ui-monospace,Menlo,monospace;font-weight:500}`)
108}
109
110func drawText(c *svg.Canvas, x, y int, s, fill string, size int, anchor, class string) {
111 attrs := ` text-anchor="` + anchor + `"`
112 if class != "" {
113 attrs += ` class="` + class + `"`
114 }
115 c.Text(x, y, s, fill, size, attrs)
116}
117
118// draw draws a ticket; serial 0 is the specimen shown before purchase.
119func draw(e concert, serial int, attended bool) *svg.Canvas {
120 c := newCanvas(640, 250)
121 // Black ticket with perforation and side notches cut out.
122 c.Raw(`<mask id="h"><rect width="640" height="250" fill="#fff"/>`)
123 for y := 18; y < 250; y += 16 {
124 c.Circle(470, y, 3, "#000")
125 }
126 c.Circle(470, 0, 14, "#000")
127 c.Circle(470, 250, 14, "#000")
128 c.Raw(`</mask><g mask="url(#h)">`)
129 c.Rect(0, 0, 640, 250, 24, ink, "")
130 c.Raw(`</g>`)
131
132 drawText(c, 30, 44, "GNORADIO · ADMIT ONE", grey, 12, "start", "l")
133 drawText(c, 30, 96, svg.Clip(e.title, 24), paper, 32, "start", "")
134 drawText(c, 30, 128, svg.Clip(ArtistName(e.artist), 34), grey, 18, "start", "m")
135 drawText(c, 30, 182, svg.Clip(e.venue, 34), paper, 15, "start", "m")
136 drawText(c, 30, 206, time.Unix(e.start, 0).UTC().Format("2006-01-02 15:04")+" UTC", paper, 15, "start", "m")
137 price := "FREE"
138 if e.price > 0 {
139 price = text.GNOT(e.price)
140 }
141 drawText(c, 440, 206, price, grey, 14, "end", "l")
142
143 // Stub: serial, barcode, red dot.
144 num := "SPECIMEN"
145 if serial > 0 {
146 num = "#" + pad3(serial) + "/" + strconv.Itoa(e.capacity)
147 }
148 drawText(c, 555, 54, num, paper, 16, "middle", "n")
149 h := svg.FNV("event" + strconv.Itoa(e.id) + e.title)
150 for i := 0; i < 22; i++ {
151 h ^= uint32(i + 1)
152 h *= 16777619
153 c.Rect(502+i*5, 80, 1+int(h>>24)%3, 80, 0, paper, "")
154 }
155 c.Circle(555, 200, 14, accent)
156
157 if e.cancelled {
158 stamp(c, "CANCELLED")
159 } else if attended {
160 stamp(c, "I WAS THERE")
161 }
162 return c
163}
164
165func stamp(c *svg.Canvas, label string) {
166 c.Raw(`<g transform="rotate(-10 240 125)">`)
167 c.Rect(70, 95, 340, 60, 8, "none", ` stroke="`+accent+`" stroke-width="4"`)
168 drawText(c, 240, 137, label, accent, 30, "middle", "l")
169 c.Raw(`</g>`)
170}
171
172func pad3(n int) string {
173 s := strconv.Itoa(n)
174 for len(s) < 3 {
175 s = "0" + s
176 }
177 return s
178}
179
180// artistVisible reports whether artist id exists and is not hidden.
181func artistVisible(id int) bool {
182 rec := artist(id)
183 return rec != "" && store.Field(rec, AHidden) == ""
184}
185
186// ArtistName is a visible artist's name, "" otherwise (one data read: the
187// catalog's artist record, through the frozen layout).
188func ArtistName(id int) string {
189 rec := artist(id)
190 if rec == "" || store.Field(rec, AHidden) != "" {
191 return ""
192 }
193 name := store.Field(rec, AName)
194 if !text.Valid(name, 1, 64) {
195 return ""
196 }
197 return name
198}