mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-09-19 08:13:59 +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 csv
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import plotly.express as px
|
import plotly.graph_objects as go
|
||||||
import pycountry
|
import pycountry
|
||||||
import requests
|
import requests
|
||||||
from geopy.geocoders import Nominatim
|
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")
|
CSV_PATH = Path(".github/stargazer_countries.csv")
|
||||||
PNG_PATH = Path(".github/stargazer_map.png")
|
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 = {
|
HEADERS = {
|
||||||
"Authorization": f"token {GITHUB_TOKEN}",
|
"Authorization": f"token {GITHUB_TOKEN}",
|
||||||
"Accept": "application/vnd.github.v3+json",
|
"Accept": "application/vnd.github.v3+json",
|
||||||
@@ -92,30 +122,207 @@ def username_to_country(login):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def build_choropleth(percent_by_iso):
|
def count_by_country(cache):
|
||||||
iso, vals = zip(*percent_by_iso.items())
|
"""Counter of country name -> stargazers, ignoring blank locations."""
|
||||||
fig = px.choropleth(
|
return Counter(c for c in cache.values() if c)
|
||||||
locations=list(iso),
|
|
||||||
locationmode="ISO-3",
|
|
||||||
color=list(vals),
|
def _log_ticks(lo, hi):
|
||||||
color_continuous_scale="Greens",
|
"""Colourbar ticks at ... 0.1, 0.3, 1, 3, 10, 30 ... spanning [lo, hi]."""
|
||||||
range_color=(0, max(vals) if vals else 1),
|
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(
|
fig.update_geos(
|
||||||
title="% stargazers",
|
projection_type="natural earth",
|
||||||
orientation="h", # <-- échelle horizontale
|
showframe=False,
|
||||||
x=0.5, # <-- centré
|
showcoastlines=False,
|
||||||
y=0, # <-- tout en bas
|
showland=True,
|
||||||
xanchor="center",
|
landcolor=LAND,
|
||||||
yanchor="bottom",
|
showocean=True,
|
||||||
thickness=15,
|
oceancolor=BG,
|
||||||
len=0.7, # <-- longueur de l'échelle, ajustable
|
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),
|
margin=dict(l=0, r=0, t=0, b=0),
|
||||||
|
annotations=annotations,
|
||||||
)
|
)
|
||||||
PNG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
return fig
|
||||||
fig.write_image(str(PNG_PATH), scale=2)
|
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
@@ -145,23 +352,12 @@ def main():
|
|||||||
|
|
||||||
save_cache(cache)
|
save_cache(cache)
|
||||||
|
|
||||||
# Build stats
|
# The cache is never pruned, so it still holds users who have since
|
||||||
countries = [c for c in cache.values() if c]
|
# unstarred. Keep them for future geocoding, but render only current stars.
|
||||||
counts = Counter(countries)
|
counts = count_by_country({u: cache[u] for u in users})
|
||||||
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)
|
|
||||||
|
|
||||||
print("Rendering PNG map…")
|
print("Rendering PNG map…")
|
||||||
build_choropleth(pct_by_iso)
|
build_choropleth(counts, len(users))
|
||||||
print(
|
print(
|
||||||
"Done – files saved:",
|
"Done – files saved:",
|
||||||
CSV_PATH.relative_to("."),
|
CSV_PATH.relative_to("."),
|
||||||
|
|||||||
Reference in New Issue
Block a user