Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

nameplate.gno

18.77 Kb · 479 lines
  1package nsview
  2
  3import (
  4	"strings"
  5	"time"
  6
  7	"gno.land/p/nt/ufmt/v0"
  8)
  9
 10/*
 11THE NAMEPLATE — the name, the asterisk and the expiry, composited over
 12somebody else's artwork.
 13
 14WHY IT IS THE CONTRACT'S JOB AND NOT THE ARTIST'S. A skin is a picture
 15somebody else drew. If they also controlled the lettering they could
 16omit it, cover it, shrink it to nothing, or write different text — and a
 17card that does not say which name it is, is not a name card. So the
 18artist chooses three things from short lists (face, position, ink) and
 19the contract does the rest.
 20
 21THE ASTERISK IS DRAWN, NEVER TYPED. Every typeface sets `*` small and
 22lifted to cap height, because in running text it is a footnote marker.
 23Here it is a letter — it is the entire brand — so it is drawn at the
 24weight of the type around it, at the x-height of the lowercase letters
 25beside it, and centred on their optical band. Typing it produces a small
 26mark floating above the words, which is exactly what it is for and
 27exactly wrong here.
 28
 29ONE ASTERISK IN ALL THREE FACES, cut square. Three shapes were tried —
 30tapered for serif, square for sans, square and lighter for mono — and the
 31lightest square one won outright, so the other two are gone. It is the
 32mark, not a glyph borrowed from whatever face happens to be set; making
 33it agree with the type was solving a problem nobody had.
 34
 35A REALM CANNOT MEASURE TEXT, and that used to shape the whole algorithm.
 36It matters much less now: the widths below are real per-character
 37metrics rather than one average, and textLength makes the renderer honour
 38them. See the long note in NameplateSVG.
 39
 40This file is the single source of truth for the geometry. Anything that
 41previews a card — the site, a mockup, a shop listing — must reproduce
 42these numbers exactly or it is showing something that will not ship.
 43tools/diff-nameplate.sh enforces that against the site's mirror.
 44*/
 45
 46// The card is 400x400 and the name may use all but a margin either side.
 47const (
 48	plateW      = 400
 49	plateMargin = 30
 50	plateAvail  = plateW - 2*plateMargin // 340
 51
 52	plateMaxSize = 56 // any larger and a two-letter name dominates the art
 53
 54	// A LAST-RESORT floor, not a readability one, and the difference is
 55	// a bug worth recording. This was 18 — "any smaller and it stops
 56	// being readable in a thumbnail" — which is a fine aspiration and an
 57	// impossible guarantee. maxLabelLen is 63, and sixty-three m's need
 58	// about five pixels to fit a 400px card. The clamp won that argument
 59	// and the name ran off the edge, inside somebody else's wallet.
 60	//
 61	// Fitting beats legibility because they are not comparable failures:
 62	// a small name is small, a name that leaves the card is broken. And
 63	// there is no third option — the card has to show the WHOLE name, so
 64	// truncating it is not available. So the solver goes as small as the
 65	// width demands and this is only the point below which a glyph would
 66	// round away to nothing whatever we did. In practice it never binds:
 67	// the longest legal name still solves above it.
 68	plateMinSize = 4
 69)
 70
 71/*
 72GLYPH WIDTHS, per generic family, in thousandths of the type size.
 73
 74This replaced a single average-width ratio per family, and the reason is
 75worth keeping, because the average was not merely imprecise — it put
 76names visibly off centre, by an amount that depended on which letters
 77somebody had picked.
 78
 79A serif "l" is 278 and a serif "m" is 833. One number cannot stand in for
 80both. The old ratio was 560 against a true average of 488, so a name made
 81of narrow letters — tessellation, say — came out forty per cent too wide,
 82and because the asterisk is placed using that figure, the whole name slid
 83sideways by half the error. That is exactly what showed up on the proof
 84sheet: tessellation pushed right, cosmos slightly left, say*gm slightly
 85right. Monospace was never off, because there one number IS the answer
 86and 600 was within two thousandths of it.
 87
 88Only the three generic families are offered. Every renderer that shows
 89these puts the SVG in a sandbox with no network, so a web font never
 90loads — it silently substitutes, and the card becomes whatever that
 91viewer happens to default to. serif, sans-serif and monospace are the
 92only families that resolve everywhere. These are the metrics of the bold
 93faces they resolve to almost everywhere; textLength covers the rest.
 94
 95Three digits per character, in the order of plateOrder. Names are
 96[a-z0-9-] — lowercase is enforced at registration — so that is the whole
 97alphabet this has to cover.
 98*/
 99const plateOrder = "abcdefghijklmnopqrstuvwxyz0123456789-"
100
101const (
102	plateWSerif = "500556444556444333500556278333556278833" +
103		"556500556556444389333556500722500500444" +
104		"500500500500500500500500500500333"
105
106	plateWSans = "556611556611556333611611278278556278889" +
107		"611611611611389556333611556778556556500" +
108		"556556556556556556556556556556333"
109
110	plateWMono = "602602602602602602602602602602602602602" +
111		"602602602602602602602602602602602602602" +
112		"602602602602602602602602602602602"
113)
114
115func plateWidths(face string) (tbl string, fallback int) {
116	switch face {
117	case "mono":
118		return plateWMono, 602
119	case "serif":
120		return plateWSerif, 488
121	default:
122		return plateWSans, 538
123	}
124}
125
126func plateFamily(face string) string {
127	switch face {
128	case "mono":
129		return "monospace"
130	case "serif":
131		return "serif"
132	default:
133		return "sans-serif"
134	}
135}
136
137// plateAdvance sums a string's width in thousandths of the type size.
138// A character outside the alphabet falls back to the family's average
139// rather than to zero: a name that somehow escapes the pattern should
140// lay out slightly wrong, not collapse into the asterisk.
141func plateAdvance(s, face string) int {
142	tbl, fallback := plateWidths(face)
143	total := 0
144	for i := 0; i < len(s); i++ {
145		idx := strings.IndexByte(plateOrder, s[i])
146		if idx < 0 || (idx*3+2) >= len(tbl) {
147			total += fallback
148			continue
149		}
150		total += int(tbl[idx*3]-'0')*100 + int(tbl[idx*3+1]-'0')*10 + int(tbl[idx*3+2]-'0')
151	}
152	return total
153}
154
155/*
156plateSize solves for the largest type size whose width fits.
157
158The asterisk occupies a column of its own, 0.86 of the type size wide,
159which is why it is in the denominator rather than added afterwards: the
160letters and the asterisk have to shrink together or the proportions
161drift as a name gets longer. That is the whole reason this is one
162calculation instead of two.
163
164`adv` is the summed advance of both halves, in thousandths.
165*/
166func plateSize(adv int, face string) int {
167	if adv < 1 {
168		adv = 1
169	}
170	// size * (adv/1000 + 0.86) <= avail
171	denom := adv + 860
172	size := plateAvail * 1000 / denom
173	if size > plateMaxSize {
174		size = plateMaxSize
175	}
176	if size < plateMinSize {
177		size = plateMinSize
178	}
179	return size
180}
181
182/*
183GEOMETRY, all derived from the type size so every proportion holds at
184every length.
185
186	capH       0.70 * size   the height of an uppercase letter
187	xH         0.52 * size   the height of a lowercase e — no ascender
188	starR      0.26 * size   half the x-height, so the asterisk is exactly
189	                         as tall as an "e" and not as tall as a "b".
190	                         Matching the cap height made it the tallest
191	                         thing on the line; names are lowercase, so the
192	                         x-height is what the eye reads as "letter
193	                         height" and anything above it reads as a mark
194	starArm    0.104 * size  the asterisk's own stroke. This is the weight a
195	                         MONOSPACED face sets an asterisk at — lighter
196	                         than the letters around it, because every glyph
197	                         there has to fit one fixed width — and it is
198	                         the one that read best of the three that were
199	                         tried, so it is now the weight in every face
200	outline    0.05 * size   the border, on the letters AND the asterisk,
201	                         the same on both because a border that differs
202	                         between them looks like a mistake rather than
203	                         a choice. Thin enough to be a contrast device
204	                         and not a second typeface: a heavy outline
205	                         thickens every stem and the name stops looking
206	                         like the face it was set in
207*/
208func plateGeom(size int) (capH, xH, starR, starArm, outline int) {
209	capH = size * 70 / 100
210	xH = size * 52 / 100
211	starR = xH / 2
212	starArm = size * 104 / 1000
213	outline = size * 5 / 100
214	if outline < 1 {
215		outline = 1
216	}
217	if starArm < 2 {
218		starArm = 2
219	}
220	return
221}
222
223/*
224THE SIX RAYS, as unit vectors in thousandths.
225
226An asterisk is six rays at sixty degrees. In SVG's coordinate system y
227grows downward, so "up" is negative. cos30 is 866 and cos60 is 500.
228
229Listed in CLOCKWISE SCREEN ORDER starting from straight up, which
230starPoly depends on: it walks them in sequence to trace the outline, and
231any other order produces a self-intersecting polygon.
232*/
233var starRays = [6][2]int{
234	{0, -1000}, {866, -500}, {866, 500},
235	{0, 1000}, {-866, 500}, {-866, -500},
236}
237
238/*
239starPoints returns the asterisk's outline, IN HUNDREDTHS OF A PIXEL.
240
241WHY IT IS AN OUTLINE AND NOT THREE CROSSING LINES. The border has to be
242the same thickness the whole way round, exactly as it is on the letters,
243and drawing the shape twice at two stroke widths cannot give that.
244
245A wider stroke offsets a line sideways, but where two arms MEET the two
246wide strokes overlap and fill the valley between them in, so the border
247is fat at the hub. At the tips it only extends by its own half-width, so
248it is thin there. That is not a rounding error, it is the wrong tool.
249Widening a stroke is not the same operation as offsetting a boundary, and
250they only agree on a shape with no concave corners. An asterisk is
251nothing but concave corners.
252
253The letters do not have this problem because a renderer strokes a glyph's
254OUTLINE, which is a true offset — every point of the border ends up the
255same distance from the edge. So the asterisk gets an outline too, and
256then exactly the same two passes the text gets.
257
258WHY HUNDREDTHS, AND NOT WHOLE PIXELS. Because whole pixels made the six
259arms different widths, which is worse than the problem above and was
260plainly visible. The arm half-width is arm/2, and at the sizes this
261actually renders arm is 5, so the half-width is 2.5 — a number a pixel
262grid cannot hold. Worse, each direction lost a DIFFERENT amount to
263truncation: the vertical arm computed 1000*5/2000 = 2 exactly, while a
264diagonal computed (500*5/2000, 866*5/2000) = (1,2), whose length is
2652.236. So four arms came out twelve per cent fatter than the other two,
266and every arm was a fifth thinner than intended.
267
268SVG coordinates are not integers, so the fix is simply to stop rounding
269to them. Everything below is computed in hundredths of a pixel and
270printed with two decimals, which brings the six half-widths to 2.500 and
2712.496 — a spread of 0.16% instead of 12%.
272
273THE GEOMETRY. Each ray is a rectangle of half-width arm/2 out to radius
274r, cut square at the tip. Where two adjacent rays meet, their inner edges
275cross on the bisector — and for rays sixty degrees apart that crossing
276sits at radius exactly arm, which falls out of the algebra rather than
277being tuned. Eighteen points: two tip corners and one valley per ray,
278walked in the ray order above.
279*/
280func starPoints(cx, cy, r, arm int) [18][2]int {
281	var out [18][2]int
282	for i := 0; i < 6; i++ {
283		u := starRays[i]
284		n := starRays[(i+1)%6]
285		// The perpendicular, pointing toward the NEXT ray.
286		px, py := -u[1], u[0]
287		// u is in thousandths and the result is in hundredths, so
288		// u*r/1000*100 collapses to u*r/10. Likewise p/1000 * arm/2 * 100
289		// is p*arm/20.
290		ox, oy := cx*100+u[0]*r/10, cy*100+u[1]*r/10
291		dx, dy := px*arm/20, py*arm/20
292		out[i*3] = [2]int{ox - dx, oy - dy}   // tip, trailing corner
293		out[i*3+1] = [2]int{ox + dx, oy + dy} // tip, leading corner
294		// The valley, on the bisector at radius arm. |u+n| is 1732 for
295		// rays sixty degrees apart, so this normalises it.
296		out[i*3+2] = [2]int{
297			cx*100 + (u[0]+n[0])*arm*100/1732,
298			cy*100 + (u[1]+n[1])*arm*100/1732,
299		}
300	}
301	return out
302}
303
304// hundredths formats a coordinate held in hundredths of a pixel.
305//
306// Two things it must not do, both found the hard way. The sign is peeled
307// off first, because integer division truncates toward zero on BOTH
308// halves and -250 would otherwise print as "-2.-50". And the fraction is
309// assembled a digit at a time rather than with "%02d", because ufmt does
310// not implement the zero-padding flag — it silently printed 68.0 where
311// two digits were asked for, which the mirror diff caught as drift.
312func hundredths(v int) string {
313	sign := ""
314	if v < 0 {
315		sign, v = "-", -v
316	}
317	f := v % 100
318	return ufmt.Sprintf("%s%d.%d%d", sign, v/100, f/10, f%10)
319}
320
321func starPoly(cx, cy, r, arm int) string {
322	pts := starPoints(cx, cy, r, arm)
323	var b strings.Builder
324	for i := 0; i < len(pts); i++ {
325		if i > 0 {
326			b.WriteString(" ")
327		}
328		b.WriteString(hundredths(pts[i][0]))
329		b.WriteString(",")
330		b.WriteString(hundredths(pts[i][1]))
331	}
332	return b.String()
333}
334
335// starSVG draws the asterisk in the two passes the lettering uses:
336// border first at twice the width, ink over it. No face argument — one
337// asterisk in every face, see the note at the top of the file.
338func starSVG(cx, cy, r, arm, outline int, ink, edge string) string {
339	pts := starPoly(cx, cy, r, arm)
340	return ufmt.Sprintf(
341		`<polygon points="%s" fill="%s" stroke="%s" stroke-width="%d" stroke-linejoin="round"/>`+
342			`<polygon points="%s" fill="%s"/>`,
343		pts, edge, edge, outline*2, pts, ink)
344}
345
346/*
347NameplateSVG composites the name, the asterisk and the expiry over
348whatever is already on the card.
349
350  face   "mono" | "serif" | "sans"
351  pos    "top" | "middle" | "bottom"
352  light  true  = white letters with a black border
353         false = black letters with a white border
354
355Only two ink pairs exist, and that is not a limitation waiting to be
356lifted. Both read on every background there is; a free colour choice does
357not, and mid-grey lettering on a mid-grey drawing is exactly what
358somebody will pick.
359*/
360// `now` is deliberately absent from the signature. Nothing here counts
361// down, so nothing here needs the clock — see the note on the expiry.
362func NameplateSVG(label, domain, face, pos string, light bool, expires int64) string {
363	lAdv := plateAdvance(label, face)
364	dAdv := plateAdvance(domain, face)
365	size := plateSize(lAdv+dAdv, face)
366	_, xH, starR, starArm, outline := plateGeom(size)
367	fam := plateFamily(face)
368
369	ink, edge := "#ffffff", "#0b0d10"
370	if !light {
371		ink, edge = "#0b0d10", "#ffffff"
372	}
373
374	var baseY, dateY int
375	switch pos {
376	case "top":
377		baseY, dateY = 96, 356
378	case "bottom":
379		baseY, dateY = 322, 96
380	default:
381		baseY, dateY = 212, 356
382	}
383
384	/*
385		THE TWO HALVES ARE ANCHORED TO THE ASTERISK, AND THEIR WIDTHS ARE
386		DICTATED RATHER THAN GUESSED.
387
388		Two faults came out of the same root, and the same pair of
389		mechanisms closes both.
390
391		The GAP before the asterisk: the width was estimated, and the
392		estimate placed both the end of the label and the asterisk, so any
393		error between the two opened up as a hole.
394
395		The whole name sitting OFF CENTRE: the composition is centred on
396		x0, computed from that same estimate. Work it through and the
397		asterisk lands at 200 + (lw-dw)/2 when it needs to land at
398		200 + (trueLw-trueDw)/2. The error is half the difference of the
399		two errors — so a long narrow label against a short domain slid
400		right, and the reverse slid left. Exactly what the proof sheet
401		showed.
402
403		FIRST, the halves are anchored TO the asterisk — label right,
404		domain left — which hands the placement of the glyphs relative to
405		it back to the renderer. That kills the gap outright.
406
407		SECOND, and this is what makes the centring exact: textLength. SVG
408		lets an element declare its own advance width and the renderer
409		fits the text to it. So lw and dw stop being predictions and
410		become instructions: whatever font the viewer actually resolved,
411		the label occupies exactly lw and the domain exactly dw, and the
412		arithmetic that centres them is true by construction rather than
413		by luck.
414
415		lengthAdjust="spacing", never "spacingAndGlyphs" — it moves the
416		letters apart, it does not squeeze their shapes. With the real
417		width table above, the adjustment on the usual fonts is nil, and
418		on a substituted one it is a fraction of a pixel of tracking.
419		Distorting glyphs to save a rounding error would be a bad trade.
420
421		The one case left is a single-character half on a substituted
422		font: there are no inter-glyph gaps, so "spacing" has nothing to
423		adjust. The table is exact for one character by definition, so
424		this only misses when the font ALSO substituted, and then by a few
425		pixels. Left deliberately — the alternative distorts the glyph.
426	*/
427	lw := lAdv * size / 1000
428	dw := dAdv * size / 1000
429	starCol := size * 86 / 100
430	total := lw + starCol + dw
431	x0 := (plateW - total) / 2
432
433	starCX := x0 + lw + starCol/2
434	// Centred on the x-height, so the asterisk sits in the same optical
435	// band as the lowercase letters either side of it.
436	starCY := baseY - xH/2
437	labelX := starCX - starCol/2
438	domainX := starCX + starCol/2
439
440	var b strings.Builder
441
442	// One pass per layer. Border first, ink second, so the border sits
443	// behind the letter rather than eating half of it. textLength goes on
444	// every pass, or the two would disagree about where the letters are.
445	const half = `<text x="%d" y="%d" text-anchor="%s" textLength="%d" lengthAdjust="spacing"` +
446		` fill="none" stroke="%s" stroke-width="%d" stroke-linejoin="round">%s</text>`
447	const fill = `<text x="%d" y="%d" text-anchor="%s" textLength="%d" lengthAdjust="spacing"` +
448		` fill="%s">%s</text>`
449
450	b.WriteString(ufmt.Sprintf(`<g font-family="%s" font-size="%d" font-weight="bold">`, fam, size))
451	b.WriteString(ufmt.Sprintf(half, labelX, baseY, "end", lw, edge, outline*2, esc(label)))
452	b.WriteString(ufmt.Sprintf(half, domainX, baseY, "start", dw, edge, outline*2, esc(domain)))
453	b.WriteString(ufmt.Sprintf(fill, labelX, baseY, "end", lw, ink, esc(label)))
454	b.WriteString(ufmt.Sprintf(fill, domainX, baseY, "start", dw, ink, esc(domain)))
455	b.WriteString(`</g>`)
456
457	b.WriteString(starSVG(starCX, starCY, starR, starArm, outline, ink, edge))
458
459	/*
460		THE EXPIRY, ON EVERY CARD, AS A DATE AND NOTHING ELSE.
461
462		The ordinary card alternates a date with a countdown and carries a
463		long note about which one a still frame must land on. A skinned
464		card skips the problem: a marketplace thumbnail of "412 days left"
465		is a number that was true once and is a lie afterwards, while a
466		date is true forever. One sentence, always the same one.
467	*/
468	dateSize := 15
469	// Same format the ordinary card already uses for an expiry, so a
470	// skinned card and a plain one do not disagree about how a date looks.
471	txt := "EXPIRES ON " + strings.ToUpper(time.Unix(expires, 0).UTC().Format("Jan 2, 2006"))
472	b.WriteString(ufmt.Sprintf(
473		`<g font-family="monospace" font-size="%d" font-weight="bold" text-anchor="middle">`+
474			`<text x="200" y="%d" fill="none" stroke="%s" stroke-width="4" stroke-linejoin="round">%s</text>`+
475			`<text x="200" y="%d" fill="%s">%s</text></g>`,
476		dateSize, dateY, edge, txt, dateY, ink, txt))
477
478	return b.String()
479}