{t}
" for t in paragraphs) + + +def h(title): + return f"{text}" + + + +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 IIIe 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": "Ier 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 XIXe 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": "Ier-Ve 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 " + "cent mètres de façade, 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 façade monumentale tournée vers la " + "vallée, rythmée par une longue galerie couverte.", + "Des dizaines de pièces autour de la cour : " + "réception, appartements privés, quartiers de service.", + "Des jardins et bassins d'agrément, mis en " + "scène dans l'axe de la villa.", + "Une partie agricole qui faisait vivre le " + "domaine et approvisionnait la région.") + + h("Vivre au bord de la Sûre") + + p("Sols et murs recevaient enduits peints 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 IIe siècle avant " + "d'être délaissée dans les troubles de l'Antiquité tardive.") + + h("En bref") + + ul("Fouilles : 1975 et 1976", + "Façade : plus de 100 mètres", + "Apogée : IIe siècle apr. J.-C.", + "Cadre : 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 " + "hundert Meter Fassade, 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 monumentale Fassade zum Tal hin, " + "gegliedert durch eine lange überdachte Galerie.", + "Dutzende Räume um den Hof: Empfang, " + "Privatgemächer, Wirtschaftsräume.", + "Gärten und Zierbecken, in der Achse der " + "Villa in Szene gesetzt.", + "Ein landwirtschaftlicher Teil, der das " + "Anwesen trug und die Region versorgte.") + + h("Leben an der Sauer") + + p("Böden und Wände trugen bemalten Putz 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 " + "2. Jahrhundert ihren Höhepunkt, ehe sie " + "in den Wirren der Spätantike aufgegeben wurde.") + + h("Kurz gefasst") + + ul("Ausgrabungen: 1975 und 1976", + "Fassade: über 100 Meter", + "Höhepunkt: 2. Jahrhundert n. Chr.", + "Lage: 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 a " + "hundred metres of frontage, 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 monumental frontage facing the valley, " + "set out along a long covered gallery.", + "Dozens of rooms around the court: reception, " + "private apartments, service quarters.", + "Gardens and ornamental pools, staged along " + "the axis of the villa.", + "A farming quarter that sustained the estate " + "and supplied the region.") + + h("Living beside the Sûre") + + p("Floors and walls carried painted plaster 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 " + "second century before being left behind " + "in the upheavals of late antiquity.") + + h("In brief") + + ul("Excavations: 1975 and 1976", + "Frontage: over 100 metres", + "Height: second century AD", + "Setting: 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 honderd meter gevel, 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 monumentale gevel naar het dal, geleed " + "door een lange overdekte galerij.", + "Tientallen vertrekken rond de binnenplaats: " + "ontvangst, privévertrekken, dienstruimten.", + "Tuinen en siervijvers, in de as van de villa " + "geënsceneerd.", + "Een landbouwgedeelte dat het domein droeg en " + "de streek bevoorraadde.") + + h("Leven aan de Sûre") + + p("Vloeren en muren kregen beschilderd pleisterwerk " + "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 " + "2e eeuw, voordat zij in de onrust van de " + "late oudheid werd verlaten.") + + h("In het kort") + + ul("Opgravingen: 1975 en 1976", + "Gevel: meer dan 100 meter", + "Hoogtepunt: 2e eeuw n.Chr.", + "Ligging: 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}, +] diff --git a/outputs/mnaha/seed/gen_images.py b/outputs/mnaha/seed/gen_images.py new file mode 100644 index 0000000..f83a451 --- /dev/null +++ b/outputs/mnaha/seed/gen_images.py @@ -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() diff --git a/outputs/mnaha/seed/images/_apercu.png b/outputs/mnaha/seed/images/_apercu.png new file mode 100644 index 0000000..159562e Binary files /dev/null and b/outputs/mnaha/seed/images/_apercu.png differ diff --git a/outputs/mnaha/seed/images/_apercu2.png b/outputs/mnaha/seed/images/_apercu2.png new file mode 100644 index 0000000..3cbea6e Binary files /dev/null and b/outputs/mnaha/seed/images/_apercu2.png differ diff --git a/outputs/mnaha/seed/images/app-accueil.png b/outputs/mnaha/seed/images/app-accueil.png new file mode 100644 index 0000000..bb18a51 Binary files /dev/null and b/outputs/mnaha/seed/images/app-accueil.png differ diff --git a/outputs/mnaha/seed/images/app-chargement.png b/outputs/mnaha/seed/images/app-chargement.png new file mode 100644 index 0000000..2ee7fd0 Binary files /dev/null and b/outputs/mnaha/seed/images/app-chargement.png differ diff --git a/outputs/mnaha/seed/images/art-demantelement.png b/outputs/mnaha/seed/images/art-demantelement.png new file mode 100644 index 0000000..df1e5b4 Binary files /dev/null and b/outputs/mnaha/seed/images/art-demantelement.png differ diff --git a/outputs/mnaha/seed/images/art-goeblange.png b/outputs/mnaha/seed/images/art-goeblange.png new file mode 100644 index 0000000..5e15d77 Binary files /dev/null and b/outputs/mnaha/seed/images/art-goeblange.png differ diff --git a/outputs/mnaha/seed/images/art-maquettes.png b/outputs/mnaha/seed/images/art-maquettes.png new file mode 100644 index 0000000..f4fac8e Binary files /dev/null and b/outputs/mnaha/seed/images/art-maquettes.png differ diff --git a/outputs/mnaha/seed/images/art-medailles.png b/outputs/mnaha/seed/images/art-medailles.png new file mode 100644 index 0000000..bd5db96 Binary files /dev/null and b/outputs/mnaha/seed/images/art-medailles.png differ diff --git a/outputs/mnaha/seed/images/art-thermes.png b/outputs/mnaha/seed/images/art-thermes.png new file mode 100644 index 0000000..5bb6253 Binary files /dev/null and b/outputs/mnaha/seed/images/art-thermes.png differ diff --git a/outputs/mnaha/seed/images/art-thungen.png b/outputs/mnaha/seed/images/art-thungen.png new file mode 100644 index 0000000..99041dc Binary files /dev/null and b/outputs/mnaha/seed/images/art-thungen.png differ diff --git a/outputs/mnaha/seed/images/art-vichten.png b/outputs/mnaha/seed/images/art-vichten.png new file mode 100644 index 0000000..77bffc3 Binary files /dev/null and b/outputs/mnaha/seed/images/art-vichten.png differ diff --git a/outputs/mnaha/seed/images/art-villa.png b/outputs/mnaha/seed/images/art-villa.png new file mode 100644 index 0000000..ddd87af Binary files /dev/null and b/outputs/mnaha/seed/images/art-villa.png differ diff --git a/outputs/mnaha/seed/images/art-villeroy.png b/outputs/mnaha/seed/images/art-villeroy.png new file mode 100644 index 0000000..b7fe1d6 Binary files /dev/null and b/outputs/mnaha/seed/images/art-villeroy.png differ diff --git a/outputs/mnaha/seed/images/parcours-echternach.png b/outputs/mnaha/seed/images/parcours-echternach.png new file mode 100644 index 0000000..5fa81c6 Binary files /dev/null and b/outputs/mnaha/seed/images/parcours-echternach.png differ diff --git a/outputs/mnaha/seed/images/parcours-escape.png b/outputs/mnaha/seed/images/parcours-escape.png new file mode 100644 index 0000000..5a831e0 Binary files /dev/null and b/outputs/mnaha/seed/images/parcours-escape.png differ diff --git a/outputs/mnaha/seed/images/poi-bassins.png b/outputs/mnaha/seed/images/poi-bassins.png new file mode 100644 index 0000000..453fdcd Binary files /dev/null and b/outputs/mnaha/seed/images/poi-bassins.png differ diff --git a/outputs/mnaha/seed/images/poi-cour.png b/outputs/mnaha/seed/images/poi-cour.png new file mode 100644 index 0000000..a4c279f Binary files /dev/null and b/outputs/mnaha/seed/images/poi-cour.png differ diff --git a/outputs/mnaha/seed/images/poi-entree.png b/outputs/mnaha/seed/images/poi-entree.png new file mode 100644 index 0000000..e6f2d52 Binary files /dev/null and b/outputs/mnaha/seed/images/poi-entree.png differ diff --git a/outputs/mnaha/seed/images/poi-panorama.png b/outputs/mnaha/seed/images/poi-panorama.png new file mode 100644 index 0000000..70b1e29 Binary files /dev/null and b/outputs/mnaha/seed/images/poi-panorama.png differ diff --git a/outputs/mnaha/seed/images/poi-thermes.png b/outputs/mnaha/seed/images/poi-thermes.png new file mode 100644 index 0000000..5c8ce9e Binary files /dev/null and b/outputs/mnaha/seed/images/poi-thermes.png differ diff --git a/outputs/mnaha/seed/images/site-draieechelen.png b/outputs/mnaha/seed/images/site-draieechelen.png new file mode 100644 index 0000000..f007f5b Binary files /dev/null and b/outputs/mnaha/seed/images/site-draieechelen.png differ diff --git a/outputs/mnaha/seed/images/site-echternach.png b/outputs/mnaha/seed/images/site-echternach.png new file mode 100644 index 0000000..9b4a8ff Binary files /dev/null and b/outputs/mnaha/seed/images/site-echternach.png differ diff --git a/outputs/mnaha/seed/images/site-feschmaart.png b/outputs/mnaha/seed/images/site-feschmaart.png new file mode 100644 index 0000000..a199816 Binary files /dev/null and b/outputs/mnaha/seed/images/site-feschmaart.png differ diff --git a/outputs/mnaha/seed/patch_villa_content.py b/outputs/mnaha/seed/patch_villa_content.py new file mode 100644 index 0000000..738b411 --- /dev/null +++ b/outputs/mnaha/seed/patch_villa_content.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""Met à jour le seul champ `content` de l'article « La villa romaine ». + +Rejouer `step2_sections.py` remettrait `contents` à vide et écraserait l'ordre +posé par les étapes 6 et 7 : on relit donc la section telle qu'elle est en base +et on ne remplace que son contenu. +""" + +import json +from pathlib import Path + +from api import Api, tr +from content import ARTICLES + +STATE = Path(__file__).resolve().parent / "state.json" +LABEL = "La villa romaine" + + +def main(): + state = json.loads(STATE.read_text(encoding="utf-8")) + api = Api() + + config_id = state["configs"]["echternach"] + sections = api.get(f"/api/Section/configuration/{config_id}") or [] + listed = next(s for s in sections if s["label"] == LABEL) + + section = api.get(f"/api/Section/{listed['id']}") + article = next(a for a in ARTICLES["echternach"] if a["label"] == LABEL) + section["content"] = tr(**article["content"]) + api.put("/api/Section", section) + print(f" ~ {LABEL} : contenu mis à jour ({len(section['content'])} langues)") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/serve_images.py b/outputs/mnaha/seed/serve_images.py new file mode 100644 index 0000000..371220c --- /dev/null +++ b/outputs/mnaha/seed/serve_images.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""Sert les visuels de démonstration sur http://localhost:8099. + +`POST /api/Resource/upload` n'écrit que des métadonnées : le fichier lui-même +vit dans le bucket Firebase, où c'est `manager-app` qui le dépose depuis le +navigateur. Un script qui parle à l'API n'a donc aucun moyen d'y placer un blob. + +Les ressources de démonstration sont créées en type `ImageUrl` et pointent ici. +**Ce serveur doit tourner pendant les captures**, sinon les images ne +s'affichent pas. +""" + +import http.server +from pathlib import Path + +PORT = 8099 +ROOT = Path(__file__).resolve().parent / "images" + + +class Handler(http.server.SimpleHTTPRequestHandler): + # Next demande une dizaine de visuels en parallèle : en HTTP/1.0 chaque + # image ouvre puis ferme sa propre connexion, ce qui multiplie les allers-retours. + protocol_version = "HTTP/1.1" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(ROOT), **kwargs) + + def end_headers(self): + # manager-app et visitapp-web tournent sur d'autres origines. + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Cache-Control", "public, max-age=3600") + super().end_headers() + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + http.server.ThreadingHTTPServer.allow_reuse_address = True + with http.server.ThreadingHTTPServer(("127.0.0.1", PORT), Handler) as httpd: + print(f"visuels servis sur http://localhost:{PORT} depuis {ROOT}") + httpd.serve_forever() diff --git a/outputs/mnaha/seed/step10_colors.py b/outputs/mnaha/seed/step10_colors.py new file mode 100644 index 0000000..4b2b37f --- /dev/null +++ b/outputs/mnaha/seed/step10_colors.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""Étape 10 — format des couleurs. + +`manager-app` enregistre les couleurs telles que Flutter les sérialise : +`Color(0xaarrggbb)`. Mon seed écrivait `0xFF1F3A5F`, que personne ne sait lire : + +- `visitapp-web` ne dénormalise que la forme `Color(0x…)` (`lib/theme.ts`) et + laisse passer le reste tel quel, ce qui donne une couleur CSS invalide ; +- les apps Flutter font `raw.split('(0x')[1]` et retombent sur leur couleur par + défaut. + +On réécrit donc les couleurs posées par le seed. Celles réglées depuis le CMS +sont déjà au bon format et ne sont pas touchées. +""" + +import json +import re +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + +SITE_COLORS = { + "feschmaart": ("0xFF1F3A5F", "0xFFB08D57"), + "draieechelen": ("0xFF4A5C3D", "0xFF9C7A4A"), + "echternach": ("0xFF7A4A2F", "0xFFC0A16B"), +} + +KIOSK_COLORS = ("0xFF1F3A5F", "0xFFB08D57") + + +def flutter(colour): + """« 0xFF1F3A5F » -> « Color(0xff1f3a5f) ». Déjà converti : inchangé.""" + if colour is None or colour.startswith("Color("): + return colour + match = re.fullmatch(r"0x([0-9a-fA-F]{8})", colour.strip()) + return f"Color(0x{match.group(1).lower()})" if match else colour + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + + for key, (primary, secondary) in SITE_COLORS.items(): + config = api.get(f"/api/Configuration/{state['configs'][key]}") + wanted = (flutter(primary), flutter(secondary)) + if (config.get("primaryColor"), config.get("secondaryColor")) == wanted: + print(f" = {key}") + continue + config["primaryColor"], config["secondaryColor"] = wanted + api.put("/api/Configuration", config) + print(f" ~ {key} -> {wanted[0]} / {wanted[1]}") + + apps = api.get(f"/api/ApplicationInstance?instanceId={state['instanceId']}") or [] + for app in apps: + if app["id"] != state["apps"]["kiosk"]: + continue # mobile et web sont réglées depuis le CMS + wanted = (flutter(KIOSK_COLORS[0]), flutter(KIOSK_COLORS[1])) + if (app.get("primaryColor"), app.get("secondaryColor")) == wanted: + print(" = borne") + continue + app["primaryColor"], app["secondaryColor"] = wanted + api.put("/api/ApplicationInstance", app) + print(f" ~ borne -> {wanted[0]} / {wanted[1]}") + + print("\nétape 10 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step1_instance.py b/outputs/mnaha/seed/step1_instance.py new file mode 100644 index 0000000..ca3e507 --- /dev/null +++ b/outputs/mnaha/seed/step1_instance.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""Étape 1 — l'instance MNAHA, ses trois sites et ses trois applications. + +Idempotent : relancer le script ne crée pas de doublon, il réutilise ce qui existe. +""" + +import json +from pathlib import Path + +from api import Api, LANGS, tr + +STATE = Path(__file__).resolve().parent / "state.json" + +INSTANCE_NAME = "MNAHA" + +# Les couleurs se stockent comme Flutter les sérialise — « Color(0xaarrggbb) ». +# C'est la seule forme que `visitapp-web` dénormalise et que les apps Flutter +# savent relire ; un « 0xFF… » nu donne une couleur invalide, donc du gris. +SITES = [ + { + "key": "feschmaart", + "label": "Nationalmusée um Fëschmaart", + "title": {"FR": "Nationalmusée um Fëschmaart", + "DE": "Nationalmuseum am Fischmarkt", + "EN": "National Museum on the Fish Market", + "NL": "Nationaal Museum aan de Vismarkt"}, + "primary": "Color(0xff1f3a5f)", + "secondary": "Color(0xffb08d57)", + }, + { + "key": "draieechelen", + "label": "Musée Dräi Eechelen", + "title": {"FR": "Musée Dräi Eechelen", + "DE": "Museum Dräi Eechelen", + "EN": "Dräi Eechelen Museum", + "NL": "Dräi Eechelen Museum"}, + "primary": "Color(0xff4a5c3d)", + "secondary": "Color(0xff9c7a4a)", + }, + { + "key": "echternach", + "label": "Réimervilla Echternach", + "title": {"FR": "Villa romaine d'Echternach", + "DE": "Römische Villa Echternach", + "EN": "Roman Villa of Echternach", + "NL": "Romeinse villa van Echternach"}, + "primary": "Color(0xff7a4a2f)", + "secondary": "Color(0xffc0a16b)", + }, +] + +APP_TYPES = {"mobile": 0, "kiosk": 1, "web": 2} + +# Tuiles de l'accueil mobile : le Fëschmaart occupe deux colonnes, c'est le site +# principal ; les deux autres se partagent la ligne suivante. +BENTO = {"feschmaart": (2, 1), "draieechelen": (1, 1), "echternach": (1, 1)} + + +def load_state(): + return json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {} + + +def save_state(state): + STATE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + + +def main(): + api = Api() + state = load_state() + + instances = api.get("/api/Instance") + existing = next((i for i in instances if i["name"] == INSTANCE_NAME), None) + + if existing: + instance = existing + print(f"instance existante : {instance['id']}") + else: + instance = api.post("/api/Instance", { + "name": INSTANCE_NAME, + "isMobile": True, + "isTablet": True, + "isWeb": True, + "isPushNotification": True, + "isAssistant": True, + "isVisitorQuestionCollectionEnabled": True, + "guideName": "Melusina", + "guidePersonaPrompt": ( + "Tu es le guide numérique du Musée national d'archéologie, d'histoire " + "et d'art du Luxembourg. Tu réponds avec précision et sobriété, en " + "t'appuyant uniquement sur les contenus du musée. Quand une " + "information ne figure pas dans ces contenus, tu le dis clairement " + "et tu invites le visiteur à s'adresser à l'accueil."), + "guideFallbackMessages": tr( + FR="Cette information ne figure pas encore dans nos contenus. " + "L'équipe d'accueil pourra vous répondre.", + DE="Diese Information ist in unseren Inhalten noch nicht enthalten. " + "Unser Empfangsteam hilft Ihnen gerne weiter.", + EN="That information is not in our content yet. Our front desk team " + "will be able to help you.", + NL="Die informatie staat nog niet in onze inhoud. Ons onthaalteam " + "helpt u graag verder."), + "subscriptionPlanId": "plan-premium", + }) + print(f"instance créée : {instance['id']} (slug {instance.get('webSlug')})") + + instance_id = instance["id"] + state["instanceId"] = instance_id + state["webSlug"] = instance.get("webSlug") + state["pinCode"] = instance.get("pinCode") + + # --- les trois sites, un par configuration + configs = api.get(f"/api/Configuration?instanceId={instance_id}") or [] + by_label = {c["label"]: c for c in configs} + state.setdefault("configs", {}) + + for site in SITES: + found = by_label.get(site["label"]) + if found: + cfg = found + print(f" config existante : {site['label']}") + else: + cfg = api.post("/api/Configuration", { + "instanceId": instance_id, + "label": site["label"], + "languages": LANGS, + "primaryColor": site["primary"], + "secondaryColor": site["secondary"], + "isOffline": site["key"] == "echternach", + }) + print(f" config créée : {site['label']} -> {cfg['id']}") + + # La création écrase le titre par « FR - Title », « DE - Title »… : + # le vrai titre ne peut être posé qu'ensuite. + if [t.get("value") for t in (cfg.get("title") or [])] != [t["value"] for t in tr(**site["title"])]: + cfg["title"] = tr(**site["title"]) + cfg = api.put("/api/Configuration", cfg) + print(f" titre posé : {site['label']}") + state["configs"][site["key"]] = cfg["id"] + + # --- les trois applications + app_instances = api.get(f"/api/ApplicationInstance?instanceId={instance_id}") or [] + # Le GET renvoie `appType` en chaîne (« Mobile »), le POST l'attend en entier. + names = {0: "Mobile", 1: "Tablet", 2: "Web", 3: "VR", 4: "Voice"} + by_type = {} + for ai in app_instances: + raw = ai["appType"] + by_type[names.get(raw, raw) if isinstance(raw, int) else raw] = ai + state.setdefault("apps", {}) + + for name, app_type in APP_TYPES.items(): + found = by_type.get(names[app_type]) + if found: + app = found + print(f" application existante : {name}") + else: + app = api.post("/api/ApplicationInstance", { + "instanceId": instance_id, + "appType": app_type, + "languages": LANGS, + "primaryColor": "Color(0xff1f3a5f)", + "secondaryColor": "Color(0xffb08d57)", + "isAssistant": True, + "hasStats": True, + }) + print(f" application créée : {name} -> {app['id']}") + state["apps"][name] = app["id"] + + # Les liens de la borne naissent de l'appairage d'une tablette : + # `POST /api/Device` en crée un par appareil. En créer ici en plus + # donnerait deux cartes pour le même site (cf. step9_dedupe_links.py). + if name == "kiosk": + continue + + # Les liens se lisent sur leur propre route : le DTO d'application + # renvoie un tableau vide, et s'y fier ferait recréer les liens à + # chaque exécution. + existing_links = api.get( + f"/api/ApplicationInstance/{app['id']}/application-link") or [] + linked = {l["configurationId"] for l in existing_links} + for order, site in enumerate(SITES): + cfg_id = state["configs"][site["key"]] + if cfg_id in linked: + continue + col, row = BENTO[site["key"]] + api.post(f"/api/ApplicationInstance/{app['id']}/application-link", { + "applicationInstanceId": app["id"], + "configurationId": cfg_id, + "order": order, + "isActive": True, + "gridColSpan": col if app_type != 1 else 1, + "gridRowSpan": row, + }) + print(f" lien {name} <- {site['label']}") + + save_state(state) + print(f"\nétat écrit dans {STATE}") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step2_sections.py b/outputs/mnaha/seed/step2_sections.py new file mode 100644 index 0000000..c1cb62e --- /dev/null +++ b/outputs/mnaha/seed/step2_sections.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""Étape 2 — articles, sommaire et quiz. + +Une section se crée vide (POST /api/Section, qui ne connaît que le type), puis se +remplit par un PUT typé. C'est le chemin que suit manager-app. +""" + +import json +from pathlib import Path + +from api import Api, LANGS, tr +from content import ARTICLES, MENUS, QUIZZES + +STATE = Path(__file__).resolve().parent / "state.json" + +ARTICLE, MENU, QUIZ = 6, 4, 5 + + +def empty_translations(): + return [{"language": lang, "value": ""} for lang in LANGS] + + +def create_section(api, state, config_key, section_type, label): + """Crée la section si son label n'existe pas déjà dans cette configuration.""" + config_id = state["configs"][config_key] + existing = api.get(f"/api/Section/configuration/{config_id}") or [] + found = next((s for s in existing if s["label"] == label), None) + if found: + return found, False + created = api.post("/api/Section", { + "configurationId": config_id, + "instanceId": state["instanceId"], + "label": label, + "type": section_type, + "isSubSection": False, + }) + return created, True + + +def seed_articles(api, state): + for config_key, articles in ARTICLES.items(): + for order, art in enumerate(articles): + section, created = create_section(api, state, config_key, ARTICLE, + art["label"]) + lat, lon = art["coords"] + api.put("/api/Section", { + **section, + "type": ARTICLE, + "order": order, + "title": tr(**art["title"]), + "description": tr(**art["description"]), + "content": tr(**art["content"]), + "isContentTop": False, + "isReadAudioAuto": False, + "audioIds": empty_translations(), + "contents": [], + "isBeacon": True, + "beaconId": art["beacon"], + "latitude": lat, + "longitude": lon, + "meterZoneGPS": 12, + }) + print(f" {'+' if created else '=':1} article {config_key} / {art['label']}") + + +def seed_menus(api, state): + for config_key, menu in MENUS.items(): + section, created = create_section(api, state, config_key, MENU, menu["label"]) + api.put("/api/Section", { + **section, + "type": MENU, + "order": 10, + "title": tr(**menu["title"]), + "description": tr(**menu["description"]), + "sections": [], + }) + print(f" {'+' if created else '='} sommaire {config_key} / {menu['label']}") + + +def seed_quizzes(api, state): + for config_key, quiz in QUIZZES.items(): + section, created = create_section(api, state, config_key, QUIZ, quiz["label"]) + api.put("/api/Section", { + **section, + "type": QUIZ, + "order": 11, + "title": tr(**quiz["title"]), + "description": tr(FR="Trois questions, une minute.", + DE="Drei Fragen, eine Minute.", + EN="Three questions, one minute.", + NL="Drie vragen, één minuut."), + "questions": [], + "bad_level": tr(FR="Il reste des salles à explorer.", + DE="Es gibt noch Säle zu entdecken.", + EN="There are still rooms to explore.", + NL="Er zijn nog zalen te ontdekken."), + "medium_level": tr(FR="Bon début — continuez la visite.", + DE="Guter Anfang — setzen Sie den Rundgang fort.", + EN="Good start — carry on with the visit.", + NL="Goed begin — zet het bezoek voort."), + "good_level": tr(FR="Belle attention aux détails.", + DE="Schöne Aufmerksamkeit für Details.", + EN="Fine attention to detail.", + NL="Mooie aandacht voor details."), + "great_level": tr(FR="Sans faute. Vous avez tout lu !", + DE="Fehlerfrei. Sie haben alles gelesen!", + EN="Flawless. You read everything!", + NL="Foutloos. U hebt alles gelezen!"), + }) + + already = api.get(f"/api/SectionQuiz/{section['id']}/questions") or [] + if already: + print(f" = quiz {config_key} ({len(already)} questions déjà là)") + continue + + for order, question in enumerate(quiz["questions"]): + api.post(f"/api/SectionQuiz/{section['id']}/questions", { + "sectionQuizId": section["id"], + "order": order, + "label": [{"language": lang, "value": question["q"].get(lang, + question["q"]["FR"])} + for lang in LANGS], + "responses": [ + {"order": i, + "isGood": good, + "label": [{"language": lang, "value": text.get(lang, text["FR"])} + for lang in LANGS]} + for i, (text, good) in enumerate(question["answers"]) + ], + }) + print(f" + quiz {config_key} ({len(quiz['questions'])} questions)") + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + seed_articles(api, state) + seed_menus(api, state) + seed_quizzes(api, state) + print("\nétape 2 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step3_map_parcours.py b/outputs/mnaha/seed/step3_map_parcours.py new file mode 100644 index 0000000..d27ebd2 --- /dev/null +++ b/outputs/mnaha/seed/step3_map_parcours.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +"""Étape 3 — carte extérieure, parcours guidé, escape game et agenda.""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from api import Api, LANGS, tr +from content import AGENDA_EVENTS, MAP_CATEGORIES, MAP_POINTS + +STATE = Path(__file__).resolve().parent / "state.json" + +MAP, AGENDA, PARCOURS = 0, 9, 12 + + +def create_section(api, state, config_key, section_type, label): + config_id = state["configs"][config_key] + existing = api.get(f"/api/Section/configuration/{config_id}") or [] + found = next((s for s in existing if s["label"] == label), None) + if found: + return found, False + created = api.post("/api/Section", { + "configurationId": config_id, + "instanceId": state["instanceId"], + "label": label, + "type": section_type, + "isSubSection": False, + }) + return created, True + + +def utc(dt): + """Suffixe Z obligatoire : un décalage « +00:00 » est désérialisé en Kind=Local + et Npgsql refuse alors d'écrire dans une colonne timestamptz.""" + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def point(lon, lat): + return {"type": "Point", "coordinates": [lon, lat]} + + +def seed_map(api, state): + section, created = create_section(api, state, "echternach", MAP, + "Parcours extérieur") + # PUT /api/SectionMap met à jour un point, pas la section : la section + # se met à jour comme les autres, par PUT /api/Section typé. + api.put("/api/Section", { + **section, + "type": MAP, + "order": 0, + "title": tr(FR="Le site, point par point", DE="Die Anlage, Punkt für Punkt", + EN="The site, point by point", NL="De site, punt voor punt"), + "description": tr( + FR="Cinq arrêts sur l'emprise de la villa. Fonctionne sans réseau une fois " + "le site téléchargé.", + DE="Fünf Stationen auf dem Gelände der Villa. Funktioniert ohne Netz, " + "sobald der Standort heruntergeladen ist.", + EN="Five stops across the villa grounds. Works offline once the site is " + "downloaded.", + NL="Vijf haltes op het terrein van de villa. Werkt zonder netwerk zodra de " + "site is gedownload."), + "zoom": 18, + "mapProvider": 0, + "isListViewEnabled": True, + "centerLatitude": "49.80640", + "centerLongitude": "6.40205", + "categories": [ + {"id": c["id"], "order": c["id"], "icon": None, + "label": tr(**c["label"])} + for c in MAP_CATEGORIES + ], + "points": [], + }) + + existing = api.get(f"/api/SectionMap/{section['id']}/points") or [] + if existing: + print(f" = carte Echternach ({len(existing)} points déjà là)") + else: + for pt in MAP_POINTS: + api.post(f"/api/SectionMap/{section['id']}/points", { + "sectionMapId": section["id"], + "title": tr(**pt["title"]), + "description": tr(**pt["description"]), + "categorieId": pt["cat"], + "geometry": point(pt["lon"], pt["lat"]), + "contents": [], + "schedules": [], "prices": [], "phone": [], "email": [], "site": [], + }) + print(f" + carte Echternach ({len(MAP_POINTS)} points)") + state["mapEchternach"] = section["id"] + return section + + +GUIDED_STEPS_ECHTERNACH = [ + { + "title": {"FR": "Franchir le seuil", "DE": "Über die Schwelle", + "EN": "Crossing the threshold", "NL": "Over de drempel"}, + "description": { + "FR": "Vous entrez par où entraient les visiteurs du maître de maison. " + "Devant vous, plus de cent mètres de façade.", + "DE": "Sie betreten das Gelände dort, wo die Gäste des Hausherrn eintraten. " + "Vor Ihnen: über hundert Meter Fassade.", + "EN": "You enter where the master's guests once did. Ahead of you, more " + "than a hundred metres of frontage.", + "NL": "U komt binnen waar de gasten van de heer des huizes binnenkwamen. " + "Voor u: meer dan honderd meter gevel."}, + "lat": 49.80680, "lon": 6.40255, + }, + { + "title": {"FR": "La cour et ses portiques", "DE": "Der Hof und seine Portiken", + "EN": "The court and its porticoes", "NL": "De hof en zijn zuilengangen"}, + "description": { + "FR": "La colonnade abritait la circulation entre les ailes. Le sol que " + "vous foulez suit encore son tracé.", + "DE": "Die Kolonnade schützte den Weg zwischen den Flügeln. Der Boden unter " + "Ihnen folgt noch ihrem Verlauf.", + "EN": "The colonnade sheltered movement between the wings. The ground you " + "walk on still follows its line.", + "NL": "De zuilengang beschutte de doorgang tussen de vleugels. De grond " + "onder u volgt nog haar lijn."}, + "lat": 49.80655, "lon": 6.40210, + }, + { + "title": {"FR": "Sous le plancher chauffé", "DE": "Unter dem beheizten Boden", + "EN": "Beneath the heated floor", "NL": "Onder de verwarmde vloer"}, + "description": { + "FR": "Les pilettes que vous voyez portaient un sol sous lequel circulait " + "l'air chaud. Un esclave alimentait le foyer, jour et nuit.", + "DE": "Die sichtbaren Pfeiler trugen einen Boden, unter dem warme Luft " + "zirkulierte. Ein Sklave hielt das Feuer Tag und Nacht in Gang.", + "EN": "The pillars you see carried a floor beneath which hot air " + "circulated. A slave fed the furnace, day and night.", + "NL": "De pijlers die u ziet droegen een vloer waaronder warme lucht " + "circuleerde. Een slaaf voedde het vuur, dag en nacht."}, + "lat": 49.80630, "lon": 6.40165, + }, + { + "title": {"FR": "Les jardins en terrasse", "DE": "Die Terrassengärten", + "EN": "The terraced gardens", "NL": "De terrastuinen"}, + "description": { + "FR": "Bassins, allées, points d'eau : le luxe d'un domaine tourné vers la " + "vallée.", + "DE": "Becken, Wege, Wasserstellen: der Luxus eines zum Tal hin " + "ausgerichteten Anwesens.", + "EN": "Pools, walkways, water features: the luxury of an estate turned " + "towards the valley.", + "NL": "Bekkens, paden, waterpartijen: de luxe van een domein gericht op de " + "vallei."}, + "lat": 49.80610, "lon": 6.40230, + }, + { + "title": {"FR": "Ce que voyait le maître", "DE": "Was der Hausherr sah", + "EN": "What the master saw", "NL": "Wat de heer des huizes zag"}, + "description": { + "FR": "D'ici, le domaine surveillait la vallée de la Sûre et la route qui " + "la suivait. Une villa n'est jamais posée au hasard.", + "DE": "Von hier überblickte das Anwesen das Sauertal und die Straße, die " + "ihm folgte. Eine Villa steht nie zufällig.", + "EN": "From here the estate watched the Sûre valley and the road that " + "followed it. A villa is never sited by chance.", + "NL": "Vanhier keek het domein uit over het Sûredal en de weg die het " + "volgde. Een villa ligt nooit toevallig."}, + "lat": 49.80590, "lon": 6.40170, + }, +] + +ESCAPE_STEPS = [ + { + "title": {"FR": "Le message du mosaïste", "DE": "Die Botschaft des Mosaizisten", + "EN": "The mosaicist's message", "NL": "De boodschap van de mozaïeklegger"}, + "description": { + "FR": "L'atelier a signé son œuvre. Comptez les figures qui entourent le " + "poète : votre premier chiffre est là.", + "DE": "Die Werkstatt hat ihr Werk signiert. Zählen Sie die Figuren um den " + "Dichter: dort steht Ihre erste Ziffer.", + "EN": "The workshop signed its work. Count the figures surrounding the " + "poet: your first digit is there.", + "NL": "Het atelier ondertekende zijn werk. Tel de figuren rond de dichter: " + "daar staat uw eerste cijfer."}, + "question": { + "label": {"FR": "Combien de Muses entourent Homère ?", + "DE": "Wie viele Musen umgeben Homer?", + "EN": "How many Muses surround Homer?", + "NL": "Hoeveel Muzen omringen Homerus?"}, + "answers": [({"FR": "Neuf", "DE": "Neun", "EN": "Nine", "NL": "Negen"}, True), + ({"FR": "Sept", "DE": "Sieben", "EN": "Seven", "NL": "Zeven"}, False), + ({"FR": "Douze", "DE": "Zwölf", "EN": "Twelve", "NL": "Twaalf"}, False)], + }, + }, + { + "title": {"FR": "Le banquet interrompu", "DE": "Das unterbrochene Gastmahl", + "EN": "The interrupted banquet", "NL": "Het onderbroken banket"}, + "description": { + "FR": "Dans les tombes de Goeblange-Nospelt, on a déposé de quoi banqueter " + "dans l'au-delà. Un objet trahit l'origine du vin.", + "DE": "In den Gräbern von Goeblange-Nospelt lag alles für ein Gastmahl im " + "Jenseits. Ein Gegenstand verrät die Herkunft des Weins.", + "EN": "In the Goeblange-Nospelt tombs lay everything needed to feast in the " + "afterlife. One object betrays the wine's origin.", + "NL": "In de graven van Goeblange-Nospelt lag alles voor een feestmaal in " + "het hiernamaals. Eén voorwerp verraadt de herkomst van de wijn."}, + "question": { + "label": {"FR": "Dans quel récipient transportait-on le vin ?", + "DE": "In welchem Gefäß wurde der Wein transportiert?", + "EN": "In which vessel was wine transported?", + "NL": "In welk vat werd wijn vervoerd?"}, + "answers": [({"FR": "Une amphore", "DE": "Eine Amphore", + "EN": "An amphora", "NL": "Een amfoor"}, True), + ({"FR": "Une urne", "DE": "Eine Urne", + "EN": "An urn", "NL": "Een urn"}, False), + ({"FR": "Un cratère", "DE": "Ein Krater", + "EN": "A krater", "NL": "Een krater"}, False)], + }, + }, + { + "title": {"FR": "La marque de la manufacture", + "DE": "Die Marke der Manufaktur", + "EN": "The maker's mark", "NL": "Het merk van de fabriek"}, + "description": { + "FR": "Montez d'un étage. Les céramiques de l'entre-deux-guerres portent " + "une signature, et un lieu de production.", + "DE": "Ein Stockwerk höher. Die Keramiken der Zwischenkriegszeit tragen " + "eine Signatur und einen Produktionsort.", + "EN": "Go up one floor. The interwar ceramics carry a signature — and a " + "place of manufacture.", + "NL": "Eén verdieping hoger. Het keramiek uit het interbellum draagt een " + "signatuur en een productieplaats."}, + "question": { + "label": {"FR": "Où se trouvait la manufacture de Villeroy & Boch au Luxembourg ?", + "DE": "Wo befand sich die Manufaktur Villeroy & Boch in Luxemburg?", + "EN": "Where was the Villeroy & Boch works in Luxembourg?", + "NL": "Waar bevond de fabriek van Villeroy & Boch zich in Luxemburg?"}, + "answers": [({"FR": "Septfontaines", "DE": "Septfontaines", + "EN": "Septfontaines", "NL": "Septfontaines"}, True), + ({"FR": "Dudelange", "DE": "Düdelingen", + "EN": "Dudelange", "NL": "Dudelange"}, False), + ({"FR": "Echternach", "DE": "Echternach", + "EN": "Echternach", "NL": "Echternach"}, False)], + }, + }, + { + "title": {"FR": "La dernière frappe", "DE": "Die letzte Prägung", + "EN": "The final strike", "NL": "De laatste slag"}, + "description": { + "FR": "Au cabinet des médailles, une pièce ferme le coffre. Trouvez-la et " + "le secret de la villa est à vous.", + "DE": "Im Münzkabinett schließt eine Münze die Truhe. Finden Sie sie, und " + "das Geheimnis der Villa gehört Ihnen.", + "EN": "In the coin cabinet, one piece closes the chest. Find it and the " + "villa's secret is yours.", + "NL": "In het penningkabinet sluit één munt de kist. Vind ze en het geheim " + "van de villa is van u."}, + "question": { + "label": {"FR": "Quel peuple gaulois occupait la région ?", + "DE": "Welches gallische Volk bewohnte die Region?", + "EN": "Which Gaulish people occupied the region?", + "NL": "Welk Gallisch volk bewoonde de streek?"}, + "answers": [({"FR": "Les Trévires", "DE": "Die Treverer", + "EN": "The Treveri", "NL": "De Treveri"}, True), + ({"FR": "Les Éburons", "DE": "Die Eburonen", + "EN": "The Eburones", "NL": "De Eburonen"}, False), + ({"FR": "Les Rèmes", "DE": "Die Remer", + "EN": "The Remi", "NL": "De Remen"}, False)], + }, + }, +] + + +def question_payload(question, order=0): + return { + "order": order, + "label": [{"language": lang, + "value": question["label"].get(lang, question["label"]["FR"])} + for lang in LANGS], + "responses": [ + {"order": i, "isGood": good, + "label": [{"language": lang, "value": text.get(lang, text["FR"])} + for lang in LANGS]} + for i, (text, good) in enumerate(question["answers"]) + ], + } + + +def seed_parcours(api, state, config_key, label, path_spec, steps, base_map_id=None): + section, created = create_section(api, state, config_key, PARCOURS, label) + api.put("/api/Section", { + **section, + "type": PARCOURS, + "order": 1, + "title": tr(**path_spec["title"]), + "description": tr(**path_spec["description"]), + "showMap": path_spec["show_map"], + "baseSectionMapId": base_map_id, + "guidedPaths": [], + }) + + existing = api.get(f"/api/SectionParcours/{section['id']}/guided-path") or [] + if existing: + print(f" = parcours {config_key} / {label} (déjà peuplé)") + return section + + path = api.post(f"/api/SectionParcours/{section['id']}/guided-path", { + "sectionParcoursId": section["id"], + "instanceId": state["instanceId"], + "title": tr(**path_spec["title"]), + "description": tr(**path_spec["description"]), + "isLinear": True, + "requireSuccessToAdvance": path_spec["game"], + "hideNextStepsUntilComplete": path_spec["game"], + "estimatedDurationMinutes": path_spec["minutes"], + "order": 0, + "isGameMode": path_spec["game"], + "gameMessageDebut": [ + {"language": lang, + "value": path_spec["start"].get(lang, path_spec["start"]["FR"])} + for lang in LANGS] if path_spec["game"] else [], + "gameMessageFin": [ + {"language": lang, + "value": path_spec["end"].get(lang, path_spec["end"]["FR"])} + for lang in LANGS] if path_spec["game"] else [], + }) + + for order, step in enumerate(steps): + body = { + "guidedPathId": path["id"], + "order": order, + "title": tr(**step["title"]), + "description": tr(**step["description"]), + "isGeoTriggered": "lat" in step, + "contents": [], + "audioIds": [], + "isStepTimer": False, + } + if "lat" in step: + body["geometry"] = point(step["lon"], step["lat"]) + body["zoneRadiusMeters"] = 20 + if "question" in step: + body["quizQuestions"] = [question_payload(step["question"])] + api.post(f"/api/SectionParcours/guided-path/{path['id']}/guided-step", body) + + print(f" + parcours {config_key} / {label} ({len(steps)} étapes)") + return section + + +def seed_agenda(api, state): + section, created = create_section(api, state, "feschmaart", AGENDA, + "Expositions et rendez-vous") + api.put("/api/Section", { + **section, + "type": AGENDA, + "order": 12, + "title": tr(FR="Expositions et rendez-vous", DE="Ausstellungen und Termine", + EN="Exhibitions and events", NL="Tentoonstellingen en afspraken"), + "description": tr(FR="La programmation des trois sites.", + DE="Das Programm der drei Standorte.", + EN="Programming across the three sites.", + NL="De programmatie van de drie sites."), + "isOnlineAgenda": False, + "resourceIds": [], + "events": [], + }) + + existing = api.get(f"/api/SectionAgenda/{section['id']}/events") or [] + if existing: + print(f" = agenda ({len(existing)} événements déjà là)") + return section + + today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + for ev in AGENDA_EVENTS: + start = today + timedelta(days=ev["day_offset"], hours=ev["hour"]) + api.post(f"/api/SectionAgenda/{section['id']}/event", { + "sectionAgendaId": section["id"], + "label": tr(**ev["title"]), + "description": tr(**ev["description"]), + "dateFrom": utc(start), + "dateTo": utc(start + timedelta(hours=2)), + "dateAdded": utc(today), + "isSynced": False, + }) + print(f" + agenda ({len(AGENDA_EVENTS)} événements)") + return section + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + + map_section = seed_map(api, state) + + seed_parcours(api, state, "echternach", "Sur les pas du maître", { + "title": {"FR": "Sur les pas du maître de la villa", + "DE": "Auf den Spuren des Villenherrn", + "EN": "In the footsteps of the villa's master", + "NL": "In de voetsporen van de heer van de villa"}, + "description": { + "FR": "Cinq étapes en extérieur, déclenchées à l'approche. Environ 40 minutes.", + "DE": "Fünf Stationen im Freien, ausgelöst bei Annäherung. Etwa 40 Minuten.", + "EN": "Five outdoor stops, triggered as you approach. About 40 minutes.", + "NL": "Vijf haltes buiten, geactiveerd bij nadering. Ongeveer 40 minuten."}, + "show_map": True, "game": False, "minutes": 40, + "start": {}, "end": {}, + }, GUIDED_STEPS_ECHTERNACH, base_map_id=map_section["id"]) + + seed_parcours(api, state, "feschmaart", "Le secret de la villa", { + "title": {"FR": "Le secret de la villa de Vichten", + "DE": "Das Geheimnis der Villa von Vichten", + "EN": "The secret of the Vichten villa", + "NL": "Het geheim van de villa van Vichten"}, + "description": { + "FR": "Quatre énigmes à travers les collections. Comptez une heure.", + "DE": "Vier Rätsel quer durch die Sammlungen. Rechnen Sie mit einer Stunde.", + "EN": "Four riddles across the collections. Allow an hour.", + "NL": "Vier raadsels doorheen de collecties. Reken op een uur."}, + "show_map": False, "game": True, "minutes": 60, + "start": { + "FR": "Un mosaïste du IIIe siècle a caché un message dans son " + "pavement. Quatre salles, quatre indices. À vous de jouer.", + "DE": "Ein Mosaizist des 3. Jahrhunderts verbarg eine Botschaft in seinem " + "Boden. Vier Säle, vier Hinweise. Sie sind am Zug.", + "EN": "A third-century mosaicist hid a message in his floor. Four rooms, " + "four clues. Your turn.", + "NL": "Een mozaïeklegger uit de derde eeuw verborg een boodschap in zijn " + "vloer. Vier zalen, vier aanwijzingen. Aan u."}, + "end": { + "FR": "Le message est reconstitué. Vous avez traversé mille huit cents ans " + "en une heure.", + "DE": "Die Botschaft ist entschlüsselt. Sie haben tausendachthundert Jahre " + "in einer Stunde durchquert.", + "EN": "The message is restored. You have crossed eighteen centuries in an " + "hour.", + "NL": "De boodschap is hersteld. U hebt achttien eeuwen in een uur " + "doorkruist."}, + }, ESCAPE_STEPS) + + seed_agenda(api, state) + + STATE.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + print("\nétape 3 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step4_stats.py b/outputs/mnaha/seed/step4_stats.py new file mode 100644 index 0000000..3dc3a99 --- /dev/null +++ b/outputs/mnaha/seed/step4_stats.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""Étape 4 — 90 jours de fréquentation, puis ré-indexation de l'assistant. + +Le tableau de bord ne vaut comme argument que s'il montre une courbe crédible : +pics de week-end, saisonnalité douce, répartition par canal et par langue. +""" + +import json +import random +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + +DAYS = 90 +SESSIONS_PER_DAY = 22 # moyenne en semaine +WEEKEND_FACTOR = 2.3 +CHANNELS = [("Web", 0.45), ("Mobile", 0.35), ("Tablet", 0.20)] +LANGUAGES = [("FR", 0.45), ("DE", 0.25), ("EN", 0.20), ("NL", 0.10)] + +random.seed(20260825) # même jeu de données à chaque exécution + + +def weighted(pairs): + r = random.random() + cumulative = 0.0 + for value, weight in pairs: + cumulative += weight + if r <= cumulative: + return value + return pairs[-1][0] + + +def build_events(state, catalogue): + """Produit la liste des événements à envoyer, sans appel réseau.""" + today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + events = [] + + for day_back in range(DAYS, 0, -1): + day = today - timedelta(days=day_back) + weekend = day.weekday() >= 5 + base = SESSIONS_PER_DAY * (WEEKEND_FACTOR if weekend else 1.0) + # Légère montée en charge sur la période, plus un aléa quotidien. + trend = 0.75 + 0.5 * (DAYS - day_back) / DAYS + sessions = max(1, int(random.gauss(base * trend, base * 0.25))) + + for _ in range(sessions): + channel = weighted(CHANNELS) + language = weighted(LANGUAGES) + session_id = f"s{day.strftime('%y%m%d')}-{random.randrange(10**6):06d}" + site = weighted([("feschmaart", 0.55), ("draieechelen", 0.28), + ("echternach", 0.17)]) + config_id = state["configs"][site] + sections = catalogue[site] + if not sections: + continue + + start = day + timedelta(hours=random.randint(10, 17), + minutes=random.randint(0, 59)) + visited = random.sample(sections, min(len(sections), + random.randint(2, 6))) + cursor = start + + def add(event_type, section=None, duration=None, metadata=None): + events.append({ + "instanceId": state["instanceId"], + "configurationId": config_id, + "sectionId": section["id"] if section else None, + "sessionId": session_id, + "eventType": event_type, + "appType": channel, + "language": language, + "durationSeconds": duration, + "metadata": json.dumps(metadata) if metadata else None, + "timestamp": cursor.strftime("%Y-%m-%dT%H:%M:%SZ"), + }) + + # Un visiteur sur cinq arrive par un code QR posé sur un cartel. + if random.random() < 0.20: + add("QrScan", visited[0], metadata={"valid": random.random() > 0.06}) + + for section in visited: + duration = int(random.triangular(25, 300, 90)) + add("SectionView", section, duration) + cursor += timedelta(seconds=duration) + # La durée moyenne de visite se calcule en sommant les SectionLeave + # par session : sans eux, le tableau de bord affiche zéro minute. + add("SectionLeave", section, duration) + cursor += timedelta(seconds=random.randint(5, 40)) + + if section["type"] == "Article" and random.random() < 0.55: + add("ArticleRead", section, duration) + if section["type"] == "Menu": + add("MenuItemTap", section) + if section["type"] == "Map" and random.random() < 0.7: + poi = random.choice(state["poi"]) + add("MapPoiTap", section, + metadata={"geoPointId": poi["id"], + "geoPointTitle": poi["title"]}) + if section["type"] == "Quiz" and random.random() < 0.45: + total = 3 + add("QuizComplete", section, + metadata={"score": float(random.choice([1, 2, 2, 3, 3, 3])), + "totalQuestions": total}) + if section["type"] == "Agenda" and random.random() < 0.5 and state["agenda"]: + ev = random.choice(state["agenda"]) + add("AgendaEventTap", section, + metadata={"eventId": str(ev["id"]), "eventTitle": ev["title"]}) + if section["type"] == "Parcours" and random.random() < 0.3: + add("GameComplete", section, int(random.triangular(900, 3600, 1800))) + + # L'assistant est sollicité sur une visite sur six, deux questions en moyenne. + if random.random() < 0.17: + for _ in range(random.randint(1, 3)): + cursor += timedelta(seconds=random.randint(20, 120)) + add("AssistantMessage", random.choice(visited)) + + return events + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + + catalogue = {} + for key, config_id in state["configs"].items(): + sections = api.get(f"/api/Section/configuration/{config_id}") or [] + catalogue[key] = [{"id": s["id"], "type": s["type"]} for s in sections] + print(f" {key}: {len(sections)} sections") + + points = api.get(f"/api/SectionMap/{state['mapEchternach']}/points") or [] + state["poi"] = [{"id": p["id"], + "title": next((t["value"] for t in p["title"] + if t["language"] == "FR"), "")} + for p in points] + + agenda_section = next((s for s in catalogue["feschmaart"] if s["type"] == "Agenda"), + None) + state["agenda"] = [] + if agenda_section: + for ev in api.get(f"/api/SectionAgenda/{agenda_section['id']}/events") or []: + title = next((t["value"] for t in ev["label"] if t["language"] == "FR"), "") + state["agenda"].append({"id": ev["id"], "title": title}) + + events = build_events(state, catalogue) + print(f"\n{len(events)} événements à envoyer…") + + def send(event): + api.post("/api/Stats/event", event) + + with ThreadPoolExecutor(max_workers=12) as pool: + for i, _ in enumerate(pool.map(send, events), 1): + if i % 2000 == 0: + print(f" {i}/{len(events)}") + + print("\nré-indexation de l'assistant…") + api.post(f"/api/Ai/reindex/{state['instanceId']}") + print("étape 4 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step5_assistant.py b/outputs/mnaha/seed/step5_assistant.py new file mode 100644 index 0000000..21ef2f5 --- /dev/null +++ b/outputs/mnaha/seed/step5_assistant.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""Étape 5 — conversations de visiteurs avec l'assistant. + +Deux tiers de questions auxquelles le contenu répond, un tiers auxquelles il ne +répond pas : c'est ce dernier tiers qui remplit l'onglet « questions sans réponse » +du Guide IA, celui qui vaut la démonstration. +""" + +import json +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + +ANSWERABLE = [ + ("FR", "Que représente la mosaïque de Vichten ?"), + ("FR", "Qu'a-t-on trouvé dans les tombes de Goeblange-Nospelt ?"), + ("DE", "Warum heißt das Fort Thüngen „Dräi Eechelen“?"), + ("EN", "What is a hypocaust and where can I see one?"), + ("FR", "Pourquoi la forteresse de Luxembourg a-t-elle été démantelée ?"), + ("NL", "Wanneer werd de Romeinse villa van Echternach opgegraven?"), + ("DE", "Was zeigt die Sammlung Villeroy & Boch?"), +] + +# Volontairement hors périmètre du contenu : horaires, accès, tarifs, services. +UNANSWERABLE = [ + ("FR", "Est-ce que le musée est accessible en fauteuil roulant ?"), + ("FR", "Combien coûte l'entrée pour une famille de quatre personnes ?"), + ("DE", "Gibt es einen Parkplatz in der Nähe des Museums?"), + ("EN", "Is there a cloakroom for luggage?"), + ("FR", "À quelle heure ferme le musée le dimanche ?"), + ("EN", "Do you have a café on site?"), +] + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + instance_id = state["instanceId"] + + for label, questions in (("répondues", ANSWERABLE), + ("sans réponse", UNANSWERABLE)): + for language, question in questions: + result = api.post("/api/Ai/chat", { + "instanceId": instance_id, + "message": question, + "language": language, + }) + sources = result.get("sources") or [] + print(f" [{language}] {question[:52]:<52} " + f"{len(sources)} source(s), {result.get('tokensUsed')} jetons") + + insights = api.get(f"/api/Ai/insights/{instance_id}") + print("\ninsights :", json.dumps(insights, ensure_ascii=False)[:400]) + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step6_order.py b/outputs/mnaha/seed/step6_order.py new file mode 100644 index 0000000..b38d6ca --- /dev/null +++ b/outputs/mnaha/seed/step6_order.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""Étape 6 — ordre d'affichage des sections. + +La création réinsère chaque nouvelle section en tête ; l'ordre final est donc +l'inverse de l'ordre de saisie. On le remet dans l'ordre de lecture voulu. +""" + +import json +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + +ORDER = { + "feschmaart": [ + "Collections", + "Mosaïque de Vichten", + "Goeblange-Nospelt", + "Villeroy & Boch Art déco", + "Cabinet des médailles", + "Le secret de la villa", + "Quiz archéologie", + "Expositions et rendez-vous", + ], + "draieechelen": [ + "Fort Thüngen", + "Démantèlement 1867", + "Maquettes et plans-reliefs", + ], + "echternach": [ + "Parcours extérieur", + "Sur les pas du maître", + "La villa romaine", + "Thermes et hypocauste", + ], +} + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + + for key, labels in ORDER.items(): + config_id = state["configs"][key] + sections = api.get(f"/api/Section/configuration/{config_id}") or [] + by_label = {s["label"]: s for s in sections} + + for order, label in enumerate(labels): + section = by_label.get(label) + if not section: + print(f" ! introuvable : {key} / {label}") + continue + detail = api.get(f"/api/Section/{section['id']}") + api.put("/api/Section", {**detail, "order": order}) + + final = api.get(f"/api/Section/configuration/{config_id}") or [] + ordered = sorted(final, key=lambda s: s["order"]) + print(f" {key}: " + " | ".join(s["label"] for s in ordered)) + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step7_images.py b/outputs/mnaha/seed/step7_images.py new file mode 100644 index 0000000..0f2c2db --- /dev/null +++ b/outputs/mnaha/seed/step7_images.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +"""Étape 7 — visuels de démonstration, créés en ressources et rattachés. + +`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 ne peut pas l'y écrire. + +Les ressources sont donc créées en type `ImageUrl`, servies par +`serve_images.py`. **Ce serveur doit tourner pendant les captures.** + +Idempotent : une ressource déjà créée sous le même libellé est réutilisée. +""" + +import json +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" +IMAGES = Path(__file__).resolve().parent / "images" +HOST = "http://localhost:8099" + +IMAGE_URL = 2 # ResourceType.ImageUrl — hors bucket, donc hors quota + +SECTION_IMAGES = { + "Mosaïque de Vichten": "art-vichten", + "Goeblange-Nospelt": "art-goeblange", + "Villeroy & Boch Art déco": "art-villeroy", + "Cabinet des médailles": "art-medailles", + "Fort Thüngen": "art-thungen", + "Démantèlement 1867": "art-demantelement", + "Maquettes et plans-reliefs": "art-maquettes", + "La villa romaine": "art-villa", + "Thermes et hypocauste": "art-thermes", + "Parcours extérieur": "parcours-echternach", + "Sur les pas du maître": "parcours-echternach", + "Le secret de la villa": "parcours-escape", + "Collections": "site-feschmaart", + "Quiz archéologie": "art-vichten", + "Expositions et rendez-vous": "site-feschmaart", +} + +CONFIG_IMAGES = { + "feschmaart": "site-feschmaart", + "draieechelen": "site-draieechelen", + "echternach": "site-echternach", +} + +POI_IMAGES = { + "Entrée du site": "poi-entree", + "Cour intérieure": "poi-cour", + "Les thermes": "poi-thermes", + "Les bassins": "poi-bassins", + "Point de vue sur la Sûre": "poi-panorama", +} + + +def sync_resources(api, instance_id): + """Une ressource ImageUrl par PNG. + + Les lignes laissées sans `url` par `upload` sont corrigées sur place plutôt + que supprimées : elles gardent leur identifiant, donc les rattachements déjà + posés restent valides — et la suppression d'une ressource échoue de toute + façon tant que le correctif de `ResourceController` n'est pas déployé. + """ + existing = {r["label"]: r for r in + (api.get(f"/api/Resource?instanceId={instance_id}") or [])} + + for label, resource in existing.items(): + if resource.get("url") or not (IMAGES / f"{label}.png").exists(): + continue + resource["type"] = IMAGE_URL + resource["url"] = f"{HOST}/{label}.png" + resource["sizeBytes"] = (IMAGES / f"{label}.png").stat().st_size + api.put("/api/Resource", resource) + print(f" ~ {label} (URL ajoutée)") + + existing = {r["label"]: r for r in + (api.get(f"/api/Resource?instanceId={instance_id}") or [])} + resources = {} + + for path in sorted(IMAGES.glob("*.png")): + if path.name.startswith("_"): + continue + label = path.stem + if label in existing: + resources[label] = existing[label] + print(f" = {label}") + continue + created = api.post("/api/Resource", { + "instanceId": instance_id, + "label": label, + "type": IMAGE_URL, + "url": f"{HOST}/{path.name}", + "sizeBytes": path.stat().st_size, + }) + resources[label] = created + print(f" + {label}") + + return resources + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + instance_id = state["instanceId"] + + print("ressources") + resources = sync_resources(api, instance_id) + + print("\nsites") + for key, image in CONFIG_IMAGES.items(): + resource = resources[image] + config = api.get(f"/api/Configuration/{state['configs'][key]}") + config["imageId"] = resource["id"] + config["imageSource"] = resource["url"] + api.put("/api/Configuration", config) + print(f" {key} <- {image}") + + print("\nsections") + for key, config_id in state["configs"].items(): + for section in api.get(f"/api/Section/configuration/{config_id}") or []: + image = SECTION_IMAGES.get(section["label"]) + if not image: + continue + resource = resources[image] + detail = api.get(f"/api/Section/{section['id']}") + if detail.get("imageSource") == resource["url"]: + print(f" = {section['label']}") + continue + detail["imageId"] = resource["id"] + detail["imageSource"] = resource["url"] + api.put("/api/Section", detail) + print(f" + {section['label']} <- {image}") + + print("\nétape 7 terminée — points d'intérêt et applications : step7b_finish.py") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step7b_finish.py b/outputs/mnaha/seed/step7b_finish.py new file mode 100644 index 0000000..7715120 --- /dev/null +++ b/outputs/mnaha/seed/step7b_finish.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Fin de l'étape 7 — points d'intérêt et habillage des applications. + +Séparé de `step7_images.py` parce que chaque point mis à jour relance +l'indexation de toute la carte : c'est lent, et inutile à rejouer quand seules +les applications restent à habiller. +""" + +import json +from pathlib import Path + +from api import Api +from step7_images import POI_IMAGES + +STATE = Path(__file__).resolve().parent / "state.json" + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + instance_id = state["instanceId"] + + resources = {r["label"]: r for r in + (api.get(f"/api/Resource?instanceId={instance_id}") or [])} + + print("points d'intérêt") + for point in api.get(f"/api/SectionMap/{state['mapEchternach']}/points") or []: + title = next((t["value"] for t in point["title"] if t["language"] == "FR"), "") + image = POI_IMAGES.get(title) + if not image: + continue + resource = resources[image] + # On compare l'URL, pas l'identifiant : un point rattaché lors d'un + # passage précédent porte le bon identifiant mais une URL nulle. + if point.get("imageUrl") == resource.get("url"): + print(f" = {title}") + continue + point["imageResourceId"] = resource["id"] + point["imageUrl"] = resource.get("url") + api.put("/api/SectionMap", point) + print(f" + {title} <- {image}") + + print("\nhabillage des applications") + main_res = resources["app-accueil"] + loader_res = resources["app-chargement"] + for app in api.get(f"/api/ApplicationInstance?instanceId={instance_id}") or []: + app["mainImageId"] = main_res["id"] + app["mainImageUrl"] = main_res.get("url") + app["loaderImageId"] = loader_res["id"] + app["loaderImageUrl"] = loader_res.get("url") + api.put("/api/ApplicationInstance", app) + print(f" + type {app['appType']}") + + print("\nterminé") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step8_devices.py b/outputs/mnaha/seed/step8_devices.py new file mode 100644 index 0000000..0e231d0 --- /dev/null +++ b/outputs/mnaha/seed/step8_devices.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +"""Étape 8 — parc de bornes. + +Trois tablettes déclarées, avec leur état de connexion, leur batterie et leur +version : c'est ce que montre l'écran « Applications → Kiosk » du back-office. +Chaque appareil est ensuite rattaché au lien de configuration de son site, sans +quoi les cartes de l'écran restent « aucune tablette appairée ». +""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + +DEVICES = [ + {"site": "feschmaart", "name": "Fëschmaart — hall d'accueil", + "identifier": "MNAHA-FM-01", "wlan": "192.168.20.41", "eth": "10.0.20.41", + "battery": "94", "connection": "excellent", "version": "3.4.1", + "connected": True, "seen_minutes": 2}, + {"site": "feschmaart", "name": "Fëschmaart — salles d'archéologie", + "identifier": "MNAHA-FM-02", "wlan": "192.168.20.42", "eth": "", + "battery": "61", "connection": "bon", "version": "3.4.1", + "connected": True, "seen_minutes": 7}, + {"site": "draieechelen", "name": "Dräi Eechelen — billetterie", + "identifier": "MNAHA-DE-01", "wlan": "192.168.30.11", "eth": "10.0.30.11", + "battery": "18", "connection": "faible", "version": "3.3.8", + "connected": False, "seen_minutes": 1490}, +] + + +def utc(dt): + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + instance_id = state["instanceId"] + now = datetime.now(timezone.utc) + + existing = {d["identifier"]: d + for d in (api.get(f"/api/Device?instanceId={instance_id}") or [])} + + created = {} + for spec in DEVICES: + seen = now - timedelta(minutes=spec["seen_minutes"]) + body = { + "instanceId": instance_id, + "identifier": spec["identifier"], + "name": spec["name"], + "configurationId": state["configs"][spec["site"]], + "ipAddressWLAN": spec["wlan"], + "ipAddressETH": spec["eth"], + "connected": spec["connected"], + "batteryLevel": spec["battery"], + "lastBatteryLevel": utc(seen), + "connectionLevel": spec["connection"], + "lastConnectionLevel": utc(seen), + "appVersion": spec["version"], + "lastSeen": utc(seen), + } + if spec["identifier"] in existing: + body["id"] = existing[spec["identifier"]]["id"] + device = api.put("/api/Device", body) + print(f" ~ {spec['name']}") + else: + device = api.post("/api/Device", body) + print(f" + {spec['name']}") + created[spec["identifier"]] = device["id"] if device else \ + existing[spec["identifier"]]["id"] + + # Rattachement aux liens de configuration de l'application borne. + kiosk_id = state["apps"]["kiosk"] + links = api.get(f"/api/ApplicationInstance/{kiosk_id}/application-link") or [] + by_config = {l["configurationId"]: l for l in links} + + for spec in DEVICES: + link = by_config.get(state["configs"][spec["site"]]) + if not link or link.get("deviceId"): + continue + link["deviceId"] = created[spec["identifier"]] + api.put("/api/ApplicationInstance/application-link", link) + print(f" lien borne {spec['site']} <- {spec['identifier']}") + + print("\nétape 8 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/seed/step9_dedupe_links.py b/outputs/mnaha/seed/step9_dedupe_links.py new file mode 100644 index 0000000..d557e71 --- /dev/null +++ b/outputs/mnaha/seed/step9_dedupe_links.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""Étape 9 — dédoublonnage des liens de configuration. + +`PUT /api/ApplicationInstance` fait `Configurations = dto.configurations` : une +collection détachée réaffectée à une entité suivie, qu'EF insère comme neuve. +Toute mise à jour d'application qui renvoie ses liens les duplique donc. + +On garde, par couple (application, configuration), le lien porteur d'un +appareil, sinon le plus ancien. +""" + +import json +from pathlib import Path + +from api import Api + +STATE = Path(__file__).resolve().parent / "state.json" + + +def main(): + api = Api() + state = json.loads(STATE.read_text(encoding="utf-8")) + + for name, app_id in state["apps"].items(): + links = api.get(f"/api/ApplicationInstance/{app_id}/application-link") or [] + + by_config = {} + for link in links: + by_config.setdefault(link["configurationId"], []).append(link) + + for config_id, group in by_config.items(): + if len(group) == 1: + continue + group.sort(key=lambda l: (l.get("deviceId") is None, + l.get("dateCreation") or "")) + keep, extra = group[0], group[1:] + for link in extra: + api.delete(f"/api/ApplicationInstance/{app_id}" + f"/application-link/{link['id']}") + print(f" {name} / {config_id[:8]} : {len(extra)} doublon(s) supprimé(s), " + f"gardé {keep['id'][:8]}" + f"{' (avec appareil)' if keep.get('deviceId') else ''}") + + remaining = api.get(f"/api/ApplicationInstance/{app_id}/application-link") or [] + print(f" {name} : {len(remaining)} liens") + + print("\nétape 9 terminée") + + +if __name__ == "__main__": + main() diff --git a/outputs/mnaha/whitelabel/MNAHA_presentation_whitelabel.pptx b/outputs/mnaha/whitelabel/MNAHA_presentation_whitelabel.pptx new file mode 100644 index 0000000..46a15f5 Binary files /dev/null and b/outputs/mnaha/whitelabel/MNAHA_presentation_whitelabel.pptx differ diff --git a/outputs/platform/CHIFFRES.md b/outputs/platform/CHIFFRES.md new file mode 100644 index 0000000..bc592eb --- /dev/null +++ b/outputs/platform/CHIFFRES.md @@ -0,0 +1,74 @@ +# Chiffres du deck plateforme — d'où ils viennent + +Mesurés le 2026-08-24 sur les dépôts locaux. Aucun chiffre du deck n'est estimé, sauf +la slide « Reconstruire à l'identique », explicitement présentée comme un ordre de +grandeur avec son hypothèse affichée. + +## Volume de code + +Lignes non vides et non commentées, hors `node_modules`, `build`, `.dart_tool`, `obj`, +`bin`, `ios`, `android`, `windows`, `macos`, `linux`. + +| Dépôt | Fichiers | Lignes | +|-------|----------|--------| +| manager-service (C#) | 372 | 92 184 | +| manager-app (Dart, hors client généré) | 159 | 33 543 | +| tablet-app (Dart) | 78 | 11 911 | +| mymuseum-visitapp (Dart) | 139 | 24 014 | +| visitapp-web (TS) | 56 | 9 006 | +| myinfomate-landing (TS) | 26 | 9 538 | +| unov-landing (TS) | 6 | 1 177 | +| **Total écrit à la main** | **836** | **181 373** | +| manager_api_new (client API généré) | 326 | 33 648 | + +## Surface fonctionnelle + +- 22 contrôleurs, **145 points d'API** (`[HttpGet|Post|Put|Delete|Patch]`) +- **23 tables métier** (`DbSet<>` dans `MyInfoMateDbContext`), **67 migrations** +- **202 tests** automatisés (`[Fact]` / `[Theory]`) sur le back-end +- 31 services, 65 DTO +- Écrans : 79 (manager-app), 54 (mymuseum-visitapp), 38 (tablet-app), + 34 composants (visitapp-web) +- 14 types de sections, 10 langues de contenu, interface du CMS en 3 langues + +## Historique + +| Dépôt | Commits | Période | +|-------|---------|---------| +| manager-service | 193 | 2021-04-06 → 2026-08-14 | +| manager-app | 258 | 2021-04-06 → 2026-08-14 | +| tablet-app | 203 | 2020-12-28 → 2026-08-12 | +| mymuseum-visitapp | 201 | 2022-06-11 → 2026-08-14 | +| visitapp-web | 12 | 2026-04-28 → 2026-08-13 | +| **Total** | **867** | **5 ans et 8 mois** | + +## Tarifs + +Source de vérité : `myinfomate-landing`, répliquée dans le seed de +`MyInfoMateDbContext` — Essentiel 39 € / Pro 99 € / Premium 179 € / Enterprise sur +devis. Quotas de stockage et de jetons IA tirés du même seed. + +## Slide « Reconstruire à l'identique » — hypothèses + +- **1 000 à 1 400 jours-homme** : équipe de trois profils (back-end .NET, mobile + Flutter, front web) sur 18 à 24 mois, pour reconstruire le périmètre fonctionnel + existant, hors reprise de données. À ajuster si tu veux défendre une autre taille + d'équipe. +- **30 à 70 M de jetons** : 181 373 lignes × ~12 jetons/ligne ≈ 2,2 M de jetons pour + le seul code final ; facteur ×15 à ×30 pour les lectures, essais et reprises d'un + travail assisté par IA réel. + +## Captures à insérer + +| Réf | Slide | Écran | +|-----|-------|-------| +| 01 | 5 | Swagger du back-end | +| 02 | 6 | Le CMS en usage | +| 03 | 8 | Bibliothèque de formats de contenu | +| 04 | 9 | Tableau de bord statistiques | +| 05 | 10 | Conversation avec l'assistant | +| 06 | 11 | Questions des visiteurs sans réponse | +| 07 | 12 | Parcours en cours | +| 08 | 13 | Contenu suggéré par balise | +| 09 | 14 | Téléchargement hors-ligne | +| 10 | 16 | Deux instances côte à côte (marque blanche) | diff --git a/outputs/platform/MyInfoMate_plateforme.pptx b/outputs/platform/MyInfoMate_plateforme.pptx new file mode 100644 index 0000000..c7e5a65 Binary files /dev/null and b/outputs/platform/MyInfoMate_plateforme.pptx differ diff --git a/outputs/platform/build_platform_deck.py b/outputs/platform/build_platform_deck.py new file mode 100644 index 0000000..aab25c3 --- /dev/null +++ b/outputs/platform/build_platform_deck.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- +"""Deck de présentation de la plateforme MyInfoMate — audience interne / acquéreur. + +Réutilise les primitives de mise en page du deck MNAHA. Les chiffres viennent de +mesures réelles sur les dépôts (voir CHIFFRES.md), pas d'estimations. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT.parent / "mnaha")) + +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 Inches, Pt + +from build_decks import ( # noqa: E402 + H, LOGO, MARGIN, W, Theme, Y_BODY_NO_SUB, bullets, footer, kicker_title, + page_base, placeholder, rect, sanitize_package, text, +) + + +def slide_cover(prs, th): + 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.05), Inches(10.4), Inches(0.4), + [("PLATEFORME DE MÉDIATION NUMÉRIQUE", 11, True, th.accent_bright, + th.body_font, 0)]) + text(s, MARGIN, Inches(2.6), Inches(10.6), Inches(1.8), + [("MyInfoMate", 52, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], + line_spacing=1.02) + text(s, MARGIN, Inches(3.95), Inches(9.8), Inches(1.2), + [("Un back-end, un CMS, trois applications visiteur.\n" + "Six ans de développement, trois sites en production.", 16, False, + RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.4) + if LOGO.exists(): + s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.4), Inches(0.8), + height=Inches(1.2)) + return s + + +def slide_pitch(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "En une phrase", + "Un musée saisit son contenu une fois. Il le diffuse sur une borne, " + "un téléphone et le web.") + text(s, MARGIN, Inches(3.35), Inches(11.0), Inches(1.6), + [("La valeur n'est pas dans une application : elle est dans le back-end " + "multi-locataire qui les alimente toutes, et dans le CMS qui rend la mise " + "à jour possible sans développeur.", 17, False, th.ink, th.body_font, 0)], + line_spacing=1.45) + rect(s, MARGIN, Inches(5.35), Inches(11.0), Pt(1), fill=th.rule) + text(s, MARGIN, Inches(5.6), Inches(11.0), Inches(0.8), + [("Chaque client est une instance isolée, avec ses contenus, ses couleurs, " + "ses langues, ses quotas et ses statistiques.", 13, False, th.muted, + th.body_font, 0)], line_spacing=1.35) + footer(s, th, n, "En une phrase") + return s + + +def slide_bricks(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Les cinq briques", + "Une seule source de données, quatre surfaces d'usage.") + hub_w, hub_h = Inches(3.2), Inches(1.5) + hub_x = (W - hub_w) / 2 + hub_y = Inches(3.10) + rect(s, hub_x, hub_y, hub_w, hub_h, fill=th.ink) + text(s, hub_x + Inches(0.25), hub_y, hub_w - Inches(0.5), hub_h, + [("manager-service", 16, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0), + ("API REST, base PostgreSQL,\nmulti-locataire", 11, False, + RGBColor(0xC5, 0xCE, 0xD8), th.body_font, 6)], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2) + + cw = Inches(2.75) + ch = Inches(1.35) + gap = Inches(0.28) + total = 4 * cw + 3 * gap + x0 = (W - total) / 2 + y = hub_y + hub_h + Inches(0.75) + apps = [ + ("manager-app", "Le CMS", "Flutter web — 79 écrans"), + ("tablet-app", "Borne tactile", "Flutter Android — 38 écrans"), + ("mymuseum-visitapp", "App visiteur", "Flutter iOS/Android — 54 écrans"), + ("visitapp-web", "Visite sans install.", "Next.js — 34 composants"), + ] + for i, (name, role, tech) in enumerate(apps): + x = x0 + i * (cw + gap) + rect(s, x, y, cw, ch, fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + rect(s, x + cw / 2 - Pt(1), hub_y + hub_h, Pt(2), Inches(0.75), fill=th.rule) + text(s, x + Inches(0.2), y + Inches(0.28), cw - Inches(0.4), Inches(0.3), + [(name, 12.5, True, th.ink, th.title_font, 0)], align=PP_ALIGN.CENTER) + text(s, x + Inches(0.2), y + Inches(0.68), cw - Inches(0.4), Inches(0.3), + [(role, 11.5, False, th.accent, th.body_font, 0)], align=PP_ALIGN.CENTER) + text(s, x + Inches(0.2), y + Inches(1.02), cw - Inches(0.4), Inches(0.3), + [(tech, 9.5, False, th.muted, th.body_font, 0)], align=PP_ALIGN.CENTER) + rect(s, x0, hub_y + hub_h + Inches(0.37), total, Pt(2), fill=th.rule) + footer(s, th, n, "Architecture") + return s + + +def slide_figures(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Le patrimoine", + "Ce qui existe, mesuré sur les dépôts.") + figures = [ + ("181 000", "lignes de code écrites à la main", + "hors 34 000 lignes de client API généré"), + ("867", "commits", "décembre 2020 → août 2026"), + ("5", "applications", "back-end, CMS, borne, mobile, web"), + ("145", "points d'API", "répartis sur 22 contrôleurs"), + ("67", "migrations de base", "23 tables métier"), + ("202", "tests automatisés", "sur le back-end"), + ] + cols = 3 + cw = (W - 2 * MARGIN - Inches(0.5)) / cols + ch = Inches(1.4) + for i, (big, label, sub) in enumerate(figures): + x = MARGIN + (i % cols) * (cw + Inches(0.25)) + y = Inches(3.25) + (i // cols) * (ch + Inches(0.3)) + rect(s, x, y, cw, ch, fill=th.surface, line=th.rule) + rect(s, x, y, Pt(3), ch, fill=th.accent) + text(s, x + Inches(0.3), y + Inches(0.18), cw - Inches(0.5), Inches(0.5), + [(big, 27, True, th.ink, th.title_font, 0)]) + text(s, x + Inches(0.3), y + Inches(0.72), cw - Inches(0.5), Inches(0.3), + [(label, 12, True, th.ink, th.body_font, 0)]) + text(s, x + Inches(0.3), y + Inches(1.02), cw - Inches(0.5), Inches(0.3), + [(sub, 9.5, False, th.muted, th.body_font, 0)]) + footer(s, th, n, "Le patrimoine") + return s + + +def slide_split(prs, th, n, kicker, title, subtitle, items, ref=None, cap_label=None, + cap_hint=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.85), Inches(5.75), items, size=12.5, + gap=Inches(0.15)) + if ref: + 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, kicker) + return s + + +def slide_cards(prs, th, n, kicker, title, subtitle, cards, label=None): + s = page_base(prs, th) + y = kicker_title(s, th, kicker, title, subtitle) + if subtitle: + y = Inches(3.85) + cw = (W - 2 * MARGIN - Inches(0.6)) / 3 + for i, (head, body) in enumerate(cards): + x = MARGIN + i * (cw + Inches(0.3)) + rect(s, x, y, cw, Inches(2.6), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.38), y + Inches(0.38), cw - Inches(0.76), Inches(0.8), + [(head, 15, True, th.ink, th.title_font, 0)], line_spacing=1.18) + text(s, x + Inches(0.38), y + Inches(1.12), cw - Inches(0.76), Inches(1.3), + [(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35) + footer(s, th, n, label or kicker) + return s + + +def slide_pricing(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Modèle économique", + "Abonnement mensuel par site, options facturées à part.") + plans = [ + ("Essentiel", "39 € / mois", ["1 Go de médias", "Statistiques 30 jours", + "Assistant IA en option"]), + ("Pro", "99 € / mois", ["15 Go de médias", "Statistiques 30 jours", + "Assistant IA en option"]), + ("Premium", "179 € / mois", ["50 Go de médias", "Statistiques avancées", + "20 M de jetons IA / mois"]), + ("Enterprise", "sur devis", ["Stockage illimité", "Statistiques avancées", + "Quota IA sur mesure"]), + ] + cw = (W - 2 * MARGIN - Inches(0.75)) / 4 + y = Inches(3.15) + for i, (name, price, feats) in enumerate(plans): + x = MARGIN + i * (cw + Inches(0.25)) + rect(s, x, y, cw, Inches(2.75), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.3), y + Inches(0.32), cw - Inches(0.6), Inches(0.3), + [(name, 14, True, th.ink, th.title_font, 0)]) + text(s, x + Inches(0.3), y + Inches(0.72), cw - Inches(0.6), Inches(0.35), + [(price, 16, True, th.accent, th.body_font, 0)]) + cy = y + Inches(1.3) + for f in feats: + text(s, x + Inches(0.3), cy, cw - Inches(0.6), Inches(0.45), + [(f, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.25) + cy += Inches(0.42) + text(s, MARGIN, Inches(6.15), Inches(11.0), Inches(0.5), + [("Application native, hors-ligne et balises, notifications et traduction " + "automatique se règlent par instance, en dehors du plan.", 11, False, + th.muted, th.body_font, 0)], line_spacing=1.3) + footer(s, th, n, "Modèle économique") + return s + + +def slide_roadmap(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Feuille de route", + "Ce qui est déjà écrit, et ce qui reste à construire.") + rows = [ + ("Avatar IA sur borne", "À construire", + "Un guide incarné à l'écran, qui parle et écoute. Le socle est là : " + "l'assistant, la synthèse vocale et la reconnaissance existent déjà."), + ("Assistant vocal proactif", "Code écrit, non déployé", + "Le commentaire se déclenche à l'approche d'une balise ou d'une zone. " + "Orchestrateur vocal, mot de réveil et moteurs TTS sont dans le dépôt."), + ("Lunettes connectées", "R&D avancée", + "Intégration Ray-Ban Meta : le visiteur regarde une œuvre et pose sa " + "question, sans sortir son téléphone."), + ("Plan d'étage interactif", "À construire", + "Les cartes sont géographiques. Le plan de salles cliquable est la " + "demande la plus fréquente des musées d'intérieur."), + ("Borne VR d'accueil", "Étude", + "Le rendu du CMS porté sur casque, pour une expérience d'accueil."), + ] + y = Inches(3.05) + for name, status, body in rows: + rect(s, MARGIN, y, W - 2 * MARGIN, Inches(0.68), fill=th.surface, line=th.rule) + rect(s, MARGIN, y, Pt(3), Inches(0.68), fill=th.accent) + text(s, MARGIN + Inches(0.28), y + Inches(0.21), Inches(2.85), Inches(0.3), + [(name, 12.5, True, th.ink, th.title_font, 0)]) + text(s, MARGIN + Inches(3.25), y + Inches(0.23), Inches(2.1), Inches(0.3), + [(status.upper(), 8.5, True, th.accent, th.body_font, 0)]) + text(s, MARGIN + Inches(5.55), y + Inches(0.11), Inches(5.85), Inches(0.5), + [(body, 10, False, th.muted, th.body_font, 0)], line_spacing=1.22) + y += Inches(0.76) + footer(s, th, n, "Feuille de route") + return s + + +def slide_rebuild(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Reconstruire à l'identique", + "L'ordre de grandeur, et ce qu'aucun budget ne rachète.") + left = [ + ("1 000 à 1 400", "jours-homme", + "Une équipe de trois — back-end, mobile, front — sur 18 à 24 mois."), + ("30 à 70 M", "de jetons d'IA", + "Le code final pèse à lui seul ~2,2 M de jetons ; le travail réel coûte " + "quinze à trente fois cela en lectures et reprises."), + ] + y = Inches(3.3) + for big, unit, body in left: + rect(s, MARGIN, y, Inches(5.75), Inches(1.45), fill=th.surface, line=th.rule) + rect(s, MARGIN, y, Pt(3), Inches(1.45), fill=th.accent) + text(s, MARGIN + Inches(0.32), y + Inches(0.16), Inches(5.1), Inches(0.75), + [(big, 22, True, th.ink, th.title_font, 0), + (unit, 12, False, th.muted, th.body_font, 2)]) + text(s, MARGIN + Inches(0.32), y + Inches(0.86), Inches(5.1), Inches(0.5), + [(body, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.28) + y += Inches(1.65) + text(s, MARGIN, Inches(6.55), Inches(5.75), Inches(0.4), + [("Hypothèse assumée : reconstruction du périmètre fonctionnel existant, " + "hors reprise de données.", 9.5, False, th.muted, th.body_font, 0)], + line_spacing=1.25) + + rx = Inches(7.35) + rect(s, rx, Inches(3.3), Inches(5.1), Inches(3.35), fill=th.ink) + text(s, rx + Inches(0.42), Inches(3.62), Inches(4.3), Inches(0.4), + [("Ce qui ne se reconstruit pas", 15, True, RGBColor(0xFF, 0xFF, 0xFF), + th.title_font, 0)]) + items = [ + "Six ans de cas limites appris en production, pas en réunion.", + "Trois sites qui tournent, dont un intégralement hors réseau.", + "Les décisions de quota, de purge et de consentement déjà tranchées.", + "Un CMS que des équipes non techniques utilisent réellement.", + ] + cy = Inches(4.25) + for it in items: + rect(s, rx + Inches(0.42), cy + Inches(0.08), Inches(0.11), Inches(0.11), + fill=th.accent_bright, shape=MSO_SHAPE.OVAL) + text(s, rx + Inches(0.72), cy - Inches(0.02), Inches(4.0), Inches(0.6), + [(it, 11, False, RGBColor(0xD8, 0xDF, 0xE7), th.body_font, 0)], + line_spacing=1.3) + cy += Inches(0.58) + footer(s, th, n, "Reconstruire à l'identique") + return s + + +def slide_closing(prs, th): + s = page_base(prs, th, dark=True) + text(s, MARGIN, Inches(2.3), Inches(10), Inches(0.35), + [("CE QUE C'EST, AU FOND", 11, True, th.accent_bright, th.body_font, 0)]) + text(s, MARGIN, Inches(2.9), Inches(10.4), Inches(1.6), + [("Une plateforme finie,\nen production, qui se vend\nà l'abonnement.", 34, + True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], line_spacing=1.15) + text(s, MARGIN, Inches(5.4), Inches(9.5), Inches(0.8), + [("Pas un prototype à finir. Pas un projet client à généraliser.", 15, False, + RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.35) + return s + + +def build(out_path): + th = Theme(branded=True) + prs = Presentation() + prs.slide_width, prs.slide_height = W, H + + n = 1 + slide_cover(prs, th); n += 1 + slide_pitch(prs, th, n); n += 1 + slide_bricks(prs, th, n); n += 1 + slide_figures(prs, th, n); n += 1 + + slide_split(prs, th, n, "Le back-end", "manager-service — le cœur du produit.", + "ASP.NET Core, PostgreSQL, dockerisé. Tout le reste n'en est qu'un " + "client.", + ["Multi-locataire : chaque client est une instance isolée, avec ses " + "quotas de stockage et d'IA.", + "145 points d'API sur 22 contrôleurs, client typé généré pour les " + "applications.", + "Diffusion temps réel vers les bornes : un contenu publié part vers " + "l'appareil sans intervention.", + "Abonnements, essais et facturation intégrés ; journal d'audit, " + "purges automatiques et quotas mesurés par client."], + "CAPTURE 01", "Documentation d'API", "Swagger du back-end"); n += 1 + + slide_split(prs, th, n, "Le CMS", "manager-app — là où le client travaille.", + "L'outil de production de contenu. C'est lui qui fait la différence " + "entre une application et un produit vendable.", + ["79 écrans : contenus, médias, applications, appareils, " + "statistiques, utilisateurs, facturation.", + "Éditeur riche par champ, avec ses versions linguistiques.", + "Composition visuelle de l'application mobile, aperçu en direct.", + "Interface traduite en trois langues, utilisée par des équipes de " + "médiation et non par des développeurs."], + "CAPTURE 02", "Le CMS en usage", "Écran de configuration"); n += 1 + + slide_cards(prs, th, n, "Les applications visiteur", + "Trois surfaces, un seul contenu.", None, + [("Borne tactile", "Flutter Android, plein écran, sélection de langue, " + "veille et retour à l'accueil. Pilotée à distance, un contenu " + "différent par appareil."), + ("Application mobile", "iOS et Android. Hors-ligne complet, balises " + "Bluetooth, notifications, audioguide, parcours, assistant."), + ("Application web", "Next.js, sans installation. Même contenu, même " + "thème, accessible depuis un QR code.")]); n += 1 + + slide_split(prs, th, n, "Contenus", + "Quatorze formats, composables par le client.", + "Aucun développement par client : un nouveau musée se configure, il " + "ne se code pas.", + ["Article, carte, diaporama, vidéo, audio, PDF, agenda, sommaire.", + "Quiz, jeu, parcours, événement, page web, météo.", + "Chaque format existe dans dix langues, avec traduction assistée par " + "IA champ par champ.", + "Thème, couleurs et disposition d'accueil réglés par instance."], + "CAPTURE 03", "Bibliothèque de formats"); n += 1 + + slide_split(prs, th, n, "Statistiques", + "Le tableau de bord que le client regarde chaque mois.", + "C'est l'argument de renouvellement d'abonnement : le client voit ce " + "que sa médiation produit.", + ["Visites par jour, contenus les plus et les moins consultés, points " + "d'intérêt, quiz achevés, scans de QR.", + "Filtrable par période et par canal : borne, mobile, web.", + "Rapport PDF exportable pour un conseil d'administration ou une " + "tutelle.", + "Profondeur d'historique liée au plan d'abonnement — c'est un levier " + "commercial."], + "CAPTURE 04", "Tableau de bord"); n += 1 + + slide_split(prs, th, n, "Assistant IA", + "Un guide qui ne connaît que le contenu du client.", + "Recherche sémantique sur les contenus indexés de l'instance, puis " + "réponse construite à partir des seuls extraits trouvés.", + ["Indexation automatique à chaque publication, par morceaux et par " + "langue.", + "Quand la recherche ne remonte rien, l'assistant le dit — il " + "n'invente pas.", + "Quota de jetons par client, mesuré et plafonné, avec garde-fou " + "pendant l'essai gratuit.", + "Disponible sur les trois applications visiteur."], + "CAPTURE 05", "Conversation avec l'assistant"); n += 1 + + slide_split(prs, th, n, "Pilotage de l'IA", + "Les questions sans réponse deviennent un produit.", + "Chaque échange est journalisé sans donnée nominative. Le CMS en tire " + "la liste des trous de contenu.", + ["Onglet des questions auxquelles l'assistant n'a rien pu répondre.", + "Réponses de repli rédigées par le client, traduites automatiquement.", + "Activation canal par canal, consommation suivie en jetons.", + "Consentement du visiteur et purge automatique à 90 jours."], + "CAPTURE 06", "Questions des visiteurs"); n += 1 + + slide_split(prs, th, n, "Parcours et escape game", + "Le même moteur sert la visite guidée et le jeu de piste.", + "Un parcours est une suite d'étapes déclenchables, avec ou sans carte, " + "avec ou sans énigme.", + ["Étapes ordonnées ou libres, révélées au fur et à mesure ou toutes " + "visibles.", + "Énigmes bloquantes, minuteur par étape, messages de début et de fin.", + "Déclenchement par zone géographique, par balise Bluetooth ou par " + "code QR.", + "Écran de réussite et progression sur la carte — vendu comme " + "animation scolaire ou familiale."], + "CAPTURE 07", "Parcours en cours"); n += 1 + + slide_split(prs, th, n, "Balises Bluetooth", + "Le contenu vient au visiteur.", + "Une balise posée dans une salle est associée à un contenu. " + "À l'approche, l'application propose ce contenu d'elle-même.", + ["Suggestion automatique du contenu de la salle, sans manipulation.", + "Fonctionne sans réseau : le déclenchement est local à l'appareil.", + "Le même mécanisme sert le déclenchement d'étape de parcours.", + "Déjà déployé sur un site entièrement hors réseau ; extension vocale " + "écrite mais pas encore mise en service."], + "CAPTURE 08", "Contenu suggéré par balise", portrait=True); n += 1 + + slide_split(prs, th, n, "Hors-ligne", + "Une visite entière sans réseau.", + "Textes, images et audio sont téléchargés sur l'appareil. C'est ce qui " + "rend les sites souterrains et ruraux adressables.", + ["Téléchargement d'un espace complet en une action, base locale sur " + "l'appareil.", + "L'application reste utilisable sans aucune connexion.", + "Les statistiques se synchronisent au retour du réseau.", + "Différenciant réel : peu de solutions du marché le font vraiment."], + "CAPTURE 09", "Téléchargement hors-ligne", portrait=True); n += 1 + + slide_cards(prs, th, n, "Le reste de la surface", + "Ce qui est déjà là et qu'on oublie de citer.", None, + [("Codes QR", "Génération des codes par contenu, export PDF prêt à " + "imprimer, scan côté visiteur, comptage des scans valides et " + "invalides."), + ("Notifications", "Envoi immédiat ou programmé vers les applications " + "mobiles, rédaction par langue, historique et annulation."), + ("Parc d'appareils", "État de connexion, batterie, version, dernière " + "activité, attribution du contenu affiché appareil par appareil.")], + label="Le reste de la surface"); n += 1 + + slide_split(prs, th, n, "Livrer un client", + "Marque blanche complète, sans fork de code.", + "Un nouveau client ne crée pas de branche. Il crée une instance.", + ["Nom, icône, couleurs, écran de chargement et langues par instance.", + "Application native publiée sous le nom du client sur les magasins.", + "Application web sur une adresse dédiée.", + "Clés d'API par application, quatre niveaux de rôle, et aucune ligne " + "de code spécifique client dans le tronc commun."], + "CAPTURE 10", "Deux instances côte à côte"); n += 1 + + slide_cards(prs, th, n, "Références", + "Trois sites en production, le plus ancien depuis 2021.", None, + [("Musée de la Fraise, Wépion", "2021 — borne tactile en salle. Le " + "premier déploiement, toujours en service."), + ("Fort de Saint-Héribert", "2023 — application mobile intégralement " + "hors-ligne avec balises Bluetooth."), + ("Visit Namur", "2024 — borne tactile grand format en office de " + "tourisme.")]); n += 1 + + slide_pricing(prs, th, n); n += 1 + slide_roadmap(prs, th, n); n += 1 + slide_rebuild(prs, th, n); n += 1 + slide_closing(prs, th) + + cp = prs.core_properties + cp.author = "MyInfoMate" + cp.last_modified_by = "MyInfoMate" + cp.title = "MyInfoMate — la plateforme" + cp.subject = "" + cp.comments = "" + cp.keywords = "" + cp.category = "" + cp.content_status = "" + cp.identifier = "" + cp.language = "fr-BE" + cp.revision = 1 + + 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 + + +if __name__ == "__main__": + path, count = build(ROOT / "MyInfoMate_plateforme.pptx") + print(f"{path} ({count} slides)") diff --git a/outputs/platform/captures/api.png b/outputs/platform/captures/api.png new file mode 100644 index 0000000..1ea149b Binary files /dev/null and b/outputs/platform/captures/api.png differ diff --git a/outputs/platform/captures/assistant.png b/outputs/platform/captures/assistant.png new file mode 100644 index 0000000..c53044f Binary files /dev/null and b/outputs/platform/captures/assistant.png differ diff --git a/outputs/platform/captures/cms.png b/outputs/platform/captures/cms.png new file mode 100644 index 0000000..edfe34f Binary files /dev/null and b/outputs/platform/captures/cms.png differ diff --git a/outputs/platform/captures/formats.png b/outputs/platform/captures/formats.png new file mode 100644 index 0000000..a182bbd Binary files /dev/null and b/outputs/platform/captures/formats.png differ diff --git a/outputs/platform/captures/horsligne.png b/outputs/platform/captures/horsligne.png new file mode 100644 index 0000000..b674f20 Binary files /dev/null and b/outputs/platform/captures/horsligne.png differ diff --git a/outputs/platform/captures/marqueblanche.png b/outputs/platform/captures/marqueblanche.png new file mode 100644 index 0000000..b9650b4 Binary files /dev/null and b/outputs/platform/captures/marqueblanche.png differ diff --git a/outputs/platform/captures/marqueblanche_2.png b/outputs/platform/captures/marqueblanche_2.png new file mode 100644 index 0000000..2be4990 Binary files /dev/null and b/outputs/platform/captures/marqueblanche_2.png differ diff --git a/outputs/platform/captures/parcours.png b/outputs/platform/captures/parcours.png new file mode 100644 index 0000000..70c0810 Binary files /dev/null and b/outputs/platform/captures/parcours.png differ diff --git a/outputs/platform/captures/questions.png b/outputs/platform/captures/questions.png new file mode 100644 index 0000000..4036c5c Binary files /dev/null and b/outputs/platform/captures/questions.png differ diff --git a/outputs/platform/captures/stats.png b/outputs/platform/captures/stats.png new file mode 100644 index 0000000..da137a8 Binary files /dev/null and b/outputs/platform/captures/stats.png differ diff --git a/outputs/prospect/MyInfoMate_prospect.pptx b/outputs/prospect/MyInfoMate_prospect.pptx new file mode 100644 index 0000000..245feb0 Binary files /dev/null and b/outputs/prospect/MyInfoMate_prospect.pptx differ diff --git a/outputs/prospect/build_prospect_deck.py b/outputs/prospect/build_prospect_deck.py new file mode 100644 index 0000000..11e9e8c --- /dev/null +++ b/outputs/prospect/build_prospect_deck.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +"""Deck de présentation MyInfoMate — audience prospect. + +Même matière que le deck plateforme, mais retournée : celui-là défendait un +patrimoine devant un acquéreur (volume de code, historique, coût de +reconstruction), celui-ci défend un bénéfice devant un client. D'où trois règles +de rédaction tenues d'un bout à l'autre : + + - aucun chiffre de production interne (lignes, commits, écrans, points d'API) ; + - aucun nom de dépôt ni de technologie dans le corps du propos ; + - chaque titre énonce ce que le client gagne, pas ce que le produit contient. + +Les captures viennent du deck plateforme, extraites dans ../platform/captures. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT.parent / "mnaha")) + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt + +from build_decks import ( # noqa: E402 + H, LOGO, MARGIN, W, Theme, bullets, footer, kicker_title, page_base, rect, + sanitize_package, text, +) + +CAPTURES = ROOT.parent / "platform" / "captures" + + +def picture(slide, name, x, y, w=None, h=None): + """Insère une capture si elle existe ; sinon ne pose rien.""" + path = CAPTURES / name + if not path.exists(): + return None + kw = {} + if w is not None: + kw["width"] = w + if h is not None: + kw["height"] = h + return slide.shapes.add_picture(str(path), x, y, **kw) + + +def slide_cover(prs, th): + 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.05), Inches(10.4), Inches(0.4), + [("MÉDIATION NUMÉRIQUE POUR LIEUX CULTURELS", 11, True, th.accent_bright, + th.body_font, 0)]) + text(s, MARGIN, Inches(2.6), Inches(10.6), Inches(1.8), + [("MyInfoMate", 52, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], + line_spacing=1.02) + text(s, MARGIN, Inches(3.95), Inches(9.8), Inches(1.2), + [("Votre contenu, saisi une fois.\n" + "Vécu sur une borne, un téléphone et le web.", 16, False, + RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.4) + if LOGO.exists(): + s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.4), Inches(0.8), + height=Inches(1.2)) + return s + + +def slide_problem(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Le constat", + "La médiation coûte cher, et elle vieillit vite.") + items = [ + ("Le panneau imprimé", "Une correction, et c'est un nouveau tirage. Une " + "langue de plus, et c'est un nouveau panneau."), + ("L'audioguide loué", "Un parc de boîtiers à charger, désinfecter et " + "remplacer, pour un contenu qu'on ne peut plus modifier."), + ("L'application sur devis", "Livrée une fois, jamais reprise. Le prestataire " + "part, le contenu se fige, et la refonte se repaie entièrement."), + ] + cw = (W - 2 * MARGIN - Inches(0.6)) / 3 + y = Inches(3.15) + for i, (head, body) in enumerate(items): + x = MARGIN + i * (cw + Inches(0.3)) + rect(s, x, y, cw, Inches(2.2), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.35), y + Inches(0.35), cw - Inches(0.7), Inches(0.5), + [(head, 15, True, th.ink, th.title_font, 0)], line_spacing=1.18) + text(s, x + Inches(0.35), y + Inches(0.95), cw - Inches(0.7), Inches(1.1), + [(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35) + text(s, MARGIN, Inches(5.75), Inches(11.0), Inches(0.8), + [("Le point commun : le contenu appartient à l'objet qui le porte. " + "Changez l'objet, vous perdez le contenu.", 14, False, th.ink, + th.body_font, 0)], line_spacing=1.35) + footer(s, th, n, "Le constat") + return s + + +def slide_promise(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "La proposition", + "Vous écrivez votre contenu une fois. Il vit partout, et il reste " + "à vous.") + text(s, MARGIN, Inches(3.35), Inches(11.0), Inches(1.6), + [("MyInfoMate sépare ce que vous racontez de l'endroit où on l'écoute. " + "Votre contenu vit dans un espace qui vous appartient ; la borne, " + "l'application et le site web ne sont que des façons de le consulter.", + 17, False, th.ink, th.body_font, 0)], line_spacing=1.45) + rect(s, MARGIN, Inches(5.35), Inches(11.0), Pt(1), fill=th.rule) + text(s, MARGIN, Inches(5.6), Inches(11.0), Inches(0.8), + [("Une correction se fait une fois et se voit partout, le jour même. " + "Sans développeur, sans devis, sans attendre.", 13, False, th.muted, + th.body_font, 0)], line_spacing=1.35) + footer(s, th, n, "La proposition") + return s + + +def slide_channels(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Trois façons de visiter", + "Un seul contenu, trois expériences.") + hub_w, hub_h = Inches(3.6), Inches(1.4) + hub_x = (W - hub_w) / 2 + hub_y = Inches(3.10) + rect(s, hub_x, hub_y, hub_w, hub_h, fill=th.ink) + text(s, hub_x + Inches(0.25), hub_y, hub_w - Inches(0.5), hub_h, + [("Votre contenu", 16, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0), + ("textes, images, sons,\nen dix langues", 11, False, + RGBColor(0xC5, 0xCE, 0xD8), th.body_font, 6)], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2) + + cw, ch, gap = Inches(3.35), Inches(1.5), Inches(0.35) + total = 3 * cw + 2 * gap + x0 = (W - total) / 2 + y = hub_y + hub_h + Inches(0.8) + apps = [ + ("La borne en salle", "Grand écran tactile, choix de la langue, " + "retour à l'accueil automatique."), + ("L'application mobile", "Sur le téléphone du visiteur. Fonctionne " + "sans réseau."), + ("La visite web", "Un QR code, rien à installer. Ouvre dans le " + "navigateur."), + ] + for i, (name, body) in enumerate(apps): + x = x0 + i * (cw + gap) + rect(s, x, y, cw, ch, fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + rect(s, x + cw / 2 - Pt(1), hub_y + hub_h, Pt(2), Inches(0.8), fill=th.rule) + text(s, x + Inches(0.25), y + Inches(0.3), cw - Inches(0.5), Inches(0.3), + [(name, 13.5, True, th.ink, th.title_font, 0)], align=PP_ALIGN.CENTER) + text(s, x + Inches(0.25), y + Inches(0.72), cw - Inches(0.5), Inches(0.7), + [(body, 11, False, th.muted, th.body_font, 0)], align=PP_ALIGN.CENTER, + line_spacing=1.3) + rect(s, x0, hub_y + hub_h + Inches(0.4), total, Pt(2), fill=th.rule) + footer(s, th, n, "Trois façons de visiter") + return s + + +def slide_feature(prs, th, n, kicker, title, subtitle, items, capture=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.85), Inches(5.75), items, size=12.5, + gap=Inches(0.15)) + if capture: + if portrait: + picture(s, capture, Inches(8.9), Inches(1.35), h=Inches(5.4)) + else: + picture(s, capture, Inches(7.35), Inches(2.15), w=Inches(5.1)) + footer(s, th, n, kicker) + return s + + +def slide_references(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Ils l'utilisent déjà", + "Trois sites en production, le plus ancien depuis 2021.") + cards = [ + ("Musée de la Fraise", "Wépion — depuis 2021", + "Une borne tactile en salle, toujours en service cinq ans après sa pose."), + ("Fort de Saint-Héribert", "Namur — depuis 2023", + "Un site souterrain sans réseau : la visite mobile fonctionne entièrement " + "hors-ligne, déclenchée par des balises."), + ("Visit Namur", "Office de tourisme — depuis 2024", + "Une borne grand format à l'accueil du public."), + ] + cw = (W - 2 * MARGIN - Inches(0.6)) / 3 + y = Inches(3.15) + for i, (head, sub, body) in enumerate(cards): + x = MARGIN + i * (cw + Inches(0.3)) + rect(s, x, y, cw, Inches(2.6), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.38), y + Inches(0.38), cw - Inches(0.76), Inches(0.5), + [(head, 15, True, th.ink, th.title_font, 0)], line_spacing=1.18) + text(s, x + Inches(0.38), y + Inches(0.92), cw - Inches(0.76), Inches(0.3), + [(sub, 11, True, th.accent, th.body_font, 0)]) + text(s, x + Inches(0.38), y + Inches(1.35), cw - Inches(0.76), Inches(1.1), + [(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35) + text(s, MARGIN, Inches(6.05), Inches(11.0), Inches(0.6), + [("Le premier site installé n'a jamais été refait. Il a été mis à jour.", + 13, False, th.ink, th.body_font, 0)], line_spacing=1.3) + footer(s, th, n, "Ils l'utilisent déjà") + return s + + +def slide_pricing(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Nos formules", + "Un abonnement mensuel par site. Pas de frais de développement.") + plans = [ + ("Essentiel", "39 € / mois", ["Visite web par QR code", + "1 Go de médias", + "Statistiques de fréquentation"]), + ("Pro", "99 € / mois", ["Borne et application mobile", + "15 Go de médias", + "Assistant IA en option"]), + ("Premium", "179 € / mois", ["Tous les canaux", + "50 Go de médias", + "Assistant IA inclus"]), + ("Sur mesure", "sur devis", ["Stockage illimité", + "Statistiques avancées", + "Accompagnement dédié"]), + ] + cw = (W - 2 * MARGIN - Inches(0.75)) / 4 + y = Inches(3.15) + for i, (name, price, feats) in enumerate(plans): + x = MARGIN + i * (cw + Inches(0.25)) + rect(s, x, y, cw, Inches(2.75), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.3), y + Inches(0.32), cw - Inches(0.6), Inches(0.3), + [(name, 14, True, th.ink, th.title_font, 0)]) + text(s, x + Inches(0.3), y + Inches(0.72), cw - Inches(0.6), Inches(0.35), + [(price, 16, True, th.accent, th.body_font, 0)]) + cy = y + Inches(1.3) + for f in feats: + text(s, x + Inches(0.3), cy, cw - Inches(0.6), Inches(0.45), + [(f, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.25) + cy += Inches(0.42) + text(s, MARGIN, Inches(6.15), Inches(11.0), Inches(0.5), + [("Application publiée à votre nom sur les magasins, mode hors-ligne, " + "balises et notifications se règlent site par site, en dehors du plan.", + 11, False, th.muted, th.body_font, 0)], line_spacing=1.3) + footer(s, th, n, "Nos formules") + return s + + +def slide_roadmap(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Ce qui arrive", + "Le produit continue d'avancer, et votre abonnement suit.") + rows = [ + ("Guide vocal proactif", "En test", + "Le commentaire se déclenche tout seul à l'approche d'une salle. Le " + "visiteur n'a plus rien à manipuler."), + ("Lunettes connectées", "En développement", + "Le visiteur regarde une œuvre, pose sa question à voix haute et obtient " + "sa réponse, sans sortir son téléphone."), + ("Plan de salles interactif", "Prévu", + "Un plan d'étage cliquable, pour les sites d'intérieur. C'est la demande " + "la plus fréquente."), + ("Guide incarné à l'écran", "À l'étude", + "Un personnage qui accueille et répond sur la borne."), + ] + y = Inches(3.25) + for name, status, body in rows: + rect(s, MARGIN, y, W - 2 * MARGIN, Inches(0.75), fill=th.surface, line=th.rule) + rect(s, MARGIN, y, Pt(3), Inches(0.75), fill=th.accent) + text(s, MARGIN + Inches(0.28), y + Inches(0.24), Inches(2.85), Inches(0.3), + [(name, 12.5, True, th.ink, th.title_font, 0)]) + text(s, MARGIN + Inches(3.25), y + Inches(0.26), Inches(2.1), Inches(0.3), + [(status.upper(), 8.5, True, th.accent, th.body_font, 0)]) + text(s, MARGIN + Inches(5.55), y + Inches(0.14), Inches(5.85), Inches(0.5), + [(body, 10, False, th.muted, th.body_font, 0)], line_spacing=1.22) + y += Inches(0.85) + footer(s, th, n, "Ce qui arrive") + return s + + +def slide_start(prs, th, n): + s = page_base(prs, th) + kicker_title(s, th, "Démarrer", + "Une visite en ligne avant la fin du mois.") + steps = [ + ("1", "On ouvre votre espace", + "Vos couleurs, votre logo, vos langues. Comptez une journée."), + ("2", "Vous saisissez vos contenus", + "Vos équipes de médiation, avec notre accompagnement. Pas de développeur."), + ("3", "Vous publiez", + "Le QR code est prêt. La borne et l'application suivent quand vous voulez."), + ] + cw = (W - 2 * MARGIN - Inches(0.6)) / 3 + y = Inches(3.15) + for i, (num, head, body) in enumerate(steps): + x = MARGIN + i * (cw + Inches(0.3)) + rect(s, x, y, cw, Inches(2.3), fill=th.surface, line=th.rule) + rect(s, x, y, cw, Pt(3), fill=th.accent) + text(s, x + Inches(0.38), y + Inches(0.32), cw - Inches(0.76), Inches(0.5), + [(num, 26, True, th.accent, th.title_font, 0)]) + text(s, x + Inches(0.38), y + Inches(0.95), cw - Inches(0.76), Inches(0.45), + [(head, 14, True, th.ink, th.title_font, 0)], line_spacing=1.18) + text(s, x + Inches(0.38), y + Inches(1.5), cw - Inches(0.76), Inches(0.7), + [(body, 11, False, th.muted, th.body_font, 0)], line_spacing=1.3) + text(s, MARGIN, Inches(5.85), Inches(11.0), Inches(0.6), + [("Essai gratuit, sans engagement, avec vos vrais contenus.", 15, True, + th.ink, th.body_font, 0)], line_spacing=1.3) + footer(s, th, n, "Démarrer") + return s + + +def slide_closing(prs, th): + 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.4), Inches(10.6), Inches(2.2), + [("Faisons vivre\nvotre lieu autrement.", 40, True, + RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], line_spacing=1.1) + text(s, MARGIN, Inches(4.75), Inches(9.8), Inches(1.0), + [("MyInfoMate — une solution Unov\n" + "myinfomate.be · fransolet.thomas@gmail.com", 15, False, + RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.45) + if LOGO.exists(): + s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.4), Inches(0.8), + height=Inches(1.2)) + return s + + +def build(out_path): + prs = Presentation() + prs.slide_width, prs.slide_height = W, H + th = Theme(branded=True) + n = 2 + + slide_cover(prs, th) + slide_problem(prs, th, n); n += 1 + slide_promise(prs, th, n); n += 1 + slide_channels(prs, th, n); n += 1 + + slide_feature(prs, th, n, "Vous gardez la main", + "Votre équipe met à jour, seule, le jour même.", + "L'outil de saisie est fait pour des chargés de médiation, pas " + "pour des informaticiens.", + ["Un texte, une photo, un son : vous corrigez et vous publiez.", + "Dix langues de visite, avec traduction proposée automatiquement.", + "Vous composez vous-même l'écran d'accueil, avec un aperçu en " + "direct de ce que verra le visiteur.", + "Plusieurs comptes, avec des droits différents selon les rôles."], + "cms.png"); n += 1 + + slide_feature(prs, th, n, "Vos contenus", + "Quatorze façons de raconter, à composer librement.", + "Rien à développer : un nouveau parcours se configure en quelques " + "heures.", + ["Article, carte, diaporama, vidéo, audio, document, agenda.", + "Quiz, jeu, parcours, événement, page web.", + "Chaque contenu existe dans toutes vos langues.", + "Vos couleurs et votre logo s'appliquent partout, sans " + "intervention."], + "formats.png"); n += 1 + + slide_feature(prs, th, n, "Un guide qui répond", + "Vos visiteurs posent leurs questions. À votre contenu.", + "L'assistant ne répond qu'à partir de ce que vous avez écrit. " + "S'il ne sait pas, il le dit — il n'invente rien.", + ["Disponible sur la borne, le mobile et le web.", + "Répond dans la langue du visiteur.", + "Vous rédigez vous-même les réponses de repli sur vos sujets " + "sensibles.", + "Aucune donnée nominative conservée."], + "assistant.png"); n += 1 + + slide_feature(prs, th, n, "Ce que vos visiteurs vous apprennent", + "Les questions restées sans réponse deviennent votre feuille de " + "route.", + "Vous voyez ce que le public cherche et ne trouve pas. C'est " + "rarement ce qu'on imagine.", + ["La liste des questions auxquelles rien n'a pu répondre.", + "Fréquentation par jour, contenus les plus et les moins consultés.", + "Quiz terminés, points d'intérêt visités, QR codes scannés.", + "Un rapport exportable pour votre conseil ou votre tutelle."], + "questions.png"); n += 1 + + slide_feature(prs, th, n, "Parcours et jeux de piste", + "La même mécanique sert la visite guidée et l'animation scolaire.", + "Une suite d'étapes, avec ou sans carte, avec ou sans énigme à " + "résoudre pour avancer.", + ["Étapes libres ou imposées, révélées au fur et à mesure.", + "Énigmes bloquantes, minuteur, écran de réussite.", + "Une étape se déclenche par la position, une balise ou un QR code.", + "Vendu comme animation famille ou groupe scolaire."], + "parcours.png"); n += 1 + + slide_feature(prs, th, n, "Sans réseau, sans installation", + "Une cave, un fort, un site rural : ça marche quand même.", + "Le visiteur télécharge la visite avant d'entrer. Tout reste " + "disponible ensuite, sans aucune connexion.", + ["Textes, images et audio stockés sur le téléphone.", + "Des balises posées en salle proposent le bon contenu au bon " + "endroit, sans manipulation.", + "Les statistiques remontent toutes seules au retour du réseau.", + "Peu de solutions du marché le font réellement."], + "horsligne.png", portrait=True); n += 1 + + slide_feature(prs, th, n, "À votre nom", + "Vos visiteurs voient votre marque, pas la nôtre.", + "Votre lieu a son propre espace : ses couleurs, son icône, son " + "adresse.", + ["Application publiée sous le nom de votre lieu sur les magasins.", + "Visite web sur une adresse qui vous est dédiée.", + "Écran d'accueil et de chargement à votre image.", + "Vos contenus restent les vôtres, et restent exportables."], + "marqueblanche_2.png", portrait=True); n += 1 + + slide_references(prs, th, n); n += 1 + slide_pricing(prs, th, n); n += 1 + slide_roadmap(prs, th, n); n += 1 + slide_start(prs, th, n); n += 1 + slide_closing(prs, th) + + cp = prs.core_properties + cp.author = "MyInfoMate" + cp.last_modified_by = "MyInfoMate" + cp.title = "MyInfoMate — la visite augmentée de votre lieu" + cp.subject = "" + cp.comments = "" + cp.keywords = "" + cp.category = "" + cp.content_status = "" + cp.identifier = "" + cp.language = "fr-BE" + cp.revision = 1 + + 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 + + +if __name__ == "__main__": + path, count = build(ROOT / "MyInfoMate_prospect.pptx") + print("%s (%d slides)" % (path, count))