orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "fmt"
5 "math"
6 "strconv"
7 "strings"
8)
9
10// Sparkline geometry. The SVG is drawn with preserveAspectRatio="none" so these
11// are a coordinate space rather than pixels, and the card decides the real size
12// in CSS.
13const (
14 sparkWidth = 100.0
15 sparkHeight = 32.0
16
17 // Half a stroke of room at the top and bottom, so a day that closes at its
18 // own high does not get its line clipped in half by the viewBox edge.
19 sparkPad = 1.5
20)
21
22// Spark is one sparkline as the template writes it out: a path for the line, a
23// closed path for the fill under it, and the y of the previous close so the
24// card can rule a line across at the level the day is measured from. This is
25// how Yahoo draws it, and the reference line is what makes the shape mean
26// anything on its own.
27type Spark struct {
28 Line string `json:"line"`
29 Area string `json:"area"`
30 Baseline float64 `json:"baseline"`
31 HasBase bool `json:"has_base"`
32 Points int `json:"points"`
33
34 // How far across the box the data reaches, and whether that is short of the
35 // end. The axis is the whole session rather than the bars that have printed
36 // so far, so ten minutes after the open the line covers a fortieth of the
37 // card instead of all of it. Yahoo and every other intraday sparkline draw
38 // it this way, and stretching five bars across a full card is a lie about
39 // how much of the day has happened.
40 Span float64 `json:"span"`
41 Partial bool `json:"partial"`
42
43 // A card whose market shut while the rest of the strip kept trading. The VIX
44 // stops at the bell every night with seven cards beside it still moving, so
45 // its rule at the last print has to say closed rather than say now.
46 Closed bool `json:"closed"`
47}
48
49// buildSpark scales closes into the viewBox. previous is included in the range
50// so the baseline is always drawn inside the box rather than off the top of a
51// day that only went up.
52//
53// times are the unix seconds of each close and axis is the session they are
54// drawn against. Either being absent falls back to spacing the points evenly
55// across the full width, which is right for anything that never closes.
56func buildSpark(closes []float64, times []int64, previous float64, axis tradingAxis) Spark {
57 if len(closes) < 2 {
58 return Spark{}
59 }
60
61 lo, hi := closes[0], closes[0]
62 for _, c := range closes {
63 lo, hi = math.Min(lo, c), math.Max(hi, c)
64 }
65 hasBase := previous > 0
66 if hasBase {
67 lo, hi = math.Min(lo, previous), math.Max(hi, previous)
68 }
69
70 // A flat day has no range to scale against, so give it one and let the line
71 // sit in the middle instead of dividing by zero.
72 if hi-lo < 1e-9 {
73 hi = lo + 1
74 }
75
76 scaleY := func(v float64) float64 {
77 frac := (v - lo) / (hi - lo)
78 // SVG y grows downward, so the high price is the small number.
79 return sparkPad + (1-frac)*(sparkHeight-2*sparkPad)
80 }
81
82 xs := scaleX(closes, times, axis)
83
84 var line strings.Builder
85 for i, c := range closes {
86 if i == 0 {
87 line.WriteString("M")
88 } else {
89 line.WriteString("L")
90 }
91 line.WriteString(num(xs[i]))
92 line.WriteString(",")
93 line.WriteString(num(scaleY(c)))
94 if i < len(closes)-1 {
95 line.WriteString(" ")
96 }
97 }
98
99 // The fill runs from the line down to the floor of the box, which is the
100 // shape Yahoo uses. Filling to the baseline instead would need two clipped
101 // halves to colour the above and below parts differently, and at 32 units
102 // tall that reads as noise.
103 first, last := xs[0], xs[len(xs)-1]
104 area := line.String() +
105 fmt.Sprintf(" L%s,%s L%s,%s Z", num(last), num(sparkHeight), num(first), num(sparkHeight))
106
107 s := Spark{
108 Line: line.String(),
109 Area: area,
110 Points: len(closes),
111 Span: round2(last),
112 // A hair short of the end still counts as finished, since the last bar
113 // of a session prints at its start and never at its close.
114 Partial: last < sparkWidth-1,
115 }
116 if hasBase {
117 s.Baseline = round2(scaleY(previous))
118 s.HasBase = true
119 }
120 return s
121}
122
123// scaleX places each close along the session rather than along the list. A
124// point outside the axis is clamped rather than dropped, since an extended
125// hours bar is still a price and pushing it off the box would lose it.
126func scaleX(closes []float64, times []int64, axis tradingAxis) []float64 {
127 xs := make([]float64, len(closes))
128
129 span := axis.end - axis.start
130 if !axis.ok || span <= 0 || len(times) != len(closes) {
131 step := sparkWidth / float64(len(closes)-1)
132 for i := range closes {
133 xs[i] = float64(i) * step
134 }
135 return xs
136 }
137
138 for i, t := range times {
139 frac := float64(t-axis.start) / float64(span)
140 xs[i] = math.Min(sparkWidth, math.Max(0, frac*sparkWidth))
141 }
142 return xs
143}
144
145// num keeps the path short. Two decimals in a 100 by 32 box is well under a
146// rendered pixel and takes a third of the bytes that %g would.
147func num(v float64) string {
148 return strconv.FormatFloat(round2(v), 'f', -1, 64)
149}
150
151func round2(v float64) float64 {
152 return math.Round(v*100) / 100
153}
154
155// formatNumber writes a price the way the rest of the page reads it: grouped
156// thousands, fixed decimals.
157func formatNumber(v float64, decimals int) string {
158 s := strconv.FormatFloat(v, 'f', decimals, 64)
159
160 neg := strings.HasPrefix(s, "-")
161 s = strings.TrimPrefix(s, "-")
162
163 whole, frac, _ := strings.Cut(s, ".")
164 var b strings.Builder
165 for i, r := range whole {
166 if i > 0 && (len(whole)-i)%3 == 0 {
167 b.WriteByte(',')
168 }
169 b.WriteRune(r)
170 }
171 out := b.String()
172 if frac != "" {
173 out += "." + frac
174 }
175 if neg {
176 out = "-" + out
177 }
178 return out
179}
180
181// signed always carries the sign, because a change of zero read as "0.00" and a
182// change of +0.00 mean different things on a card whose whole job is direction.
183func signed(v float64, decimals int) string {
184 s := formatNumber(math.Abs(v), decimals)
185 if v < 0 {
186 return "-" + s
187 }
188 return "+" + s
189}