Auf der Suche nach einem Verfahren, Geodaten ins Terminal zu malen, gerät Mike Schilli in die wunderbare Welt der Landkartenprojektionen.
Mit dem Wanderwegfinder aus der vorletzten Ausgabe [1], der auf der Kommandozeile aus einer Sammlung von GPX-Dateien mit Track-Punkten von Touren die passende heraussucht, kam mir die Idee, gleich die Konturen gefundener Touren ins Terminalfenster zu zeichnen. Allerdings stehen in einer GPX-Datei, generiert von einer App wie Komoot oder einem Garmin-Tracker, erst einmal nur Geokoordinaten. Sie beziehen sich auf Orte auf der Erdkugel, durch die der Wanderweg führt (Abbildung 1).
Diese Geopunkte auf einer Kugeloberfläche gilt es nun in ein zweidimensionales Koordinatensystem zu überführen, damit sie auf einer flachen Landkarte möglichst naturgetreu erscheinen. Dieses Problem ist schon seit Jahrhunderten gelöst: Jede Landkarte, egal ob aus Papier oder digital, basiert auf dem gedanklichen Sprung, Geopunkte auf der Erdkugel, die als geografische Breite und Länge vorliegen, in ein XY-Koordinatensystem in einer Ebene zu projizieren.
Zurück ins Jahr 1569
Schon 1569 machte sich der Kartograf Gerhard Mercator aus Flandern daran, die von Seefahrern ermittelten Kugeldaten auszuflachen. Hierzu projizierte er kurzerhand die Kugeloberfläche der Erde auf einen herumgewickelten Zylinder (Abbildung 2), dessen Mantel sich wiederum leicht abrollen und als flache Landkarte betrachten lässt. Allerdings stimmt die Projektion (bei einem senkrecht stehenden Wickelzylinder) nur am Äquator zu 100 Prozent und führt nördlich oder südlich davon zu Verzerrungen, bis schließlich an den Polregionen grotesk aufgeblähte Landmassen entstehen.

Abbildung 2: Für die Mercator-Projektion um die Erdkugel gewickelter Zylinder. Quelle: Wikipedia, CC BY-SA 4.0
Für die Projektion meiner Wanderwege ins Terminal kann allerdings ein noch simplerer Mechanismus ran: Er interpretiert lediglich die numerischen Gradzahlen für geografische Länge und Breite als linear mit der wahren Entfernung anwachsende Werte. Das Verfahren interpretiert also eine von Längen- und Breitengraden beschränkte Fläche auf der Kugeloberfläche als simples Rechteck, obwohl sich das wahre Objekt im Raum biegt. Das stimmt zwar nicht genau, kommt aber bei relativ kleinen Flächen im Vergleich zum gigantischen Kugelradius der Erde der Wahrheit sehr nahe.
Minima und Maxima
Erstreckt sich zum Beispiel ein Wanderweg zwischen den Längengraden -31,002 und -31,001 (Längen westlich vom Nullmeridian führen negative Werte), und das darstellende Terminal ist 80 Zeichen breit, bildet die lineare Projektion die Längengrade auf ganzzahlige X-Werte zwischen 0 und 79 ab (Abbildung 3). Analoges gilt für Breitengrade und ihre projizierten Y-Werte.
Listing 1 bestimmt dazu in der Funktion »projectSimple()« ab Zeile 25 zunächst in den Variablen »latMin/Max« und »lonMin/Max« die minimalen und die maximalen Werte für die geografische Breite und Länge auf allen Track-Punkten des aufgezeichneten Wanderwegs. In der ersten Runde der For-Schleife ab Zeile 29 ist »first« auf »true« gesetzt, und die Funktion initialisiert die Min/Max-Werte auf die Position des ersten Track-Punkts. In den folgenden Durchläufen ist »first« auf »false« gesetzt, und die Extrempunkte ändern sich nur noch, falls der aktuelle Track-Punkt sich außerhalb des bislang abgesteckten Fensters befindet.
Listing 1
gpx.go
package main
import (
"flag"
"fmt"
"github.com/tkrajina/gpxgo/gpx"
"image"
"os"
"path"
)
func gpxPoints(path string) ([]gpx.GPXPoint, error) {
gpxData, err := gpx.ParseFile(path)
points := []gpx.GPXPoint{}
if err != nil {
return points, err
}
for _, trk := range gpxData.Tracks {
for _, seg := range trk.Segments {
for _, pt := range seg.Points {
points = append(points, pt)
}
}
}
return points, nil
}
func projectSimple(geo []gpx.GPXPoint, width int, height int) []image.Point {
xy := []image.Point{}
var latMin, latMax, lonMin, lonMax float64
first := true
for _, gpxp := range geo {
if first {
latMin = gpxp.Latitude
latMax = gpxp.Latitude
lonMin = gpxp.Longitude
lonMax = gpxp.Longitude
first = false
continue
}
if gpxp.Latitude < latMin {
latMin = gpxp.Latitude
}
if gpxp.Latitude > latMax {
latMax = gpxp.Latitude
}
if gpxp.Longitude < lonMin {
lonMin = gpxp.Longitude
}
if gpxp.Longitude > lonMax {
lonMax = gpxp.Longitude
}
}
latSpan := latMax - latMin
lonSpan := lonMax - lonMin
for _, gpxp := range geo {
x := int((gpxp.Longitude - lonMin) / lonSpan * float64(width-1))
y := int((gpxp.Latitude - latMin) / latSpan * float64(height-1))
y = height - y - 1 // y counts top to bottom
xy = append(xy, image.Pt(x, y))
}
return xy
}
func cmdLineParse() (string, string) {
flag.Parse()
prog := path.Base(os.Args[0])
flag.Usage = func() {
fmt.Printf("usage: %s gpxfile\n", prog)
os.Exit(1)
}
args := flag.Args()
if len(args) != 1 {
flag.Usage()
}
return prog, args[0]
}
Hat die For-Schleife alle Track-Punkte abgegrast, setzen die Zeilen 51 und 52 die Breite dieser Fenster in »latSpan« und »lonSpan«. Mit dieser maximalen Bandbreite errechnen die Zeilen 54 und 55 die X- und Y-Koordinaten für das Zielsystem im Terminalfenster. Dazu ermitteln sie den Abstand des aktuellen Track-Punkts in den GPX-Daten vom linken beziehungsweise unteren Fensterrand, dividieren durch die Fensterbreite und multiplizieren den erhaltenen Fließkommawert mit der Breite des Zielsystems.
Heraus kommen X- und Y-Werte, die von 0 bis »width-1« beziehungsweise »height-1« im Zielfenster laufen, das »width« Zeichen breit und »height« Zeichen hoch ist. Da die X-Koordinaten im Terminalfenster später von links nach rechts, die Y-Koordinaten jedoch (besonders in der weiter unten vorgestellten GUI) von oben nach unten laufen, stellt Zeile 56 die Y-Werte noch auf den Kopf.
Die Daten aus der GPX-Datei liest anfangs die Funktion »gpxPoints()« ab Zeile 10 in Listing 1 ein und stellt sie dem Aufrufer komfortabel als Array-Slice von Fließkommawerten zur Verfügung. Bei diesem Service hilft das Paket gpx von Github, das das GPX-Format versteht und später beim Kompilieren des Binaries als Quellcode heruntergeladen und eingebunden wird.
Am unteren Ende von Listing 1 steht noch die Funktion »cmdLineParse()«, die später in den diversen Hauptprogrammen bei der Analyse der beim Aufruf mitgegebenen Kommandozeilenparameter hilft.
Auf den Schirm!
Das Hauptprogramm für einfaches Plotten der GPX-Daten in einem Terminal (Listing 2) nimmt auf der Kommandozeile eine GPX-Datei mit den XML-kodierten Geodaten einer Tour entgegen. Es extrahiert die Track-Punkte mit der Funktion »gpxPoints()« aus Listing 1. Zeile 16 in Listing 2 ruft die Funktion »projectSimple()« aus Listing 1 auf und übergibt ihr mit den GPX-Punkten die mit dem term-Paket von Github dynamisch ermittelten Dimensionen des aktuellen Terminals. Zurück bekommt sie den Array-Slice mit den XY-Koordinaten der ins Terminal projizierten Track-Punkte.
Listing 2
gpx-plot.go
package main
import (
"fmt"
"golang.org/x/term"
"log"
)
func main() {
_, file := cmdLineParse()
geo, err := gpxPoints(file)
if err != nil {
log.Fatalf("Parse error: %v\n", err)
}
width, height, _ := term.GetSize(0)
height -= 2
isSet := map[int]bool{}
xy := projectSimple(geo, width, height)
for _, pt := range xy {
isSet[pt.Y*width+pt.X] = true
}
for row := 0; row < height; row++ {
for col := 0; col < width; col++ {
ch := " "
if isSet[row*width+col] {
ch = "*"
}
fmt.Print(ch)
}
fmt.Print("\n")
}
}
Damit die zeilenweise Ausgabe ab Zeile 20 schnell prüfen kann, ob die aktuell ausgegebene Zelle des Terminals einen Track-Punkt enthält, legt Zeile 15 eine Map namens »isSet« an. Sie referenziert über einen Schlüssel aus Zeile und Spalte einen booleschen Wert, der bei Track-Punkten wahr und sonst falsch ist. In einer Skriptsprache wie Python wäre das flugs mit einer zweidimensionalen Hashmap oder Matrix erledigt. Deren Handhabung ist in Go jedoch die reinste Sisyphusarbeit, da der Programmcode die Speicherverwaltung der zweiten Hierarchie manuell steuern muss und dies den Code unverhältnismäßig aufbläht. Deshalb behilft sich Listing 2 mit einem Trick und nutzt eine eindimensionale Map. Als Schlüssel verwendet sie den Integer-Wert »y*width+x«, also den Offset des aktuellen Elements, wenn man die Zellen in den Zeilen des Terminals als fortlaufendes Array betrachtet.
Die doppelte For-Schleife ab Zeile 20 schreitet schließlich zur zeilenweisen Ausgabe des Wanderwegs im Terminal (Abbildung 4). Dazu nimmt der Code zunächst an, dass auf der aktuell bearbeiteten Koordinate kein Track-Punkt liegt, setzt also den Ausgabe-String »ch« auf ein Leerzeichen. Fördert der Lookup in der Map »isSet()« allerdings zutage, dass dort ein Track-Punkt liegt, setzt sie den String auf das Sternchen »”*”«. Zeile 26 gibt den Inhalt der aktuellen Zelle aus, und weiter geht es in die nächste Runde, bis zum Ende der aktuellen Zeile, und dann weiter zur nächsten Zeile.
Mit dem üblichen Dreisprung aus Listing 3 wird das Ganze kompiliert und mit den von Github eingeholten Paketen gelinkt. Heraus kommt ein Binary »gpx-plot«, das in Abbildung 5 den Namen einer GPX-Datei erhält und deren Track-Punkte dann ins Terminal schreibt.
Listing 3
Kompilieren
$ go mod init hikemap $ go mod tidy $ go build gpx-plot.go gpx.go
Mehr Auflösung
Wer eine höhere Auflösung möchte, muss entweder den Font im Terminal verkleinern und das Fenster weit genug aufziehen oder aber das Terminal im Grafikmodus betreiben. Letzteres erledigt das Paket termui, das in dieser Kolumne schon des Öfteren zum Einsatz kam.
Listing 4 liest analog zu Listing 2 die GPX-Datei ein und setzt dann mit »ui.Init()« die Terminal-UI auf. Die Anzeige besteht aus zwei Widgets, einem großen Canvas-Objekt oben und einem einzeiligen Paragraph-Widget mit Rand unten, das zu Informationszwecken den Namen der oben im Canvas-Widget geplotteten GPX-Datei ausgibt.
Listing 4
gpx-tui.go
package main
import (
"fmt"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"log"
)
func main() {
prog, file := cmdLineParse()
geo, err := gpxPoints(file)
if err != nil {
log.Fatalf("Parse error: %v\n", err)
}
if err := ui.Init(); err != nil {
panic(err)
}
defer ui.Close()
w, h := ui.TerminalDimensions()
txt := widgets.NewParagraph()
txt.Text = fmt.Sprintf("%s rendered %s", prog, file)
txt.TextStyle.Fg = ui.ColorWhite
txt.SetRect(0, h-3, w, h)
c := ui.NewCanvas()
c.SetRect(0, 0, w, h-3)
xy := projectSimple(geo, 2*(w-1), 4*(h-4))
for _, pt := range xy {
c.SetPoint(pt, ui.ColorWhite)
}
ui.Render(txt, c)
for e := range ui.PollEvents() {
if e.Type == ui.KeyboardEvent {
break
}
}
}
Die Widgets platzieren sich selbst im Terminal-Fenster mittels der in termui eingebauten Funktion »SetRect()«. Sie nimmt die räumliche Begrenzung der Widgets als Koordinaten mit Zeilen- und Spaltenwerten entgegen, wobei termui die Spalten von links nach rechts, die Zeilen aber von oben nach unten zählt.
Der Aufruf von »projectSimple()« in Zeile 25 in Listing 4 liefert die projizierten Track-Punkte schon im richtigen Koordinatensystem. So muss Zeile 27 für jeden Track-Punkt nur noch die Methode »c.SetPoint()« des Canvas-Objekts der termui aufrufen, um einen Grafikpunkt an der richtigen Stelle ins Canvas-Widget einzupflanzen.
Die Funktion »Render()« in Zeile 29 bringt beide Widgets auf den Schirm, und die For-Schleife ab Zeile 30 fragt mit »ui.PollEvents()« Ereignisse wie Tastatur-Events ab, bis Zeile 32 den Reigen abbricht, sobald der Benutzer irgendeine Taste drückt.
Kompiliert wird die Chose ebenfalls mit dem oben genannten Dreisprung, nur dass das Build-Kommando diesmal »go build gpx-tui.go gpx.go« heißt. Das entstehende Binary »gpx-tui« nimmt, wie Abbildung 6 zeigt, eine GPX-Datei entgegen und zeigt die Track-Punkte des Wanderwegs mit relativ hoher Auflösung im Canvas-Widget an. Ein Druck auf eine beliebige Taste schließt die Terminal-UI, und die Shell kehrt zum Prompt zurück.

Abbildung 6: Listing 4 stellt die GPX-Datei im Grafikmodus des Terminals dar.
Was fürs Auge
Insgesamt lässt die Terminaldarstellung aber noch zu wünschen übrig. Es fehlen Bezugspunkte wie Straßen sowie natürliche Begrenzungen von Landkarten wie Küstenlinien, Flüsse oder Gebirge. Allerdings sind Kartendaten von Anbietern wie Google Maps nicht lizenzfrei erhältlich.
Doch zum Glück gibt es ja OpenStreetMap. Die Landkarten dieses Community-Projekts stehen unter der Open Data Commons Open Database License [3], und es existiert sogar ein Tile-Server, von dem beliebige Applikationen die Karten als Kacheln kostenfrei und ohne Registrierung herunterladen können. Eine Kachel zeigt je nach Zoom-Einstellung einen kleinen Ausschnitt der Weltkarte in entsprechender Detailtreue an.
Die Ermittlung der für einen bestimmten Breiten- und Längengrad bei einer vorgegebenen Zoom-Einstellung zuständigen Kachel ist gut dokumentiert [4]. Applikationen müssen die drei Werte in eine geometrische Formel einsetzen und anschließend die Kacheldaten als PNG-Datei von »https://tile.openstreetmap.org/z/x/y.png« herunterladen. Es geht sogar noch einfacher: Auf Github steht das fertige Go-Projekt Go-staticmaps bereit, das sowohl das Einholen der Kacheln für bestimmte Breiten- und Längengrade als auch das Einsetzen von Markern in die dargestellten Landkarten elegant abstrahiert.
Das Ganze wird dann kurzerhand mit »go build gpx-osm.go gpx.go« zu einem Go-Binary »gpx-osm« zusammengeleimt. Mit einer GPX-Datei als Argument aufgerufen, kommt der Wanderweg schön blau auf einer Landkarte eingezeichnet hoch (Abbildung 7).
Dazu erzeugt Zeile 16 in Listing 5 mit »NewContext()« ein neues Kartenobjekt. Die darauf folgende For-Schleife nudelt durch alle Track-Punkte der GPX-Datei und hängt jeden einzelnen als »LatLng«-Objekt an das Array-Slice »edges« an. Der Aufruf von »NewPath()« in Zeile 22 macht daraus die Segmente eines Pfads, den »AddObject()« in die virtuelle Landkarte einfügt. Die Farbe des Pfads ist mit dem RGB-Wert »0x0000ff« tiefblau eingestellt, der Alpha-Channel definiert mit »0xff« volle Farbdeckung. Das Gewicht (»weight«) des Pfads legt der Wert »10.0« als recht dick fest.
Listing 5
gpx-osm.go
package main
import (
sm "github.com/flopp/go-staticmaps"
"github.com/fogleman/gg"
"github.com/golang/geo/s2"
"image/color"
"log"
"os/exec"
)
func main() {
_, file := cmdLineParse()
geo, err := gpxPoints(file)
if err != nil {
log.Fatalf("Parse error: %v\n", err)
}
ctx := sm.NewContext()
edges := []s2.LatLng{}
for _, gpxp := range geo {
edges = append(edges, s2.LatLngFromDegrees(gpxp.Latitude, gpxp.Longitude))
}
ctx.SetSize(800, 600)
ctx.AddObject(sm.NewPath(edges, color.RGBA{0, 0, 0xff, 0xff}, 10.0))
img, err := ctx.Render()
if err != nil {
panic(err)
}
const png = "/tmp/osm.png"
if err := gg.SavePNG(png, img); err != nil {
panic(err)
}
cmd := exec.Command("eog", png)
if err := cmd.Run(); err != nil {
log.Fatal("Error: ", err)
}
}
Die Funktion »Render()« auf das Context-Objekt bringt in Zeile 23 das Ganze in Form, »SavePNG()« in Zeile 28 schreibt die PNG-Daten in eine Datei im »/tmp«-Verzeichnis. Damit das neue Kunstwerk gleich auf dem Bildschirm erscheint, ruft Zeile 31 das Gnome-Utility Eye of Gnome mit dem Pfad der temporären Datei auf – und schon kommt die Landkarte mit dem eingezeichneten Wanderpfad hoch.
Fertig ist der Lack: Kurzerhand entsteht ein praktisches Werkzeug, das ausgewählte Trail-Dateien grafisch anzeigt. Damit weiß der Anwender bei der Auswahl sofort, wo es langgeht. Das Ganze schreit nun förmlich danach, in eine Applikation integriert zu werden, vielleicht mit einer Fyne-GUI. Wie immer sind der Kreativität für Programmierer keine Grenzen gesetzt! (uba)
Der Autor
Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.
Infos
- Snapshot: Mike Schilli, “Pfadfinder”, LM 02/2023, S. 82, https://www.lm-online.de/47387
- Mercator-Projektion: https://de.wikipedia.org/wiki/Mercator-Projektion
- Daten-Lizenz von OpenStreetMap: https://opendatacommons.org/licenses/odbl/
- “Slippy map tilenames”: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon./lat._to_tile_numbers










