geohashdemo.gno
7.72 Kb · 199 lines
1// Package geohashdemo is a small gnoweb demo of the
2// [p/moul/x/daily/geohash](/p/moul/x/daily/geohash/v0) library: it shows a
3// point encoded at growing precision, the box a hash actually names, and the
4// eight cells around one, including the wrap at the antimeridian.
5//
6// It contains no geohash logic of its own. Stateless and read-only, so Render
7// is fully deterministic and takes nothing from a caller.
8package geohashdemo
9
10import (
11 "strconv"
12 "strings"
13
14 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/geohash/v0"
15)
16
17// The three fixed points the demo encodes. Coordinates only, so nothing here
18// depends on the chain, the clock or the caller.
19const (
20 jutlandLat, jutlandLon = 57.64911, 10.40744
21 leonLat, leonLon = 42.6, -5.6
22 sampleHash = "ezs42"
23)
24
25// Render renders the demo for gnoweb.
26//
27// Render("") / Render("/") -> a point at growing precision
28// Render("/cell") -> the box a hash names
29// Render("/neighbors") -> the eight cells around one, and the wrap
30func Render(path string) string {
31 var b strings.Builder
32 b.WriteString("# Geohash\n\n")
33 b.WriteString("Turning a latitude and longitude into a short base32 string, ")
34 b.WriteString("demoing the [`p/moul/x/daily/geohash`](/p/moul/x/daily/geohash/v0) library.\n\n")
35
36 switch parseArg(path) {
37 case "cell":
38 b.WriteString(renderCell())
39 case "neighbors":
40 b.WriteString(renderNeighbors())
41 default:
42 b.WriteString(renderPrecision())
43 }
44 return b.String()
45}
46
47// renderPrecision shows one point at every length, which is the property that
48// makes a geohash worth storing: the prefix is the same place, zoomed out.
49func renderPrecision() string {
50 var b strings.Builder
51 b.WriteString("## One point, at growing precision\n\n")
52 b.WriteString("Jutland, Denmark, at ")
53 b.WriteString(coord(jutlandLat, jutlandLon))
54 b.WriteString(":\n\n```\n")
55 for p := 1; p <= 11; p++ {
56 h, err := geohash.Encode(jutlandLat, jutlandLon, p)
57 if err != nil {
58 return b.String() + "```\n_unexpected error: " + err.Error() + "_\n"
59 }
60 b.WriteString(pad(strconv.Itoa(p), 2))
61 b.WriteString(" ")
62 b.WriteString(h)
63 b.WriteString("\n")
64 }
65 b.WriteString("```\n\n")
66 b.WriteString("Every character adds five bits and every prefix is itself a valid ")
67 b.WriteString("geohash of the same place, so truncating one zooms out. Two hashes ")
68 b.WriteString("sharing a long prefix are close together, which is what lets an avl ")
69 b.WriteString("tree keyed this way answer \"everything near here\" with a range scan ")
70 b.WriteString("and no geometry at all.\n\n")
71 b.WriteString("Two more places, at the length each one needs:\n\n")
72 b.WriteString(bullet("Null Island", 0, 0, 5))
73 b.WriteString(bullet("Leon, Spain", leonLat, leonLon, 5))
74 b.WriteString(bullet("Jutland, Denmark", jutlandLat, jutlandLon, 6))
75 b.WriteString("\n> Next: [`/cell`](/r/moul/x/daily/geohashdemo/v0:cell) for the box a ")
76 b.WriteString("hash names, [`/neighbors`](/r/moul/x/daily/geohashdemo/v0:neighbors) ")
77 b.WriteString("for the eight cells around one.\n")
78 return b.String()
79}
80
81// renderCell shows that a geohash is a box and not a point, which is the thing
82// most callers get wrong about it.
83func renderCell() string {
84 var b strings.Builder
85 box, err := geohash.DecodeBox(sampleHash)
86 if err != nil {
87 return "_unexpected error: " + err.Error() + "_\n"
88 }
89 b.WriteString("## The box `" + sampleHash + "` names\n\n")
90 b.WriteString("A geohash is a box, never a point, and the shorter the hash the bigger ")
91 b.WriteString("the box.\n\n```\n")
92 b.WriteString("latitude " + num(box.MinLat) + " .. " + num(box.MaxLat) + "\n")
93 b.WriteString("longitude " + num(box.MinLon) + " .. " + num(box.MaxLon) + "\n")
94 b.WriteString("size " + num(box.HeightDeg()) + " by " + num(box.WidthDeg()) + " degrees\n")
95 b.WriteString("```\n\n")
96 lat, lon := box.Center()
97 b.WriteString("`Decode` reports the centre, " + coord(lat, lon) + ", because the centre ")
98 b.WriteString("is the best single point a box can give. The coordinates that produced ")
99 b.WriteString("the hash, " + coord(leonLat, leonLon) + ", are inside it: ")
100 b.WriteString(yesNo(box.Contains(leonLat, leonLon)) + ".\n\n")
101 b.WriteString("Null Island is not: " + yesNo(box.Contains(0, 0)) + ".\n\n")
102 b.WriteString("> Next: [`/neighbors`](/r/moul/x/daily/geohashdemo/v0:neighbors).\n")
103 return b.String()
104}
105
106// renderNeighbors shows the eight cells around one, and the wrap that the
107// classic lookup-table implementations do not do.
108func renderNeighbors() string {
109 var b strings.Builder
110 b.WriteString("## The eight cells around `" + sampleHash + "`\n\n```\n")
111 // The grid read as a map: north at the top, east on the right. The centre
112 // is the hash itself, so it is not a direction.
113 rows := [3][]geohash.Direction{
114 {geohash.NorthWest, geohash.North, geohash.NorthEast},
115 {geohash.West, geohash.East},
116 {geohash.SouthWest, geohash.South, geohash.SouthEast},
117 }
118 for i, row := range rows {
119 cells := make([]string, 0, 3)
120 for _, d := range row {
121 n, err := geohash.Neighbor(sampleHash, d)
122 if err != nil {
123 return b.String() + "```\n_unexpected error: " + err.Error() + "_\n"
124 }
125 cells = append(cells, n)
126 }
127 if i == 1 {
128 // Put the hash itself back in the middle of the middle row.
129 cells = []string{cells[0], sampleHash, cells[1]}
130 }
131 b.WriteString(strings.Join(cells, " ") + "\n")
132 }
133 b.WriteString("```\n\n")
134 b.WriteString("Each one is computed by walking exactly one cell from the centre and ")
135 b.WriteString("re-encoding. Every bound is an exact binary fraction, so the step lands ")
136 b.WriteString("on another centre rather than near one, and stepping back is the identity.\n\n")
137 b.WriteString("### The antimeridian is not an edge of the world\n\n")
138 east, err := geohash.Neighbor("z", geohash.East)
139 if err != nil {
140 return b.String() + "_unexpected error: " + err.Error() + "_\n"
141 }
142 b.WriteString("`z` is the top right corner cell of the world. The cell east of it is ")
143 b.WriteString("`" + east + "`, the top left one, because longitude wraps. The classic ")
144 b.WriteString("lookup-table implementations report nothing there, which is a property ")
145 b.WriteString("of the tables and not of the globe.\n\n")
146 around, err := geohash.Neighbors("z")
147 if err != nil {
148 return b.String() + "_unexpected error: " + err.Error() + "_\n"
149 }
150 b.WriteString("Latitude genuinely stops, though, so `z` has no northern neighbour and ")
151 b.WriteString("`Neighbors` returns " + strconv.Itoa(len(around)) + " cells rather than eight: ")
152 b.WriteString("`" + strings.Join(around, "`, `") + "`.\n\n")
153 b.WriteString("> Back to [the overview](/r/moul/x/daily/geohashdemo/v0).\n")
154 return b.String()
155}
156
157// bullet renders one named place as a list item.
158func bullet(name string, lat, lon float64, precision int) string {
159 h, err := geohash.Encode(lat, lon, precision)
160 if err != nil {
161 return "- " + name + ": _unexpected error: " + err.Error() + "_\n"
162 }
163 return "- " + name + " (" + coord(lat, lon) + "): `" + h + "`\n"
164}
165
166// coord formats a latitude and longitude pair.
167func coord(lat, lon float64) string { return num(lat) + ", " + num(lon) }
168
169// num formats a float with no trailing zeros. Every value this demo prints is
170// an exact binary fraction, so the shortest round-trip form is also the exact
171// one.
172func num(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
173
174// yesNo renders a containment answer as a sentence fragment.
175func yesNo(ok bool) string {
176 if ok {
177 return "yes"
178 }
179 return "no"
180}
181
182// pad right-aligns a short string to width n, so the precision column lines up
183// inside the code block.
184func pad(s string, n int) string {
185 for len(s) < n {
186 s = " " + s
187 }
188 return s
189}
190
191// parseArg extracts the first path segment.
192func parseArg(path string) string {
193 s := strings.TrimSpace(path)
194 s = strings.TrimPrefix(s, "/")
195 if i := strings.IndexByte(s, '/'); i >= 0 {
196 s = s[:i]
197 }
198 return s
199}