diff --git a/.github/generate_map.py b/.github/generate_map.py index 744b71d67c..8509376f77 100644 --- a/.github/generate_map.py +++ b/.github/generate_map.py @@ -7,13 +7,14 @@ up once (unless the country entry is blank). """ import csv +import math import os import sys import time from collections import Counter from pathlib import Path -import plotly.express as px +import plotly.graph_objects as go import pycountry import requests from geopy.geocoders import Nominatim @@ -25,6 +26,35 @@ GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") # provided by workflow CSV_PATH = Path(".github/stargazer_countries.csv") PNG_PATH = Path(".github/stargazer_map.png") +# ---- Rendering theme -------------------------------------------------------- +# Dark, opaque panel: GitHub does not swap the image between README themes, so +# a single background has to work in both. A dark canvas with a bright +# sequential ramp stays readable on light and dark pages alike. +BG = "#0d1117" # page / ocean +LAND = "#2b323c" # countries with zero stargazers (still visible) +BORDER = "#0d1117" # country outlines, same as background +FG = "#e6edf3" # primary text +MUTED = "#8b98a5" # secondary text + +# Viridis truncated at 35 %: even a single stargazer gets a colour that is +# clearly distinct from the empty-land grey. +SCALE = ["#2c728e", "#21918c", "#35b779", "#90d743", "#fde725"] + +# pycountry names that are too long / too formal for a top-5 list +SHORT_NAMES = { + "Russian Federation": "Russia", + "Korea, Republic of": "South Korea", + "Korea, Democratic People's Republic of": "North Korea", + "Iran, Islamic Republic of": "Iran", + "Taiwan, Province of China": "Taiwan", + "Viet Nam": "Vietnam", + "Moldova, Republic of": "Moldova", + "Bolivia, Plurinational State of": "Bolivia", + "Venezuela, Bolivarian Republic of": "Venezuela", + "Tanzania, United Republic of": "Tanzania", + "Syrian Arab Republic": "Syria", +} + HEADERS = { "Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json", @@ -92,30 +122,207 @@ def username_to_country(login): return "" -def build_choropleth(percent_by_iso): - iso, vals = zip(*percent_by_iso.items()) - fig = px.choropleth( - locations=list(iso), - locationmode="ISO-3", - color=list(vals), - color_continuous_scale="Greens", - range_color=(0, max(vals) if vals else 1), +def count_by_country(cache): + """Counter of country name -> stargazers, ignoring blank locations.""" + return Counter(c for c in cache.values() if c) + + +def _log_ticks(lo, hi): + """Colourbar ticks at ... 0.1, 0.3, 1, 3, 10, 30 ... spanning [lo, hi].""" + candidates = [m * 10**k for k in range(-3, 3) for m in (1, 3)] + ticks = [t for t in candidates if lo / 1.5 <= t <= hi] + return ticks or [hi] + + +def _fmt_pct(value): + """1 -> '1%', 0.3 -> '0.3%' -- no trailing zeros.""" + return f"{value:.2f}".rstrip("0").rstrip(".") + "%" + + +def build_figure(counts, total_stargazers): + """Build the choropleth figure from a {country name: stargazers} mapping.""" + by_iso = {} + for name, n in counts.items(): + try: + code = pycountry.countries.lookup(name).alpha_3 + except LookupError: + print("Skip unknown country:", name) + continue + # two spellings can resolve to the same ISO code, so accumulate + by_iso[code] = by_iso.get(code, 0) + n + + iso = list(by_iso) + vals = [by_iso[k] for k in iso] + # count only what is actually drawn, so the caption matches the map + located = sum(vals) or 1 + pcts = [v / located * 100 for v in vals] + lo, hi = (min(pcts), max(pcts)) if pcts else (1.0, 1.0) + + # The distribution is heavily long-tailed (the top country holds ~200x the + # share of the tail), so a linear ramp collapses everything but a handful + # of countries into the first colour step. Colour on log10 of the share. + ticks = _log_ticks(lo, hi) + fig = go.Figure( + go.Choropleth( + locations=iso, + locationmode="ISO-3", + z=[math.log10(p) for p in pcts], + zmin=math.log10(lo) - 0.15, # keep the smallest share off the floor + zmax=math.log10(hi), + colorscale=SCALE, + marker_line_color=BORDER, + marker_line_width=0.5, + colorbar=dict( + title=dict( + text="share of located stargazers (log scale)", + font=dict(color=MUTED, size=13), + side="top", + ), + orientation="h", + x=0.52, + y=0.02, + xanchor="center", + yanchor="bottom", + thickness=12, + len=0.34, + outlinewidth=0, + tickvals=[math.log10(t) for t in ticks], + ticktext=[_fmt_pct(t) for t in ticks], + tickfont=dict(color=MUTED, size=12), + ), + ) ) - fig.update_layout( - coloraxis_colorbar=dict( - title="% stargazers", - orientation="h", # <-- échelle horizontale - x=0.5, # <-- centré - y=0, # <-- tout en bas - xanchor="center", - yanchor="bottom", - thickness=15, - len=0.7, # <-- longueur de l'échelle, ajustable + + fig.update_geos( + projection_type="natural earth", + showframe=False, + showcoastlines=False, + showland=True, + landcolor=LAND, + showocean=True, + oceancolor=BG, + showlakes=False, + bgcolor=BG, + lataxis_range=[-56, 84], # crop Antarctica, it is always empty + lonaxis_range=[-176, 186], + domain=dict(x=[0.0, 1.0], y=[0.04, 0.92]), + ) + + repo = REPO or "this repository" + caption = ( + f"{total_stargazers:,} stargazers" + f" | {located:,} mapped to a country" + f" | {len(by_iso)} countries" + ) + annotations = [ + dict( + text=f"Stargazers of {repo}", + x=0.012, + y=0.985, + xref="paper", + yref="paper", + xanchor="left", + yanchor="top", + showarrow=False, + font=dict(color=FG, size=25), ), + dict( + text=caption, + x=0.012, + y=0.925, + xref="paper", + yref="paper", + xanchor="left", + yanchor="top", + showarrow=False, + font=dict(color=MUTED, size=15), + ), + dict( + text="Countries in grey have no located stargazer.
" + "Location is read from the public GitHub profile,
" + "so the map covers the located subset only.", + x=0.988, + y=0.05, + xref="paper", + yref="paper", + xanchor="right", + yanchor="bottom", + align="right", + showarrow=False, + font=dict(color=MUTED, size=12), + ), + ] + + # Top 5, laid out as two separate annotations (names, share) so each column + # stays aligned whatever the country name length -- HTML text in an SVG + # annotation collapses padding spaces, so a monospace table would not line + # up. + top = counts.most_common(5) + if top: + base_y = 0.40 + columns = [ + ( + 0.022, + "left", + "
".join( + f"{i}. {SHORT_NAMES.get(name, name)}" + for i, (name, _) in enumerate(top, 1) + ), + FG, + ), + ( + 0.215, + "right", + "
".join(f"{n / located * 100:.1f}%" for _, n in top), + FG, + ), + ] + annotations.append( + dict( + text="TOP COUNTRIES", + x=0.022, + y=base_y, + xref="paper", + yref="paper", + xanchor="left", + yanchor="top", + showarrow=False, + font=dict(color=MUTED, size=13), + ) + ) + annotations += [ + dict( + text=text, + x=x, + y=base_y - 0.055, + xref="paper", + yref="paper", + xanchor=anchor, + yanchor="top", + align=anchor, + showarrow=False, + font=dict(color=color, size=15), + ) + for x, anchor, text, color in columns + ] + + fig.update_layout( + width=1240, + height=680, + paper_bgcolor=BG, + plot_bgcolor=BG, margin=dict(l=0, r=0, t=0, b=0), + annotations=annotations, ) - PNG_PATH.parent.mkdir(parents=True, exist_ok=True) - fig.write_image(str(PNG_PATH), scale=2) + return fig + + +def build_choropleth(counts, total_stargazers, path=PNG_PATH): + fig = build_figure(counts, total_stargazers) + path.parent.mkdir(parents=True, exist_ok=True) + # 1.5x of 1240x680 -> 1860x1020, sharp on HiDPI at README width without + # committing a multi-megabyte PNG every week. + fig.write_image(str(path), scale=1.5) def main(): @@ -145,23 +352,12 @@ def main(): save_cache(cache) - # Build stats - countries = [c for c in cache.values() if c] - counts = Counter(countries) - total = sum(counts.values()) or 1 - pct_by_country = {c: v / total for c, v in counts.items()} - - # convert to ISO-3 for plotly - pct_by_iso = {} - for c, pct in pct_by_country.items(): - try: - iso = pycountry.countries.lookup(c).alpha_3 - pct_by_iso[iso] = pct * 100 # plotly wants numeric - except LookupError: - print("Skip unknown country:", c) + # The cache is never pruned, so it still holds users who have since + # unstarred. Keep them for future geocoding, but render only current stars. + counts = count_by_country({u: cache[u] for u in users}) print("Rendering PNG map…") - build_choropleth(pct_by_iso) + build_choropleth(counts, len(users)) print( "Done – files saved:", CSV_PATH.relative_to("."),