repos
/ orchard main

orchard

mirror

Every 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

5.6 KB · 190 lines · Go Raw History
  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}