| 1 |
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 |
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 |
5 |
# complet — toutes les sections (KPI, jauges, séries + stats, multi- |
| 6 |
6 |
# séries, empilées, distributions, répartitions, géo, |
| 7 |
7 |
# heatmap horaire, tableaux, records) |
| 9 |
9 |
# tendances — KPI + toutes les séries temporelles + stats de séries |
| 10 |
10 |
# repartitions — breakdowns, distributions, géo, activité horaire |
| 11 |
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 |
17 |
# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. |
| 13 |
18 |
# Usage : |
| 14 |
|
−# from kapdf import GroupeKAReport, REPORT_MODES, filename |
|
19 |
+# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename |
| 15 |
20 |
# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", |
| 16 |
21 |
# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, |
| 17 |
22 |
# mode="complet").build() |
|
23 |
+# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise", |
|
24 |
+# spec={"title": "Mon rapport", "blocks": [...]}).build() |
| 18 |
25 |
# Dépendance : pip install fpdf2 (aucune autre) |
| 19 |
26 |
from __future__ import annotations |
| 20 |
27 |
|
| 40 |
47 |
"repartitions": "Répartitions & géographie", |
| 41 |
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 |
145 |
EMAILS = [ |
| 45 |
146 |
("contact@groupe-ka.com", "Projets, partenariats & données"), |
| 127 |
228 |
|
| 128 |
229 |
|
| 129 |
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 |
233 |
self.site = site |
| 132 |
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 |
237 |
self.accent = _hex(site.get("accent", "#d9f26b")) |
| 135 |
238 |
period = dashboard.get("period", {}) or {} |
| 136 |
239 |
self.period_label = period.get("label") or "toute la période" |
| 137 |
240 |
self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) |
| 138 |
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 |
250 |
# ---------- primitives ---------- |
| 141 |
251 |
def _card(self, x, y, w, h, fill=WHITE): |
| 142 |
252 |
p = self.pdf |
| 213 |
323 |
p.set_xy(24, 100) |
| 214 |
324 |
p.set_font("helvetica", "", 13) |
| 215 |
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 |
327 |
now = datetime.now(ZoneInfo("America/Toronto")) |
| 218 |
328 |
per = self.d.get("period", {}) or {} |
| 219 |
329 |
p.set_xy(24, 125) |
| 222 |
332 |
("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), |
| 223 |
333 |
("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), |
| 224 |
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 |
337 |
y = 128 |
| 228 |
338 |
for k, v in rows: |
| 685 |
795 |
p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") |
| 686 |
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 |
1063 |
def _table(self, t, max_rows=200): |
| 689 |
1064 |
p = self.pdf |
| 690 |
1065 |
cols = t.get("columns") or [] |
| 809 |
1184 |
p = self.pdf |
| 810 |
1185 |
p.alias_nb_pages() |
| 811 |
1186 |
self._cover() |
| 812 |
|
− with_toc = self.mode in ("complet", "donnees") |
|
1187 |
+ with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE) |
| 813 |
1188 |
toc_page_no = None |
| 814 |
1189 |
if self.mode == "synthese": |
| 815 |
1190 |
p.add_page() |
| 835 |
1210 |
for t in self.d.get("tables") or []: |
| 836 |
1211 |
self._table(t, max_rows=400) |
| 837 |
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 |
1228 |
else: # complet |
| 839 |
1229 |
p.add_page() |
| 840 |
1230 |
toc_page_no = p.page_no() |
| 841 |
1231 |
|