feat: catalogue data vague 1 - charts, waterfall, heatmap, funnel, agenda, pyramid (C5)
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c5a.py — Chantier C5 · lot 1 (charts natifs)
|
||||
================================================================
|
||||
Patch strict de render_engine_v2.py — trois layouts de données en
|
||||
graphiques NATIFS python-pptx (éditables dans PowerPoint, données
|
||||
modifiables par le lecteur — pas des images) :
|
||||
|
||||
bar_chart (L42) — colonnes ou barres (horizontal: true), max
|
||||
8 catégories × 3 séries, axe des valeurs masqué,
|
||||
étiquettes de valeurs affichées, style consulting.
|
||||
line_chart (L43) — courbes à marqueurs, max 12 points × 3 séries,
|
||||
gridlines très claires, dernier point de la
|
||||
série 1 étiqueté en corail gras (règle fixe).
|
||||
donut_split (L44) — anneau (60 % gauche, trou 65, segments au cycle
|
||||
PR) + valeur centrale + légende détaillée custom
|
||||
à droite (pastille, label, valeur).
|
||||
|
||||
Style PR en dur : séries navy → coral → glacier, Calibri, aucune
|
||||
couleur libre. Bornes appliquées par troncature tracée (warning
|
||||
console) — les schémas C1 et les prompts bornent en amont.
|
||||
|
||||
P1. Imports chart python-pptx.
|
||||
P2. Composants _num/_chart_base + 3 renderers (avant orchestration).
|
||||
P3. REGISTRY : les 3 entrées.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c5a.py
|
||||
Indépendant des patchs C3/C4 (tout ordre). Vérifie chaque ancre,
|
||||
écrit .bak-c5a, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
COMPONENTS = ''' # ---------------- charts natifs (C5 lot 1) ----------------
|
||||
|
||||
def _num(self, v, default=0.0):
|
||||
"""Nombre robuste : int/float directs, chaînes '12,5' ou '12.5'."""
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).replace(",", ".").replace(" ", ""))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
def _chart_series_colors(self):
|
||||
return [self.C["navy"], self.C["coral"], self.C["glacier"]]
|
||||
|
||||
def _chart_base(self, slide, x, y, w, h, chart_type, chart_data):
|
||||
"""Insertion + style de base commun (police, taille, couleur)."""
|
||||
gf = slide.shapes.add_chart(chart_type, Cm(x), Cm(y),
|
||||
Cm(w), Cm(h), chart_data)
|
||||
ch = gf.chart
|
||||
ch.font.name = self.F_BODY
|
||||
ch.font.size = Pt(11)
|
||||
ch.font.color.rgb = hex_to_rgb(self.C["body"])
|
||||
return ch
|
||||
|
||||
def _chart_caption(self, slide, d):
|
||||
"""Unité (droite de la zone titre) et source (bas de page)."""
|
||||
unite = pick(d, "unite", "unit")
|
||||
if unite:
|
||||
self._text(slide, self.SLIDE_W - self.MX - 8.0,
|
||||
self.TITLE_Y + 0.35, 8.0, 0.8, f"en {unite}",
|
||||
size=12, italic=True, color=self.C["muted"],
|
||||
align=PP_ALIGN.RIGHT)
|
||||
source = pick(d, "source")
|
||||
if source:
|
||||
self._text(slide, self.MX, self.CONT_B - 0.55,
|
||||
self.SLIDE_W - 2 * self.MX, 0.55,
|
||||
f"Source : {source}", size=10,
|
||||
color=self.C["muted"])
|
||||
|
||||
def _chart_zone(self, d):
|
||||
"""Zone du graphique (réserve la ligne source si présente)."""
|
||||
h = self.CONT_H - (0.7 if pick(d, "source") else 0.0)
|
||||
return self.MX, self.CONT_Y, self.SLIDE_W - 2 * self.MX, h
|
||||
|
||||
def _render_bar_chart(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
|
||||
XL_LABEL_POSITION)
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
cats = [as_label(c) for c in (d.get("categories") or [])]
|
||||
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
|
||||
if len(cats) > 8:
|
||||
print(f" ~ bar_chart : {len(cats)} catégories → 8 (borne)")
|
||||
cats = cats[:8]
|
||||
if len(series) > 3:
|
||||
print(f" ~ bar_chart : {len(series)} séries → 3 (borne)")
|
||||
series = series[:3]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = cats
|
||||
for s in series:
|
||||
vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)]
|
||||
vals += [0.0] * (len(cats) - len(vals))
|
||||
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
horiz = bool(d.get("horizontal"))
|
||||
ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz
|
||||
else XL_CHART_TYPE.COLUMN_CLUSTERED)
|
||||
ch = self._chart_base(slide, x, y, w, h, ctype, cd)
|
||||
plot = ch.plots[0]
|
||||
plot.gap_width = 60
|
||||
if len(series) > 1:
|
||||
plot.overlap = -10
|
||||
for i, ser in enumerate(ch.series):
|
||||
ser.format.fill.solid()
|
||||
ser.format.fill.fore_color.rgb = hex_to_rgb(
|
||||
self._chart_series_colors()[i % 3])
|
||||
ch.value_axis.visible = False
|
||||
ch.value_axis.has_major_gridlines = False
|
||||
cat_ax = ch.category_axis
|
||||
cat_ax.has_major_gridlines = False
|
||||
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
|
||||
cat_ax.tick_labels.font.size = Pt(11)
|
||||
plot.has_data_labels = True
|
||||
dls = plot.data_labels
|
||||
dls.font.size = Pt(10)
|
||||
dls.font.bold = True
|
||||
dls.font.color.rgb = hex_to_rgb(self.C["body"])
|
||||
dls.position = (XL_LABEL_POSITION.OUTSIDE_END)
|
||||
ch.has_legend = len(series) > 1
|
||||
if ch.has_legend:
|
||||
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||
ch.legend.include_in_layout = False
|
||||
ch.legend.font.size = Pt(11)
|
||||
|
||||
def _render_line_chart(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
|
||||
XL_LABEL_POSITION, XL_MARKER_STYLE)
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
pts = [as_label(p) for p in (d.get("points_x") or [])]
|
||||
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
|
||||
if len(pts) > 12:
|
||||
print(f" ~ line_chart : {len(pts)} points → 12 (borne)")
|
||||
pts = pts[:12]
|
||||
if len(series) > 3:
|
||||
print(f" ~ line_chart : {len(series)} séries → 3 (borne)")
|
||||
series = series[:3]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = pts
|
||||
for s in series:
|
||||
vals = [self._num(v) for v in (s.get("values") or [])][:len(pts)]
|
||||
vals += [0.0] * (len(pts) - len(vals))
|
||||
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
ch = self._chart_base(slide, x, y, w, h,
|
||||
XL_CHART_TYPE.LINE_MARKERS, cd)
|
||||
for i, ser in enumerate(ch.series):
|
||||
col = hex_to_rgb(self._chart_series_colors()[i % 3])
|
||||
ser.format.line.color.rgb = col
|
||||
ser.format.line.width = Pt(2.25)
|
||||
ser.smooth = False
|
||||
ser.marker.style = XL_MARKER_STYLE.CIRCLE
|
||||
ser.marker.size = 6
|
||||
ser.marker.format.fill.solid()
|
||||
ser.marker.format.fill.fore_color.rgb = col
|
||||
ser.marker.format.line.fill.background()
|
||||
va = ch.value_axis
|
||||
va.has_major_gridlines = True
|
||||
va.major_gridlines.format.line.color.rgb = hex_to_rgb(
|
||||
self.C["card_alt"])
|
||||
va.tick_labels.font.size = Pt(10)
|
||||
va.tick_labels.font.color.rgb = hex_to_rgb(self.C["muted"])
|
||||
va.format.line.fill.background()
|
||||
cat_ax = ch.category_axis
|
||||
cat_ax.has_major_gridlines = False
|
||||
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
|
||||
cat_ax.tick_labels.font.size = Pt(11)
|
||||
ch.has_legend = len(series) > 1
|
||||
if ch.has_legend:
|
||||
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||
ch.legend.include_in_layout = False
|
||||
ch.legend.font.size = Pt(11)
|
||||
# Règle fixe : dernier point de la série 1 étiqueté corail gras
|
||||
if series and pts:
|
||||
try:
|
||||
last = ch.series[0].points[len(pts) - 1]
|
||||
dl = last.data_label
|
||||
dl.position = XL_LABEL_POSITION.ABOVE
|
||||
v = self._num((series[0].get("values") or [0])[
|
||||
min(len(pts), len(series[0].get("values") or [])) - 1])
|
||||
txt = ("%g" % v)
|
||||
dl.text_frame.text = txt
|
||||
run = dl.text_frame.paragraphs[0].runs[0]
|
||||
run.font.size = Pt(12)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = hex_to_rgb(self.C["coral"])
|
||||
run.font.name = self.F_BODY
|
||||
except Exception as e:
|
||||
print(f" ~ line_chart : étiquette dernier point sautée "
|
||||
f"({e})")
|
||||
|
||||
def _render_donut_split(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
segs = [s for s in (d.get("segments") or []) if isinstance(s, dict)]
|
||||
if len(segs) > 6:
|
||||
print(f" ~ donut_split : {len(segs)} segments → 6 (borne)")
|
||||
segs = segs[:6]
|
||||
labels = [pick(s, "label", default="—") for s in segs]
|
||||
vals = [self._num(pick(s, "valeur", "value")) for s in segs]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = labels
|
||||
cd.add_series("Répartition", tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
cw = w * 0.55
|
||||
side = min(cw, h)
|
||||
cx0 = x + (cw - side) / 2
|
||||
cy0 = y + (h - side) / 2
|
||||
ch = self._chart_base(slide, cx0, cy0, side, side,
|
||||
XL_CHART_TYPE.DOUGHNUT, cd)
|
||||
ch.has_legend = False
|
||||
for i, pt in enumerate(ch.series[0].points):
|
||||
pt.format.fill.solid()
|
||||
pt.format.fill.fore_color.rgb = hex_to_rgb(
|
||||
self.cycle[i % len(self.cycle)])
|
||||
pt.format.line.color.rgb = hex_to_rgb(self.C["white"])
|
||||
pt.format.line.width = Pt(1.5)
|
||||
plot_el = ch.plots[0]._element # <c:doughnutChart>
|
||||
hs = plot_el.find(qn("c:holeSize"))
|
||||
if hs is None:
|
||||
hs = plot_el.makeelement(qn("c:holeSize"), {})
|
||||
plot_el.append(hs)
|
||||
hs.set("val", "65")
|
||||
centre = pick(d, "valeur_centrale", "centre")
|
||||
if centre:
|
||||
self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2,
|
||||
centre, font=self.F_DISPLAY, size=24, bold=True,
|
||||
color=self.C["navy"], align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||
# Légende détaillée custom à droite
|
||||
lx = x + cw + 1.2
|
||||
lw = w - cw - 1.2
|
||||
ih, gap = 1.35, 0.45
|
||||
tot = len(segs) * (ih + gap) - gap if segs else 0
|
||||
ly = self._cy(tot)
|
||||
for i, s in enumerate(segs):
|
||||
self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5,
|
||||
self.cycle[i % len(self.cycle)])
|
||||
self._text(slide, lx + 1.0, ly, lw - 4.2, ih,
|
||||
pick(s, "label", default="—"),
|
||||
size=14, bold=True, color=self.C["body"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
self._text(slide, lx + lw - 3.2, ly, 3.2, ih,
|
||||
"%g" % self._num(pick(s, "valeur", "value")),
|
||||
size=14, color=self.C["muted"],
|
||||
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
|
||||
ly += ih + gap
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P3 — REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"bar_chart\": \"_render_bar_chart\",\n"
|
||||
" \"line_chart\": \"_render_line_chart\",\n"
|
||||
" \"donut_split\": \"_render_donut_split\",\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_bar_chart"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_bar_chart présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5a")
|
||||
print(" + Sauvegarde : %s.bak-c5a" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + Composants charts + 3 renderers + REGISTRY appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5a", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite lot 1 : python3 patch_layouts_c5a.py puis "
|
||||
"propagation (prompt_injection_v2 + build_gallery).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user