Interaktive Karte mit offenen Problemstellen
Zu meinem Jira-Board mit offenen Problemstellen im Straßenverkehr habe ich jetzt noch eine interaktive Karte gebaut.
Ich mag es, den Fortschritt von Dingen zu verfolgen. Ich mag Kanban-Boards. Ich mag aber auch Landkarten. Die Schnittmenge davon ist eine interaktive Karte auf der jedes Ticket einen Punkt hat.
Das Board habe ich schon, siehe den Artikel zum Jira-Board. Dort habe ich auch schon in einem eigens angelegten Feld zu jedem Ticket einen Link zur Open Street Map hinterlegt:
Nun muss ich eigentlich nur per Jira-API dieses Feld auslesen und auf einer Karte darstellen.
Extraktion von Jira
Jira hat natürlich eine API. Dazu gibt es auch schon einen Python-Wrapper, den ich gerne genutzt habe. Nachdem ich einen API-Schlüssel angelegt habe, kann ich mich mit meiner Instanz von Jira Cloud verbinden:
import jira j = jira.JIRA( server="https://martin-ueding.atlassian.net", basic_auth=( "mu@martin-ueding.de", "…", ), )
Meine Koordinaten sind ein custom field, daher hat es intern nicht den Namen, den ich dem gegeben habe, vielmehr heißt es custom_field_1234
mit irgendeiner Zahl. Daher ist es hilfreich sich wie in diesem Beitrag beschrieben die Feldnamen geben zu lassen:
field_names = {field["name"]: field["id"] for field in j.fields()}
Als nächstes kann ich mir alle Issues herunterladen:
all_issues = j.search_issues("project = PSV", maxResults=1000)
Als nächstes habe ich die Daten in einen Pandas Data Frame konvertiert, wobei das nicht unbedingt notwendig gewesen wäre:
import datetime import dateutil.parser import pandas as pd df = pd.DataFrame( { "key": issue.key, "summary": issue.fields.summary, "status": issue.fields.status.name, "priority": issue.fields.priority.name, "updated": dateutil.parser.parse(issue.fields.updated) if issue.fields.updated else None, "duedate": dateutil.parser.parse(issue.fields.duedate) if issue.fields.duedate else None, "url": f"https://martin-ueding.atlassian.net/browse/{issue.key}", "map": issue.get_field(field_names["Koordinaten"]), } for issue in all_issues ) df["is_due"] = df.duedate <= datetime.datetime.now()
Export der Daten für die Karte
Für die Karte möchte ich Leaflet nutzen. Da kann ich meine Daten irgendwie reinpacken, ich kann aber auch GeoJSON nutzen, was ein Standardformat ist.
Um das einfach zu erzeugen, habe ich das Python-Paket geojson
genutzt. Damit kann ich eine feature collection erzeugen die pro Punkt ein feature hat:
import re from geojson import Feature, Point, FeatureCollection features = [] for index, row in df.iterrows(): if row["map"]: m = re.search("(\d+.\d+)/(\d+.\d+)", row["map"]) if m: lon, lat = map(float, m.groups()) features.append( Feature( geometry=Point((lat, lon)), properties={ "jira_url": row["url"], "name": row["summary"], "status": row["status"], "is_due": row["is_due"], "priority": row["priority"], "duedate": row["duedate"].date().isoformat(), "updated": row["duedate"].date().isoformat(), }, ) ) fc = FeatureCollection(features)
Das ganze können wir dann als JSON-Datei exportieren:
import geojson with open("export.json", "w") as f: geojson.dump(fc, f, sort_keys=True, indent=4)
Weil es standardisiertes Format ist, kann man sich das ganze dann zum Beispiel auf GeoJSON.io einmal visualisieren lassen und erhält das hier:
Karte mit Leaflet
Ich wollte die Karte allerdings nett bunt machen und noch schöner formatierte Pop-Ups haben.
Dazu braucht man erstmal ein Grundgerüst als HTML-Datei:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> <style> #map { height: 700px; } </style> </head> <body> <div id="map"></div> <script src="map.js"></script> </body> </html>
Als nächstes noch die map.js
Datei, in der die Karte mit den GeoJSON-Daten gefüllt wird und von der Open Street Map die Kacheln geladen werden. Dabei habe ich dann auch noch den Status durch Farbe ausgedrückt, die Priorität durch Linienstärke und die Fälligkeit durch Ausfüllen der Kreise.
var map = L.map('map').setView([50.7362, 7.1106], 13); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map); var status_colors = { "Blogartikel schreiben": "#4daf4a", "Antwort ausstehend": "#377eb8", "Verkehrsschau nötig": "#e41a1c", "Nachhaken nötig": "#984ea3", "Backlog": "#ff7f00", } var priority_weights = { "Highest": 3, "High": 2.5, "Medium": 2, "Low": 1.5, "Lowest": 1 } var add_points_to_map = function (feature_collection) { console.log(feature_collection) L.geoJSON(feature_collection, { pointToLayer: function (feature, latlon) { var p = feature.properties return L.circleMarker(latlon, { radius: 8, color: status_colors[p.status], fillColor: status_colors[p.status], weight: priority_weights[p.priority], fillOpacity: p.is_due ? 0.6 : 0.3 }).bindPopup(`<p><b><a href="${p.jira_url}">${p.name}</a></b></p><p>Status: ${p.status}</br>Priorität: ${p.priority}</br>Fällig: ${p.duedate}</br>Aktualisiert: ${p.updated}</br></p>`) } }).addTo(map) } fetch('./export.json') .then((response) => response.json()) .then((json) => add_points_to_map(json));
Am Ende sieht das ganze dann so im Browser aus:
Die roten Kreise sind jene, bei denen ich mal eine Verkehrsschau machen muss. Man sieht mehrere ausgefüllte rote Kreise bei Bornheim und Alfter, da muss ich dann wohl einmal hin!
Das ganze habe ich dann noch in die Skripte eingepflegt, mit denen ich das HTML für den Blog erzeuge. Somit ist diese Karte nun Teil des Blogs. Die Jira-Tickets sind allerdings nicht öffentlich, sodass dort nur Titel und Ort einsehbar sind. Das sollte aber auch schon eine praktische Information sein um festzustellen, wo ich schon dran bin.