Livrables de presentation, seed de demo MNAHA et wireframes

- outputs/platform, outputs/prospect : scripts de construction des decks
  commerciaux + captures sources et .pptx generes.
- outputs/mnaha : seed complet de l'instance de demo luxembourgeoise
  (scripts par etape, contenus, images generees), le deck en version
  marque blanche et en version brandee, le PDF de presentation.
- outputs/Wireframes : maquettes app mobile, app web et manager.
- interview clients : notes de l'entretien Fourneau St Michel.

state.json et les __pycache__ sont ignores : le premier porte les cles
d'API de l'instance seedee, les scripts le regenerent.
This commit is contained in:
Thomas Fransolet 2026-09-11 15:47:57 +02:00
parent 29da4fcb83
commit d14f151517
135 changed files with 4723 additions and 0 deletions

6
.gitignore vendored
View File

@ -2,3 +2,9 @@
Thumbs.db
*.tmp
*~
__pycache__/
*.pyc
# Clés d'API de l'instance de démo seedée en local — régénérées par les scripts de seed
outputs/mnaha/seed/state.json

View File

@ -0,0 +1,68 @@
Fourneau st Michel
- Comment avez-vous eu les contacts ?
- Expliquer votre projet ?
musée du fer, tout est là. EN nl
Musée de plein air : panneaux multiplie, traduction.. attraction et tourisme, anglais et allemand, FR et neerl
Projet européen mercice ?
son téléphone informative
carnet de visite? non pas papier
musée archeo arlon ; app mobile ; plus loin que les panneaux et traduction
télécharger maison avant etc, point wifi charger, 4g peu de réseau
autonomie plus besoin de réseau du tout
batiment, téléphone, 1 st marc, objet mis en évidence thématique exposée - objet emblématique - photos, detail, dessin schéma
PROVINCE LUX -> budget dispo, payé dès le départ, mise à jour
Site internet.
deeple -> IA
- Les langues envisagée ? Contenu existe déjà de votre côté ou non ?
- internet public qq part ou couverture réseau correcte à l'accueil ?
- Sur ce type de projet, comment ça se décide chez vous ?
- Est-ce que avez besoin d'un budget ou quelque chose ? Avez-vous déjà une enveloppe arbitrée, ou est-ce encore à construire ?
- Echeance visée ?
Soit budget cette année. engagement -> Début 2027 avant février.
Ete 2027 ->
Descriptif termes technique -> Appel ? 2500 HTVA 5000 HTVA appel d'offre
Entrée musée plein air.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@ -0,0 +1,46 @@
# Plan des captures — deck MNAHA
Chaque emplacement du deck porte une référence `CAPTURE nn`. Ce tableau dit d'où vient
l'image. Une fois les captures faites, elles s'insèrent à la place des cadres pointillés
sans toucher à la mise en page (`build_decks.py`, fonction `placeholder`).
| Réf | Slide | Écran à capturer | Source | Statut |
|-----|-------|------------------|--------|--------|
| 01 | 5 — Le back-office | Éditeur d'une fiche d'œuvre, onglets de langue visibles | manager-app (Chrome) | à capturer |
| 02 | 7 — Les bornes | Accueil borne + sélection de la langue | tablet-app (émulateur Pixel Tablet) | à capturer |
| 03 | 8 — Application mobile | Accueil bento avec les trois sites | mymuseum-visitapp (Pixel 7) | à capturer |
| 04 | 9 — Identité visuelle | Écran de composition de l'accueil + aperçu téléphone | manager-app → Applications | à capturer |
| 05 | 10 — Langues | Champ multilingue + bouton « Traduire via IA » | manager-app | à capturer |
| 06 | 11 — Assistant | Conversation, deux langues côte à côte | mymuseum-visitapp / tablet-app | à capturer |
| 07 | 12 — Pilotage assistant | Onglet « questions des visiteurs » sans réponse | manager-app → Guide IA | à capturer |
| 08 | 13 — Codes QR | Export PDF des QR codes | manager-app → section Article | à capturer |
| 09 | 14 — Hors-ligne | Indicateur de contenu téléchargé sur l'accueil | mymuseum-visitapp | à capturer |
| 10 | 15 — Parcours | Parcours en cours : carte + étape à résoudre | mymuseum-visitapp → Parcours | à capturer |
| 11 | 16 — Notifications | Écran d'envoi / programmation | manager-app → Notifications | à capturer |
| 12 | 17 — Fréquentation | Tableau de bord statistiques | manager-app → Statistiques | à capturer |
| 13 | 20 — Références | Musée de la Fraise, Wépion | `tablet-app/Screenshot_2024*.png` | asset existant |
| 14 | 20 — Références | Fort de Saint-Héribert | `myinfomate-landing/public/app-screenshot-1.png` | asset existant |
| 15 | 20 — Références | Visit Namur | à retrouver | à fournir |
| 16 | 18 — Parc d'appareils | Liste des appareils : état, batterie, contenu | manager-app → Kiosk devices | à capturer |
| 17 | 19 — Gouvernance | Gestion des utilisateurs et des rôles | manager-app → Users | à capturer |
## Pour produire les captures
Il faut la stack locale : Docker Desktop → Postgres (`Deployment/Dockerfile.postgres`) →
`dotnet ef database update``manager-service` en local → instance de démo MNAHA créée
via l'API → `manager-app` dans Chrome et les apps Flutter sur émulateur.
Surcharger la configuration pour ne pointer que sur le Postgres local :
`appsettings.Development.json` contient encore la chaîne du **MongoDB de production**.
## Points à trancher avant la réunion
- **Luxembourgeois** : `LB` n'est pas dans la liste des langues gérées
(`manager-app/lib/constants.dart:165` et `mymuseum-visitapp/lib/constants.dart:59`
FR, NL, EN, DE, IT, ES, PL, CN, AR, UK), et il manque le drapeau `lu.svg`. Le deck reste
volontairement neutre sur ce point. Après ajout de `LB`, passer
`LUXEMBOURGEOIS_ACTIF = True` en tête de `build_decks.py` et régénérer : la slide 10
annoncera le luxembourgeois.
- **Plan d'étage cliquable** : n'existe pas (les cartes sont géographiques uniquement).
Aucune slide ne le promet.
- **Réalité augmentée** : rien en production. Aucune slide ne l'évoque.

Binary file not shown.

View File

@ -0,0 +1,746 @@
# -*- coding: utf-8 -*-
"""Génère les deux decks MNAHA (brandé + marque blanche) depuis un contenu unique.
Chaque slide visuelle porte un emplacement de capture nommé, à remplacer plus tard
par la capture réelle sans toucher à la structure.
"""
import copy
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Emu, Inches, Pt
# Le luxembourgeois n'est pas dans la liste des langues gérées par le code
# (FR, NL, EN, DE, IT, ES, PL, CN, AR, UK). Passer à True une fois "LB" ajouté
# aux constantes de manager-app et des applications visiteur.
LUXEMBOURGEOIS_ACTIF = False
ROOT = Path(__file__).resolve().parent
LOGO = ROOT.parent.parent / "myinfomate-landing" / "public" / "myinfomate-logo.png"
W = Inches(13.333)
H = Inches(7.5)
MARGIN = Inches(0.85)
class Theme:
def __init__(self, branded):
self.branded = branded
if branded:
self.ink = RGBColor(0x0A, 0x12, 0x22)
self.bg = RGBColor(0xF5, 0xF7, 0xFA)
self.surface = RGBColor(0xFF, 0xFF, 0xFF)
self.accent = RGBColor(0x0E, 0x8F, 0x8A)
self.accent_bright = RGBColor(0x0D, 0xF2, 0xDF)
self.muted = RGBColor(0x64, 0x74, 0x8B)
self.rule = RGBColor(0xD6, 0xDD, 0xE5)
self.title_font = "Segoe UI Semibold"
self.body_font = "Segoe UI"
else:
self.ink = RGBColor(0x1C, 0x1A, 0x17)
self.bg = RGBColor(0xFA, 0xF8, 0xF5)
self.surface = RGBColor(0xFF, 0xFF, 0xFF)
self.accent = RGBColor(0x8C, 0x6A, 0x3F)
self.accent_bright = RGBColor(0xB8, 0x93, 0x62)
self.muted = RGBColor(0x6B, 0x65, 0x58)
self.rule = RGBColor(0xE0, 0xDA, 0xD0)
self.title_font = "Georgia"
self.body_font = "Segoe UI"
def blank(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def rect(slide, x, y, w, h, fill=None, line=None, line_w=Pt(1), shape=MSO_SHAPE.RECTANGLE,
dash=False):
s = slide.shapes.add_shape(shape, x, y, w, h)
s.shadow.inherit = False
if fill is None:
s.fill.background()
else:
s.fill.solid()
s.fill.fore_color.rgb = fill
if line is None:
s.line.fill.background()
else:
s.line.color.rgb = line
s.line.width = line_w
if dash:
s.line.dash_style = 4 # MSO_LINE_DASH_STYLE.DASH
s.text_frame.clear()
return s
def text(slide, x, y, w, h, runs, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
line_spacing=1.15):
"""runs = [(texte, taille_pt, gras, couleur, police, espace_avant_pt), ...]"""
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
for i, (content, size, bold, color, font, space_before) in enumerate(runs):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
p.line_spacing = line_spacing
if space_before:
p.space_before = Pt(space_before)
r = p.add_run()
r.text = content
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
r.font.name = font
return box
def page_base(prs, th, dark=False):
s = blank(prs)
rect(s, 0, 0, W, H, fill=th.ink if dark else th.bg)
return s
def footer(slide, th, number, label=None):
color = th.muted
if label:
text(slide, MARGIN, H - Inches(0.62), Inches(8), Inches(0.3),
[(label, 9, False, color, th.body_font, 0)])
text(slide, W - MARGIN - Inches(1.2), H - Inches(0.62), Inches(1.2), Inches(0.3),
[(str(number), 9, False, color, th.body_font, 0)], align=PP_ALIGN.RIGHT)
"""Grille verticale fixe — le titre tient sur deux lignes au maximum, donc rien
ne se déplace d'une slide à l'autre et rien ne peut se chevaucher."""
Y_KICKER = Inches(0.95)
Y_TITLE = Inches(1.38)
Y_RULE = Inches(2.78)
Y_SUB = Inches(3.05)
Y_BODY = Inches(3.62)
Y_BODY_NO_SUB = Inches(3.10)
def kicker_title(slide, th, kicker, title, subtitle=None, width=None):
width = width or (W - 2 * MARGIN)
if kicker:
text(slide, MARGIN, Y_KICKER, width, Inches(0.3),
[(kicker.upper(), 10.5, True, th.accent, th.body_font, 0)])
text(slide, MARGIN, Y_TITLE, width, Inches(1.3),
[(title, 30, True, th.ink, th.title_font, 0)], line_spacing=1.06)
rect(slide, MARGIN, Y_RULE, Inches(0.9), Pt(2.5), fill=th.accent)
if subtitle:
text(slide, MARGIN, Y_SUB, min(width, Inches(9.2)), Inches(0.85),
[(subtitle, 13.5, False, th.muted, th.body_font, 0)], line_spacing=1.3)
return Y_BODY
return Y_BODY_NO_SUB
def placeholder(slide, th, x, y, w, h, ref, label, hint=None):
rect(slide, x, y, w, h, fill=th.surface, line=th.rule, line_w=Pt(1))
rect(slide, x + Inches(0.12), y + Inches(0.12), w - Inches(0.24), h - Inches(0.24),
fill=None, line=th.accent_bright, line_w=Pt(1), dash=True)
runs = [(ref, 11, True, th.accent, th.body_font, 0),
(label, 13, False, th.ink, th.body_font, 8)]
if hint:
runs.append((hint, 10, False, th.muted, th.body_font, 6))
text(slide, x + Inches(0.5), y, w - Inches(1.0), h, runs,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.25)
def bullets(slide, th, x, y, w, items, size=13, gap=Inches(0.17)):
"""Hauteur estimée à partir d'une largeur de glyphe volontairement pessimiste :
mieux vaut un blanc en bas qu'un chevauchement."""
chars_per_line = max(20, int((w - Inches(0.34)) / (Inches(0.0098) * size)))
cur = y
for item in items:
rect(slide, x, cur + Inches(0.075), Inches(0.12), Inches(0.12),
fill=th.accent, shape=MSO_SHAPE.OVAL)
lines = -(-len(item) // chars_per_line)
h = Inches(0.245) * lines
text(slide, x + Inches(0.34), cur - Inches(0.03), w - Inches(0.34), h + Inches(0.1),
[(item, size, False, th.ink, th.body_font, 0)], line_spacing=1.3)
cur += h + gap
return cur
# --------------------------------------------------------------------------- slides
def slide_cover(prs, th, n):
s = page_base(prs, th, dark=True)
rect(s, 0, H - Inches(0.9), W, Inches(0.9), fill=th.accent)
text(s, MARGIN, Inches(2.15), Inches(10.4), Inches(0.4),
[("MUSÉE NATIONAL D'ARCHÉOLOGIE, D'HISTOIRE ET D'ART — LUXEMBOURG", 11, True,
th.accent_bright, th.body_font, 0)])
text(s, MARGIN, Inches(2.75), Inches(10.6), Inches(1.8),
[("Piloter les contenus\nde vos trois sites", 44, True,
RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], line_spacing=1.05)
text(s, MARGIN, Inches(4.75), Inches(9.6), Inches(1.0),
[("Un système unique de gestion pour les bornes, l'application mobile "
"et le web — sans intervention technique.", 15, False,
RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.35)
if th.branded and LOGO.exists():
s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.25), Inches(0.75),
height=Inches(1.05))
return s
def slide_constat(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "Le constat",
"Les dispositifs sont en place. Les contenus, eux, ne bougent plus.")
cards = [
("Chaque modification passe par un tiers",
"Corriger une date, remplacer une photo, ajouter une langue : autant de demandes "
"à formuler, à budgéter, à attendre."),
("Le délai décourage la mise à jour",
"Quand publier prend des semaines, on renonce. Le contenu vieillit sur des écrans "
"qui, eux, fonctionnent parfaitement."),
("Trois sites, trois chaînes séparées",
"Fëschmaart, Dräi Eechelen, Echternach : rien n'est mutualisé, et rien ne se "
"mesure d'un site à l'autre."),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for title, body in cards:
rect(s, x, y, cw, Inches(2.9), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.4), y + Inches(0.42), cw - Inches(0.8), Inches(0.8),
[(title, 15, True, th.ink, th.title_font, 0)], line_spacing=1.2)
text(s, x + Inches(0.4), y + Inches(1.35), cw - Inches(0.8), Inches(1.4),
[(body, 12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.3)
footer(s, th, n, "Le constat")
return s
def slide_proposition(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "La proposition",
"Un seul back-office alimente tous vos dispositifs.",
"Vos équipes saisissent le contenu une fois. Il apparaît là où vous "
"décidez qu'il apparaisse — sur les bornes des salles, dans "
"l'application des visiteurs, sur le web.")
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for head, sub in [
("Les bornes en salle", "Écrans et bornes déjà installés au Fëschmaart et à "
"Dräi Eechelen, pilotés à distance."),
("L'application mobile", "Les trois sites réunis dans une seule application, "
"sur le téléphone du visiteur."),
("Le web", "Le même contenu accessible depuis un navigateur, sans installation."),
]:
rect(s, x, y, cw, Inches(2.15), fill=th.surface, line=th.rule)
text(s, x + Inches(0.4), y + Inches(0.38), cw - Inches(0.8), Inches(0.4),
[(head, 16, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.4), y + Inches(0.92), cw - Inches(0.8), Inches(1.0),
[(sub, 12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.3)
footer(s, th, n, "La proposition")
return s
def slide_architecture(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Comment cela fonctionne",
"Un contenu saisi une fois, diffusé partout, mesuré en retour.")
cy = Inches(3.9)
bw, bh = Inches(2.55), Inches(1.35)
left_x = MARGIN + Inches(0.15)
rect(s, left_x, cy - bh / 2, bw, bh, fill=th.ink)
text(s, left_x + Inches(0.25), cy - bh / 2, bw - Inches(0.5), bh,
[("Le back-office", 15, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0),
("Vos équipes\nde médiation", 11, False, RGBColor(0xC5, 0xCE, 0xD8),
th.body_font, 6)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
hub_x = left_x + bw + Inches(1.15)
hub_w = Inches(2.35)
rect(s, hub_x, cy - bh / 2, hub_w, bh, fill=th.surface, line=th.accent, line_w=Pt(1.5))
text(s, hub_x + Inches(0.2), cy - bh / 2, hub_w - Inches(0.4), bh,
[("Contenus, médias\net langues", 13.5, True, th.ink, th.title_font, 0),
("Une source unique", 11, False, th.muted, th.body_font, 6)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
rect(s, left_x + bw, cy - Pt(1), Inches(1.15), Pt(2), fill=th.accent)
out_x = hub_x + hub_w + Inches(1.15)
rect(s, hub_x + hub_w, cy - Pt(1), Inches(1.15), Pt(2), fill=th.accent)
ow = Inches(2.75)
oh = Inches(0.92)
tops = [cy - Inches(1.55), cy - Inches(0.46), cy + Inches(0.63)]
for top, (name, sub) in zip(tops, [
("Bornes en salle", "Fëschmaart · Dräi Eechelen"),
("Application mobile", "Les trois sites"),
("Web", "Sans installation"),
]):
rect(s, out_x, top, ow, oh, fill=th.surface, line=th.rule)
text(s, out_x + Inches(0.22), top, ow - Inches(0.44), oh,
[(name, 13, True, th.ink, th.title_font, 0),
(sub, 10, False, th.muted, th.body_font, 4)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.15)
ret_y = cy + Inches(2.05)
rect(s, left_x, ret_y, out_x + ow - left_x, Pt(2), fill=th.rule)
rect(s, left_x, cy + bh / 2, Pt(2), ret_y - cy - bh / 2, fill=th.rule)
rect(s, out_x + ow - Pt(2), tops[2] + oh, Pt(2), ret_y - tops[2] - oh, fill=th.rule)
arrow = s.shapes.add_shape(MSO_SHAPE.ISOSCELES_TRIANGLE, left_x - Inches(0.02),
ret_y - Inches(0.09), Inches(0.2), Inches(0.2))
arrow.rotation = 270
arrow.shadow.inherit = False
arrow.fill.solid(); arrow.fill.fore_color.rgb = th.rule
arrow.line.fill.background()
chip_w = Inches(5.0)
chip_x = left_x + ((out_x + ow - left_x) - chip_w) / 2
rect(s, chip_x, ret_y - Inches(0.16), chip_w, Inches(0.34), fill=th.bg)
text(s, chip_x, ret_y - Inches(0.11), chip_w, Inches(0.3),
[("Statistiques de fréquentation et questions des visiteurs", 11.5, False,
th.muted, th.body_font, 0)], align=PP_ALIGN.CENTER)
footer(s, th, n, "Architecture fonctionnelle")
return s
def slide_split(prs, th, n, kicker, title, subtitle, items, ref, cap_label, cap_hint=None,
footer_label=None, portrait=False):
s = page_base(prs, th)
kicker_title(s, th, kicker, title, subtitle, width=Inches(6.05))
bullets(s, th, MARGIN, Inches(3.98), Inches(5.75), items)
if portrait:
placeholder(s, th, Inches(8.35), Inches(1.15), Inches(2.85), Inches(5.5),
ref, cap_label, cap_hint)
else:
placeholder(s, th, Inches(7.35), Inches(1.75), Inches(5.1), Inches(3.9),
ref, cap_label, cap_hint)
footer(s, th, n, footer_label or kicker)
return s
def slide_grid(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Types de contenus",
"Quatorze formats, combinables librement dans chaque espace.",
"Un espace se compose comme un sommaire : on y ajoute les formats voulus, "
"on les ordonne, on les traduit.")
tiles = [
("Article", "Texte enrichi, images, audio"),
("Carte", "Points d'intérêt, zones, catégories"),
("Diaporama", "Séries d'images légendées"),
("Vidéo", "Fichier ou lien en ligne"),
("Document PDF", "Consultable dans l'app"),
("Agenda", "Événements datés, filtrables"),
("Sommaire", "Menu thématique de sous-espaces"),
("Quiz", "Questions, réponses, score"),
("Jeu", "Puzzle, taquin"),
("Parcours", "Étapes guidées, escape game"),
("Événement", "Programme et plan dédiés"),
("Page web", "Site externe intégré"),
("Météo", "Conditions du jour"),
("Compte à rebours", "Mise en avant d'un événement"),
]
cols, rows = 5, 3
gx, gy = Inches(0.2), Inches(0.16)
tw = (W - 2 * MARGIN - gx * (cols - 1)) / cols
thh = Inches(0.9)
x0, y0 = MARGIN, Inches(3.68)
for i, (name, sub) in enumerate(tiles):
c, r = i % cols, i // cols
if r >= rows:
break
x = x0 + c * (tw + gx)
y = y0 + r * (thh + gy)
rect(s, x, y, tw, thh, fill=th.surface, line=th.rule)
rect(s, x, y, Pt(3), thh, fill=th.accent)
text(s, x + Inches(0.22), y + Inches(0.14), tw - Inches(0.35), Inches(0.3),
[(name, 12.5, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.22), y + Inches(0.47), tw - Inches(0.35), Inches(0.38),
[(sub, 9.5, False, th.muted, th.body_font, 0)], line_spacing=1.15)
footer(s, th, n, "Types de contenus")
return s
def slide_stats(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "Fréquentation",
"Ce que les visiteurs consultent vraiment.",
width=Inches(6.05))
kpis = [("Visites", "par jour et par période"),
("Contenus", "les plus et les moins consultés"),
("Points d'intérêt", "salles et œuvres les plus visitées"),
("Quiz et parcours", "taux d'achèvement, scores")]
x = MARGIN
kw = (Inches(6.0) - Inches(0.25)) / 2
for i, (t, sub) in enumerate(kpis):
cx = x + (i % 2) * (kw + Inches(0.25))
cy = y + (i // 2) * Inches(1.15)
rect(s, cx, cy, kw, Inches(0.95), fill=th.surface, line=th.rule)
text(s, cx + Inches(0.28), cy + Inches(0.16), kw - Inches(0.5), Inches(0.3),
[(t, 13, True, th.ink, th.title_font, 0)])
text(s, cx + Inches(0.28), cy + Inches(0.5), kw - Inches(0.5), Inches(0.3),
[(sub, 10, False, th.muted, th.body_font, 0)])
text(s, MARGIN, y + Inches(2.45), Inches(6.0), Inches(0.9),
[("Filtrable par période et par dispositif — borne, mobile, web. "
"Exportable en rapport PDF pour un comité ou une tutelle.",
12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
placeholder(s, th, Inches(7.35), Inches(1.75), Inches(5.1), Inches(3.9),
"CAPTURE 12", "Tableau de bord des statistiques",
"KPI, courbe des visites par jour, contenus les plus consultés")
footer(s, th, n, "Fréquentation")
return s
def slide_references(prs, th, n):
s = page_base(prs, th)
if th.branded:
kicker_title(s, th, "Références",
"Trois déploiements en production, depuis 2021.")
cases = [
("Musée de la Fraise, Wépion", "2021 — borne tactile en salle"),
("Fort de Saint-Héribert", "2023 — application mobile intégralement "
"hors-ligne, balises BLE"),
("Visit Namur", "2024 — borne tactile grand format en office de tourisme"),
]
else:
kicker_title(s, th, "Références",
"La solution est déployée sur trois sites en Belgique, depuis 2021.")
cases = [
("Musée thématique", "2021 — borne tactile en salle"),
("Site fortifié", "2023 — application mobile intégralement hors-ligne, "
"balises BLE"),
("Office de tourisme", "2024 — borne tactile grand format"),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for i, (name, sub) in enumerate(cases):
rect(s, x, Inches(3.10), cw, Inches(3.2), fill=th.surface, line=th.rule)
placeholder(s, th, x + Inches(0.25), Inches(3.35), cw - Inches(0.5), Inches(1.75),
f"CAPTURE {13 + i}",
name if th.branded else f"Déploiement {i + 1}")
text(s, x + Inches(0.3), Inches(5.35), cw - Inches(0.6), Inches(0.35),
[(name, 13.5, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.3), Inches(5.74), cw - Inches(0.6), Inches(0.45),
[(sub, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.2)
x += cw + Inches(0.3)
footer(s, th, n, "Références")
return s
def slide_phases(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Mise en œuvre",
"Par étapes, en commençant par un périmètre maîtrisable.")
phases = [
("Phase 1", "Pilote au Fëschmaart",
"Reprise des contenus d'une aile, mise en service sur les bornes existantes, "
"formation de l'équipe de médiation."),
("Phase 2", "Application mobile des trois sites",
"Les trois sites réunis dans une application unique : contenus, cartes, "
"audioguide, assistant."),
("Phase 3", "Echternach et parcours hors-ligne",
"Parcours extérieur, balises, téléchargement complet pour les zones sans réseau."),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.7)) / 3
for i, (num, title, body) in enumerate(phases):
rect(s, x, Inches(3.10), cw, Inches(3.0), fill=th.surface, line=th.rule)
rect(s, x, Inches(3.10), cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.35), Inches(3.45), cw - Inches(0.7), Inches(0.3),
[(num.upper(), 10.5, True, th.accent, th.body_font, 0)])
text(s, x + Inches(0.35), Inches(3.85), cw - Inches(0.7), Inches(0.8),
[(title, 15, True, th.ink, th.title_font, 0)], line_spacing=1.15)
text(s, x + Inches(0.35), Inches(4.80), cw - Inches(0.7), Inches(1.1),
[(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.35)
footer(s, th, n, "Mise en œuvre")
return s
def slide_next(prs, th, n):
s = page_base(prs, th, dark=True)
text(s, MARGIN, Inches(1.35), Inches(10), Inches(0.35),
[("PROCHAINES ÉTAPES", 11, True, th.accent_bright, th.body_font, 0)])
text(s, MARGIN, Inches(1.95), Inches(9.5), Inches(1.0),
[("Ce que nous proposons\npour avancer", 34, True, RGBColor(0xFF, 0xFF, 0xFF),
th.title_font, 0)], line_spacing=1.08)
steps = [
"Une démonstration du back-office sur vos propres contenus — une salle du "
"Fëschmaart suffit.",
"Un atelier avec l'équipe de médiation pour cadrer les formats et les langues.",
"Un périmètre de pilote chiffré et daté, sans engagement sur la suite.",
]
y = Inches(4.0)
for i, st in enumerate(steps, 1):
rect(s, MARGIN, y, Inches(0.42), Inches(0.42), fill=th.accent,
shape=MSO_SHAPE.OVAL)
text(s, MARGIN, y + Inches(0.06), Inches(0.42), Inches(0.3),
[(str(i), 12, True, RGBColor(0xFF, 0xFF, 0xFF), th.body_font, 0)],
align=PP_ALIGN.CENTER)
text(s, MARGIN + Inches(0.72), y + Inches(0.02), Inches(9.6), Inches(0.6),
[(st, 14, False, RGBColor(0xE2, 0xE8, 0xF0), th.body_font, 0)],
line_spacing=1.3)
y += Inches(0.92)
return s
def build(branded, out_path):
th = Theme(branded)
prs = Presentation()
prs.slide_width = W
prs.slide_height = H
n = 1
slide_cover(prs, th, n); n += 1
slide_constat(prs, th, n); n += 1
slide_proposition(prs, th, n); n += 1
slide_architecture(prs, th, n); n += 1
slide_split(prs, th, n, "Le back-office",
"Modifier un contenu prend une minute.",
"L'éditeur ressemble à un traitement de texte. Aucune compétence "
"technique, aucun prestataire à solliciter.",
["Texte enrichi, images, audio, vidéo et documents dans un même écran.",
"Publication immédiate : les bornes connectées se mettent à jour sans "
"manipulation sur place.",
"Chaque champ existe dans toutes les langues déclarées.",
"Journal des modifications : qui a changé quoi, et quand."],
"CAPTURE 01", "Éditeur de contenu",
"Fiche d'une œuvre avec ses onglets de langue", "Le back-office"); n += 1
slide_grid(prs, th, n); n += 1
slide_split(prs, th, n, "Les bornes",
"Vos bornes existantes, alimentées à distance.",
"Le matériel déjà installé au Fëschmaart et à Dräi Eechelen reste en "
"place. Seul le contenu change de main.",
["Choix de la langue dès le premier écran.",
"Navigation par thème : archéologie, cabinet des médailles, arts "
"décoratifs, beaux-arts.",
"Fiches d'œuvre avec image, texte et écoute audio.",
"Chaque borne peut afficher un contenu différent."],
"CAPTURE 02", "Accueil de la borne",
"Sélection de la langue, Fëschmaart", "Les bornes"); n += 1
slide_split(prs, th, n, "Application mobile",
"Les trois sites dans une seule application.",
"Le visiteur télécharge une application. Il y trouve le Fëschmaart, "
"Dräi Eechelen et la Réimervilla, et choisit où il se trouve.",
["Un espace par site, avec ses propres contenus et ses propres couleurs.",
"Cartes, audioguide, quiz, parcours : chaque site compose le sien.",
"Le visiteur passe d'un site à l'autre sans changer d'application.",
"Une même fréquentation mesurée sur les trois sites."],
"CAPTURE 03", "Accueil de l'application",
"Les trois sites en tuiles", "Application mobile", portrait=True); n += 1
slide_split(prs, th, n, "Identité visuelle",
"L'apparence de l'application se règle ici.",
"Couleurs, images, disposition : le musée compose sa page d'accueil "
"et voit le résultat en direct, sans nouvelle version de l'application.",
["Couleurs principales et secondaires, image d'accueil, écran de "
"chargement.",
"Tuiles redimensionnables : un site ou un événement peut occuper une "
"place plus large.",
"Aperçu en temps réel dans une maquette de téléphone.",
"L'application porte le nom et l'identité du musée, pas ceux d'un "
"éditeur."],
"CAPTURE 04", "Composition de l'accueil",
"Tuiles, couleurs et aperçu téléphone", "Identité visuelle"); n += 1
langue_titre = ("Luxembourgeois, français, allemand, anglais."
if LUXEMBOURGEOIS_ACTIF
else "Chaque contenu existe dans toutes vos langues.")
langue_puce = ("Onze langues gérées, dont le luxembourgeois, activables site par site."
if LUXEMBOURGEOIS_ACTIF
else "Dix langues gérées, activables site par site et dispositif par "
"dispositif.")
slide_split(prs, th, n, "Langues", langue_titre,
"Le gestionnaire saisit son texte une fois. Le visiteur choisit sa "
"langue au premier écran.",
[langue_puce,
"Traduction assistée : un bouton propose la version des autres langues "
"à partir du texte source.",
"Le gestionnaire relit et corrige avant publication — rien n'est publié "
"automatiquement.",
"L'assistant de visite répond dans la langue de la question."],
"CAPTURE 05", "Champ multilingue",
"Onglets de langue et traduction assistée", "Langues"); n += 1
slide_split(prs, th, n, "Assistant de visite",
"Un guide qui ne connaît que vos collections.",
"Le visiteur pose sa question en langage courant. La réponse est "
"construite à partir de vos contenus, et uniquement des vôtres.",
["Les réponses citent les contenus du musée — pas de source extérieure.",
"Quand l'information n'existe pas dans vos contenus, l'assistant le dit "
"plutôt que d'inventer.",
"Disponible sur la borne, dans l'application et sur le web.",
"Répond dans la langue du visiteur."],
"CAPTURE 06", "Conversation avec l'assistant",
"Deux langues différentes, côte à côte", "Assistant de visite"); n += 1
slide_split(prs, th, n, "Pilotage de l'assistant",
"Les questions sans réponse guident la médiation.",
"Chaque échange est journalisé sans donnée nominative. Les questions "
"restées sans réponse deviennent une liste de travail pour la médiation.",
["Liste des questions auxquelles l'assistant n'a rien pu répondre.",
"Réponses de repli rédigées par le musée pour les sujets sensibles.",
"Activation dispositif par dispositif, avec consommation suivie.",
"Consentement du visiteur et purge automatique des questions."],
"CAPTURE 07", "Écran de pilotage de l'assistant",
"Onglet des questions sans réponse", "Pilotage de l'assistant"); n += 1
slide_split(prs, th, n, "Codes QR",
"Du cartel au contenu, sans application préalable.",
"Le back-office produit les codes QR de vos salles et de vos œuvres, "
"dans un document prêt à imprimer.",
["Un code par contenu, généré et exporté en PDF depuis le back-office.",
"Le scan ouvre directement la fiche, dans la langue du visiteur.",
"Les scans sont comptés : vous savez quels cartels fonctionnent.",
"Un code reste valable même si le contenu derrière change."],
"CAPTURE 08", "Codes QR générés",
"Export PDF prêt à imprimer", "Codes QR"); n += 1
slide_split(prs, th, n, "Hors-ligne et balises",
"Fonctionne dans la roche comme en plein champ.",
"Le visiteur télécharge un site entier avant sa visite. Textes, images "
"et audio restent disponibles sans aucun réseau.",
["Téléchargement complet d'un site ou d'un parcours, en une action.",
"Salles creusées dans la roche : aucune connexion nécessaire.",
"Balises Bluetooth : le contenu de la salle s'affiche à l'approche.",
"Réimervilla : déclenchement par zone géographique en extérieur."],
"CAPTURE 09", "Téléchargement hors-ligne",
"Indicateur de contenu téléchargé", "Hors-ligne et balises",
portrait=True); n += 1
slide_split(prs, th, n, "Parcours et jeux",
"Du parcours thématique à la chasse au trésor.",
"Un même outil sert la visite guidée classique et le jeu de piste "
"familial ou scolaire.",
["Étapes ordonnées ou libres, visibles ou révélées au fur et à mesure.",
"Énigmes à résoudre pour débloquer l'étape suivante, minuteur optionnel.",
"Messages de début et de fin, écran de réussite, progression suivie "
"sur la carte.",
"Déclenchement d'une étape par balise, par zone géographique ou par "
"code QR."],
"CAPTURE 10", "Parcours en cours",
"Progression sur la carte et étape à résoudre", "Parcours et jeux"); n += 1
slide_split(prs, th, n, "Notifications",
"Prévenir vos visiteurs en une minute.",
"Les personnes ayant installé l'application reçoivent une notification, "
"envoyée depuis le back-office.",
["Envoi immédiat ou programmé à une date et une heure.",
"Rédaction dans chaque langue déclarée.",
"Historique des envois et annulation d'un envoi programmé.",
"Aucune donnée nominative n'est collectée."],
"CAPTURE 11", "Écran d'envoi de notification",
None, "Notifications"); n += 1
slide_stats(prs, th, n); n += 1
slide_split(prs, th, n, "Parc d'appareils",
"Savoir ce qu'affiche chaque borne.",
"Chaque appareil déclaré remonte son état. Vous changez le contenu "
"affiché depuis le back-office.",
["État de connexion, niveau de batterie, dernière activité, version "
"installée.",
"Attribution du contenu affiché appareil par appareil.",
"Un changement de contenu part vers l'appareil sans intervention "
"sur place.",
"Utile quand les bornes sont réparties sur trois sites distants."],
"CAPTURE 16", "Liste des appareils",
"État, batterie et contenu affiché", "Parc d'appareils"); n += 1
slide_split(prs, th, n, "Gouvernance",
"Chacun accède à ce qui le concerne.",
"Les droits se règlent par personne. Les modifications sont tracées.",
["Quatre niveaux : administration, gestion du site, rédaction, "
"consultation.",
"Journal d'audit des créations et modifications.",
"Hébergement européen, questions des visiteurs purgées "
"automatiquement.",
"Aucun compte visiteur, aucune donnée nominative collectée."],
"CAPTURE 17", "Gestion des utilisateurs",
None, "Gouvernance"); n += 1
slide_references(prs, th, n); n += 1
slide_phases(prs, th, n); n += 1
slide_next(prs, th, n)
scrub(prs, branded)
out_path.parent.mkdir(parents=True, exist_ok=True)
count = len(prs.slides._sldIdLst)
prs.save(str(out_path))
sanitize_package(out_path)
return out_path, count
APP_XML = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
'extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/'
'2006/docPropsVTypes"><Application></Application><PresentationFormat>'
'Widescreen</PresentationFormat><ScaleCrop>false</ScaleCrop>'
'<LinksUpToDate>false</LinksUpToDate><SharedDoc>false</SharedDoc>'
'<HyperlinksChanged>false</HyperlinksChanged></Properties>'
)
def sanitize_package(path):
"""Le gabarit par défaut de python-pptx laisse traîner un producteur, un format
4:3 erroné, des dates de 2013 et une vignette d'aperçu. On réécrit l'archive."""
import re
import zipfile
src = zipfile.ZipFile(path)
items = [(i, src.read(i.filename)) for i in src.infolist()]
src.close()
stamp = "2026-08-24T09:00:00Z"
out = zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED)
for info, data in items:
name = info.filename
if name == "docProps/thumbnail.jpeg":
continue
if name == "docProps/app.xml":
data = APP_XML.encode("utf-8")
elif name == "docProps/core.xml":
txt = data.decode("utf-8")
txt = re.sub(r"(<dcterms:(?:created|modified)[^>]*>)[^<]*(</)",
lambda m: m.group(1) + stamp + m.group(2), txt)
data = txt.encode("utf-8")
elif name == "_rels/.rels":
txt = data.decode("utf-8")
txt = re.sub(r"<Relationship[^>]*thumbnail\.jpeg[^>]*/>", "", txt)
data = txt.encode("utf-8")
elif name == "[Content_Types].xml":
txt = data.decode("utf-8")
txt = txt.replace('<Default Extension="jpeg" ContentType="image/jpeg"/>', "")
data = txt.encode("utf-8")
out.writestr(info, data)
out.close()
def scrub(prs, branded):
cp = prs.core_properties
cp.author = "MyInfoMate" if branded else ""
cp.last_modified_by = "MyInfoMate" if branded else ""
cp.company = ""
cp.title = "MNAHA — Gestion de contenu multi-sites"
cp.subject = ""
cp.comments = ""
cp.category = ""
cp.keywords = ""
cp.content_status = ""
cp.identifier = ""
cp.language = "fr-BE"
cp.revision = 1
if __name__ == "__main__":
for branded, sub, name in [
(True, "branded", "MNAHA_presentation_branded.pptx"),
(False, "whitelabel", "MNAHA_presentation_whitelabel.pptx"),
]:
path, count = build(branded, ROOT / sub / name)
print(f"{path} ({count} slides)")

View File

@ -0,0 +1,170 @@
# Instance de démonstration MNAHA
Jeu de données local pour les captures d'écran des decks MNAHA. Créé via l'API
(`https://localhost:5001`), pas en SQL direct : l'indexation de l'assistant se
déclenche alors toute seule à chaque publication.
## Ce qui a été créé
| | |
|---|---|
| Instance | **MNAHA**, slug `mnaha`, plan Premium, assistant activé (guide « Melusina ») |
| Langues | FR, DE, EN, NL — le luxembourgeois n'est pas géré par le code |
| Sites | Nationalmusée um Fëschmaart · Musée Dräi Eechelen · Réimervilla Echternach |
| Applications | mobile, borne, web — les trois liées aux trois sites, tuiles bento réglées |
| Contenus | 9 articles en 4 langues, 1 sommaire, 1 quiz (3 questions), 1 agenda (4 événements) |
| Carte | Echternach, 5 points d'intérêt géolocalisés, 3 catégories |
| Parcours | « Sur les pas du maître de la villa » — 5 étapes déclenchées par zone GPS |
| Escape game | « Le secret de la villa de Vichten » — 4 étapes, 4 énigmes bloquantes |
| Balises | un identifiant de balise sur chacun des 9 articles |
| Fréquentation | 23 980 événements, 2 503 sessions sur 90 jours, visite moyenne 8,1 min |
| Assistant | 14 questions de visiteurs, dont 5 restées sans réponse |
Répartition de la fréquentation : web 47 %, mobile 33 %, borne 20 % ; FR 44 %,
DE 24 %, EN 21 %, NL 10 %. Entre 29 et 254 visites par jour, avec des pics de
week-end et une légère montée sur la période.
## Accès
| | |
|---|---|
| Back-office | `test@email.be` — SuperAdmin, voit toutes les entrées de menu |
| Code PIN de l'instance | **1867** — sert à l'appairage des applications visiteur |
| Adresse web | slug `mnaha``http://localhost:3000/mnaha` avec `visitapp-web` |
| Clés d'application | `state.json`, champs `apiKeys.VisitApp` et `apiKeys.TabletApp` |
Lancer une application visiteur sur cette instance :
```bash
flutter run -d chrome \
--dart-define=API_BASE_URL=https://localhost:5001 \
--dart-define=INSTANCE_ID=<state.json instanceId> \
--dart-define=API_KEY=<state.json apiKeys.VisitApp>
```
L'instance est en plan Premium complet : mobile + borne + web, assistant IA
(20 M de jetons/mois), notifications push, statistiques avancées sur 395 jours,
50 Go de stockage, collecte des questions visiteurs activée. Seul `isVR` reste à
faux — l'écran VR du back-office n'affiche qu'un « TODO ».
## Rejouer
```bash
cd outputs/mnaha/seed
python step1_instance.py # instance, sites, applications
python step2_sections.py # articles, sommaire, quiz
python step3_map_parcours.py # carte, parcours, escape game, agenda
python step4_stats.py # 90 jours de fréquentation + ré-indexation
python step5_assistant.py # conversations visiteurs (consomme du quota IA)
python step6_order.py # ordre d'affichage des sections
python step7_images.py # ressources ImageUrl + rattachement aux sections
python step7b_finish.py # points d'intérêt et habillage des applications
python step8_devices.py # parc de trois bornes, rattachées aux liens kiosk
```
Les étapes 1 à 3 et 6 sont idempotentes. **L'étape 4 ne l'est pas** : la relancer
ajoute 24 000 événements de plus. Pour repartir à zéro :
```sql
DELETE FROM "VisitEvents" WHERE "InstanceId" = '<id de l''instance>';
```
`state.json` garde les identifiants créés ; le supprimer force une nouvelle
instance (le nom « MNAHA » étant unique, la création échouerait alors en 409).
## Deux pièges rencontrés, notés pour la prochaine fois
**Dates.** Une date sérialisée en `+00:00` est désérialisée par .NET en
`Kind=Local`, et Npgsql refuse alors de l'écrire dans une colonne `timestamptz`
— erreur 500 opaque. Il faut le suffixe `Z`.
**Durée de visite.** Elle se calcule en sommant les `DurationSeconds` des
événements `SectionLeave`, pas des `SectionView`. Sans `SectionLeave`, le
tableau de bord affiche zéro minute.
## Visuels
21 images générées par `gen_images.py` : 3 sites, 9 articles, 2 parcours,
5 points d'intérêt, l'image d'accueil et l'écran de chargement des trois
applications.
> **Lancer `serve_images.py` avant toute capture.** Les visuels sont servis sur
> `http://localhost:8099` et les ressources y pointent. Serveur éteint, images
> absentes.
Pourquoi un serveur local plutôt qu'un téléversement : **`POST /api/Resource/upload`
ne stocke aucun octet.** Il lit le fichier, l'encode en base64, jette le
résultat, et n'enregistre que le libellé, le chemin de bucket et la taille. Le
blob est déposé dans Firebase Storage par `manager-app` depuis le navigateur —
un script qui parle à l'API n'a aucun moyen de l'y écrire. Les ressources créées
par cet endpoint ressortent donc sans `url`, et `manager-app` plante dessus
(`NetworkImage(resource.url!)`, `resource_input_container.dart:170`).
Les ressources de démonstration sont donc créées en type `ImageUrl`, qui pointe
hors bucket et ne pèse pas sur le quota. Le jour où les visuels du dossier de
presse arrivent, le chemin normal est de les déposer par le CMS.
Ce sont des compositions abstraites — trame de tesselles, plan orthogonal,
courbes de niveau, éventails Art déco, monnaies — déclinées dans la palette de
chaque site. **Aucune photographie de collection** : les droits n'appartiennent
pas au projet. Elles se remplacent par les visuels du dossier de presse du MNAHA
le jour où il est disponible.
Quatre autres motifs (`bastion`, `pillars`, `arcs`, `strata`) restent dans le
fichier mais ne sont pas utilisés : réduits à une vignette, ils se lisent comme
un aplat vide ou comme une frise trop littérale.
## Liens de borne dupliqués — artefact du script, pas bug produit
`POST /api/Device` crée lui-même un `AppConfigurationLink` pour chaque nouvel
appareil (`DeviceController.cs:169-177`), sans se rapprocher d'un lien déjà
présent pour ce couple application/configuration. `step1_instance.py` liait les
trois sites aux trois applications, borne comprise : les trois appareils créés
ensuite ont donc ajouté trois liens de plus.
`step1_instance.py` ne crée plus de lien pour la borne — ils naissent de
l'appairage, ce qui est le comportement réel du produit. `step9_dedupe_links.py`
nettoie ceux qui existent déjà.
**Ce n'est pas atteignable depuis le CMS** : `showAddConfigurationLink` n'est
utilisé que par l'écran des applications mobile et web, jamais par
`kiosk_screen.dart`. Un gestionnaire ne peut pas créer un lien de borne à la
main. La fragilité reste (la création d'appareil ne vérifie rien) mais elle
dort derrière un chemin que seule l'API expose.
Vérifié au passage, contrairement à ce que j'avais d'abord écrit : un
`PUT /api/ApplicationInstance` portant ses `configurations` **ne duplique pas**,
il échoue en 500 sur la clé primaire — et le `GET` ne renvoie de toute façon pas
ce tableau. Un `PUT` de lien isolé ne duplique pas non plus.
## Quatre correctifs apportés au produit en passant
**`DELETE /api/Resource/{id}` échouait sur toute carte ayant des catégories.**
Le nettoyage des références faisait `categorie.resourceDTO.id == id` sans
vérifier que `resourceDTO` existe ; une catégorie sans icône le laisse nul.
Passé en `?.`.
**L'écran des bornes plantait sur un lien sans appareil.**
`kiosk_screen.dart` faisait `deviceLink.device!` alors qu'un contenu peut être
attribué à une borne avant qu'une tablette ne s'y appaire. La carte affiche
maintenant « aucune tablette appairée » au lieu de lever une exception.
**`GET /api/Ai/insights/{instanceId}` renvoyait 500**, sur toutes les instances,
y compris sans aucune question. `AiController` faisait un `SelectMany` sur
`CitedContentIds`, une `List<string>` stockée en jsonb via un convertisseur de
valeur : EF ne sait pas traduire cette expression en SQL. Les listes sont
maintenant rapatriées avant d'être aplaties côté client. `manager-app` avalait
l'erreur en silence, donc l'onglet « questions des visiteurs » restait
désespérément vide sans que rien ne le signale.
**Le changement d'instance plantait `manager-app`** (`Bad state: No element`).
La bascule vidait `menu.sections` puis naviguait vers la même route : Flutter
réutilisait le `State`, `initState` ne rejouait pas, et le menu perdait
définitivement Applications / Configurations / Ressources / Statistiques /
Guide IA. Construction du menu extraite dans `_buildMenu`, appelée aussi depuis
`didUpdateWidget`, et repli au lieu d'une exception quand la position courante
ne correspond à aucune entrée.
Les deux exigent un redémarrage : `dotnet run` pour le backend, **hot restart**
(pas hot reload) pour `manager-app`.

64
outputs/mnaha/seed/api.py Normal file
View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""Client minimal pour l'API locale de manager-service.
Le certificat de dev n'est pas vérifié : on tape sur localhost, pas sur la prod.
"""
import json
import urllib3
import requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
BASE = "https://localhost:5001"
EMAIL = "test@email.be"
PASSWORD = "kljqsdkljqsd"
LANGS = ["FR", "DE", "EN", "NL"]
class Api:
def __init__(self):
self.s = requests.Session()
self.s.verify = False
r = self.s.post(f"{BASE}/api/Authentication/Authenticate",
json={"email": EMAIL, "password": PASSWORD}, timeout=30)
r.raise_for_status()
self.token = r.json()["access_token"]
self.s.headers.update({"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"})
def _call(self, method, path, timeout=600, **kw):
# Généreux à dessein : un PUT sur une section déclenche sa ré-indexation
# pour l'assistant, donc un embedding par morceau et par langue.
r = self.s.request(method, f"{BASE}{path}", timeout=timeout, **kw)
if not r.ok:
raise RuntimeError(f"{method} {path} -> {r.status_code}\n{r.text[:600]}")
if not r.content:
return None
try:
return r.json()
except json.JSONDecodeError:
return r.text
def get(self, path, **kw):
return self._call("GET", path, **kw)
def post(self, path, body=None, **kw):
return self._call("POST", path, json=body, **kw)
def put(self, path, body=None, **kw):
return self._call("PUT", path, json=body, **kw)
def delete(self, path, **kw):
return self._call("DELETE", path, **kw)
def tr(**by_lang):
"""{'FR': 'Bonjour'} -> [{'language': 'FR', 'value': 'Bonjour'}], toutes langues.
Une langue absente reprend le français : mieux vaut un contenu non traduit
qu'un champ vide qui casserait l'affichage sur la borne.
"""
fallback = by_lang.get("FR") or next(iter(by_lang.values()), "")
return [{"language": lang, "value": by_lang.get(lang, fallback)} for lang in LANGS]

View File

@ -0,0 +1,695 @@
# -*- coding: utf-8 -*-
"""Contenus de démonstration MNAHA.
Textes rédigés pour la démonstration à partir d'informations publiques sur les
collections. Aucune photographie des collections n'est utilisée : les droits
n'appartiennent pas au projet.
"""
def p(*paragraphs):
"""Le CMS produit du HTML via Quill ; on reproduit sa sortie."""
return "".join(f"<p>{t}</p>" for t in paragraphs)
def h(title):
return f"<h3>{title}</h3>"
def ul(*items):
return "<ul>" + "".join(f"<li>{i}</li>" for i in items) + "</ul>"
def quote(text):
return f"<blockquote>{text}</blockquote>"
ARTICLES = {
"feschmaart": [
{
"label": "Mosaïque de Vichten",
"beacon": 101,
"coords": ("49.61165", "6.13205"),
"title": {
"FR": "La mosaïque de Vichten",
"DE": "Das Mosaik von Vichten",
"EN": "The Vichten Mosaic",
"NL": "Het mozaïek van Vichten",
},
"description": {
"FR": "240 apr. J.-C. — Salle d'archéologie, niveau -2",
"DE": "240 n. Chr. — Archäologiesaal, Ebene -2",
"EN": "AD 240 — Archaeology hall, level -2",
"NL": "240 n.Chr. — Archeologiezaal, niveau -2",
},
"content": {
"FR": p(
"Découverte en 1995 dans les vestiges d'une villa gallo-romaine à "
"Vichten, cette mosaïque de sol compte parmi les plus remarquables "
"au nord des Alpes. Elle occupait la salle de réception du domaine.",
"Au centre, Homère est entouré des neuf Muses, chacune identifiée "
"par son nom et son attribut. La composition, d'une finesse rare, "
"témoigne de la culture littéraire des propriétaires du domaine au "
"milieu du III<sup>e</sup> siècle."),
"DE": p(
"1995 in den Überresten einer gallo-römischen Villa in Vichten "
"entdeckt, zählt dieses Bodenmosaik zu den bedeutendsten nördlich "
"der Alpen. Es schmückte den Empfangsraum des Anwesens.",
"In der Mitte ist Homer von den neun Musen umgeben, jede mit Namen "
"und Attribut bezeichnet. Die außergewöhnlich feine Komposition "
"zeugt von der literarischen Bildung der Besitzer um die Mitte des "
"3. Jahrhunderts."),
"EN": p(
"Discovered in 1995 in the remains of a Gallo-Roman villa at "
"Vichten, this floor mosaic is among the most remarkable north of "
"the Alps. It adorned the reception room of the estate.",
"At its centre, Homer is surrounded by the nine Muses, each named "
"and identified by her attribute. The unusually fine composition "
"speaks to the literary culture of the owners in the middle of the "
"third century."),
"NL": p(
"In 1995 ontdekt in de resten van een Gallo-Romeinse villa in "
"Vichten, behoort dit vloermozaïek tot de merkwaardigste ten "
"noorden van de Alpen. Het sierde de ontvangstzaal van het domein.",
"In het midden is Homerus omringd door de negen Muzen, elk met naam "
"en attribuut. De verfijnde compositie getuigt van de literaire "
"cultuur van de eigenaars omstreeks het midden van de derde eeuw."),
},
},
{
"label": "Goeblange-Nospelt",
"beacon": 102,
"coords": ("49.61170", "6.13190"),
"title": {
"FR": "Les chambres funéraires de Goeblange-Nospelt",
"DE": "Die Grabkammern von Goeblange-Nospelt",
"EN": "The Goeblange-Nospelt burial chambers",
"NL": "De grafkamers van Goeblange-Nospelt",
},
"description": {
"FR": "I<sup>er</sup> siècle av. J.-C. — Salle d'archéologie, niveau -2",
"DE": "1. Jahrhundert v. Chr. — Archäologiesaal, Ebene -2",
"EN": "1st century BC — Archaeology hall, level -2",
"NL": "1e eeuw v.Chr. — Archeologiezaal, niveau -2",
},
"content": {
"FR": p(
"Les tombes de l'aristocratie trévire mises au jour à "
"Goeblange-Nospelt livrent un mobilier funéraire d'une richesse "
"exceptionnelle : services à vin en bronze, amphores, armes.",
"Ces dépôts éclairent le moment où les élites gauloises adoptent "
"les usages romains sans renoncer à leurs propres rites."),
"DE": p(
"Die in Goeblange-Nospelt freigelegten Gräber der treverischen "
"Aristokratie enthielten außergewöhnlich reiche Beigaben: "
"Weinservice aus Bronze, Amphoren, Waffen.",
"Diese Funde beleuchten den Moment, in dem die gallischen Eliten "
"römische Gebräuche übernahmen, ohne ihre eigenen Riten aufzugeben."),
"EN": p(
"The tombs of the Treveran aristocracy uncovered at "
"Goeblange-Nospelt held exceptionally rich grave goods: bronze wine "
"services, amphorae, weapons.",
"These deposits illuminate the moment when Gaulish elites adopted "
"Roman customs without abandoning their own rites."),
"NL": p(
"De in Goeblange-Nospelt blootgelegde graven van de Trevirische "
"aristocratie bevatten uitzonderlijk rijke grafgiften: bronzen "
"wijnserviezen, amforen, wapens.",
"Deze vondsten belichten het moment waarop de Gallische elites "
"Romeinse gebruiken overnamen zonder hun eigen riten op te geven."),
},
},
{
"label": "Villeroy & Boch Art déco",
"beacon": 103,
"coords": ("49.61180", "6.13215"),
"title": {
"FR": "Villeroy & Boch, l'Art déco luxembourgeois",
"DE": "Villeroy & Boch, Luxemburger Art déco",
"EN": "Villeroy & Boch, Luxembourg Art Deco",
"NL": "Villeroy & Boch, Luxemburgse art deco",
},
"description": {
"FR": "1920-1940 — Arts décoratifs, niveau 2",
"DE": "1920-1940 — Kunstgewerbe, Ebene 2",
"EN": "1920-1940 — Decorative arts, level 2",
"NL": "1920-1940 — Sierkunst, niveau 2",
},
"content": {
"FR": p(
"La manufacture de Septfontaines a produit, entre les deux guerres, "
"des séries de céramiques aux lignes géométriques caractéristiques "
"de l'Art déco.",
"La collection du musée retrace cette production industrielle "
"devenue patrimoine, des pièces de série aux modèles de créateurs."),
"DE": p(
"Die Manufaktur Septfontaines produzierte zwischen den Weltkriegen "
"Keramikserien mit den geometrischen Linien des Art déco.",
"Die Sammlung des Museums zeichnet diese Industrieproduktion nach, "
"die zum Kulturerbe wurde — von der Serienware bis zum "
"Künstlerentwurf."),
"EN": p(
"Between the wars, the Septfontaines works produced ceramic ranges "
"in the geometric lines characteristic of Art Deco.",
"The museum's collection traces this industrial output as it became "
"heritage, from production runs to designer models."),
"NL": p(
"Tussen beide wereldoorlogen produceerde de fabriek van "
"Septfontaines keramiekseries met de geometrische lijnen van de art "
"deco.",
"De collectie van het museum volgt deze industriële productie die "
"erfgoed werd, van serieproductie tot ontwerpersmodellen."),
},
},
{
"label": "Cabinet des médailles",
"beacon": 104,
"coords": ("49.61158", "6.13198"),
"title": {
"FR": "Le cabinet des médailles",
"DE": "Das Münzkabinett",
"EN": "The coin cabinet",
"NL": "Het penningkabinet",
},
"description": {
"FR": "Numismatique — niveau 1",
"DE": "Numismatik — Ebene 1",
"EN": "Numismatics — level 1",
"NL": "Numismatiek — niveau 1",
},
"content": {
"FR": p(
"Monnaies celtiques, romaines, médiévales et modernes : le cabinet "
"conserve la mémoire monétaire du territoire luxembourgeois sur "
"plus de deux millénaires.",
"Chaque pièce documente un pouvoir, un échange, une frontière."),
"DE": p(
"Keltische, römische, mittelalterliche und moderne Münzen: das "
"Kabinett bewahrt das monetäre Gedächtnis des luxemburgischen "
"Raums über mehr als zwei Jahrtausende.",
"Jedes Stück dokumentiert eine Macht, einen Austausch, eine Grenze."),
"EN": p(
"Celtic, Roman, medieval and modern coinage: the cabinet holds the "
"monetary memory of Luxembourg's territory across more than two "
"millennia.",
"Every piece documents a power, an exchange, a border."),
"NL": p(
"Keltische, Romeinse, middeleeuwse en moderne munten: het kabinet "
"bewaart het monetaire geheugen van het Luxemburgse gebied over "
"meer dan twee millennia.",
"Elk stuk documenteert een macht, een uitwisseling, een grens."),
},
},
],
"draieechelen": [
{
"label": "Fort Thüngen",
"beacon": 201,
"coords": ("49.62115", "6.14185"),
"title": {
"FR": "Le Fort Thüngen",
"DE": "Das Fort Thüngen",
"EN": "Fort Thüngen",
"NL": "Fort Thüngen",
},
"description": {
"FR": "1732 — Le fort et ses trois tours",
"DE": "1732 — Das Fort und seine drei Türme",
"EN": "1732 — The fort and its three towers",
"NL": "1732 — Het fort en zijn drie torens",
},
"content": {
"FR": p(
"Édifié en 1732 par les Autrichiens, le Fort Thüngen doit son "
"surnom de « Dräi Eechelen » aux trois glands dorés qui couronnent "
"ses tours.",
"Démantelé après le traité de Londres de 1867, il a été restauré "
"puis transformé en musée consacré à l'histoire de la forteresse."),
"DE": p(
"1732 von den Österreichern errichtet, verdankt das Fort Thüngen "
"seinen Beinamen „Dräi Eechelen“ den drei vergoldeten Eicheln auf "
"seinen Türmen.",
"Nach dem Londoner Vertrag von 1867 geschleift, wurde es restauriert "
"und in ein Museum zur Geschichte der Festung umgewandelt."),
"EN": p(
"Built in 1732 by the Austrians, Fort Thüngen owes its nickname "
"“Dräi Eechelen” to the three gilded acorns crowning its towers.",
"Dismantled after the 1867 Treaty of London, it was restored and "
"turned into a museum devoted to the history of the fortress."),
"NL": p(
"Het in 1732 door de Oostenrijkers gebouwde Fort Thüngen dankt zijn "
"bijnaam „Dräi Eechelen“ aan de drie vergulde eikels op zijn torens.",
"Na het Verdrag van Londen van 1867 ontmanteld, werd het "
"gerestaureerd en omgevormd tot een museum over de geschiedenis van "
"de vesting."),
},
},
{
"label": "Démantèlement 1867",
"beacon": 202,
"coords": ("49.62130", "6.14170"),
"title": {
"FR": "1867 : le démantèlement de la forteresse",
"DE": "1867: die Schleifung der Festung",
"EN": "1867: dismantling the fortress",
"NL": "1867: de ontmanteling van de vesting",
},
"description": {
"FR": "Traité de Londres — salle 4",
"DE": "Londoner Vertrag — Saal 4",
"EN": "Treaty of London — room 4",
"NL": "Verdrag van Londen — zaal 4",
},
"content": {
"FR": p(
"Le traité de Londres impose la neutralité du Luxembourg et la "
"démolition de ses fortifications. Seize années de travaux effacent "
"l'une des places fortes les plus puissantes d'Europe.",
"Ce qu'il en reste — casemates, redoutes, murs — dessine encore la "
"ville d'aujourd'hui."),
"DE": p(
"Der Londoner Vertrag verordnet Luxemburgs Neutralität und den "
"Abriss seiner Befestigungen. Sechzehn Jahre Arbeit tilgen eine der "
"stärksten Festungen Europas.",
"Was blieb — Kasematten, Redouten, Mauern — prägt bis heute das "
"Stadtbild."),
"EN": p(
"The Treaty of London imposed Luxembourg's neutrality and the "
"demolition of its fortifications. Sixteen years of work erased one "
"of Europe's most powerful strongholds.",
"What remains — casemates, redoubts, walls — still shapes the city "
"today."),
"NL": p(
"Het Verdrag van Londen legde de neutraliteit van Luxemburg op en "
"de sloop van zijn vestingwerken. Zestien jaar werk wiste een van "
"de sterkste vestingen van Europa uit.",
"Wat overbleef — kazematten, redoutes, muren — tekent vandaag nog "
"de stad."),
},
},
{
"label": "Maquettes et plans-reliefs",
"beacon": 203,
"coords": ("49.62100", "6.14200"),
"title": {
"FR": "Maquettes et plans-reliefs",
"DE": "Modelle und Reliefpläne",
"EN": "Models and relief maps",
"NL": "Maquettes en reliëfkaarten",
},
"description": {
"FR": "La forteresse en trois dimensions — salle 6",
"DE": "Die Festung in drei Dimensionen — Saal 6",
"EN": "The fortress in three dimensions — room 6",
"NL": "De vesting in drie dimensies — zaal 6",
},
"content": {
"FR": p(
"Les plans-reliefs servaient aux ingénieurs militaires à penser le "
"terrain avant de le fortifier. Ils offrent aujourd'hui la lecture "
"la plus claire d'un système défensif disparu.",
"Les maquettes du musée restituent l'ampleur des ouvrages successifs, "
"du Moyen Âge au XIX<sup>e</sup> siècle."),
"DE": p(
"Reliefpläne dienten Militäringenieuren dazu, das Gelände vor der "
"Befestigung zu erfassen. Heute bieten sie den klarsten Zugang zu "
"einem verschwundenen Verteidigungssystem.",
"Die Modelle des Museums vergegenwärtigen den Umfang der "
"aufeinanderfolgenden Anlagen vom Mittelalter bis ins 19. "
"Jahrhundert."),
"EN": p(
"Relief maps allowed military engineers to read the terrain before "
"fortifying it. Today they offer the clearest view of a vanished "
"defensive system.",
"The museum's models convey the scale of successive works, from the "
"Middle Ages to the nineteenth century."),
"NL": p(
"Reliëfkaarten hielpen militaire ingenieurs het terrein te lezen "
"voor ze het versterkten. Vandaag bieden ze het helderste beeld van "
"een verdwenen verdedigingsstelsel.",
"De maquettes van het museum tonen de omvang van de opeenvolgende "
"werken, van de middeleeuwen tot de negentiende eeuw."),
},
},
],
"echternach": [
{
"label": "La villa romaine",
"beacon": 301,
"coords": ("49.80655", "6.40210"),
"title": {
"FR": "La villa romaine d'Echternach",
"DE": "Die römische Villa von Echternach",
"EN": "The Roman villa of Echternach",
"NL": "De Romeinse villa van Echternach",
},
"description": {
"FR": "I<sup>er</sup>-V<sup>e</sup> siècle — entrée du site",
"DE": "1.-5. Jahrhundert — Eingang der Anlage",
"EN": "1st-5th century — site entrance",
"NL": "1e-5e eeuw — ingang van de site",
},
"content": {
"FR": (
p("Fouillée en 1975 et 1976, la villa d'Echternach est l'un des "
"plus vastes domaines gallo-romains au nord des Alpes : plus de "
"<strong>cent mètres de façade</strong>, des portiques, des cours "
"intérieures et des bassins.")
+ h("Un domaine hors norme")
+ p("Le bâtiment résidentiel s'organisait autour d'une cour "
"centrale, prolongée par des jardins descendant vers la Sûre. "
"L'ensemble tenait autant de la résidence de prestige que de "
"l'exploitation agricole.")
+ ul("Une <strong>façade monumentale</strong> tournée vers la "
"vallée, rythmée par une longue galerie couverte.",
"Des <strong>dizaines de pièces</strong> autour de la cour : "
"réception, appartements privés, quartiers de service.",
"Des <strong>jardins et bassins d'agrément</strong>, mis en "
"scène dans l'axe de la villa.",
"Une <strong>partie agricole</strong> qui faisait vivre le "
"domaine et approvisionnait la région.")
+ h("Vivre au bord de la Sûre")
+ p("Sols et murs recevaient <strong>enduits peints</strong> et "
"revêtements soignés ; le domaine disposait de ses propres "
"thermes. Entretenir un tel ensemble supposait une fortune "
"considérable — et le goût de la montrer.")
+ quote("Le saviez-vous ? La villa ne s'est pas construite d'un "
"seul geste : agrandie au fil des générations, elle atteint "
"son apogée au <strong>II<sup>e</sup> siècle</strong> avant "
"d'être délaissée dans les troubles de l'Antiquité tardive.")
+ h("En bref")
+ ul("<strong>Fouilles :</strong> 1975 et 1976",
"<strong>Façade :</strong> plus de 100 mètres",
"<strong>Apogée :</strong> II<sup>e</sup> siècle apr. J.-C.",
"<strong>Cadre :</strong> vallée de la Sûre, Echternach")
),
"DE": (
p("1975 und 1976 ausgegraben, ist die Villa von Echternach eine "
"der größten gallo-römischen Anlagen nördlich der Alpen: über "
"<strong>hundert Meter Fassade</strong>, Portiken, Innenhöfe und "
"Becken.")
+ h("Eine Anlage von seltenem Ausmaß")
+ p("Das Wohngebäude gruppierte sich um einen zentralen Hof, "
"verlängert durch Gärten, die zur Sauer hin abfielen. Das "
"Anwesen war ebenso Prunkresidenz wie Landwirtschaftsbetrieb.")
+ ul("Eine <strong>monumentale Fassade</strong> zum Tal hin, "
"gegliedert durch eine lange überdachte Galerie.",
"<strong>Dutzende Räume</strong> um den Hof: Empfang, "
"Privatgemächer, Wirtschaftsräume.",
"<strong>Gärten und Zierbecken</strong>, in der Achse der "
"Villa in Szene gesetzt.",
"Ein <strong>landwirtschaftlicher Teil</strong>, der das "
"Anwesen trug und die Region versorgte.")
+ h("Leben an der Sauer")
+ p("Böden und Wände trugen <strong>bemalten Putz</strong> und "
"sorgfältige Beläge; das Anwesen besaß eigene Thermen. Ein "
"solches Ensemble zu unterhalten setzte großen Reichtum "
"voraus — und die Lust, ihn zu zeigen.")
+ quote("Wussten Sie schon? Die Villa entstand nicht in einem Zug: "
"über Generationen erweitert, erreichte sie im "
"<strong>2. Jahrhundert</strong> ihren Höhepunkt, ehe sie "
"in den Wirren der Spätantike aufgegeben wurde.")
+ h("Kurz gefasst")
+ ul("<strong>Ausgrabungen:</strong> 1975 und 1976",
"<strong>Fassade:</strong> über 100 Meter",
"<strong>Höhepunkt:</strong> 2. Jahrhundert n. Chr.",
"<strong>Lage:</strong> Sauertal, Echternach")
),
"EN": (
p("Excavated in 1975 and 1976, the Echternach villa is one of the "
"largest Gallo-Roman estates north of the Alps: over <strong>a "
"hundred metres of frontage</strong>, porticoes, inner courtyards "
"and pools.")
+ h("An estate on a rare scale")
+ p("The residential building was arranged around a central court, "
"extended by gardens sloping down towards the Sûre. It was as "
"much a prestige residence as a working farm.")
+ ul("A <strong>monumental frontage</strong> facing the valley, "
"set out along a long covered gallery.",
"<strong>Dozens of rooms</strong> around the court: reception, "
"private apartments, service quarters.",
"<strong>Gardens and ornamental pools</strong>, staged along "
"the axis of the villa.",
"A <strong>farming quarter</strong> that sustained the estate "
"and supplied the region.")
+ h("Living beside the Sûre")
+ p("Floors and walls carried <strong>painted plaster</strong> and "
"careful finishes, and the estate had baths of its own. Keeping "
"such a place going called for considerable wealth — and a "
"taste for showing it.")
+ quote("Did you know? The villa was not built in one go: enlarged "
"over generations, it reached its height in the "
"<strong>second century</strong> before being left behind "
"in the upheavals of late antiquity.")
+ h("In brief")
+ ul("<strong>Excavations:</strong> 1975 and 1976",
"<strong>Frontage:</strong> over 100 metres",
"<strong>Height:</strong> second century AD",
"<strong>Setting:</strong> Sûre valley, Echternach")
),
"NL": (
p("De in 1975 en 1976 opgegraven villa van Echternach is een van "
"de grootste Gallo-Romeinse domeinen ten noorden van de Alpen: "
"meer dan <strong>honderd meter gevel</strong>, zuilengangen, "
"binnenplaatsen en bekkens.")
+ h("Een domein van zeldzame omvang")
+ p("Het woongebouw lag rond een centrale binnenplaats, verlengd "
"door tuinen die afliepen naar de Sûre. Het geheel was even "
"goed pronkresidentie als landbouwbedrijf.")
+ ul("Een <strong>monumentale gevel</strong> naar het dal, geleed "
"door een lange overdekte galerij.",
"<strong>Tientallen vertrekken</strong> rond de binnenplaats: "
"ontvangst, privévertrekken, dienstruimten.",
"<strong>Tuinen en siervijvers</strong>, in de as van de villa "
"geënsceneerd.",
"Een <strong>landbouwgedeelte</strong> dat het domein droeg en "
"de streek bevoorraadde.")
+ h("Leven aan de Sûre")
+ p("Vloeren en muren kregen <strong>beschilderd pleisterwerk</strong> "
"en verzorgde afwerkingen; het domein had eigen thermen. Zo'n "
"geheel onderhouden vergde een aanzienlijk fortuin — en de lust "
"om het te tonen.")
+ quote("Wist u dat? De villa ontstond niet in één keer: over "
"generaties uitgebreid bereikte zij haar hoogtepunt in de "
"<strong>2e eeuw</strong>, voordat zij in de onrust van de "
"late oudheid werd verlaten.")
+ h("In het kort")
+ ul("<strong>Opgravingen:</strong> 1975 en 1976",
"<strong>Gevel:</strong> meer dan 100 meter",
"<strong>Hoogtepunt:</strong> 2e eeuw n.Chr.",
"<strong>Ligging:</strong> Sûre-vallei, Echternach")
),
},
},
{
"label": "Thermes et hypocauste",
"beacon": 302,
"coords": ("49.80630", "6.40165"),
"title": {
"FR": "Les thermes et le chauffage par le sol",
"DE": "Thermen und Fußbodenheizung",
"EN": "The baths and underfloor heating",
"NL": "De thermen en vloerverwarming",
},
"description": {
"FR": "Aile ouest — vestiges en extérieur",
"DE": "Westflügel — Freigelände",
"EN": "West wing — outdoor remains",
"NL": "Westvleugel — buitenresten",
},
"content": {
"FR": p(
"L'hypocauste chauffait les sols en faisant circuler l'air chaud "
"d'un foyer sous des pilettes de terre cuite. Les vestiges visibles "
"en montrent encore l'agencement.",
"Le domaine disposait de ses propres thermes : salle froide, salle "
"tiède, salle chaude — le confort d'une villa de premier rang."),
"DE": p(
"Das Hypokaustum beheizte die Böden, indem warme Luft aus einem "
"Ofen unter Pfeilern aus Ziegel zirkulierte. Die sichtbaren Reste "
"zeigen die Anordnung noch heute.",
"Das Anwesen besaß eigene Thermen: Kaltbad, Laubad, Warmbad — der "
"Komfort einer erstrangigen Villa."),
"EN": p(
"The hypocaust heated the floors by circulating hot air from a "
"furnace beneath brick pillars. The visible remains still show the "
"layout.",
"The estate had its own baths: cold room, warm room, hot room — the "
"comfort of a first-rank villa."),
"NL": p(
"Het hypocaustum verwarmde de vloeren door hete lucht uit een "
"stookplaats onder bakstenen pijlers te laten circuleren. De "
"zichtbare resten tonen de opzet nog.",
"Het domein had eigen thermen: koude, lauwe en warme zaal — het "
"comfort van een villa van de eerste rang."),
},
},
],
}
MENUS = {
"feschmaart": {
"label": "Collections",
"title": {"FR": "Les collections", "DE": "Die Sammlungen",
"EN": "The collections", "NL": "De collecties"},
"description": {
"FR": "Archéologie, cabinet des médailles, arts décoratifs, beaux-arts",
"DE": "Archäologie, Münzkabinett, Kunstgewerbe, bildende Kunst",
"EN": "Archaeology, coin cabinet, decorative arts, fine arts",
"NL": "Archeologie, penningkabinet, sierkunst, schone kunsten"},
},
}
QUIZZES = {
"feschmaart": {
"label": "Quiz archéologie",
"title": {"FR": "Connaissez-vous l'archéologie luxembourgeoise ?",
"DE": "Kennen Sie die Luxemburger Archäologie?",
"EN": "How well do you know Luxembourg archaeology?",
"NL": "Kent u de Luxemburgse archeologie?"},
"questions": [
{
"q": {"FR": "En quelle année la mosaïque de Vichten a-t-elle été découverte ?",
"DE": "In welchem Jahr wurde das Mosaik von Vichten entdeckt?",
"EN": "In which year was the Vichten mosaic discovered?",
"NL": "In welk jaar werd het mozaïek van Vichten ontdekt?"},
"answers": [
({"FR": "1995", "DE": "1995", "EN": "1995", "NL": "1995"}, True),
({"FR": "1963", "DE": "1963", "EN": "1963", "NL": "1963"}, False),
({"FR": "1978", "DE": "1978", "EN": "1978", "NL": "1978"}, False),
],
},
{
"q": {"FR": "Qui figure au centre de la mosaïque de Vichten ?",
"DE": "Wer steht im Zentrum des Mosaiks von Vichten?",
"EN": "Who is at the centre of the Vichten mosaic?",
"NL": "Wie staat in het midden van het mozaïek van Vichten?"},
"answers": [
({"FR": "Homère", "DE": "Homer", "EN": "Homer", "NL": "Homerus"}, True),
({"FR": "Jupiter", "DE": "Jupiter", "EN": "Jupiter", "NL": "Jupiter"}, False),
({"FR": "Orphée", "DE": "Orpheus", "EN": "Orpheus", "NL": "Orpheus"}, False),
],
},
{
"q": {"FR": "Que signifie « Dräi Eechelen » ?",
"DE": "Was bedeutet „Dräi Eechelen“?",
"EN": "What does “Dräi Eechelen” mean?",
"NL": "Wat betekent „Dräi Eechelen“?"},
"answers": [
({"FR": "Trois glands", "DE": "Drei Eicheln",
"EN": "Three acorns", "NL": "Drie eikels"}, True),
({"FR": "Trois tours", "DE": "Drei Türme",
"EN": "Three towers", "NL": "Drie torens"}, False),
({"FR": "Trois portes", "DE": "Drei Tore",
"EN": "Three gates", "NL": "Drie poorten"}, False),
],
},
],
},
}
# Points d'intérêt de la carte extérieure d'Echternach, sur l'emprise réelle du site.
MAP_POINTS = [
{
"title": {"FR": "Entrée du site", "DE": "Eingang", "EN": "Site entrance",
"NL": "Ingang"},
"description": {"FR": "Accueil, billetterie et départ du parcours.",
"DE": "Empfang, Kasse und Start des Rundgangs.",
"EN": "Reception, ticketing and start of the trail.",
"NL": "Onthaal, ticketing en start van de route."},
"lat": 49.80680, "lon": 6.40255, "cat": 1,
},
{
"title": {"FR": "Cour intérieure", "DE": "Innenhof", "EN": "Inner courtyard",
"NL": "Binnenplaats"},
"description": {"FR": "Le cœur du bâtiment résidentiel et ses portiques.",
"DE": "Das Herz des Wohngebäudes und seine Portiken.",
"EN": "The heart of the residential building and its porticoes.",
"NL": "Het hart van het woongebouw en zijn zuilengangen."},
"lat": 49.80655, "lon": 6.40210, "cat": 2,
},
{
"title": {"FR": "Les thermes", "DE": "Die Thermen", "EN": "The baths",
"NL": "De thermen"},
"description": {"FR": "Salles froide, tiède et chaude ; hypocauste visible.",
"DE": "Kalt-, Lau- und Warmbad; sichtbares Hypokaustum.",
"EN": "Cold, warm and hot rooms; visible hypocaust.",
"NL": "Koude, lauwe en warme zalen; zichtbaar hypocaustum."},
"lat": 49.80630, "lon": 6.40165, "cat": 2,
},
{
"title": {"FR": "Les bassins", "DE": "Die Becken", "EN": "The pools",
"NL": "De bekkens"},
"description": {"FR": "Bassins d'agrément des jardins en terrasse.",
"DE": "Zierbecken der Terrassengärten.",
"EN": "Ornamental pools of the terraced gardens.",
"NL": "Sierbekkens van de terrastuinen."},
"lat": 49.80610, "lon": 6.40230, "cat": 2,
},
{
"title": {"FR": "Point de vue sur la Sûre", "DE": "Aussichtspunkt auf die Sauer",
"EN": "Viewpoint over the Sûre", "NL": "Uitzicht over de Sûre"},
"description": {"FR": "Le domaine dominait la vallée et sa voie de passage.",
"DE": "Das Anwesen beherrschte das Tal und seinen Verkehrsweg.",
"EN": "The estate overlooked the valley and its through route.",
"NL": "Het domein beheerste de vallei en haar doorgangsweg."},
"lat": 49.80590, "lon": 6.40170, "cat": 3,
},
]
MAP_CATEGORIES = [
{"id": 1, "label": {"FR": "Accueil", "DE": "Empfang", "EN": "Reception",
"NL": "Onthaal"}, "color": "0xFF1F3A5F"},
{"id": 2, "label": {"FR": "Vestiges", "DE": "Überreste", "EN": "Remains",
"NL": "Overblijfselen"}, "color": "0xFF7A4A2F"},
{"id": 3, "label": {"FR": "Panorama", "DE": "Panorama", "EN": "Viewpoint",
"NL": "Panorama"}, "color": "0xFF4A5C3D"},
]
AGENDA_EVENTS = [
{"title": {"FR": "Visite guidée : la mosaïque de Vichten",
"DE": "Führung: das Mosaik von Vichten",
"EN": "Guided tour: the Vichten mosaic",
"NL": "Rondleiding: het mozaïek van Vichten"},
"description": {"FR": "Une heure avec un médiateur, autour de la pièce maîtresse "
"des salles d'archéologie.",
"DE": "Eine Stunde mit einem Vermittler rund um das Hauptwerk der "
"Archäologiesäle.",
"EN": "An hour with a guide around the centrepiece of the "
"archaeology halls.",
"NL": "Een uur met een gids rond het pronkstuk van de "
"archeologiezalen."},
"day_offset": 6, "hour": 15},
{"title": {"FR": "Atelier famille : fouiller comme un archéologue",
"DE": "Familienworkshop: graben wie ein Archäologe",
"EN": "Family workshop: dig like an archaeologist",
"NL": "Gezinsworkshop: graven als een archeoloog"},
"description": {"FR": "Dès 8 ans. Sur inscription.",
"DE": "Ab 8 Jahren. Anmeldung erforderlich.",
"EN": "Ages 8 and up. Registration required.",
"NL": "Vanaf 8 jaar. Inschrijving vereist."},
"day_offset": 13, "hour": 14},
{"title": {"FR": "Nocturne aux chandelles au Fort Thüngen",
"DE": "Abendöffnung bei Kerzenschein im Fort Thüngen",
"EN": "Candlelit evening at Fort Thüngen",
"NL": "Avondopening bij kaarslicht in Fort Thüngen"},
"description": {"FR": "Le fort ouvert jusqu'à 22 h, parcours éclairé.",
"DE": "Das Fort bis 22 Uhr geöffnet, beleuchteter Rundgang.",
"EN": "The fort open until 10 pm, illuminated route.",
"NL": "Het fort open tot 22 uur, verlichte route."},
"day_offset": 20, "hour": 19},
{"title": {"FR": "Conférence : fouiller Echternach, cinquante ans après",
"DE": "Vortrag: Echternach ausgraben, fünfzig Jahre danach",
"EN": "Lecture: excavating Echternach, fifty years on",
"NL": "Lezing: Echternach opgraven, vijftig jaar later"},
"description": {"FR": "Retour sur les campagnes de 1975-1976 et sur ce que les "
"méthodes actuelles y liraient.",
"DE": "Rückblick auf die Kampagnen 1975-1976 und darauf, was "
"heutige Methoden dort lesen würden.",
"EN": "Revisiting the 1975-1976 campaigns and what today's methods "
"would read there.",
"NL": "Terugblik op de campagnes van 1975-1976 en wat de huidige "
"methodes daar zouden lezen."},
"day_offset": 27, "hour": 18},
]

View File

@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
"""Visuels d'illustration pour l'instance de démonstration.
Compositions abstraites, générées : aucune photographie de collection n'est
utilisée, les droits n'appartiennent pas au projet. Chaque motif évoque son sujet
sans le représenter trame de tesselles, géométrie de bastion, pilettes
d'hypocauste — dans la palette du site auquel il appartient.
"""
import math
import random
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
OUT = Path(__file__).resolve().parent / "images"
PALETTES = {
"feschmaart": {"deep": (18, 32, 54), "mid": (31, 58, 95), "warm": (176, 141, 87),
"pale": (214, 199, 176)},
"draieechelen": {"deep": (28, 36, 24), "mid": (74, 92, 61), "warm": (156, 122, 74),
"pale": (206, 199, 176)},
"echternach": {"deep": (48, 28, 18), "mid": (122, 74, 47), "warm": (192, 161, 107),
"pale": (222, 206, 180)},
}
def blend(a, b, t):
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
def background(size, palette, tilt=0.0):
"""Dégradé diagonal doux, du plus sombre en haut à gauche."""
w, h = size
base = Image.new("RGB", size)
px = base.load()
for y in range(h):
for x in range(0, w, 4):
t = (x / w * (0.35 + tilt) + y / h * 0.65)
colour = blend(palette["deep"], palette["mid"], min(1.0, t))
for dx in range(4):
if x + dx < w:
px[x + dx, y] = colour
return base
def grain(img, amount=7, seed=0):
rnd = random.Random(seed)
noise = Image.new("L", img.size)
noise.putdata([128 + rnd.randint(-amount, amount)
for _ in range(img.size[0] * img.size[1])])
noise = noise.filter(ImageFilter.GaussianBlur(0.4))
return Image.blend(img, Image.merge("RGB", (noise, noise, noise)), 0.06)
def vignette(img, strength=0.45):
w, h = img.size
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
d.ellipse([-w * 0.25, -h * 0.35, w * 1.25, h * 1.35], fill=255)
mask = mask.filter(ImageFilter.GaussianBlur(min(w, h) * 0.18))
dark = Image.new("RGB", (w, h), (0, 0, 0))
return Image.composite(img, Image.blend(img, dark, strength), mask)
def layer(size):
return Image.new("RGBA", size, (0, 0, 0, 0))
# ----------------------------------------------------------------- motifs
def tesserae(size, palette, seed):
"""Trame de tesselles, pour la mosaïque."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
cell = max(14, w // 46)
for y in range(0, h + cell, cell):
for x in range(0, w + cell, cell):
if rnd.random() < 0.14:
continue
jx, jy = rnd.randint(-2, 2), rnd.randint(-2, 2)
t = rnd.random()
colour = blend(palette["warm"], palette["pale"], t)
radial = math.hypot(x - w * 0.5, y - h * 0.5) / (w * 0.6)
alpha = int(max(0, 150 * (1.05 - radial)) * (0.45 + rnd.random() * 0.55))
d.rectangle([x + jx, y + jy, x + jx + cell - 3, y + jy + cell - 3],
fill=colour + (alpha,))
return lay
def bastion(size, palette, seed):
"""Tracé bastionné : enceintes concentriques, glacis hachuré, lignes de tir."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
cx, cy = w * 0.5, h * 0.52
for i in range(0, int(w * 1.6), max(9, w // 90)):
d.line([(i - w * 0.3, h), (i, 0)], fill=palette["warm"] + (16,), width=1)
for ring in range(15):
radius = min(w, h) * (0.05 + ring * 0.031)
points = []
spikes = 6
for i in range(spikes * 2):
angle = math.pi * i / spikes + ring * 0.05
r = radius * (1.0 if i % 2 == 0 else 0.74)
points.append((cx + r * math.cos(angle), cy + r * math.sin(angle) * 0.86))
colour = blend(palette["warm"], palette["pale"], ring / 15)
d.line(points + [points[0]], fill=colour + (max(34, int(130 - ring * 6)),),
width=max(2, int(w * 0.0028)))
if ring % 4 == 0:
d.polygon(points, fill=colour + (10,))
for i in range(12):
angle = math.tau * i / 12
d.line([cx + min(w, h) * 0.07 * math.cos(angle),
cy + min(w, h) * 0.07 * math.sin(angle) * 0.86,
cx + w * 0.75 * math.cos(angle),
cy + w * 0.75 * math.sin(angle) * 0.86],
fill=palette["pale"] + (30,), width=1)
return lay
def pillars(size, palette, seed):
"""Champ de pilettes en fuite : l'hypocauste sous le plancher."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
rows = 9
for r in range(rows):
depth = (r + 1) / rows
y = h * (0.16 + 0.86 * depth ** 1.7)
pw = w * 0.012 + w * 0.030 * depth
ph = h * 0.035 + h * 0.10 * depth
gap = pw * 2.35
colour = blend(palette["warm"], palette["pale"], 0.25 + depth * 0.5)
alpha = int(12 + depth * 62)
x = (r % 2) * gap * 0.5 - gap * 0.5
while x < w + gap:
d.rectangle([x, y - ph, x + pw, y], fill=colour + (alpha,))
d.rectangle([x - pw * 0.22, y - ph - ph * 0.10,
x + pw * 1.22, y - ph + ph * 0.02],
fill=colour + (min(255, alpha + 26),))
x += gap
return lay
def strata(size, palette, seed):
"""Couches sédimentaires : la fouille, la stratigraphie."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
y = h * 0.06
while y < h:
thickness = rnd.uniform(h * 0.018, h * 0.055)
t = y / h
colour = blend(palette["warm"], palette["pale"], rnd.uniform(0.1, 0.9))
alpha = int(18 + 46 * t)
points = [(0, y)]
x = 0
while x < w:
x += w / 18
points.append((x, y + rnd.uniform(-h * 0.014, h * 0.014)))
points += [(w, y + thickness), (0, y + thickness)]
d.polygon(points, fill=colour + (alpha,))
# Inclusions : éclats et tessons pris dans la couche.
for _ in range(int(thickness / h * 90)):
ix = rnd.uniform(0, w)
iy = rnd.uniform(y + thickness * 0.15, y + thickness * 0.85)
ir = rnd.uniform(w * 0.002, w * 0.006)
d.ellipse([ix - ir, iy - ir * 0.6, ix + ir, iy + ir * 0.6],
fill=palette["pale"] + (int(alpha * 0.9),))
y += thickness + rnd.uniform(h * 0.002, h * 0.012)
return lay
def arcs(size, palette, seed):
"""Portique : arcades en enfilade sur stylobate."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
bays = 5
span = w / bays
for register, (ground, bays_here) in enumerate([(h * 0.52, 7), (h * 0.94, 5)]):
span = w / bays_here
for b in range(bays_here + 1):
cx = span * b
for k in range(4):
r = span * (0.50 - k * 0.045)
top = ground - r * 1.15
colour = blend(palette["pale"], palette["warm"], k / 4)
d.arc([cx - r, top - r * 0.35, cx + r, top + r * 1.5], 180, 360,
fill=colour + (int(120 - k * 20),), width=max(2, int(w * 0.0028)))
cw = span * 0.075
d.rectangle([cx - cw, ground - span * 0.62, cx + cw, ground],
fill=palette["warm"] + (44,))
d.line([(0, ground), (w, ground)], fill=palette["warm"] + (70,),
width=max(2, int(w * 0.0025)))
return lay
def medallions(size, palette, seed):
"""Monnaies : disques serrés, frappés, qui se recouvrent."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for _ in range(78):
r = rnd.uniform(w * 0.025, w * 0.085)
cx = rnd.uniform(-w * 0.02, w * 1.02)
cy = rnd.uniform(-h * 0.02, h * 1.02)
colour = blend(palette["warm"], palette["pale"], rnd.random())
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=colour + (16,),
outline=colour + (110,), width=max(2, int(w * 0.0022)))
d.ellipse([cx - r * 0.66, cy - r * 0.66, cx + r * 0.66, cy + r * 0.66],
outline=colour + (62,), width=max(1, int(w * 0.0014)))
beads = 18
for i in range(beads):
a = math.tau * i / beads
bx, by = cx + r * 0.84 * math.cos(a), cy + r * 0.84 * math.sin(a)
d.ellipse([bx - 1.4, by - 1.4, bx + 1.4, by + 1.4], fill=colour + (70,))
return lay
def deco(size, palette, seed):
"""Éventails et arcs : le vocabulaire Art déco, en trame pleine."""
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for cx, cy, scale in [(w * 0.16, h * 1.02, 1.0), (w * 0.5, h * 1.02, 1.25),
(w * 0.84, h * 1.02, 1.0), (w * 0.33, h * -0.02, 0.7),
(w * 0.67, h * -0.02, 0.7)]:
upward = cy < h * 0.5
for i in range(19):
angle = (0 if upward else math.pi) + i * math.pi / 18
length = min(w, h) * scale * (0.62 if i % 2 == 0 else 0.48)
d.line([cx, cy, cx + length * math.cos(angle),
cy + length * math.sin(angle)],
fill=blend(palette["pale"], palette["warm"], i / 19)
+ (88 if i % 2 else 120,),
width=max(2, int(w * 0.0026)))
for ring in range(6):
r = min(w, h) * scale * (0.11 + ring * 0.105)
d.arc([cx - r, cy - r, cx + r, cy + r],
0 if upward else 180, 180 if upward else 360,
fill=palette["warm"] + (48,), width=max(1, int(w * 0.0018)))
return lay
def contours(size, palette, seed):
"""Courbes de niveau : le site, le terrain, la vallée."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
for i in range(32):
points = []
base = h * (0.02 + i * 0.032)
phase = rnd.uniform(0, math.tau)
for x in range(0, w + 12, 12):
y = base + math.sin(x / w * math.tau * 1.4 + phase) * h * 0.055 \
+ math.sin(x / w * math.tau * 3.1 + phase) * h * 0.018
points.append((x, y))
colour = blend(palette["warm"], palette["pale"], i / 32)
d.line(points, fill=colour + (int(100 - i * 1.6),),
width=max(1, int(w * 0.0018)))
return lay
def plan(size, palette, seed):
"""Trame orthogonale : le plan, la maquette, le relevé."""
rnd = random.Random(seed)
lay = layer(size)
d = ImageDraw.Draw(lay)
w, h = size
step = w // 30
for x in range(0, w, step):
d.line([(x, 0), (x, h)], fill=palette["warm"] + (34,), width=1)
for y in range(0, h, step):
d.line([(0, y), (w, y)], fill=palette["warm"] + (34,), width=1)
for _ in range(22):
x0 = rnd.randrange(0, w - step * 5, step)
y0 = rnd.randrange(0, h - step * 4, step)
x1 = x0 + step * rnd.randint(2, 7)
y1 = y0 + step * rnd.randint(2, 6)
colour = blend(palette["pale"], palette["warm"], rnd.random())
d.rectangle([x0, y0, x1, y1], fill=colour + (14,), outline=colour + (120,),
width=max(2, int(w * 0.0024)))
return lay
MOTIFS = {
"tesserae": tesserae, "bastion": bastion, "pillars": pillars, "strata": strata,
"arcs": arcs, "medallions": medallions, "deco": deco, "contours": contours,
"plan": plan,
}
def compose(name, site, motif, size=(1600, 1000), seed=0, tilt=0.0):
palette = PALETTES[site]
img = background(size, palette, tilt).convert("RGBA")
img = Image.alpha_composite(img, MOTIFS[motif](size, palette, seed))
img = vignette(img.convert("RGB"))
img = grain(img, seed=seed)
OUT.mkdir(parents=True, exist_ok=True)
path = OUT / f"{name}.png"
img.save(path, "PNG", optimize=True)
print(f" {path.name:34} {size[0]}x{size[1]} {path.stat().st_size // 1024} Ko")
return path
# Cinq motifs retenus : ceux qui tiennent à taille de vignette. `bastion`,
# `pillars`, `arcs` et `strata` restent définis mais ne sont pas utilisés — ils
# se lisent comme un aplat vide ou comme une frise trop littérale une fois
# réduits. Un plan orthogonal évoque d'ailleurs mieux une fouille, un fort ou
# une villa que le motif inventé qu'ils remplaçaient.
#
# nom de fichier -> (site, motif, taille, graine)
IMAGES = [
# sites (tuiles d'accueil)
("site-feschmaart", "feschmaart", "tesserae", (1600, 1000), 11),
("site-draieechelen", "draieechelen", "plan", (1600, 1000), 12),
("site-echternach", "echternach", "contours", (1600, 1000), 13),
# habillage de l'application
("app-accueil", "feschmaart", "tesserae", (1200, 1600), 21),
("app-chargement", "feschmaart", "medallions", (1080, 1920), 22),
# articles du Fëschmaart
("art-vichten", "feschmaart", "tesserae", (1600, 1000), 31),
("art-goeblange", "feschmaart", "plan", (1600, 1000), 32),
("art-villeroy", "feschmaart", "deco", (1600, 1000), 33),
("art-medailles", "feschmaart", "medallions", (1600, 1000), 34),
# articles de Dräi Eechelen
("art-thungen", "draieechelen", "plan", (1600, 1000), 41),
("art-demantelement", "draieechelen", "contours", (1600, 1000), 42),
("art-maquettes", "draieechelen", "plan", (1600, 1000), 43),
# articles d'Echternach
("art-villa", "echternach", "plan", (1600, 1000), 51),
("art-thermes", "echternach", "tesserae", (1600, 1000), 52),
# parcours
("parcours-echternach", "echternach", "contours", (1600, 1000), 61),
("parcours-escape", "feschmaart", "tesserae", (1600, 1000), 62),
# points d'intérêt de la carte
("poi-entree", "echternach", "plan", (1200, 800), 71),
("poi-cour", "echternach", "plan", (1200, 800), 72),
("poi-thermes", "echternach", "tesserae", (1200, 800), 73),
("poi-bassins", "echternach", "contours", (1200, 800), 74),
("poi-panorama", "echternach", "contours", (1200, 800), 75),
]
def main():
print(f"génération dans {OUT}")
for name, site, motif, size, seed in IMAGES:
compose(name, site, motif, size, seed, tilt=(seed % 5) * 0.04)
print(f"\n{len(IMAGES)} visuels générés")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Some files were not shown because too many files have changed in this diff Show More