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:

Bildschirmfoto von Jira-Ticket mit Link zu Open Street Map

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:

Bildschirmfoto von geojson.io

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:

Bildschirmfoto einer interaktiven Karte mit Markierungen und Pop-Up

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.