mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-09-14 21:59:09 +02:00
feat(stargazer-map): readable log-scale map with baked-in stats (#2953)
* feat(stargazer-map): readable log-scale map with baked-in stats Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stargazer-map): count only current stargazers, honest caption wording Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(stargazer-map): show shares only, drop absolute per-country counts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
268
.github/generate_map.py
vendored
268
.github/generate_map.py
vendored
@@ -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"<b>Stargazers of {repo}</b>",
|
||||
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.<br>"
|
||||
"Location is read from the public GitHub profile,<br>"
|
||||
"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",
|
||||
"<br>".join(
|
||||
f"{i}. {SHORT_NAMES.get(name, name)}"
|
||||
for i, (name, _) in enumerate(top, 1)
|
||||
),
|
||||
FG,
|
||||
),
|
||||
(
|
||||
0.215,
|
||||
"right",
|
||||
"<br>".join(f"{n / located * 100:.1f}%" for _, n in top),
|
||||
FG,
|
||||
),
|
||||
]
|
||||
annotations.append(
|
||||
dict(
|
||||
text="<b>TOP COUNTRIES</b>",
|
||||
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("."),
|
||||
|
||||
Reference in New Issue
Block a user