Höhlenmaler (Linux-Magazin, April 2023)

Mit dem Wanderwegfinder aus der vorletzten Ausgabe ([2]), der auf der Kommandozeile aus einer Sammlung von .gpx-Dateien mit Trackpunkten von Touren die passende heraussucht, kam mir die Idee, gleich die Kontouren gefundener Touren ins Terminalfenster zu zeichnen. Nun stehen in einer .gpx-Datei, generiert von einer App wie Komoot oder einem Garmin-Tracker, erstmal Geo-Koordinaten, die sich auf Orte auf der Erdkugel beziehen, durch die der Wanderweg führt (Abbildung 1).

Abbildung 1: Geo-Koordinaten in einer .gpx-Datei

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. Dabei ist dieses Problem schon seit Jahrhunderten gelöst, denn jede Landkarte, egal ob aus Papier oder digital, basiert auf diesem gedanklichen Sprung, Geopunkte auf der Erdkugel, die als geografische Breite und Länge vorliegen, in ein XY-Koordinatensystem in einer flachen Ebene zu projizieren.

Abbildung 2: Gerhard Mercator, Kupferstich von Frans Hogenberg, 1574 (Quelle: Wikipedia)

Zurück ins Jahr 1569

Schon im Jahr 1569 machte sich der Kartograf Gerhard Mercator aus Flandern (Abbildung 2) daran, die von Seefahrern ermittelten Kugeldaten auszuflachen. Hierzu projizierte er kurzerhand die Kugeloberfläche der Erde auf einen herumgewickelten Zylinder (Abbildung 3), dessen Mantel sich wiederum leicht abrollen und als flache Landkarte betrachten lässt. Fertig war der Lack, allerdings stimmt die Projektion (bei einem senkrecht stehenden Wickelzylinder) nur am Äquator zu 100% und führt nördlich oder südlich davon zu Verzerrungen, bis schließlich an den Polregionen grotesk aufgeblähte Landmassen entstehen. Abbildung 4 zeigt die unkorrierte Mercator-Projektion auf einer Weltkarte, auf der der Grönland im Nordatlantik überproportional groß herauskommt und die Antarktis am unteren Rand als Monsterkontinent erscheint.

Abbildung 3: Für die Mercator-Projektion um die Erdkugel gewickelter Zylinder Quelle: Wikipedia (https://en.wikipedia.org/wiki/Mercator_projection#/media/File:Comparison_of_Mercator_projections.svg)

Abbildung 4: Klassische Verzerrung der Polregionen bei der Mercator-Projektion (Quelle: Wikipedia)

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 die 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.

Abbildung 5: Die simple lineare Projektion von geografischer Länge (lon) zu X-Werten ist gut genug.

Minima und Maxima

Erstreckt sich zum Beispiel ein Wanderweg zwischen den Längengraden -31,002 und -31,001 (Längen westlich vom Null-Meridian 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 5). Analoges gilt für Breitengrade und ihre projizierten Y-Werte.

Listing 1 bestimmt hierzu 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 Trackpunkten des aufgezeichneten Wanderwegs. In der ersten Runde der For-Schleife ab Zeile 29 ist first auf true gesetzt und die Funktion initialisiert die MinMax-Werte auf die Position des ersten Trackpunktes. In den folgenden Durchläufen ist first auf false gesetzt und die Extrempunkte ändern sich nur noch, falls der aktuelle Trackpunkt sich außerhalb des bislang abgesteckten Fensters befindet.

Listing 1: gpx.go

    01 package main
    02 import (
    03   "flag"
    04   "fmt"
    05   "github.com/tkrajina/gpxgo/gpx"
    06   "image"
    07   "os"
    08   "path"
    09 )
    10 func gpxPoints(path string) ([]gpx.GPXPoint, error) {
    11   gpxData, err := gpx.ParseFile(path)
    12   points := []gpx.GPXPoint{}
    13   if err != nil {
    14     return points, err
    15   }
    16   for _, trk := range gpxData.Tracks {
    17     for _, seg := range trk.Segments {
    18       for _, pt := range seg.Points {
    19         points = append(points, pt)
    20       }
    21     }
    22   }
    23   return points, nil
    24 }
    25 func projectSimple(geo []gpx.GPXPoint, width int, height int) []image.Point {
    26   xy := []image.Point{}
    27   var latMin, latMax, lonMin, lonMax float64
    28   first := true
    29   for _, gpxp := range geo {
    30     if first {
    31       latMin = gpxp.Latitude
    32       latMax = gpxp.Latitude
    33       lonMin = gpxp.Longitude
    34       lonMax = gpxp.Longitude
    35       first = false
    36       continue
    37     }
    38     if gpxp.Latitude < latMin {
    39       latMin = gpxp.Latitude
    40     }
    41     if gpxp.Latitude > latMax {
    42       latMax = gpxp.Latitude
    43     }
    44     if gpxp.Longitude < lonMin {
    45       lonMin = gpxp.Longitude
    46     }
    47     if gpxp.Longitude > lonMax {
    48       lonMax = gpxp.Longitude
    49     }
    50   }
    51   latSpan := latMax - latMin
    52   lonSpan := lonMax - lonMin
    53   for _, gpxp := range geo {
    54     x := int((gpxp.Longitude - lonMin) / lonSpan * float64(width-1))
    55     y := int((gpxp.Latitude - latMin) / latSpan * float64(height-1))
    56     y = height - y - 1 // y counts top to bottom
    57     xy = append(xy, image.Pt(x, y))
    58   }
    59   return xy
    60 }
    61 func cmdLineParse() (string, string) {
    62   flag.Parse()
    63   prog := path.Base(os.Args[0])
    64   flag.Usage = func() {
    65     fmt.Printf("usage: %s gpxfile\n", prog)
    66     os.Exit(1)
    67   }
    68   args := flag.Args()
    69   if len(args) != 1 {
    70     flag.Usage()
    71   }
    72   return prog, args[0]
    73 }

Hat die For-Schleife alle Trackpunkte abgegrast, setzen die Zeilen 51 und 52 die Breite dieser Fenster in latSpan und lonSpan. Mit dieser maximalen Bandbreite können anschließend die Zeilen 54 und 55 die X- und Y-Koordinaten für das Zielsystem im Terminalfenster errechnen. Hierzu ermitteln sie den Abstand des aktuellen Trackpunktes in den GPX-Daten vom linken beziehungsweise unteren Fensterrand, dividieren durch die Fensterbreite und multiplizieren den errechneten 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 Compilieren des Binaries als Source-Code 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's einfache Plotten der Gpx-Daten in einem Terminal in Listing 2 nimmt auf der Kommandozeile eine .gpx-Datei mit den XML-kodierten Geodaten einer Tour entgegen. Es extrahiert die Trackpunkte mit der Funktion gpxPoints() aus Listing 1. Zeile 16 in Listing 2 ruft die Funktion projectSimple() aus Listing 1 auf, übergibt ihr mit den Gpx-Punkten die mit dem term-Paket von Github dynamisch ermittelten Dimensionen des aktuellen Terminals, und bekommt den Array-Slice mit den XY-Koordinaten der ins Terminal projizierten Trackpunkte zurück.

Listing 2: gpx-plot.go

    01 package main
    02 import (
    03   "fmt"
    04   "golang.org/x/term"
    05   "log"
    06 )
    07 func main() {
    08   _, file := cmdLineParse()
    09   geo, err := gpxPoints(file)
    10   if err != nil {
    11     log.Fatalf("Parse error: %v\n", err)
    12   }
    13   width, height, _ := term.GetSize(0)
    14   height -= 2
    15   isSet := map[int]bool{}
    16   xy := projectSimple(geo, width, height)
    17   for _, pt := range xy {
    18     isSet[pt.Y*width+pt.X] = true
    19   }
    20   for row := 0; row < height; row++ {
    21     for col := 0; col < width; col++ {
    22       ch := " "
    23       if isSet[row*width+col] {
    24         ch = "*"
    25       }
    26       fmt.Print(ch)
    27     }
    28     fmt.Print("\n")
    29   }
    30 }

Damit die zeilenweise Ausgabe ab Zeile 20 schnell prüfen kann, ob die aktuell ausgegebene Zelle des Terminals nun einen Trackpunkt enthält oder nicht, legt Zeile 15 eine Map namens isSet an, die über einen Schlüssel aus Zeile und Spalte einen Bool-Wert referenziert, der bei Trackpunkten wahr und sonst falsch ist. In einer Skriptsprache wie Python wäre das flugs mit einer zweidimensionalen Hashmap oder Matrix erledigt, aber leider ist deren Handhabung in Go die reinste Sisyphusarbeit, da der Programmcode die Speicheverwaltung der zweite Hierarchie manuell steuern muss und dies den Code unverhältnismäßig aufbläht. Deshalb hilft 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 fortlaufenden Array betrachtet.

Abbildung 6: Die Tour auf Komoot im Browser ...

Abbildung 7: ... und im Terminal mittels gpx-plot.

Die Doppel-For-Schleife ab Zeile 20 schreitet schließlich zur zeilenweisen Ausgabe des Wanderwegs im Terminal. Dazu nimmt der Code zunächst an, dass auf der aktuell bearbeiteten Koordinate kein Trackpunkt liegt, setzt also den Ausgabe-String ch auf ein Leerzeichen. Fördert der Lookup in der Map isSet() allerdings zutage, dass dort ein Trackpunkt liegt, setzt sie den String auf das Sternchen "*". Zeile 26 gibt den Inhalt der aktuellen Zelle aus, und weiter geht's in die nächste Runde, bis zum Ende der aktuellen Zeile, und dann weiter zur nächsten Zeile.

Mit dem üblichen Dreisprung

    go mod init hikemap
    go mod tidy
    go build gpx-plot.go gpx.go

wird das Ganze kompiliert und mit den von Github eingeholten Paketen gelinkt. Heraus kommt ein Binary gpx-plot, das in Abbildung 7 den Namen einer .gpx-Datei erhält und deren Trackpunkte dann ins Terminal schreibt.

Mehr Auflösung

Wer eine bessere 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 3 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.

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 die Zeilen aber von oben nach unten zählt.

Abbildung 8: Listing 3 stellt die GPX-Datei im Grafikmodus des Terminals dar.

Listing 3: gpx-tui.go

    01 package main
    02 import (
    03   "fmt"
    04   ui "github.com/gizak/termui/v3"
    05   "github.com/gizak/termui/v3/widgets"
    06   "log"
    07 )
    08 func main() {
    09   prog, file := cmdLineParse()
    10   geo, err := gpxPoints(file)
    11   if err != nil {
    12     log.Fatalf("Parse error: %v\n", err)
    13   }
    14   if err := ui.Init(); err != nil {
    15     panic(err)
    16   }
    17   defer ui.Close()
    18   w, h := ui.TerminalDimensions()
    19   txt := widgets.NewParagraph()
    20   txt.Text = fmt.Sprintf("%s rendered %s", prog, file)
    21   txt.TextStyle.Fg = ui.ColorWhite
    22   txt.SetRect(0, h-3, w, h)
    23   c := ui.NewCanvas()
    24   c.SetRect(0, 0, w, h-3)
    25   xy := projectSimple(geo, 2*(w-1), 4*(h-4))
    26   for _, pt := range xy {
    27     c.SetPoint(pt, ui.ColorWhite)
    28   }
    29   ui.Render(txt, c)
    30   for e := range ui.PollEvents() {
    31     if e.Type == ui.KeyboardEvent {
    32       break
    33     }
    34   }
    35 }

Der Aufruf von projectSimple() in Zeile 25 in Listing 3 liefert die projizierten Trackpunkte schon im richtigen Koordinatensystem, sodass Zeile 27 für jeden Trackpunkt nur noch die Methode c.SetPoint() des Canvas-Objektes der termui aufrufen muss, 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 frägt mit ui.PollEvents() Ereignisse wie Tastatur-Events ab, bis Zeile 32 den Reigen abbricht, sobald der User 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 daraus entstehende Binary gpx-tui nimmt, wie Abbildung 8 zeigt, eine .gpx-Datei entgegen und zeigt die Trackpunkte des Wanderweges mit relativ hoher Auflösung im Canvas-Widget an. Ein Druck auf irgendeine Taste schließt die Terminal-UI und die Shell kehrt zum Prompt zurück.

Was fürs Auge

Insgesamt lässt die Terminal-Darstellung aber doch noch zu wünschen übrig. Es fehlen die Bezugspunkte wie Straßen oder natürliche Begrenzungen, die Landkarten bieten, wie Küstenlinien, Flüsse oder Gebirge. Leider sind Kartendaten von Anbietern wie Google Maps nicht lizenzfrei erhältlich, doch halt, zum Glück gibt's ja Openstreetmap! Die Landkarten dieses Community-Projekts stehen unter der Open Data Commons Open Database License ([5]) und es existiert sogar ein Tile-Server, von dem beliebige Applikationen die Karten als Kacheln kosten- und registrationsfrei 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 auf [4] dokumentiert. 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. Aber, es geht sogar einfacher: Auf Github steht das fertige Go-Projekt go-staticmaps, 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 und mit einer .gpx Datei als Argument aufgerufen kommt der Wanderweg schön blau auf einer Landkarte eingezeichnet hoch (Abbildung 9).

Abbildung 9: Mit Openstreetmap-Daten angereicherter Wanderweg

Dazu erzeugt Zeile 16 in Listing 4 mit NewContext() ein neues Kartenobjekt, und die darauf folgende For-Schleife nudelt durch alle Trackpunkte der .gpx-Datei und hängt jeden einzelnen als LatLng-Objekt an den Array-Slice edges an. Die Aufruf von NewPath() in Zeile 22 macht daraus die Segmente eines Pfades, und AddObject() fügt letzteren in die virtuelle Landkarte ein. Die Farbe des Pfades ist mit dem RGB-Werte 0x0000ff als tiefblau eingestellt, der Alpha-Channel definiert mit 0xff volle Farbdeckung. Das "Gewicht" ("weight") des Pfades ist mit dem numerischen Werte 10.0 auf relativ dick eingestellt.

Listing 4: gpx-osm.go

    01 package main
    02 import (
    03   sm "github.com/flopp/go-staticmaps"
    04   "github.com/fogleman/gg"
    05   "github.com/golang/geo/s2"
    06   "image/color"
    07   "log"
    08   "os/exec"
    09 )
    10 func main() {
    11   _, file := cmdLineParse()
    12   geo, err := gpxPoints(file)
    13   if err != nil {
    14     log.Fatalf("Parse error: %v\n", err)
    15   }
    16   ctx := sm.NewContext()
    17   edges := []s2.LatLng{}
    18   for _, gpxp := range geo {
    19     edges = append(edges, s2.LatLngFromDegrees(gpxp.Latitude, gpxp.Longitude))
    20   }
    21   ctx.SetSize(800, 600)
    22   ctx.AddObject(sm.NewPath(edges, color.RGBA{0, 0, 0xff, 0xff}, 10.0))
    23   img, err := ctx.Render()
    24   if err != nil {
    25     panic(err)
    26   }
    27   const png = "/tmp/osm.png"
    28   if err := gg.SavePNG(png, img); err != nil {
    29     panic(err)
    30   }
    31   cmd := exec.Command("open", png)
    32   if err := cmd.Run(); err != nil {
    33     log.Fatal("Error: ", err)
    34   }
    35 }

Die Funktion Render() auf das Context-Objekt in Zeile 23 bringt das Ganze in Form und 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 die Gnome-Utility eog mit dem Pfad der temporären Datei auf, und schon kommt die Landkarte mit dem eingezeichneten Wanderpfad hoch.

Fertig ist der Lack, kurzerhand entsteht eine praktische Utility, die ausgewählte Trail-Dateien grafisch anzeigt. Damit weiß der User bei der Auswahl sofort wo's lang geht. Das Ganze schreit nun förmlich danach, in eine Applikation, vielleicht mit einer Fyne-GUI, integriert zu werden, wie immer sind der Kreativität für Programmierer keine Grenzen gesetzt!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/04/snapshot/

[2]

Michael Schilli, "Pfadfinder": Linux-Magazin 02/2023, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2023/02/snapshot/<U>

[3]

Mercator-Projektion, Wikipedia, https://de.wikipedia.org/wiki/Mercator-Projektion

[4]

"Slippy map tilenames", https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon./lat._to_tile_numbers

[5]

Daten-Lizenz von Openstreetmap, https://opendatacommons.org/licenses/odbl/

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 mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc