toile() laissait les marges par défaut de matplotlib : une unité valait ~0,78 cm au lieu de 1 cm, les boîtes étaient donc trop petites pour leur texte. Les axes occupent désormais toute la figure, et boite() réduit la police si le texte dépasse encore (ajuster_texte). En-tête et pied de page : grille et align() à la place de #h(1fr), neutralisé par le préambule de Quarto (titre renvoyé à la ligne, numéro de page à gauche). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
7.2 KiB
Python
153 lines
7.2 KiB
Python
"""Briques graphiques communes : toile de dessin, boîtes, flèches, chronogrammes, séquences."""
|
||
import matplotlib.pyplot as plt
|
||
from matplotlib.patches import FancyBboxPatch
|
||
|
||
from style import BLEU, CM, ENCRE, GRIS, GRIS_CLAIR
|
||
|
||
CLAIR = { # fonds pâles associés aux couleurs de la charte
|
||
BLEU: "#e8f0fe", "#e07a1f": "#fdf0e3", "#2e8b57": "#e6f4ea",
|
||
"#c0392b": "#fce8e6", GRIS: "#f3f4f6",
|
||
}
|
||
|
||
|
||
def toile(largeur_cm, hauteur_cm, xmax=None, ymax=None):
|
||
"""Figure sans axes, en coordonnées « cm » (1 unité = 1 cm à l'échelle d'origine)."""
|
||
fig = plt.figure(figsize=(largeur_cm * CM, hauteur_cm * CM))
|
||
ax = fig.add_axes((0, 0, 1, 1)) # pas de marges : 1 unité = 1 cm, police et boîtes à la même échelle
|
||
ax.set_xlim(0, xmax or largeur_cm)
|
||
ax.set_ylim(0, ymax or hauteur_cm)
|
||
ax.set_aspect("equal")
|
||
ax.axis("off")
|
||
return fig, ax
|
||
|
||
|
||
def boite(ax, x, y, l, h, texte="", couleur=BLEU, taille=9, gras=False, fond=None, style="round", alpha=1):
|
||
"""Boîte arrondie centrée sur (x, y). Renvoie un dict des points d'ancrage."""
|
||
fond = fond if fond is not None else CLAIR.get(couleur, "white")
|
||
ax.add_patch(FancyBboxPatch((x - l / 2, y - h / 2), l, h,
|
||
boxstyle=f"{style},pad=0,rounding_size=0.15" if style == "round" else style,
|
||
facecolor=fond, edgecolor=couleur, linewidth=1.4, alpha=alpha))
|
||
if texte:
|
||
t = ax.text(x, y, texte, ha="center", va="center", fontsize=taille, color=ENCRE,
|
||
fontweight="bold" if gras else "normal", linespacing=1.3)
|
||
ajuster_texte(ax, t, l, h)
|
||
return {"c": (x, y), "g": (x - l / 2, y), "d": (x + l / 2, y),
|
||
"h": (x, y + h / 2), "b": (x, y - h / 2)}
|
||
|
||
|
||
def ajuster_texte(ax, t, l, h, marge=0.86, taille_min=5.5):
|
||
"""Réduit la police du texte t jusqu'à ce qu'il tienne dans une boîte l × h (unités de données)."""
|
||
rendu = ax.figure.canvas.get_renderer()
|
||
ax.apply_aspect() # sinon la mise à l'échelle « equal » n'est appliquée qu'au rendu final
|
||
(x0, y0), (x1, y1) = ax.transData.transform([(0, 0), (l, h)])
|
||
largeur_max, hauteur_max = abs(x1 - x0) * marge, abs(y1 - y0) * marge
|
||
while t.get_fontsize() > taille_min:
|
||
ext = t.get_window_extent(rendu)
|
||
if ext.width <= largeur_max and ext.height <= hauteur_max:
|
||
break
|
||
t.set_fontsize(t.get_fontsize() * 0.95)
|
||
|
||
|
||
def fleche(ax, depart, arrivee, texte="", couleur=ENCRE, taille=8, double=False, pointille=False,
|
||
cote="haut", decalage=0.18, courbe=0, fond=False):
|
||
"""Flèche entre deux points, avec étiquette optionnelle au milieu.
|
||
|
||
fond=True place l'étiquette sur fond blanc (utile quand elle croise des traits).
|
||
"""
|
||
ax.annotate("", xy=arrivee, xytext=depart,
|
||
arrowprops=dict(arrowstyle="<|-|>" if double else "-|>", color=couleur, lw=1.3,
|
||
linestyle="--" if pointille else "-", shrinkA=0, shrinkB=0,
|
||
mutation_scale=11, connectionstyle=f"arc3,rad={courbe}"))
|
||
if texte:
|
||
cadre = dict(boxstyle="square,pad=0.1", fc="white", ec="none") if fond else None
|
||
mx, my = (depart[0] + arrivee[0]) / 2, (depart[1] + arrivee[1]) / 2
|
||
vertical = abs(arrivee[0] - depart[0]) < abs(arrivee[1] - depart[1])
|
||
if vertical:
|
||
ax.text(mx + decalage, my, texte, ha="left", va="center", fontsize=taille, color=couleur,
|
||
bbox=cadre)
|
||
else:
|
||
dy = decalage if cote == "haut" else -decalage
|
||
ax.text(mx, my + dy, texte, ha="center", va="bottom" if cote == "haut" else "top",
|
||
fontsize=taille, color=couleur, bbox=cadre)
|
||
|
||
|
||
def chronogramme(voies, duree, largeur_cm=16, hauteur_voie_cm=1.5, graduations=None, unite="",
|
||
couleurs=None):
|
||
"""Chronogramme logique.
|
||
|
||
voies : liste de (nom, [(t, niveau), ...]) — niveau 0/1, maintenu jusqu'au point suivant.
|
||
Renvoie (fig, ax, y_voie) où y_voie(i) donne l'ordonnée de base de la voie i.
|
||
"""
|
||
n = len(voies)
|
||
fig, ax = plt.subplots(figsize=(largeur_cm * CM, (n * hauteur_voie_cm + 1.2) * CM))
|
||
ax.set_xlim(-duree * 0.02, duree * 1.01)
|
||
ax.set_ylim(-0.6, n * 1.6)
|
||
ax.spines[["left", "top", "right"]].set_visible(False)
|
||
ax.set_yticks([])
|
||
ax.grid(axis="y", visible=False)
|
||
if graduations is not None:
|
||
ax.set_xticks(graduations)
|
||
if unite:
|
||
ax.set_xlabel(unite)
|
||
y_voie = lambda i: (n - 1 - i) * 1.6
|
||
for i, (nom, points) in enumerate(voies):
|
||
base = y_voie(i)
|
||
xs, ys = [], []
|
||
for k, (t, niv) in enumerate(points):
|
||
fin = points[k + 1][0] if k + 1 < len(points) else duree
|
||
xs += [t, fin]
|
||
ys += [base + niv, base + niv]
|
||
c = (couleurs or {}).get(nom, BLEU)
|
||
ax.plot(xs, ys, color=c, lw=1.8, solid_joinstyle="miter")
|
||
ax.fill_between(xs, base, ys, color=c, alpha=0.08)
|
||
ax.text(-duree * 0.025, base + 0.5, nom, ha="right", va="center", fontsize=9,
|
||
color=ENCRE, fontweight="bold")
|
||
return fig, ax, y_voie
|
||
|
||
|
||
def sequence(acteurs, messages, largeur_cm=16, pas_cm=0.85, couleurs=None, notes=()):
|
||
"""Diagramme de séquence.
|
||
|
||
acteurs : liste de noms. messages : liste de (source, cible, texte[, pointillé]).
|
||
notes : liste de (index_message, acteur, texte) affichées à droite de la ligne de vie.
|
||
"""
|
||
n = len(messages)
|
||
hauteur = n * pas_cm + 2.2
|
||
fig, ax = toile(largeur_cm, hauteur)
|
||
xs = {a: largeur_cm * (i + 0.5) / len(acteurs) for i, a in enumerate(acteurs)}
|
||
haut = hauteur - 0.6
|
||
for a, x in xs.items():
|
||
c = (couleurs or {}).get(a, BLEU)
|
||
largeur = min(largeur_cm / len(acteurs) - 0.3, 0.2 * len(a) + 0.9)
|
||
boite(ax, x, haut, largeur, 0.8, a, couleur=c, taille=8.5, gras=True)
|
||
ax.plot([x, x], [0.55, haut - 0.4], color=GRIS, lw=1, ls=(0, (3, 3)), zorder=0)
|
||
for k, msg in enumerate(messages):
|
||
src, dst, texte = msg[:3]
|
||
pointille = len(msg) > 3 and msg[3]
|
||
y = haut - 1.2 - k * pas_cm
|
||
if src == dst:
|
||
# Boucle vers soi-même : petit rectangle ouvert à droite de la ligne de vie
|
||
x = xs[src]
|
||
ax.plot([x, x + 0.45, x + 0.45], [y + 0.12, y + 0.12, y - 0.22], color=ENCRE, lw=1.1)
|
||
fleche(ax, (x + 0.45, y - 0.22), (x + 0.02, y - 0.22))
|
||
ax.text(x + 0.6, y - 0.05, texte, fontsize=7.8, va="center", color=ENCRE,
|
||
bbox=dict(boxstyle="square,pad=0.1", fc="white", ec="none"))
|
||
continue
|
||
fleche(ax, (xs[src], y), (xs[dst], y), texte, pointille=pointille, taille=7.8, decalage=0.08,
|
||
fond=True)
|
||
for k, acteur, texte in notes:
|
||
y = haut - 1.2 - k * pas_cm
|
||
ax.text(xs[acteur] + 0.2, y, texte, fontsize=7.5, color=GRIS, style="italic", va="center")
|
||
return fig, ax
|
||
|
||
|
||
def legende_couleurs(ax, elements, x, y, taille=7.5):
|
||
"""Petite légende manuelle : elements = [(couleur, texte), ...]."""
|
||
for i, (c, t) in enumerate(elements):
|
||
ax.add_patch(plt.Rectangle((x, y - i * 0.45 - 0.12), 0.35, 0.25, color=c))
|
||
ax.text(x + 0.5, y - i * 0.45, t, fontsize=taille, va="center", color=ENCRE)
|
||
|
||
|
||
__all__ = ["toile", "boite", "fleche", "chronogramme", "sequence", "legende_couleurs",
|
||
"CLAIR", "GRIS_CLAIR"]
|