1// Package sparkline renders a numeric series as one line of Unicode block2// characters — ▁▂▃▄▅▆▇█ — so a realm's Render can show a trend inline, with no3// image, no chart library and no client-side code.4//5// Everything is integer math, deliberately. A realm's Render must produce6// byte-identical output on every validating node, and floating point is the7// usual way that quietly stops being true. Scaling here is integer division8// with the rounding rule stated below, so a given series always yields exactly9// the same runes.10//11// Rendering is O(len(values)) and allocates one builder; a realm should bound12// 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 at16// [r/moul/x/daily/sparklinedemo](/r/moul/x/daily/sparklinedemo/v0).17package sparkline1819import "strings"2021// Levels is the ramp, lowest to highest. A string constant rather than a22// []rune so callers cannot mutate the ramp out from under other realms.23const Levels = "▁▂▃▄▅▆▇█"2425// Steps is how many levels the ramp has.26const Steps = 82728// maxInt is the largest int, used to keep the scaling multiply from wrapping.29const maxInt = int(^uint(0) >> 1)3031// 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}4041// Scaled renders values against an explicit [lo, hi] window. Values outside it42// clamp to the ends rather than erroring: a window is chosen for readability43// (0..100 for a percentage, say), and one outlier should not be able to break a44// 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.Builder53 for _, v := range values {54 b.WriteRune(ramp[Level(v, lo, hi)])55 }56 return b.String()57}5859// 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 the66// bottom: a series that sits unchanged at 1000 carries no shape, but drawing it67// 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 / 271 }72 if v <= lo {73 return 074 }75 if v >= hi {76 return Steps - 177 }7879 span, d, top := hi-lo, v-lo, Steps-180 if span <= 0 {81 // hi-lo wrapped: the window is wider than an int can express, so there82 // is no usable ramp. Split at the midpoint, computed without overflowing.83 if v < lo/2+hi/2 {84 return 085 }86 return Steps - 187 }88 // d*top would wrap on an extreme span; halve both until it cannot. The89 // chosen bucket is unchanged for any realistic series, and off by at most90 // one level for the pathological ones this protects against.91 for span > maxInt/top {92 span >>= 193 d >>= 194 }95 return d * top / span96}9798// Bounds returns the smallest and largest value in values. ok is false when99// 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, false103 }104 lo, hi = values[0], values[0]105 for _, v := range values[1:] {106 if v < lo {107 lo = v108 }109 if v > hi {110 hi = v111 }112 }113 return lo, hi, true114}115Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.