sparkline.gno
3.53 Kb · 114 lines
1// Package sparkline renders a numeric series as one line of Unicode block
2// characters — ▁▂▃▄▅▆▇█ — so a realm's Render can show a trend inline, with no
3// image, no chart library and no client-side code.
4//
5// Everything is integer math, deliberately. A realm's Render must produce
6// byte-identical output on every validating node, and floating point is the
7// usual way that quietly stops being true. Scaling here is integer division
8// with the rounding rule stated below, so a given series always yields exactly
9// the same runes.
10//
11// Rendering is O(len(values)) and allocates one builder; a realm should bound
12// what it feeds in (slice to a window) rather than sparking an unbounded,
13// user-grown slice.
14//
15// A live demo of this package is at
16// [r/moul/x/daily/sparklinedemo](/r/moul/x/daily/sparklinedemo/v0).
17package sparkline
18
19import "strings"
20
21// Levels is the ramp, lowest to highest. A string constant rather than a
22// []rune so callers cannot mutate the ramp out from under other realms.
23const Levels = "▁▂▃▄▅▆▇█"
24
25// Steps is how many levels the ramp has.
26const Steps = 8
27
28// maxInt is the largest int, used to keep the scaling multiply from wrapping.
29const maxInt = int(^uint(0) >> 1)
30
31// Ints renders values scaled between their own smallest and largest element.
32// An empty series renders as the empty string.
33func Ints(values []int) string {
34 lo, hi, ok := Bounds(values)
35 if !ok {
36 return ""
37 }
38 return Scaled(values, lo, hi)
39}
40
41// Scaled renders values against an explicit [lo, hi] window. Values outside it
42// clamp to the ends rather than erroring: a window is chosen for readability
43// (0..100 for a percentage, say), and one outlier should not be able to break a
44// realm's Render.
45//
46// A window with hi <= lo is treated as flat; see Level.
47func Scaled(values []int, lo, hi int) string {
48 if len(values) == 0 {
49 return ""
50 }
51 ramp := []rune(Levels)
52 var b strings.Builder
53 for _, v := range values {
54 b.WriteRune(ramp[Level(v, lo, hi)])
55 }
56 return b.String()
57}
58
59// Level maps v within [lo, hi] to a ramp index in [0, Steps-1].
60//
61// Rounding is floor, so only a value at hi itself reaches the top of the ramp;
62// everything below it rounds down. That makes the maximum visually distinct,
63// which is what a reader is looking for in a sparkline.
64//
65// A flat window (hi <= lo) maps everything to the MIDDLE of the ramp, not the
66// bottom: a series that sits unchanged at 1000 carries no shape, but drawing it
67// along the floor would read as "zero", which is a different and wrong claim.
68func Level(v, lo, hi int) int {
69 if hi <= lo {
70 return Steps / 2
71 }
72 if v <= lo {
73 return 0
74 }
75 if v >= hi {
76 return Steps - 1
77 }
78
79 span, d, top := hi-lo, v-lo, Steps-1
80 if span <= 0 {
81 // hi-lo wrapped: the window is wider than an int can express, so there
82 // is no usable ramp. Split at the midpoint, computed without overflowing.
83 if v < lo/2+hi/2 {
84 return 0
85 }
86 return Steps - 1
87 }
88 // d*top would wrap on an extreme span; halve both until it cannot. The
89 // chosen bucket is unchanged for any realistic series, and off by at most
90 // one level for the pathological ones this protects against.
91 for span > maxInt/top {
92 span >>= 1
93 d >>= 1
94 }
95 return d * top / span
96}
97
98// Bounds returns the smallest and largest value in values. ok is false when
99// values is empty, in which case lo and hi are zero.
100func Bounds(values []int) (lo, hi int, ok bool) {
101 if len(values) == 0 {
102 return 0, 0, false
103 }
104 lo, hi = values[0], values[0]
105 for _, v := range values[1:] {
106 if v < lo {
107 lo = v
108 }
109 if v > hi {
110 hi = v
111 }
112 }
113 return lo, hi, true
114}