| 1 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
1 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2 |
2 | +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3 |
| 3 | # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit |
3 | # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit |
| 4 | −# les rapports estampillés Groupe-KA. 5 modes : |
4 | +# les rapports estampillés Groupe-KA. 5 modes fixes : |
| 5 | # complet — toutes les sections (KPI, jauges, séries + stats, multi- |
5 | # complet — toutes les sections (KPI, jauges, séries + stats, multi- |
| 6 | # séries, empilées, distributions, répartitions, géo, |
6 | # séries, empilées, distributions, répartitions, géo, |
| 7 | # heatmap horaire, tableaux, records) |
7 | # heatmap horaire, tableaux, records) |
| 9 | # tendances — KPI + toutes les séries temporelles + stats de séries |
9 | # tendances — KPI + toutes les séries temporelles + stats de séries |
| 10 | # repartitions — breakdowns, distributions, géo, activité horaire |
10 | # repartitions — breakdowns, distributions, géo, activité horaire |
| 11 | # donnees — tous les tableaux en version longue (400 lignes max) |
11 | # donnees — tous les tableaux en version longue (400 lignes max) |
| |
12 | +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par |
| |
13 | +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/ |
| |
14 | +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport |
| |
15 | +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts", |
| |
16 | +# "render": "bar"}, …]} et respecte l'ordre demandé. |
| 12 | # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. |
17 | # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. |
| 13 | # Usage : |
18 | # Usage : |
| 14 | −# from kapdf import GroupeKAReport, REPORT_MODES, filename |
19 | +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename |
| 15 | # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", |
20 | # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", |
| 16 | # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, |
21 | # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, |
| 17 | # mode="complet").build() |
22 | # mode="complet").build() |
| |
23 | +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise", |
| |
24 | +# spec={"title": "Mon rapport", "blocks": [...]}).build() |
| 18 | # Dépendance : pip install fpdf2 (aucune autre) |
25 | # Dépendance : pip install fpdf2 (aucune autre) |
| 19 | from __future__ import annotations |
26 | from __future__ import annotations |
| 20 | |
27 | |
| 40 | "repartitions": "Répartitions & géographie", |
47 | "repartitions": "Répartitions & géographie", |
| 41 | "donnees": "Données détaillées", |
48 | "donnees": "Données détaillées", |
| 42 | } |
49 | } |
| |
50 | +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes) |
| |
51 | +CUSTOM_MODE = "personnalise" |
| |
52 | +CUSTOM_LABEL = "Rapport personnalisé" |
| |
53 | + |
| |
54 | +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ; |
| |
55 | +# « table » est toujours offert : toute donnée a un équivalent tableau) |
| |
56 | +RENDER_LABELS = { |
| |
57 | + "line": "Courbe", "area": "Aire", "bar": "Barres verticales", |
| |
58 | + "bars": "Barres horizontales", "donut": "Anneau", |
| |
59 | + "lines": "Multi-courbes", "stacked": "Barres empilées", |
| |
60 | + "histogram": "Histogramme", "heatmap": "Heatmap", |
| |
61 | + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau", |
| |
62 | +} |
| |
63 | +SECTION_LABELS = { |
| |
64 | + "kpis": "Indicateurs", "gauges": "Taux & couvertures", |
| |
65 | + "series": "Évolution", "multiseries": "Comparaisons", |
| |
66 | + "stacked": "Compositions", "breakdowns": "Répartitions", |
| |
67 | + "distributions": "Distributions", "geo": "Géographie", |
| |
68 | + "heatmap": "Calendrier", "hourly": "Activité horaire", |
| |
69 | + "tables": "Tableaux", "records": "Records", |
| |
70 | +} |
| |
71 | + |
| |
72 | + |
| |
73 | +def catalog(dash: dict) -> list[dict]: |
| |
74 | + """v3 — blocs composables d'un dashboard : ce que le constructeur de |
| |
75 | + rapports personnalisés peut inclure, avec les rendus compatibles. |
| |
76 | + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard.""" |
| |
77 | + out: list[dict] = [] |
| |
78 | + |
| |
79 | + def add(key, title, renders, default=None, count=None): |
| |
80 | + b = {"key": key, "section": key.split(":")[0], "title": title, |
| |
81 | + "renders": renders, "default_render": default or renders[0]} |
| |
82 | + if count is not None: |
| |
83 | + b["count"] = count |
| |
84 | + out.append(b) |
| |
85 | + |
| |
86 | + if dash.get("kpis"): |
| |
87 | + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], |
| |
88 | + count=len(dash["kpis"])) |
| |
89 | + gs = [g for g in (dash.get("gauges") or []) |
| |
90 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] |
| |
91 | + if gs: |
| |
92 | + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], |
| |
93 | + count=len(gs)) |
| |
94 | + for s in dash.get("series") or []: |
| |
95 | + if len(s.get("points") or []) < 2: |
| |
96 | + continue |
| |
97 | + kind = s.get("kind") or "line" |
| |
98 | + default = kind if kind in ("line", "area", "bar") else "line" |
| |
99 | + add(f"series:{s.get('id')}", s.get("title", ""), |
| |
100 | + ["line", "area", "bar", "table"], default, |
| |
101 | + len(s.get("points") or [])) |
| |
102 | + for ms in dash.get("multiseries") or []: |
| |
103 | + if not (ms.get("series") or []): |
| |
104 | + continue |
| |
105 | + add(f"multiseries:{ms.get('id')}", ms.get("title", ""), |
| |
106 | + ["lines", "table"], count=len(ms["series"])) |
| |
107 | + for st in dash.get("stacked") or []: |
| |
108 | + if not (st.get("points") or []): |
| |
109 | + continue |
| |
110 | + add(f"stacked:{st.get('id')}", st.get("title", ""), |
| |
111 | + ["stacked", "table"], count=len(st.get("keys") or [])) |
| |
112 | + for b in dash.get("breakdowns") or []: |
| |
113 | + if not (b.get("items") or []): |
| |
114 | + continue |
| |
115 | + default = "donut" if b.get("kind") == "donut" else "bars" |
| |
116 | + add(f"breakdowns:{b.get('id')}", b.get("title", ""), |
| |
117 | + ["donut", "bars", "table"], default, len(b["items"])) |
| |
118 | + for d in dash.get("distributions") or []: |
| |
119 | + if not (d.get("bins") or []): |
| |
120 | + continue |
| |
121 | + add(f"distributions:{d.get('id')}", d.get("title", ""), |
| |
122 | + ["histogram", "table"], count=len(d["bins"])) |
| |
123 | + geo = dash.get("geo") or {} |
| |
124 | + if geo.get("items"): |
| |
125 | + add("geo", geo.get("title", "Répartition géographique"), |
| |
126 | + ["bars", "table"], count=len(geo["items"])) |
| |
127 | + hm = dash.get("heatmap") or {} |
| |
128 | + if hm.get("cells"): |
| |
129 | + add("heatmap", hm.get("title", "Calendrier d'activité"), |
| |
130 | + ["heatmap", "table"]) |
| |
131 | + hr = dash.get("hourly") or {} |
| |
132 | + if hr.get("cells"): |
| |
133 | + add("hourly", hr.get("title", "Activité par jour et heure"), |
| |
134 | + ["heatmap", "table"]) |
| |
135 | + for t in dash.get("tables") or []: |
| |
136 | + if not (t.get("rows") or []): |
| |
137 | + continue |
| |
138 | + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"], |
| |
139 | + count=len(t["rows"])) |
| |
140 | + if dash.get("records"): |
| |
141 | + add("records", "Records & faits marquants", ["cards", "table"], |
| |
142 | + count=len(dash["records"])) |
| |
143 | + return out |
| 43 | |
144 | |
| 44 | EMAILS = [ |
145 | EMAILS = [ |
| 45 | ("contact@groupe-ka.com", "Projets, partenariats & données"), |
146 | ("contact@groupe-ka.com", "Projets, partenariats & données"), |
| 127 | |
228 | |
| 128 | |
229 | |
| 129 | class GroupeKAReport: |
230 | class GroupeKAReport: |
| 130 | − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): |
231 | + def __init__(self, site: dict, dashboard: dict, mode: str = "complet", |
| |
232 | + spec: dict | None = None): |
| 131 | self.site = site |
233 | self.site = site |
| 132 | self.d = dashboard |
234 | self.d = dashboard |
| 133 | − self.mode = mode if mode in REPORT_MODES else "complet" |
235 | + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet" |
| |
236 | + self.spec = spec or {} |
| 134 | self.accent = _hex(site.get("accent", "#d9f26b")) |
237 | self.accent = _hex(site.get("accent", "#d9f26b")) |
| 135 | period = dashboard.get("period", {}) or {} |
238 | period = dashboard.get("period", {}) or {} |
| 136 | self.period_label = period.get("label") or "toute la période" |
239 | self.period_label = period.get("label") or "toute la période" |
| 137 | self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) |
240 | self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) |
| 138 | self.toc: list[tuple[str, int]] = [] |
241 | self.toc: list[tuple[str, int]] = [] |
| 139 | |
242 | |
| |
243 | + @property |
| |
244 | + def mode_label(self) -> str: |
| |
245 | + if self.mode == CUSTOM_MODE: |
| |
246 | + t = str(self.spec.get("title") or "").strip() |
| |
247 | + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL |
| |
248 | + return REPORT_MODES[self.mode] |
| |
249 | + |
| 140 | # ---------- primitives ---------- |
250 | # ---------- primitives ---------- |
| 141 | def _card(self, x, y, w, h, fill=WHITE): |
251 | def _card(self, x, y, w, h, fill=WHITE): |
| 142 | p = self.pdf |
252 | p = self.pdf |
| 213 | p.set_xy(24, 100) |
323 | p.set_xy(24, 100) |
| 214 | p.set_font("helvetica", "", 13) |
324 | p.set_font("helvetica", "", 13) |
| 215 | p.set_text_color(*INK2) |
325 | p.set_text_color(*INK2) |
| 216 | − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}") |
326 | + p.multi_cell(150, 7, f"{self.mode_label} — {wm}") |
| 217 | now = datetime.now(ZoneInfo("America/Toronto")) |
327 | now = datetime.now(ZoneInfo("America/Toronto")) |
| 218 | per = self.d.get("period", {}) or {} |
328 | per = self.d.get("period", {}) or {} |
| 219 | p.set_xy(24, 125) |
329 | p.set_xy(24, 125) |
| 222 | ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), |
332 | ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), |
| 223 | ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), |
333 | ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), |
| 224 | ("Plateforme", "https://" + self.site.get("domain", "")), |
334 | ("Plateforme", "https://" + self.site.get("domain", "")), |
| 225 | − ("Type de rapport", REPORT_MODES[self.mode]), |
335 | + ("Type de rapport", self.mode_label), |
| 226 | ] |
336 | ] |
| 227 | y = 128 |
337 | y = 128 |
| 228 | for k, v in rows: |
338 | for k, v in rows: |
| 685 | p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") |
795 | p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") |
| 686 | p.set_y(y0 + ly + 7 * chh + 5) |
796 | p.set_y(y0 + ly + 7 * chh + 5) |
| 687 | |
797 | |
| |
798 | + def _calheat(self, hm): |
| |
799 | + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du |
| |
800 | + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours.""" |
| |
801 | + from datetime import date as _date, timedelta as _td |
| |
802 | + cells = hm.get("cells") or [] |
| |
803 | + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")} |
| |
804 | + if not vals: |
| |
805 | + return |
| |
806 | + p = self.pdf |
| |
807 | + if p.get_y() > 215: |
| |
808 | + p.add_page() |
| |
809 | + self._chart_title(hm.get("title", "Calendrier d'activité")) |
| |
810 | + try: |
| |
811 | + end = _date.fromisoformat(max(vals)) |
| |
812 | + except ValueError: |
| |
813 | + return |
| |
814 | + weeks = 26 |
| |
815 | + start = end - _td(days=weeks * 7 - 1) |
| |
816 | + start -= _td(days=start.weekday()) # lundi |
| |
817 | + vmax = max(vals.values()) or 1 |
| |
818 | + x0, y0 = p.l_margin, p.get_y() |
| |
819 | + cw, lx, ly = 6.3, 10, 4 |
| |
820 | + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"] |
| |
821 | + p.set_font("helvetica", "", 5.8) |
| |
822 | + p.set_text_color(*INK3) |
| |
823 | + for d in range(7): |
| |
824 | + if dows[d]: |
| |
825 | + p.set_xy(x0, y0 + ly + d * cw + 1.2) |
| |
826 | + p.cell(lx - 1, 3, dows[d], align="R") |
| |
827 | + for w in range(weeks): |
| |
828 | + monday = start + _td(days=7 * w) |
| |
829 | + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois |
| |
830 | + p.set_xy(x0 + lx + w * cw, y0) |
| |
831 | + p.cell(cw * 4, 3, monday.strftime("%m")) |
| |
832 | + for d in range(7): |
| |
833 | + day = monday + _td(days=d) |
| |
834 | + v = vals.get(day.isoformat(), 0) |
| |
835 | + f = 0.15 + 0.85 * (v / vmax) if v else 0.0 |
| |
836 | + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) |
| |
837 | + for j in range(3)) if v else (235, 233, 228)) |
| |
838 | + p.set_fill_color(*col) |
| |
839 | + p.set_draw_color(215, 213, 207) |
| |
840 | + p.set_line_width(0.1) |
| |
841 | + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5, |
| |
842 | + style="DF") |
| |
843 | + p.set_y(y0 + ly + 7 * cw + 5) |
| |
844 | + |
| |
845 | + # ---------- v3 : conversions bloc → tableau ---------- |
| |
846 | + @staticmethod |
| |
847 | + def _serie_as_table(s): |
| |
848 | + unit = s.get("unit") or "Valeur" |
| |
849 | + cols = ["Date", unit.capitalize()] |
| |
850 | + cmp_ = s.get("compare") or [] |
| |
851 | + if cmp_: |
| |
852 | + cols.append("Période comparée") |
| |
853 | + rows = [] |
| |
854 | + for i, pt in enumerate(s.get("points") or []): |
| |
855 | + row = [str(pt.get("t", "")), pt.get("v", "")] |
| |
856 | + if cmp_: |
| |
857 | + row.append(cmp_[i]["v"] if i < len(cmp_) else "") |
| |
858 | + rows.append(row) |
| |
859 | + return {"id": s.get("id"), "title": s.get("title", ""), |
| |
860 | + "columns": cols, "rows": rows} |
| |
861 | + |
| |
862 | + @staticmethod |
| |
863 | + def _multi_as_table(ms): |
| |
864 | + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4] |
| |
865 | + by_t: dict[str, dict] = {} |
| |
866 | + for s in (ms.get("series") or [])[:4]: |
| |
867 | + for pt in s.get("points") or []: |
| |
868 | + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v") |
| |
869 | + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels] |
| |
870 | + for t in sorted(by_t)] |
| |
871 | + return {"id": ms.get("id"), "title": ms.get("title", ""), |
| |
872 | + "columns": ["Date"] + labels, "rows": rows} |
| |
873 | + |
| |
874 | + @staticmethod |
| |
875 | + def _stacked_as_table(st): |
| |
876 | + keys = (st.get("keys") or [])[:6] |
| |
877 | + rows = [] |
| |
878 | + for pt in st.get("points") or []: |
| |
879 | + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0 |
| |
880 | + for j in range(len(keys))] |
| |
881 | + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)]) |
| |
882 | + return {"id": st.get("id"), "title": st.get("title", ""), |
| |
883 | + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows} |
| |
884 | + |
| |
885 | + @staticmethod |
| |
886 | + def _items_as_table(id_, title, items, label_col="Libellé"): |
| |
887 | + items = items or [] |
| |
888 | + with_delta = any(it.get("delta_pct") is not None for it in items) |
| |
889 | + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else []) |
| |
890 | + rows = [] |
| |
891 | + for it in items: |
| |
892 | + row = [str(it.get("label", "")), it.get("value", "")] |
| |
893 | + if with_delta: |
| |
894 | + d = it.get("delta_pct") |
| |
895 | + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %") |
| |
896 | + rows.append(row) |
| |
897 | + return {"id": id_, "title": title, "columns": cols, "rows": rows} |
| |
898 | + |
| |
899 | + def _kpis_as_table(self): |
| |
900 | + rows = [] |
| |
901 | + for k in self.d.get("kpis") or []: |
| |
902 | + v = k.get("value") |
| |
903 | + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \ |
| |
904 | + ((" " + k["unit"]) if k.get("unit") else "") |
| |
905 | + d = k.get("delta_pct") |
| |
906 | + rows.append([str(k.get("label", "")), val, |
| |
907 | + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"]) |
| |
908 | + return {"id": "kpis", "title": "Indicateurs clés", |
| |
909 | + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows} |
| |
910 | + |
| |
911 | + def _gauges_as_table(self): |
| |
912 | + rows = [[str(g.get("label", "")), |
| |
913 | + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", |
| |
914 | + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"] |
| |
915 | + for g in self.d.get("gauges") or [] |
| |
916 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] |
| |
917 | + return {"id": "gauges", "title": "Taux & couvertures", |
| |
918 | + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows} |
| |
919 | + |
| |
920 | + def _records_as_table(self): |
| |
921 | + rows = [[str(r.get("label", "")), str(r.get("value", "")), |
| |
922 | + str(r.get("date", "") or "")] |
| |
923 | + for r in self.d.get("records") or []] |
| |
924 | + return {"id": "records", "title": "Records & faits marquants", |
| |
925 | + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows} |
| |
926 | + |
| |
927 | + @staticmethod |
| |
928 | + def _heatmap_as_table(hm, title): |
| |
929 | + cells = sorted((hm.get("cells") or []), |
| |
930 | + key=lambda c: -(c.get("value") or 0))[:40] |
| |
931 | + return {"id": "heatmap", "title": title + " — jours les plus chargés", |
| |
932 | + "columns": ["Date", "Valeur"], |
| |
933 | + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]} |
| |
934 | + |
| |
935 | + @staticmethod |
| |
936 | + def _hourly_as_table(hr, title): |
| |
937 | + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", |
| |
938 | + "Dimanche"] |
| |
939 | + cells = sorted((hr.get("cells") or []), |
| |
940 | + key=lambda c: -(c.get("value") or 0))[:40] |
| |
941 | + return {"id": "hourly", "title": title + " — créneaux les plus actifs", |
| |
942 | + "columns": ["Jour", "Heure", "Valeur"], |
| |
943 | + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?", |
| |
944 | + f"{c.get('hour', '?')} h", c.get("value") or 0] |
| |
945 | + for c in cells]} |
| |
946 | + |
| |
947 | + # ---------- v3 : rendu d'un bloc du rapport personnalisé ---------- |
| |
948 | + def _find(self, coll: str, id_: str): |
| |
949 | + for it in self.d.get(coll) or []: |
| |
950 | + if str(it.get("id")) == id_: |
| |
951 | + return it |
| |
952 | + return None |
| |
953 | + |
| |
954 | + def _toc_mark(self, title: str): |
| |
955 | + """Blocs graphiques du mode personnalisé : entrée de sommaire sans |
| |
956 | + _section_title (le graphique porte déjà son titre).""" |
| |
957 | + if self.pdf.get_y() > 235: |
| |
958 | + self.pdf.add_page() |
| |
959 | + self.toc.append((title, self.pdf.page_no())) |
| |
960 | + |
| |
961 | + def _render_block(self, key: str, render: str): |
| |
962 | + section, _, id_ = key.partition(":") |
| |
963 | + if section == "kpis": |
| |
964 | + self._table(self._kpis_as_table()) if render == "table" else self._kpis() |
| |
965 | + elif section == "gauges": |
| |
966 | + self._table(self._gauges_as_table()) if render == "table" else self._gauges() |
| |
967 | + elif section == "records": |
| |
968 | + self._table(self._records_as_table()) if render == "table" else self._records() |
| |
969 | + elif section == "series": |
| |
970 | + s = self._find("series", id_) |
| |
971 | + if not s: |
| |
972 | + return |
| |
973 | + if render == "table": |
| |
974 | + self._table(self._serie_as_table(s), max_rows=400) |
| |
975 | + else: |
| |
976 | + s2 = dict(s) |
| |
977 | + if render in ("line", "area", "bar"): |
| |
978 | + s2["kind"] = render |
| |
979 | + self._toc_mark(s2.get("title", "")) |
| |
980 | + if s2.get("kind") == "bar": |
| |
981 | + self._vbars(s2) |
| |
982 | + else: |
| |
983 | + self._line_chart(s2, with_stats=True) |
| |
984 | + elif section == "multiseries": |
| |
985 | + ms = self._find("multiseries", id_) |
| |
986 | + if not ms: |
| |
987 | + return |
| |
988 | + if render == "table": |
| |
989 | + self._table(self._multi_as_table(ms), max_rows=400) |
| |
990 | + else: |
| |
991 | + self._toc_mark(ms.get("title", "")) |
| |
992 | + self._multiline(ms) |
| |
993 | + elif section == "stacked": |
| |
994 | + st = self._find("stacked", id_) |
| |
995 | + if not st: |
| |
996 | + return |
| |
997 | + if render == "table": |
| |
998 | + self._table(self._stacked_as_table(st), max_rows=400) |
| |
999 | + else: |
| |
1000 | + self._toc_mark(st.get("title", "")) |
| |
1001 | + self._stacked(st) |
| |
1002 | + elif section == "breakdowns": |
| |
1003 | + b = self._find("breakdowns", id_) |
| |
1004 | + if not b: |
| |
1005 | + return |
| |
1006 | + if render == "table": |
| |
1007 | + self._table(self._items_as_table(id_, b.get("title", ""), |
| |
1008 | + b.get("items")), max_rows=400) |
| |
1009 | + else: |
| |
1010 | + self._toc_mark(b.get("title", "")) |
| |
1011 | + if render == "donut": |
| |
1012 | + self._donut(b) |
| |
1013 | + else: |
| |
1014 | + self._bars(b.get("title", ""), b.get("items")) |
| |
1015 | + elif section == "distributions": |
| |
1016 | + d = self._find("distributions", id_) |
| |
1017 | + if not d: |
| |
1018 | + return |
| |
1019 | + if render == "table": |
| |
1020 | + bins = [{"label": bn.get("label"), "value": bn.get("value")} |
| |
1021 | + for bn in d.get("bins") or []] |
| |
1022 | + self._table(self._items_as_table(id_, d.get("title", ""), bins, |
| |
1023 | + label_col="Tranche")) |
| |
1024 | + else: |
| |
1025 | + self._toc_mark(d.get("title", "")) |
| |
1026 | + self._vbars(d) |
| |
1027 | + elif section == "geo": |
| |
1028 | + geo = self.d.get("geo") or {} |
| |
1029 | + if not geo.get("items"): |
| |
1030 | + return |
| |
1031 | + title = geo.get("title", "Répartition géographique") |
| |
1032 | + if render == "table": |
| |
1033 | + self._table(self._items_as_table("geo", title, geo["items"], |
| |
1034 | + label_col="Zone"), max_rows=400) |
| |
1035 | + else: |
| |
1036 | + self._toc_mark(title) |
| |
1037 | + self._bars(title, geo["items"]) |
| |
1038 | + elif section == "heatmap": |
| |
1039 | + hm = self.d.get("heatmap") or {} |
| |
1040 | + if not hm.get("cells"): |
| |
1041 | + return |
| |
1042 | + title = hm.get("title", "Calendrier d'activité") |
| |
1043 | + if render == "table": |
| |
1044 | + self._table(self._heatmap_as_table(hm, title)) |
| |
1045 | + else: |
| |
1046 | + self._toc_mark(title) |
| |
1047 | + self._calheat(hm) |
| |
1048 | + elif section == "hourly": |
| |
1049 | + hr = self.d.get("hourly") or {} |
| |
1050 | + if not hr.get("cells"): |
| |
1051 | + return |
| |
1052 | + title = hr.get("title", "Activité par jour et heure") |
| |
1053 | + if render == "table": |
| |
1054 | + self._table(self._hourly_as_table(hr, title)) |
| |
1055 | + else: |
| |
1056 | + self._toc_mark(title) |
| |
1057 | + self._hourly() |
| |
1058 | + elif section == "tables": |
| |
1059 | + t = self._find("tables", id_) |
| |
1060 | + if t: |
| |
1061 | + self._table(t, max_rows=400) |
| |
1062 | + |
| 688 | def _table(self, t, max_rows=200): |
1063 | def _table(self, t, max_rows=200): |
| 689 | p = self.pdf |
1064 | p = self.pdf |
| 690 | cols = t.get("columns") or [] |
1065 | cols = t.get("columns") or [] |
| 809 | p = self.pdf |
1184 | p = self.pdf |
| 810 | p.alias_nb_pages() |
1185 | p.alias_nb_pages() |
| 811 | self._cover() |
1186 | self._cover() |
| 812 | − with_toc = self.mode in ("complet", "donnees") |
1187 | + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE) |
| 813 | toc_page_no = None |
1188 | toc_page_no = None |
| 814 | if self.mode == "synthese": |
1189 | if self.mode == "synthese": |
| 815 | p.add_page() |
1190 | p.add_page() |
| 835 | for t in self.d.get("tables") or []: |
1210 | for t in self.d.get("tables") or []: |
| 836 | self._table(t, max_rows=400) |
1211 | self._table(t, max_rows=400) |
| 837 | self._final_page() |
1212 | self._final_page() |
| |
1213 | + elif self.mode == CUSTOM_MODE: |
| |
1214 | + p.add_page() |
| |
1215 | + toc_page_no = p.page_no() |
| |
1216 | + p.add_page() |
| |
1217 | + known = {b["key"]: b for b in catalog(self.d)} |
| |
1218 | + for blk in self.spec.get("blocks") or []: |
| |
1219 | + key = str(blk.get("key", "")) |
| |
1220 | + b = known.get(key) |
| |
1221 | + if not b: |
| |
1222 | + continue |
| |
1223 | + render = str(blk.get("render") or "") |
| |
1224 | + if render not in b["renders"]: |
| |
1225 | + render = b["default_render"] |
| |
1226 | + self._render_block(key, render) |
| |
1227 | + self._final_page() |
| 838 | else: # complet |
1228 | else: # complet |
| 839 | p.add_page() |
1229 | p.add_page() |
| 840 | toc_page_no = p.page_no() |
1230 | toc_page_no = p.page_no() |
| 841 | |
1231 | |