diff --git a/.github/workflows/update-gpx.yml b/.github/workflows/update-gpx.yml index b1415be..08c4e44 100644 --- a/.github/workflows/update-gpx.yml +++ b/.github/workflows/update-gpx.yml @@ -18,17 +18,23 @@ jobs: with: python-version: "3.12" + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libgeos-dev libproj-dev + - name: Install dependencies - run: pip install requests beautifulsoup4 + run: pip install requests beautifulsoup4 cartopy matplotlib - name: Run update script run: python scripts/update_gpx.py --url "${{ vars.NAMING_URL }}" + - name: Generate map + run: python scripts/generate_map.py + - name: Commit and push if changed run: | - git diff --quiet tezos.gpx && exit 0 + git diff --quiet tezos.gpx && [ -z "$(git ls-files --others --exclude-standard map.png)" ] && git diff --quiet map.png 2>/dev/null && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add tezos.gpx - git commit -m "Update tezos.gpx with new protocol cities" + git add tezos.gpx map.png + git commit -m "Update tezos.gpx and map with new protocol cities" git push diff --git a/README.md b/README.md index 9e94d2a..7c01c4a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # tezos protocols map Mapping Tezos protocol names on the globe +![Tezos Protocol Cities Map](map.png) ## context * *cheeses* for feature test nets @@ -15,4 +16,4 @@ Mapping Tezos protocol names on the globe - script to extract cities from tezos project - trigger this project on changes from previous URL (a few times a year) -- generate map (more than just https://www.gpsvisualizer.com/map_input?country=ca&form:dynamic_data=https%3A%2F%2Fraw.githubusercontent.com%2Fcotezos%2Ftezosprotocolmap%2Fmain%2Ftezos.gpx&force_type=r&form=html&google_wpt_labels=1&google_wpt_sym=pin&trk_as_wpt=blank&trk_colorize=n&trk_opacity=100&units=metric&wpt_color=blue&wpt_list=name ) +- ~~generate map~~ done — `scripts/generate_map.py` renders `map.png` from `tezos.gpx` diff --git a/map.png b/map.png new file mode 100644 index 0000000..cd5ff9a Binary files /dev/null and b/map.png differ diff --git a/scripts/generate_map.py b/scripts/generate_map.py new file mode 100644 index 0000000..2a5dbaa --- /dev/null +++ b/scripts/generate_map.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Render tezos.gpx waypoints onto a world map using cartopy + matplotlib.""" + +import argparse +import xml.etree.ElementTree as ET + +import cartopy.crs as ccrs +import cartopy.feature as cfeature +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# Manual label offsets (dx, dy in points) for cities that would overlap. +# Positive dx = right, positive dy = up. +LABEL_OFFSETS = { + "Athens": (6, -8), + "Babylon": (6, 0), + "Carthage": (-6, 6), + "Delphi": (-6, 6), + "Florence": (-6, 6), + "Granada": (-6, -8), + "Ithaca": (-6, -8), + "Paris": (6, 6), + "Oxford": (-6, -8), + "Tallinn": (6, 0), + "Nairobi": (6, 0), +} + +DEFAULT_OFFSET = (6, -4) + + +def parse_gpx(gpx_path): + """Parse GPX file and return list of (name, lat, lon).""" + tree = ET.parse(gpx_path) + root = tree.getroot() + ns = "" + # Handle default GPX namespace if present + if root.tag.startswith("{"): + ns = root.tag.split("}")[0] + "}" + + waypoints = [] + for wpt in root.findall(f"{ns}wpt"): + lat = float(wpt.get("lat")) + lon = float(wpt.get("lon")) + name_el = wpt.find(f"{ns}name") + name = name_el.text if name_el is not None else "" + waypoints.append((name, lat, lon)) + return waypoints + + +def short_label(name): + """Strip country suffix and shorten known long names.""" + label = name.split(",")[0].strip() + if label == "Edo (Tokyo)": + label = "Edo" + if label == "Quebec City": + label = "Quebec" + if label == "Rio de Janeiro": + label = "Rio" + return label + + +def generate_map(waypoints, output_path, dpi): + """Render waypoints on a Robinson-projection world map.""" + fig = plt.figure(figsize=(12, 6)) + ax = fig.add_subplot(1, 1, 1, projection=ccrs.Robinson()) + + ax.set_global() + ax.add_feature(cfeature.OCEAN, facecolor="#ddeeff") + ax.add_feature(cfeature.LAND, facecolor="#f0f0f0", edgecolor="none") + ax.add_feature(cfeature.BORDERS, linewidth=0.3, edgecolor="#cccccc") + ax.add_feature(cfeature.COASTLINE, linewidth=0.4, edgecolor="#aaaaaa") + ax.spines["geo"].set_linewidth(0.5) + + for name, lat, lon in waypoints: + ax.plot( + lon, + lat, + marker="o", + color="#d62728", + markersize=5, + transform=ccrs.PlateCarree(), + zorder=5, + ) + + label = short_label(name) + dx, dy = LABEL_OFFSETS.get(label, DEFAULT_OFFSET) + + ax.annotate( + label, + xy=(lon, lat), + xycoords=ccrs.PlateCarree()._as_mpl_transform(ax), + xytext=(dx, dy), + textcoords="offset points", + fontsize=6, + fontweight="bold", + color="#333333", + zorder=6, + ) + + plt.tight_layout(pad=0.5) + fig.savefig(output_path, dpi=dpi, bbox_inches="tight") + plt.close(fig) + print(f"Map saved to {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Generate map from GPX waypoints") + parser.add_argument("--gpx", default="tezos.gpx", help="Path to GPX file") + parser.add_argument("--output", default="map.png", help="Output PNG path") + parser.add_argument("--dpi", type=int, default=150, help="Output DPI") + args = parser.parse_args() + + waypoints = parse_gpx(args.gpx) + print(f"Parsed {len(waypoints)} waypoints from {args.gpx}") + generate_map(waypoints, args.output, args.dpi) + + +if __name__ == "__main__": + main()