rco / app.py
Phitographix's picture
Upload 373 files
4ac3c47 verified
Raw
History Blame Contribute Delete
27.6 kB
# -*- coding: utf-8 -*-
"""
Boîte à outils — Portail analytique (Haïti)
Application Streamlit conçue pour être intégrée dans WordPress via une <iframe>.
- Source du catalogue : catalogue_indicateurs_FR.csv (libellés en français,
secteurs thématiques attribués)
- Filtres lisibles : Niveau géographique (Pays / Département / Commune),
Désagrégation (Tranche d'âge / Sexe / Toutes), Secteur, Source, Échelle,
recherche d'indicateur (cascade).
- Désagrégations sexe et tranche d'âge dérivées des libellés d'indicateurs.
- Par défaut : indicateurs par tranche d'âge, classés de la date de début à
la date de fin.
- Zone « Americas » supprimée des données.
- Choix des colonnes affichées / téléchargées dans le tableau.
- Graphique sans lignes horizontales + nombre d'indicateurs affichés.
- Bouton « Effacer les filtres ».
Lancement : streamlit run app.py
"""
import math
import os
import re
import unicodedata
import pandas as pd
import plotly.express as px
import streamlit as st
import streamlit as st
ICI = os.path.dirname(os.path.abspath(__file__))
PARQUET = os.path.join(ICI, "donnees_unifiees.parquet")
CATALOGUE = os.path.join(ICI, "catalogue_indicateurs_FR.csv")
AGREGATIONS = {"Moyenne": "mean", "Somme": "sum", "Médiane": "median",
"Minimum": "min", "Maximum": "max"}
# niveau géographique demandé -> valeur dans la colonne niveau_geo
NIVEAUX = {"National": "National", "Départemental": "Admin1", "Communal": "Admin2"}
# ----------------------------------------------------------------- configuration
# DOIT être la première commande Streamlit. Force la barre latérale (filtres)
# à s'afficher dépliée — indispensable dans l'iframe WordPress, où Streamlit
# la replie sinon automatiquement.
st.set_page_config(page_title="Portail analytique — Haïti",
layout="wide", initial_sidebar_state="expanded")
# ----------------------------------------------------------------- thème
st.markdown("""
<style>
#MainMenu, footer, header {visibility: hidden;}
/* garantir que la barre latérale reste visible (iframe étroit) */
section[data-testid="stSidebar"] {visibility:visible !important;
transform:none !important;}
/* masquer le bouton de repli « « » de la barre latérale (toutes versions) */
button[data-testid="stSidebarCollapseButton"],
[data-testid="stSidebarCollapsedControl"],
[data-testid="collapsedControl"],
[data-testid="stSidebarHeader"] button,
[data-testid="stSidebarNav"] button[kind="header"],
section[data-testid="stSidebar"] button[kind="header"],
section[data-testid="stSidebar"] > div > button {display:none !important;}
.block-container {padding-top: 0.5rem; padding-bottom: 1rem;}
.bandeau {background: linear-gradient(90deg,#12347a 0%,#1c4fb0 100%);
color:#fff; padding:14px 22px; border-radius:6px; margin-bottom:14px;}
.bandeau h1 {font-size:1.5rem; margin:0; font-weight:600;}
.bandeau span {opacity:.85; font-weight:400;}
/* --- sidebar : panneau clair et lisible (bleu pâle) --- */
/* largeur fixe + annulation du repli (sinon l'iframe étroit l'écrase et
les libellés s'empilent verticalement) */
section[data-testid="stSidebar"] {background-color:#dfeafb;
border-right:1px solid #c2d4f0;
min-width:300px !important; max-width:300px !important;
width:300px !important; margin-left:0 !important;
transform:none !important;}
section[data-testid="stSidebar"] > div:first-child {width:300px !important;}
/* le bas du panneau gauche s'aligne automatiquement sur le bas du contenu
(le tableau) au lieu de déborder : on laisse le flex étirer les colonnes */
[data-testid="stAppViewContainer"] {align-items:stretch !important;}
section[data-testid="stSidebar"] {height:auto !important;
min-height:100% !important; align-self:stretch !important;}
/* contenu des filtres défilable : on peut atteindre le bas du panneau
(section TEMPORALITÉ / Période) via un défilement interne */
section[data-testid="stSidebar"] > div:first-child {
height:100vh !important; max-height:100vh !important;
overflow-y:auto !important; position:sticky !important;
top:0 !important;}
section[data-testid="stSidebar"] * {color:#0d1b3e !important;}
section[data-testid="stSidebar"] h2 {color:#12347a !important; font-size:1.15rem;
margin-bottom:.2rem;}
.sct {color:#12347a; font-weight:700; font-size:.92rem;
text-transform:uppercase; letter-spacing:.04em;
margin:.5rem 0 .1rem; padding-top:.2rem;
border-top:1px solid #d7e0f2;}
.sct.first {border-top:none;}
section[data-testid="stSidebar"] label {font-weight:600 !important;}
.infoligne {color:#12347a; font-weight:600; margin:8px 0 2px;}
.reflet {color:#5b6a8f !important; font-size:.82rem; margin-top:-4px;}
.compteur {display:inline-block; background:#12347a; color:#fff !important;
padding:3px 12px; border-radius:14px; font-weight:600;
font-size:.95rem;}
/* étiquettes d'indicateurs sélectionnés : texte/croix blancs et lisibles
sur le fond foncé de la couleur de marque */
section[data-testid="stSidebar"] [data-baseweb="tag"] span,
section[data-testid="stSidebar"] [data-baseweb="tag"] svg {
color:#fff !important; fill:#fff !important;}
/* boutons flottants : monter / descendre */
html {scroll-behavior:smooth;}
.navscroll {position:fixed; right:14px; bottom:55px; z-index:99999;
display:flex; flex-direction:column; gap:8px;}
.navscroll a {display:flex; align-items:center; justify-content:center;
width:42px; height:42px; border-radius:50%; background:#0e3a53;
color:#fff !important; text-decoration:none; font-size:1.15rem;
box-shadow:0 2px 6px rgba(0,0,0,.3); transition:background .15s;}
.navscroll a:hover {background:#12537a;}
/* panneau « à propos de la sélection » : attributs liés aux indicateurs */
.apropos {background:#eef4fd; border:1px solid #c2d4f0;
border-left:4px solid #0e3a53; border-radius:6px; padding:10px 14px;
margin:8px 0 12px; font-size:.9rem; line-height:1.8; color:#0d1b3e;}
.apropos b {color:#0e3a53;}
</style>
""", unsafe_allow_html=True)
# ----------------------------------------------------------------- nettoyage texte
_MOJI = re.compile(r"[ÃÂ]|â€|â‚|�")
def nettoyer(s):
"""Répare les caractères mal encodés (UTF-8 lu comme cp1252) et espaces."""
s = str(s)
if _MOJI.search(s):
for enc in ("cp1252", "latin-1"):
try:
rep = s.encode(enc).decode("utf-8")
if "�" not in rep:
s = rep
break
except Exception:
pass
s = s.replace("\xa0", " ").replace("​", "")
return re.sub(r"\s+", " ", s).strip()
def sans_accents(s):
"""Minuscule + suppression des accents -> recherche insensible aux
accents et à la casse (santé = sante, sécurité = securite)."""
s = unicodedata.normalize("NFKD", str(s))
s = "".join(c for c in s if not unicodedata.combining(c))
return s.lower()
# ----------------------------------------------------------------- sexe / âge
def detect_sexe(nom):
"""Déduit le sexe à partir du libellé de l'indicateur."""
s = str(nom).lower()
fem = re.search(r"female|women|woman|femmes?|fille|girls?|maternal|gender female", s)
# on retire 'female' avant de chercher 'male' pour éviter les faux positifs
reste = re.sub(r"female|women|woman", "", s)
mal = re.search(r"\bmale\b|\bmen\b|\bman\b|hommes?|gar[cç]on|boys?|gender male", reste)
if fem and mal:
return "Mixte"
if fem:
return "Femmes"
if mal:
return "Hommes"
return None
_AGE = [
("Moins de 5 ans",
r"under[- ]?5|under[- ]?five|<\s*5\b|\b0[-–]4\b|\bunder 1\b|infant|neonat|"
r"newborn|6[-–]23 month|child mortality|under-five|moins de 5"),
("Enfants (5-14 ans)",
r"\b5[-–]14|6[-–]14|2[-–]14|10[-–]14|child labor|children age 12[-–]14|school-age"),
("Adolescents (10-24 ans)",
r"adolesc|10[-–]19|15[-–]19|15[-–]24|20[-–]24|young (men|women|people)|youth"),
("Adultes (15-64 ans)",
r"15[-–]49|25[-–]49|18[-–]49|15[-–]64|20[-–]79|\badult|reproductive age|"
r"40[-–]44|45[-–]49"),
("Personnes âgées (60 ans +)",
r"60\s*\+|65\s*\+|elderly|older (persons|people)|aged 60|65 (years )?and"),
]
def detect_age(nom):
"""Déduit une tranche d'âge à partir du libellé de l'indicateur."""
s = str(nom).lower()
for lbl, pat in _AGE:
if re.search(pat, s):
return lbl
# repli : ne retenir que de vrais signaux d'âge (éviter « years of schooling »)
if re.search(r"\baged?\b|age[- ]group|\bchildren?\b|infant|adolesc|newborn|"
r"neonat|\bans\b|under\s*\d+|\d+\s*\+|"
r"\d+\s*[-–]\s*\d+\s*(year|month|ans)", s):
return "Autre tranche d'âge"
return None
# ordre d'affichage des tranches d'âge
ORDRE_AGE = ["Moins de 5 ans", "Enfants (5-14 ans)", "Adolescents (10-24 ans)",
"Adultes (15-64 ans)", "Personnes âgées (60 ans +)", "Autre tranche d'âge"]
# ----------------------------------------------------------------- chargement
# « sig » = dates de modification des fichiers source : dès que le CSV (ou le
# parquet) est mis à jour, la clé de cache change et les données sont rechargées.
# (l'argument doit être SANS underscore pour être pris en compte dans le cache)
@st.cache_data(show_spinner="Chargement des données…")
def charger(sig):
# parquet optimisé : 8 colonnes utiles, types compacts, zone « Americas »
# déjà retirée en amont -> faible empreinte mémoire (hébergement gratuit).
# On évite tout .astype(str) sur des millions de lignes (pic mémoire fatal).
df = pd.read_parquet(PARQUET)
# zone « Americas » déjà retirée ; comparaison catégorielle peu coûteuse
# (on évite tout .astype(str) sur des millions de lignes = pic mémoire fatal)
df = df[df["zone"] != "Americas"]
df["annee"] = pd.to_numeric(df["annee"], errors="coerce").astype("Int64")
df["mois"] = pd.to_numeric(df["mois"], errors="coerce").astype("Int64")
# catalogue traduit (UTF-8 avec BOM) : libellés FR + secteurs attribués
cat = pd.read_csv(CATALOGUE, encoding="utf-8-sig")
cat = cat.loc[:, ~cat.columns.str.startswith("Unnamed")]
cat = cat.drop_duplicates(subset="indicateur_complet")
cat = cat[cat["indicateur_complet"].isin(df["indicateur_complet"].unique())].copy()
# libellé affiché / recherché = traduction française
cat["Indicateurs"] = cat["Indicateurs_FR"].map(nettoyer)
cat["source"] = cat["source"].map(nettoyer)
# filtre thématique = colonne « Secteur » d'origine,
# repli sur « Secteur_attribue » si absente
secteur_src = "Secteur" if "Secteur" in cat.columns else "Secteur_attribue"
cat["Secteur"] = (cat[secteur_src].map(nettoyer)
.replace({"nan": pd.NA, "": pd.NA, "None": pd.NA}))
cat["Date_debut"] = pd.to_numeric(cat["Date_debut"], errors="coerce")
cat["Date_fin"] = pd.to_numeric(cat["Date_fin"], errors="coerce")
# détection sexe / tranche d'âge sur le libellé anglais d'origine
# (extrait de indicateur_complet, après « :: ») — plus fiable que sur le FR
en = cat["indicateur_complet"].astype(str).str.split("::").str[-1].str.strip()
cat["sexe"] = en.map(detect_sexe)
cat["age"] = en.map(detect_age)
return df, cat
def _mtime(p):
try:
return os.path.getmtime(p)
except OSError:
return 0
df, cat = charger((_mtime(PARQUET), _mtime(CATALOGUE)))
ss = st.session_state
v = ss.setdefault("_v", 0) # version des filtres (pour le bouton « effacer »)
def k(nom):
"""Clé de widget versionnée (incrémentée par le bouton Effacer)."""
return f"{nom}_{ss['_v']}"
def effacer_filtres():
ss["_v"] = ss.get("_v", 0) + 1 # nouvelles clés -> widgets réinitialisés
ss["pg"] = 1
ss["_vider_indics"] = True # vider toute la sélection d'indicateurs
# ----------------------------------------------------------------- filtres
with st.sidebar:
st.markdown("## Filtres")
st.button("Effacer les filtres", on_click=effacer_filtres,
use_container_width=True)
# --- Niveau géographique ---
st.markdown('<div class="sct first">Niveau géographique</div>',
unsafe_allow_html=True)
f_niveau = st.radio("Afficher les données par", list(NIVEAUX.keys()),
index=0, horizontal=True, key=k("niveau"),
label_visibility="collapsed")
# --- Désagrégation ---
st.markdown('<div class="sct">Désagrégation</div>', unsafe_allow_html=True)
# défaut « Toutes » pour que les indicateurs BINUH (sans tranche d'âge)
# soient disponibles et affichés par défaut au chargement
f_desag = st.radio("Type de désagrégation",
["Tranche d'âge", "Sexe", "Toutes"], index=2,
key=k("desag"), label_visibility="collapsed")
base = cat.copy()
f_age, f_sexe = "(toutes)", "(tous)" # valeurs par défaut (toujours définies)
if f_desag == "Tranche d'âge":
base = base[base["age"].notna()]
ages = [a for a in ORDRE_AGE if a in base["age"].unique()]
f_age = st.selectbox("Tranche d'âge", ["(toutes)"] + ages, index=0,
key=k("age"))
if f_age != "(toutes)":
base = base[base["age"] == f_age]
elif f_desag == "Sexe":
base = base[base["sexe"].isin(["Femmes", "Hommes", "Mixte"])]
sexes = [s for s in ["Femmes", "Hommes", "Mixte"]
if s in base["sexe"].unique()]
f_sexe = st.selectbox("Sexe", ["(tous)"] + sexes, index=0, key=k("sexe"))
if f_sexe != "(tous)":
base = base[base["sexe"] == f_sexe]
# --- Thématique ---
st.markdown('<div class="sct">Thématique</div>', unsafe_allow_html=True)
secteurs = ["(tous)"] + sorted(base["Secteur"].dropna().unique().tolist())
f_secteur = st.selectbox("Secteur", secteurs, index=0, key=k("secteur"))
if f_secteur != "(tous)":
base = base[base["Secteur"] == f_secteur]
sources = ["(toutes)"] + sorted(base["source"].dropna().unique().tolist())
f_source = st.selectbox("Source", sources, index=0, key=k("source"))
if f_source != "(toutes)":
base = base[base["source"] == f_source]
echelles = ["(toutes)"] + sorted(base["Echelle"].dropna().unique().tolist())
f_echelle = st.selectbox("Échelle géographique", echelles, index=0,
key=k("echelle"))
if f_echelle != "(toutes)":
base = base[base["Echelle"] == f_echelle]
recherche = st.text_input("Recherche d'indicateur", "", key=k("rech"),
placeholder="mot-clé : santé, sécurité, prix…")
if recherche.strip():
# recherche insensible à la casse ET aux accents, sur TOUT le catalogue
# (ignore les autres filtres pour retrouver n'importe quel indicateur)
terme = sans_accents(recherche.strip())
base = cat[cat["Indicateurs"].map(sans_accents).str.contains(
terme, regex=False, na=False)]
# --- Indicateurs (classés de la date de début à la date de fin) ---
st.markdown('<div class="sct">Indicateurs</div>', unsafe_allow_html=True)
libelle = dict(zip(cat["indicateur_complet"], cat["Indicateurs"]))
base = base.sort_values(["Date_debut", "Date_fin", "Indicateurs"],
na_position="last")
opts = base["indicateur_complet"].tolist()
src_par_ind = dict(zip(cat["indicateur_complet"], cat["source"]))
# un filtre de catégorie est-il actif ? (secteur / source / échelle /
# désagrégation) -> dans ce cas on sélectionne TOUS les indicateurs liés
filtre_actif = (f_desag != "Toutes" or f_age != "(toutes)"
or f_sexe != "(tous)" or f_secteur != "(tous)"
or f_source != "(toutes)" or f_echelle != "(toutes)")
MAX_AUTO = 30 # plafond pour garder graphique/mémoire lisibles
if ss.pop("_vider_indics", False):
defauts = [] # après « Effacer les filtres » : rien
elif filtre_actif:
defauts = opts[:MAX_AUTO] # tous les indicateurs liés au filtre choisi
else:
defauts = [o for o in opts if src_par_ind.get(o) == "BINUH"]
if not defauts:
defauts = opts[:6]
# la clé du multiselect intègre les filtres de catégorie : dès qu'on change
# un filtre, le widget se réinitialise et resélectionne les indicateurs liés
# (les choix manuels restent tant que ces filtres ne changent pas)
fsig = abs(hash((f_desag, f_age, f_sexe, f_secteur, f_source, f_echelle)))
f_indics = st.multiselect(
"Indicateur (sélection multiple)", options=opts, default=defauts,
format_func=lambda x: libelle.get(x, x)[:75],
key=f"indics_{ss['_v']}_{fsig}",
help="Classés de la date de début à la date de fin. "
"Commencez à taper pour rechercher.")
if filtre_actif and len(opts) > MAX_AUTO:
st.caption(f"{len(opts)} indicateurs correspondent à ce filtre ; "
f"les {MAX_AUTO} premiers sont sélectionnés. "
f"Ajoutez-en d'autres au besoin.")
if f_indics:
sel = cat[cat["indicateur_complet"].isin(f_indics)]
srcs = ", ".join(sorted(sel["source"].dropna().unique())[:4])
st.markdown(f'<div class="reflet">Sources : {srcs or "—"}</div>',
unsafe_allow_html=True)
# --- Fréquence & période ---
st.markdown('<div class="sct">Temporalité</div>', unsafe_allow_html=True)
f_freq = st.radio("Fréquence", ["Année", "Trimestre", "Mois"],
horizontal=True, key=k("freq"))
f_agg_lbl = st.selectbox("Agrégation", list(AGREGATIONS.keys()), index=0,
key=k("agg"))
f_agg = AGREGATIONS[f_agg_lbl]
an_min = int(cat["Date_debut"].dropna().min())
an_max = int(cat["Date_fin"].dropna().max())
f_annees = st.slider("Période", an_min, an_max, (an_min, an_max),
key=k("annees"))
# ----------------------------------------------------------------- corps
# ancre « haut » + boutons flottants de défilement (monter / descendre)
st.markdown(
'<div id="haut"></div>'
'<div class="navscroll">'
'<a href="#bas" title="Descendre">▼</a>'
'<a href="#haut" title="Monter">▲</a>'
'</div>', unsafe_allow_html=True)
if not f_indics:
st.info("⬅️ Sélectionnez au moins un indicateur dans les filtres.")
st.stop()
# toutes les données disponibles pour les indicateurs choisis (avant filtrage
# par niveau/période) -> sert au panneau d'information « à propos de la sélection »
meta = df[df["indicateur_complet"].isin(f_indics)].copy()
sel = cat[cat["indicateur_complet"].isin(f_indics)]
_inv_niv = {"National": "National", "Admin1": "Départemental", "Admin2": "Communal"}
_niveaux = [_inv_niv.get(x, x) for x in meta["niveau_geo"].dropna().unique()]
_freqs = ["annuelle"]
if meta["mois"].notna().any():
_freqs.append("infra-annuelle (mois/trimestre)")
_an = meta["annee"].dropna()
_periode = f"{int(_an.min())}{int(_an.max())}" if not _an.empty else "—"
_desags = [d for d in meta["desagregation"].map(nettoyer).dropna().unique() if d][:6]
_thematiques = sorted(sel["Secteur"].dropna().unique())[:4]
_sources = sorted(sel["source"].dropna().unique())[:5]
_echelles = sorted(sel["Echelle"].dropna().unique())[:4]
def _info(lbl, val):
return f"<b>{lbl} :</b> {val or '—'}"
_apropos = " &nbsp;·&nbsp; ".join([
_info("Thématique", ", ".join(_thematiques)),
_info("Source(s)", ", ".join(_sources)),
_info("Niveau géographique", ", ".join(_niveaux)),
_info("Temporalité", f"{_periode} · fréquence {', '.join(_freqs)}"),
_info("Agrégation", f_agg_lbl),
_info("Désagrégation", ", ".join(_desags)),
_info("Échelle", ", ".join(_echelles)),
])
st.markdown(f'<div class="apropos">{_apropos}</div>', unsafe_allow_html=True)
sous = meta[meta["niveau_geo"] == NIVEAUX[f_niveau]].copy()
sous = sous[(sous["annee"] >= f_annees[0]) & (sous["annee"] <= f_annees[1])]
if f_freq in ("Mois", "Trimestre"):
sous = sous[sous["mois"].notna()]
if sous.empty:
st.warning(f"Aucune donnée « {f_freq} » au niveau « {f_niveau} » pour cette "
f"sélection sur {f_annees[0]}{f_annees[1]}. La plupart des séries "
f"sont annuelles et nationales — essayez « Année » et « Pays ».")
st.stop()
a = sous["annee"].astype(int)
if f_freq == "Année":
sous["sortkey"] = a
sous["periode"] = a.astype(str)
elif f_freq == "Trimestre":
q = ((sous["mois"].astype(int) - 1) // 3 + 1)
sous["sortkey"] = a * 10 + q
sous["periode"] = a.astype(str) + "-T" + q.astype(str)
else:
m = sous["mois"].astype(int)
sous["sortkey"] = a * 100 + m
sous["periode"] = a.astype(str) + "-" + m.astype(str).str.zfill(2)
sous["Indicateur"] = sous["indicateur_complet"].map(lambda x: libelle.get(x, x))
serie = (sous.groupby(["Indicateur", "periode", "sortkey"])["valeur"]
.agg(f_agg).reset_index().sort_values("sortkey"))
ordre = serie.drop_duplicates("periode").sort_values("sortkey")["periode"].tolist()
# ----------------------------------------------------------------- en-tête + graphique
n_indic = sous["Indicateur"].nunique()
n_total = cat["indicateur_complet"].nunique() # total d'indicateurs du portail
st.markdown(f'<span class="compteur">{n_indic} / {n_total} '
f'indicateur(s) affiché(s)</span>', unsafe_allow_html=True)
c1, c2, c3 = st.columns([2, 1, 1])
with c2:
type_g = st.selectbox("Type de graphique", ["Ligne", "Barres", "Aires"], index=0)
with c3:
base100 = st.checkbox(
"Comparer les évolutions (%)", value=False,
help="Met chaque indicateur à 100 au départ pour comparer leur "
"progression en pourcentage, même s'ils ont des échelles différentes.")
data_g = serie.copy()
ylab = f"{f_agg_lbl} de la valeur"
if base100:
def b100(g):
ref = g.sort_values("sortkey")["valeur"].iloc[0]
if pd.notna(ref) and ref != 0:
g["valeur"] = g["valeur"] / ref * 100
return g
data_g = data_g.groupby("Indicateur", group_keys=False).apply(b100)
ylab = "Évolution (base 100 au départ)"
if type_g == "Ligne":
fig = px.line(data_g, x="periode", y="valeur", color="Indicateur", markers=True)
elif type_g == "Barres":
fig = px.bar(data_g, x="periode", y="valeur", color="Indicateur", barmode="group")
else:
fig = px.area(data_g, x="periode", y="valeur", color="Indicateur")
fig.update_xaxes(categoryorder="array", categoryarray=ordre, title=f_freq,
showgrid=False)
# suppression des lignes horizontales (grille de l'axe vertical)
fig.update_yaxes(showgrid=False, zeroline=False)
fig.update_layout(yaxis_title=ylab, legend_title="Indicateur", height=460,
legend=dict(orientation="h", yanchor="bottom", y=-0.45),
margin=dict(l=10, r=10, t=20, b=10))
st.plotly_chart(fig, use_container_width=True)
# ----------------------------------------------------------------- tableau
src_map = dict(zip(cat["indicateur_complet"], cat["source"]))
deb_map = dict(zip(cat["indicateur_complet"], cat["Date_debut"]))
tab = sous[["indicateur_complet", "Indicateur", "desagregation", "zone",
"periode", "sortkey", "valeur", "unite"]].copy()
tab.insert(0, "Source", tab["indicateur_complet"].map(src_map))
tab["_date_debut"] = tab["indicateur_complet"].map(deb_map)
tab = tab.drop(columns="indicateur_complet").rename(columns={
"desagregation": "Désagrégation", "zone": "Échelle",
"periode": "Période", "valeur": "Valeur", "unite": "Unité"})
# classé de la date de début à la date de fin, puis chronologiquement
tab = tab.sort_values(["_date_debut", "Indicateur", "sortkey"]).reset_index(drop=True)
tab = tab.drop(columns=["sortkey", "_date_debut"])
for c in ("Désagrégation", "Échelle", "Unité"):
tab[c] = tab[c].map(nettoyer)
# valeurs arrondies à une décimale (affichage + téléchargement CSV)
tab["Valeur"] = pd.to_numeric(tab["Valeur"], errors="coerce").round(1)
# choix des colonnes à afficher / télécharger
COLS = ["Source", "Indicateur", "Désagrégation", "Échelle", "Période", "Valeur", "Unité"]
st.markdown('<div class="infoligne">Colonnes du tableau (téléchargement) :</div>',
unsafe_allow_html=True)
cols_choisies = st.multiselect("Colonnes à afficher", COLS, default=COLS,
key=k("cols"), label_visibility="collapsed")
if not cols_choisies:
cols_choisies = COLS
tab = tab[cols_choisies]
# réinitialiser la page si la sélection change
sig = (tuple(sorted(f_indics)), f_niveau, f_freq, f_agg, f_annees,
tuple(cols_choisies))
if ss.get("_sig") != sig:
ss["_sig"] = sig
ss["pg"] = 1
g1, g2 = st.columns([3, 1])
with g2:
csv = tab.to_csv(index=False).encode("utf-8-sig")
st.download_button("⬇️ Télécharger (CSV)", csv,
file_name="donnees_selection.csv", mime="text/csv",
use_container_width=True)
# contrôles de pagination
n = len(tab)
pc1, pc2, pc3, pc4, pc5 = st.columns([1.2, 1, 1.4, 1, 4])
with pc5:
taille = st.selectbox("Lignes par page", [25, 50, 100, 200], index=1,
key="psize", label_visibility="collapsed")
npages = max(1, math.ceil(n / taille))
if ss.get("pg", 1) > npages:
ss["pg"] = npages
if ss.get("pg", 1) < 1:
ss["pg"] = 1
def _go(d):
ss["pg"] = min(max(1, ss.get("pg", 1) + d), npages)
with pc1:
st.button("◀ Précédent", on_click=_go, args=(-1,),
disabled=ss.get("pg", 1) <= 1, use_container_width=True)
with pc3:
st.selectbox("Page", list(range(1, npages + 1)), key="pg",
label_visibility="collapsed")
with pc4:
st.button("Suivant ▶", on_click=_go, args=(1,),
disabled=ss.get("pg", 1) >= npages, use_container_width=True)
page = ss.get("pg", 1)
deb = (page - 1) * taille
fin = min(deb + taille, n)
with g1:
st.markdown(f'<div class="infoligne">Affichage de {deb + 1 if n else 0}{fin} '
f'sur {n} lignes · page {page}/{npages} · '
f'{n_indic} indicateur(s) · niveau : {f_niveau.lower()} · '
f'fréquence : {f_freq.lower()}</div>',
unsafe_allow_html=True)
page_df = tab.iloc[deb:fin].reset_index(drop=True)
hauteur = int(38 + 35 * len(page_df)) if len(page_df) else 80
def _zebra(row):
"""Coloration alternée des lignes : bleu pâle / blanc."""
couleur = "#e8f1fc" if row.name % 2 else "#ffffff"
return [f"background-color: {couleur}; color:#0d1b3e"] * len(row)
styled = page_df.style.apply(_zebra, axis=1)
if "Valeur" in page_df.columns: # toujours une décimale (48.0, 60.3…)
styled = styled.format({"Valeur": "{:.1f}"}, na_rep="—")
st.dataframe(styled, use_container_width=True, height=hauteur, hide_index=True)
# ancre « bas » (cible du bouton « descendre »)
st.markdown('<div id="bas"></div>', unsafe_allow_html=True)