Skip to content

Commit 4a0728e

Browse files
amtiYoclaude
andcommitted
feat(stats): show the statistics as a turning Earth with the numbers
The statistics panel was a few lines of text. It now turns into a 24-second loop: the Earth turns once under the person's pins of every day, beside their points, days played, streak, typical miss, best day, world places and the strip of their last 14 days. Only the Earth moves. A picture takes seconds to draw and the numbers change a few times a day at most, so it is drawn once per fingerprint of what it shows. Telegram's file_id is kept per person and language in profile_card (migration 009), and the pictures of everyone who answered in the last two days are drawn ahead every 10 minutes, one at a time on two workers. A press with no picture ready shows the text at once and puts the picture in when it is drawn. editMessageMedia turns the text panel into the animation. tg has no method that uploads one in place of a message and returns it, so the upload lives in internal/telegram beside tg and keeps the token out of its errors. A picture cannot turn back into text, so Back sends the panel again. Buttons under an animation act only on a statistics picture this process put there, so a forged Close cannot delete the morning post or the result. anim.Name takes over the posts' name helper, which the picture shares. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f42a820 commit 4a0728e

25 files changed

Lines changed: 1692 additions & 45 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ See [`docs/versioning.md`](docs/versioning.md) for what the numbers mean and
1111

1212
## Unreleased
1313

14+
### Added
15+
16+
- The statistics panel shows a picture above the numbers: the Earth turns
17+
slowly under the person's pins of every day, beside their points, days
18+
played, streak, typical miss, best day, place in the world and the strip
19+
of their last days. It is drawn once for the numbers it shows and kept by
20+
Telegram's `file_id`; pictures are drawn ahead in the background for
21+
everyone who played in the last days, so the button rarely waits. Back from
22+
the picture sends the panel again, since a picture cannot turn into text.
23+
Migration `009_profile_card.sql` keeps the `file_id`s.
24+
1425
## v0.0.1-alpha.5 - 2026-10-23
1526

1627
Sharing the result from the Mini App now forwards the animation the chat

‎cmd/maply-anim/main.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ func scene(name, pano string) (anim.Scene, error) {
7272
case "world":
7373
guesses, top := worldSample(zanzibar, 348)
7474
return anim.NewWorld(1, zanzibar, guesses, top, "км", "ru"), nil
75+
case "stats", "stats-de":
76+
return statsSample(strings.TrimPrefix(strings.TrimPrefix(name, "stats"), "-") + map[bool]string{true: "ru"}[name == "stats"]), nil
7577
case "intrigue":
7678
return anim.NewIntrigue(1, 3, 40, "22 °C", 16+40.0/60, "20:00", "ru"), nil
7779
case "results":
@@ -189,3 +191,36 @@ func worldSample(place anim.Place, n int) ([]anim.Guess, [3]*anim.Winner) {
189191
}
190192
return guesses, top
191193
}
194+
195+
// statsSample is a made-up person after thirty days: pins wherever they
196+
// guessed, the best day in gold, two days missed. The texts come in Russian or
197+
// in German, the longest the labels get.
198+
func statsSample(lang string) *anim.Profile {
199+
labels := map[string][9]string{
200+
"ru": {"статистика", "очков за 30 дней", "дней сыграно", "дней подряд", "средняя ошибка", "лучший день, #17", "место в мире вчера", "лучшее место", "последние 14 дней"},
201+
"de": {"Statistik", "Punkte in 30 Tagen", "Tage gespielt", "Tage in Folge", "durchschnittlicher Fehler", "bester Tag, #17", "Weltrang gestern", "bester Weltrang", "die letzten 14 Tage"},
202+
}[lang]
203+
unit := map[string]string{"ru": "км", "de": "km"}[lang]
204+
r := rand.New(rand.NewPCG(5, 0))
205+
s := anim.NewProfile(lang)
206+
s.Title, s.Name = labels[0], "_amti"
207+
s.Total = anim.Tile{Value: "84\u00a0310", Label: labels[1]}
208+
s.Tiles = [6]anim.Tile{
209+
{Value: "28", Label: labels[2]},
210+
{Value: "5", Label: labels[3], Flame: true},
211+
{Value: "1\u00a0240 " + unit, Label: labels[4]},
212+
{Value: "4\u00a0820", Label: labels[5], Gold: true},
213+
{Value: "3 / 214", Label: labels[6]},
214+
{Value: "1", Label: labels[7], Gold: true},
215+
}
216+
for range 28 {
217+
lat, lon := -40+r.Float64()*100, -120+r.Float64()*270
218+
s.Pins = append(s.Pins, anim.Guess{Lat: lat, Lon: lon, Km: r.Float64() * 4000})
219+
}
220+
s.Pins[6] = anim.Guess{Lat: 31, Lon: 48, Medal: 1}
221+
for i := range 14 {
222+
s.Days = append(s.Days, anim.Day{No: 17 + i, Played: i != 4 && i != 8, Best: i == 0})
223+
}
224+
s.DaysLabel = labels[8]
225+
return s
226+
}

‎cmd/maply/main.go‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/FreshLabDev/maply/internal/db"
2323
"github.com/FreshLabDev/maply/internal/geo"
2424
"github.com/FreshLabDev/maply/internal/health"
25+
"github.com/FreshLabDev/maply/internal/profile"
2526
"github.com/FreshLabDev/maply/internal/round"
2627
"github.com/FreshLabDev/maply/internal/schedule"
2728
"github.com/FreshLabDev/maply/internal/telegram"
@@ -83,12 +84,30 @@ func run(log *slog.Logger) error {
8384
}
8485
log.Info("telegram preflight passed", "username", me.Username, "bot_api", tg.BotAPI)
8586
tgc := telegram.New(client)
86-
b := bot.New(store, tgc, log, bot.Options{
87+
tgc.Upload = &telegram.Uploader{Base: cfg.TelegramAPIBase, Token: cfg.TelegramBotToken}
88+
// ffmpeg encodes the animated posts and the statistics picture. Without
89+
// it posts go out as photos and text, and the statistics as text.
90+
var ffmpeg string
91+
if cfg.Animations {
92+
if path, err := exec.LookPath(cfg.FFmpeg); err != nil {
93+
log.Warn("ffmpeg not found, posts go out as photos and text", "ffmpeg", cfg.FFmpeg, "error", err)
94+
} else {
95+
ffmpeg = path
96+
log.Info("animated posts on", "ffmpeg", path)
97+
}
98+
}
99+
opts := bot.Options{
87100
Username: me.Username,
88101
Version: version,
89102
AppURL: cfg.AppURL,
90103
Zones: zones,
91-
})
104+
}
105+
if ffmpeg != "" {
106+
profiles := &profile.Service{Store: store, Zones: zones, FFmpeg: ffmpeg, Log: log}
107+
opts.Profiles = profiles
108+
go profiles.Run(ctx, 10*time.Minute)
109+
}
110+
b := bot.New(store, tgc, log, opts)
92111

93112
signer := auth.NewSigner(cfg.TelegramBotToken)
94113
// Without a renderer the result goes out with the place's photo and the
@@ -182,13 +201,8 @@ func run(log *slog.Logger) error {
182201
if cards != nil {
183202
sched.Cards = cards
184203
}
185-
if cfg.Animations {
186-
if path, err := exec.LookPath(cfg.FFmpeg); err != nil {
187-
log.Warn("ffmpeg not found, posts go out as photos and text", "ffmpeg", cfg.FFmpeg, "error", err)
188-
} else {
189-
sched.Anim = &schedule.Animator{FFmpeg: path}
190-
log.Info("animated posts on", "ffmpeg", path)
191-
}
204+
if ffmpeg != "" {
205+
sched.Anim = &schedule.Animator{FFmpeg: ffmpeg}
192206
}
193207
go func() {
194208
log.Info("scheduler starting")

‎docs/design.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ How the scheduler sends them:
136136
- names and places in scripts that Sofia Sans lacks (Arabic, Chinese, Japanese, emoji) are written in English or left out of the picture; the caption keeps them;
137137
- the world loop has no slot yet: the world's numbers come in the morning post's caption.
138138

139+
### Statistics picture
140+
141+
The statistics panel shows a 24-second loop above its text: the Earth turns once under the person's pins of every day (the best day as a gold pin), beside their points, days played, streak, typical miss (the median), best day, place in the latest final world ranking, best world place, and a strip of the last 14 days. Only the Earth moves. Labels come in the person's language and shrink to fit; a language whose script Sofia Sans lacks is drawn in English, as on the posts.
142+
143+
- The picture is named by a fingerprint of everything on it. Telegram's `file_id` for it is kept in `profile_card` per person and language, so a press with unchanged numbers draws nothing.
144+
- Every 10 minutes the bot draws, one at a time and with two workers, the missing pictures of everyone who answered in the last two days, in their own language. Numbers change when someone answers (the streak), at the reveal and when a day's world ranking becomes final.
145+
- A press with no picture ready shows the text at once and puts the picture in when it is drawn, if the panel still shows the statistics.
146+
- `editMessageMedia` turns the text panel into the animation. A picture cannot turn back into text, so Back sends the panel again and deletes the picture. Buttons under an animation act only on a statistics picture this process put there; a forged Close against the morning post or the result is ignored.
147+
139148
## Intrigue 16–18
140149

141150
One message at a random moment of the window. Rules:

‎internal/anim/anim_test.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,18 @@ func TestFramesComeInOrder(t *testing.T) {
243243
}
244244
}
245245
}
246+
247+
func TestNameKeepsWhatTheFacesWrite(t *testing.T) {
248+
for in, want := range map[string]string{
249+
"Аня": "Аня",
250+
" Lucía ": "Lucía",
251+
"李雷": "?",
252+
"Ann 🙂": "Ann",
253+
"": "?",
254+
"Christopher-Alexander Long": "Christopher-Ale…",
255+
} {
256+
if got := Name(in); got != want {
257+
t.Errorf("Name(%q) = %q, want %q", in, got, want)
258+
}
259+
}
260+
}

‎internal/anim/profile.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
package anim
3+
4+
import (
5+
"math"
6+
"slices"
7+
"strconv"
8+
9+
"github.com/go-text/typesetting/language"
10+
)
11+
12+
// Day is one of the last days on the strip under the numbers.
13+
type Day struct {
14+
No int // the day's number, #17
15+
Played bool // answered and scored
16+
Best bool // the person's best day
17+
}
18+
19+
// Tile is one number of a person's statistics with what it counts.
20+
type Tile struct {
21+
Value, Label string
22+
Flame bool // a streak: the flame goes before the number
23+
Gold bool // a first place or a record
24+
}
25+
26+
// Profile is the picture of a person's own statistics, shown above the text of
27+
// the statistics panel: their pins of every day on the globe, their points,
28+
// six numbers and the strip of their last days. The numbers stand still and
29+
// the Earth turns under the pins, once a loop, so the loop has no seam.
30+
// Every text shrinks to its room, since a label in German is twice as long
31+
// as in Russian.
32+
type Profile struct {
33+
Title string // "statistics"
34+
Name string
35+
Total Tile // the big number under the name
36+
Tiles [6]Tile // two rows of three
37+
Days []Day // the last days, oldest first
38+
DaysLabel string
39+
Pins []Guess // every day's answer; Medal 1 marks the best day
40+
Lang language.Language
41+
}
42+
43+
func NewProfile(lang string) *Profile { return &Profile{Lang: language.NewLanguage(lang)} }
44+
45+
func (s *Profile) Size() (int, int) { return 1200, 630 }
46+
func (s *Profile) Duration() float64 { return profileDur }
47+
48+
// One turn of the Earth, slow enough to read the numbers beside it.
49+
const profileDur = 24
50+
51+
func (s *Profile) Draw(c *Canvas, t float64) {
52+
c.Lang = s.Lang
53+
c.Fill(field)
54+
55+
// The globe of the person's pins, turned to where most of them are.
56+
const mx, my, mw, mh = 24, 24, 540, 582
57+
c.RoundRect(mx, my, mw, mh, 28, 28, deep, 1, nil)
58+
clip := c.clip
59+
clip.Clear()
60+
clip.RoundRect(mx, my, mw, mh, 28, 28)
61+
lat0, lon0 := 20.0, 15.0
62+
if len(s.Pins) > 0 {
63+
var x, y, z float64
64+
for _, p := range s.Pins {
65+
sl, cl := math.Sincos(p.Lat * rad)
66+
so, co := math.Sincos(p.Lon * rad)
67+
x, y, z = x+cl*co, y+cl*so, z+sl
68+
}
69+
lat0, lon0 = math.Atan2(z, math.Hypot(x, y))/rad, math.Atan2(y, x)/rad
70+
lat0 = max(-35, min(45, lat0))
71+
}
72+
g := newGlobeView(mx+mw/2, my+mh/2, 250, lat0*rad, (lon0-360*t/profileDur)*rad)
73+
g.x0, g.y0, g.x1, g.y1 = mx, my, mx+mw, my+mh
74+
c.Disc(g.cx, g.cy, g.R+2, field, 0.55, clip)
75+
c.mapDots(g, ripple{}, 0.5, clip)
76+
pins := slices.Clone(s.Pins)
77+
slices.SortStableFunc(pins, func(a, b Guess) int { return a.Medal - b.Medal })
78+
for _, p := range pins {
79+
x, y, z := g.at(p.Lat, p.Lon)
80+
if z < 0.05 {
81+
continue
82+
}
83+
a := smooth(0.05, 0.2, z)
84+
if p.Medal == 1 {
85+
c.Pin(x, y, 40, PinStyle{Fill: gold, Edge: deep, Hole: deep}, a, clip)
86+
continue
87+
}
88+
c.Disc(x, y, 7.5, deep, a, clip)
89+
c.Disc(x, y, 5.5, paper.Mix(gold, 1-smooth(200, 3000, p.Km)), a, clip)
90+
}
91+
92+
// The texts on the right.
93+
const x0, x1 = 600, 1176
94+
w := c.Text(Display, 52, "maply", x0, 70, Left, white, 1, nil)
95+
nw := c.Text(Body, c.fit(Body, 26, 18, s.Name, 220), s.Name, x1, 68, Right, white, 1, nil)
96+
c.Text(Display, c.fit(Display, 30, 20, s.Title, x1-nw-24-(x0+w+12)), s.Title, x0+w+12, 70, Left, ink, 1, nil)
97+
98+
vw := c.Text(Display, 104, s.Total.Value, x0, 182, Left, white, 1, nil)
99+
c.Text(BodyMedium, c.fit(BodyMedium, 22, 14, s.Total.Label, x1-x0-vw-16), s.Total.Label, x0+vw+16, 180, Left, ink, 1, nil)
100+
101+
const tw, th, gap = 184, 104, 12
102+
for i, tile := range s.Tiles {
103+
x, y := x0+float64(i%3)*(tw+gap), 210+float64(i/3)*(th+gap)
104+
c.RoundRect(x, y, tw, th, 18, 18, deep, 1, nil)
105+
col := white
106+
if tile.Gold {
107+
col = gold
108+
}
109+
tx := x + 18
110+
if tile.Flame {
111+
c.flame(tx+11, y+58, 34, 1)
112+
tx += 32
113+
}
114+
c.Text(Display, c.fit(Display, 50, 30, tile.Value, x+tw-14-tx), tile.Value, tx, y+58, Left, col, 1, nil)
115+
c.Text(BodyMedium, c.fit(BodyMedium, 18, 12, tile.Label, tw-30), tile.Label, x+18, y+88, Left, ink, 1, nil)
116+
}
117+
118+
// The last days: a full disc for a day played, a ring for a day missed,
119+
// gold for the best one, the day's number under each.
120+
if n := len(s.Days); n > 0 {
121+
const cy, r = 548, 14
122+
c.Text(BodyMedium, c.fit(BodyMedium, 18, 12, s.DaysLabel, x1-x0), s.DaysLabel, x0, 508, Left, ink, 1, nil)
123+
step := (x1 - x0 - 2*r) / float64(max(1, n-1))
124+
for i, d := range s.Days {
125+
x := x0 + r + float64(i)*step
126+
col := ink
127+
switch {
128+
case d.Best:
129+
c.Disc(x, cy, r, gold, 1, nil)
130+
col = gold
131+
case d.Played:
132+
c.Disc(x, cy, r, paper, 1, nil)
133+
default:
134+
c.Ring(x, cy, r-1.5, 3, tint, 1, nil)
135+
}
136+
c.Text(BodyMedium, 14, "#"+strconv.Itoa(d.No), x, cy+r+22, Center, col, 1, nil)
137+
}
138+
}
139+
}
140+
141+
// fit is the size, from size down to least, at which s fits in width.
142+
func (c *Canvas) fit(f *Font, size, least float64, s string, width float64) float64 {
143+
for size > least && c.Measure(f, size, s) > width {
144+
size--
145+
}
146+
return size
147+
}

‎internal/anim/text.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"image"
88
"image/draw"
99
"math"
10+
"strings"
1011
"sync"
1112

1213
"github.com/go-text/typesetting/di"
@@ -272,3 +273,22 @@ func Covered(r rune) bool {
272273
}
273274
return true
274275
}
276+
277+
// Name is a person's first name as a post writes it: the letters the faces
278+
// have, sixteen at most, or "?" when none is left.
279+
func Name(first string) string {
280+
name := strings.Map(func(r rune) rune {
281+
if Covered(r) {
282+
return r
283+
}
284+
return -1
285+
}, strings.TrimSpace(first))
286+
name = strings.Join(strings.Fields(name), " ")
287+
if r := []rune(name); len(r) > 16 {
288+
name = string(r[:15]) + "…"
289+
}
290+
if name == "" || !Covers(name) {
291+
return "?"
292+
}
293+
return name
294+
}

‎internal/bot/bot.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type Telegram interface {
4747
SetMyCommandsForScope(ctx context.Context, commands []tg.BotCommand, scope *tg.BotCommandScope) error
4848
SendText(ctx context.Context, chatID int64, text string, markup telegram.Markup) (tg.Message, error)
4949
EditText(ctx context.Context, chatID, messageID int64, text string, markup telegram.Markup) error
50+
EditAnimation(ctx context.Context, chatID, messageID int64, a telegram.Animation, caption string, markup telegram.Markup) (tg.Message, error)
5051
DeleteMessage(ctx context.Context, chatID, messageID int64) error
5152
AnswerCallbackQuery(ctx context.Context, callbackID, text string) error
5253
AnswerCallbackQueryAlert(ctx context.Context, callbackID, text string) error
@@ -65,6 +66,9 @@ type Options struct {
6566
Zones round.Zones
6667
// Now is time.Now unless a test sets it.
6768
Now func() time.Time
69+
// Profiles draws the statistics picture; nil shows the statistics as
70+
// text only.
71+
Profiles Profiles
6872
}
6973

7074
type Bot struct {
@@ -74,6 +78,7 @@ type Bot struct {
7478
opts Options
7579
lastPoll atomic.Int64
7680
initialized atomic.Bool
81+
screens screens
7782
}
7883

7984
func New(store Store, client Telegram, log *slog.Logger, opts Options) *Bot {

‎internal/bot/bot_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ type fakeTelegram struct {
162162
alerts []string
163163
answers int
164164
members map[int64]string
165+
anims []sent
166+
animated chan struct{} // told after each animation, when set
167+
}
168+
169+
func (f *fakeTelegram) EditAnimation(_ context.Context, chatID, messageID int64, a telegram.Animation, caption string, markup telegram.Markup) (tg.Message, error) {
170+
f.anims = append(f.anims, sent{chatID, messageID, caption, markup})
171+
if f.animated != nil {
172+
f.animated <- struct{}{}
173+
}
174+
id := a.FileID
175+
if id == "" {
176+
id = "uploaded"
177+
}
178+
return tg.Message{MessageID: messageID, Chat: tg.Chat{ID: chatID}, Caption: caption, Document: &tg.Document{FileID: id}}, nil
165179
}
166180

167181
func (f *fakeTelegram) DeleteWebhook(context.Context) error { return nil }

0 commit comments

Comments
 (0)