Spaces:
Running
Running
| # -*- 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) | |
| 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 = " · ".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) | |