"""1812 - The Russian campaign, after Minard: a homage in motion - 2026. In 1869, Charles Joseph Minard drew the fate of Napoleon's army in Russia as a band whose width is the number of men: tan going in (his legend says red), black coming out, with the temperatures of the retreat below. This app opens on his original, then redraws it from his own data and animates it through time, with cards that tell the story and cite their sources. Data: Minard's figures, as digitized by Michael Friendly ("Re-Visions of Minard") and distributed in the R package HistData, fetched live from the Rdatasets collection on GitHub. Images: public domain files on Wikimedia Commons, fetched live and credited. Timing: Minard dates only the retreat (its temperature readings). The advance is placed at sourced dates for the cities it passed; positions in between are interpolated. The two detachments' timing is approximated the same way. This timing is a reconstruction, said so in the app. Space plays or pauses; playback stops at each card for the time to read it. Drag to pan, scroll to zoom, double-click to restore the view. Click an image to enlarge it. (c) 2026 Pascal Thomet. The texts, the drawings, the voice and the animation: Creative Commons BY-NC-ND 4.0 (https://creativecommons.org/licenses/by-nc-nd/4.0/). The code: all rights reserved, readable, not reusable. Minard's map, his figures and the paintings are in the public domain. """ import asyncio import csv import hashlib import io import json import math import os import pathlib import re import shutil import sys import tempfile import time import unicodedata import urllib.parse from dataclasses import dataclass, field, replace from datetime import date, timedelta from typing import Any, Dict, List, Optional, Tuple from imgui_bundle import ( ImVec2, em_size, em_to_vec2, hello_imgui, icons_fontawesome_4 as fa, imgui, imgui_md, imgui_toggle, immapp, implot, imspinner, ) # --------------------------------------------------------------------------- # Languages: English, French, or Russian (the button in the header). The Russian texts are in minard_1812_ru.json, # fetched only when Russian is chosen, or when the browser is in Russian. # --------------------------------------------------------------------------- class Lang: code = "en" # en, fr, ru wanted = "" # ru, chosen while its texts are on their way RUSSIAN: Dict[str, Any] = {} # minard_1812_ru.json, once fetched def tr(english: str) -> str: """An interface text in the app's language.""" if Lang.code == "fr": return FRENCH.get(english, english) if Lang.code == "ru": return str(RUSSIAN["ui"].get(english, english)) return english def in_language(kind: str, title: str, field_: str, english: str, french: str) -> str: """A slide's, a card's or a section's text in the app's language: kind is slides, events or sections, title its English title, field_ title, text or words.""" if Lang.code == "fr": return french if Lang.code == "ru": return str(RUSSIAN[kind].get(title, {}).get(field_, english)) return english def num(n: int) -> str: """422,000 in English, 422 000 in French and in Russian (with a no-break space).""" return f"{n:,}".replace(",", "\u00a0") if Lang.code != "en" else f"{n:,}" FRENCH = { "The Russian campaign, after Minard: a homage in motion": "La campagne de Russie, d'après Minard : un hommage en mouvement", "Minard's figurative map (1869)": "La carte figurative de Minard (1869)", "advancing": "en marche", "retreating": "en retraite", "Main army, {going}: {men} men": "Armée principale, {going} : {men} hommes", "Main army, at a halt: {men} men": "Armée principale, à l'arrêt : {men} hommes", "Main army, retreating: 000,000 men": "Armée principale, en retraite : 000 000 hommes", "Detachments: {men}": "Détachements : {men}", "Detachments: 00,000": "Détachements : 00 000", "Going in": "L'aller", "Coming out: the retreat": "Le retour : la retraite", "Going in (Minard's red)": "L'aller (le rouge de Minard)", "Width: 1 mm for 10,000 men": "Largeur : 1 mm pour 10 000 hommes", "The retreat begins: black": "La retraite commence : en noir", "Minard's words, typed out": "Le texte de Minard, retranscrit", "Minard's temperature readings start with the retreat, on 18 October": "Les relevés de température de Minard commencent avec la retraite, le 18 octobre", "Temperature readings from 18 October": "Relevés de température à partir du 18 octobre", "Temperatures of the retreat, degrees Réaumur (x 1.25 = °C)": "Températures de la retraite, en degrés Réaumur (x 1,25 = °C)", "Degrees Réaumur (x 1.25 = °C)": "Degrés Réaumur (x 1,25 = °C)", ", rain": ", pluie", "The Cossacks gallop across the frozen Niemen (Minard's note)": "Les Cosaques passent au galop le Niémen gelé (note de Minard)", "Play or pause (Space). The arrow keys go from step to step.": "Lecture ou pause (Espace). Les flèches passent d'une étape à l'autre.", "Keys:": "Touches :", "Space": "Espace", "Steps": "Étapes", "The map": "La carte", "Language": "Langue", "Settings": "Réglages", "June 1812: the start of the campaign": "Juin 1812 : le début de la campagne", "December 1812, and the note of 2026": "Décembre 1812, et la note de 2026", "Compare with the original": "Comparer avec l'original", "The map, explained": "La carte expliquée", "Double-click: the whole map": "Double-clic : toute la carte", "Double tap: the whole map": "Double appui : toute la carte", "The map explained, in nine points": "La carte expliquée, en neuf points", "The chronology of the campaign, in twenty-four steps": "La chronologie de la campagne, en vingt-quatre étapes", "The context: what the map does not say": "Le contexte : ce que la carte ne dit pas", "About: the author, the note, the sources": "À propos : l'auteur, la note, les sources", "The making-of: how this animation came to be": "Les coulisses : comment est née cette animation", "Turn your phone for a larger map.": "Tournez votre téléphone pour agrandir la carte.", "{n} days/s": "{n} jours/s", "Minard's original over the redrawing, which bends the geography where his map does: his band and his cities " "fall on ours. Play, or a move in time, fades back to the animated map.": "L'original de Minard sur le redessin, qui infléchit la géographie là où sa carte le fait : sa bande et ses " "villes tombent sur les nôtres. Lecture, ou un déplacement dans le temps, ramène à la carte animée.", "Minard's original, 1869": "L'original de Minard, 1869", "Text size": "Taille du texte", "Old Standard TT, a 19th-century book type": "Old Standard TT, un caractère de livre du XIXe siècle", "Default font": "Police par défaut", "The map keeps Minard's lettering.": "La carte garde le lettrage de Minard.", "Chronology": "Chronologie", "About": "À propos", "Context": "Contexte", "Making-of": "Coulisses", "The making-of's text": "Le texte des coulisses", "Read the making-of": "Voir les coulisses", "Back to the map": "Retour à la carte", "Nine chapters, about twenty pictures, a quarter of an hour.": "Neuf chapitres, une vingtaine d'images, un quart d'heure de lecture.", "The making-of exists in French only, for now.": "Les coulisses n'existent qu'en français, pour l'instant.", "Read more": "Lire la suite", "Data and downloads": "Données et téléchargements", "The author's voice": "La voix de l'auteur", "Where the data comes from": "D'où viennent les données", "License": "Licence", "Guestbook": "Livre d'or", "You walked with them.": "Vous avez marché avec eux.", "Leave a word: where you write from, what you keep. The words are read before they are published.": "Laissez un mot : d'où vous écrivez, ce que vous retenez. Les mots sont lus avant d'être publiés.", "Your name": "Votre nom", "Where you write from (optional)": "D'où vous écrivez (facultatif)", "Your word": "Votre mot", "Send": "Envoyer", "Sending...": "Envoi...", "Thank you. Your word will be published once read.": "Merci. Votre mot sera publié une fois lu.", "Nobody has written yet.": "Personne n'a encore écrit.", "Could not send": "L'envoi a échoué", "Could not load the guestbook": "Le livre d'or n'a pas pu être chargé", "Waiting for a reading": "En attente de lecture", "Approve": "Publier", "Hide": "Retirer", "Delete": "Supprimer", "The steps are off: tick Steps, below the map.": "Les étapes sont masquées : cocher Étapes, sous la carte.", "Fetching Minard's tables...": "Téléchargement des tables de Minard...", "Minard's tables could not be fetched: see Data and downloads, under the gear": "Les tables de Minard n'ont pu être téléchargées : voir Données et téléchargements, sous la roue dentée", "Fetching Minard's map...": "Téléchargement de la carte de Minard...", "Fetching live data...": "Téléchargement des données...", "Some files failed: see Data and downloads, under the gear": "Des fichiers manquent : voir Données et téléchargements, sous la roue dentée", "Minard's original, explained in {n} points": "L'original de Minard, expliqué en {n} points", "Start the chronology": "Lancer la chronologie", "Back to the chronology": "Reprendre la chronologie", "Next": "Suivant", "Skip": "Passer", "See the map explained again": "Revoir la carte expliquée", "Made with": "Réalisé avec", "Source: ": "Source : ", "in English": "en anglais", "in French": "en français", "in Russian": "en russe", "Image not available": "Image indisponible", "Fetching the image...": "Téléchargement de l'image...", "Fetching the text...": "Téléchargement du texte...", "Not available: see Data and downloads, under the gear": "Indisponible : voir Données et téléchargements, sous la roue dentée", "Fetching the large version...": "Téléchargement de la grande version...", "Close": "Fermer", "Pause the points (Space)": "Mettre les points en pause (Espace)", "Play the points (Space)": "Faire défiler les points (Espace)", "Drag to move it; a double tap zooms.": "Faites-la glisser pour la déplacer ; un double appui zoome.", "Drag to move it; the wheel or a double click zooms. Esc closes.": "Faites-la glisser pour la déplacer ; la molette ou un double-clic zoome. Échap ferme.", "Where the data comes from: every file is fetched live": "D'où viennent les données : chaque fichier est téléchargé en direct", "its page": "sa page", "Retry": "Réessayer", "Fetch it all again": "Tout télécharger à nouveau", "Russian texts": "Textes russes", "Requests": "Requêtes", "Troops: path and men": "Troupes : trajet et effectifs", "Cities": "Villes", "Temperatures": "Températures", "Text font": "Police du texte", "Text font, bold": "Police du texte, gras", "Text font, italic": "Police du texte, italique", "The map's lettering": "Lettrage de la carte", } # --------------------------------------------------------------------------- # Time # --------------------------------------------------------------------------- START = date(1812, 6, 24) # the army crosses the Niemen END = date(1812, 12, 14) # the last troops cross it back LAST_DAY = (END - START).days MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] MONTHS_FR = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] MONTHS_FR_SHORT = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."] MONTHS_RU = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"] MONTHS_RU_SHORT = ["янв.", "февр.", "мар.", "апр.", "мая", "июн.", "июл.", "авг.", "сент.", "окт.", "нояб.", "дек."] def day_of(month: int, day: int) -> int: return (date(1812, month, day) - START).days def date_label(day: float, short: bool = False) -> str: """24 June 1812, or 24 Jun when short (24 juin 1812, 24 juin, in French; 24 июня 1812, 24 июн., in Russian).""" d = START + timedelta(days=int(round(day))) if Lang.code == "fr": return f"{d.day} {MONTHS_FR_SHORT[d.month - 1]}" if short else f"{d.day} {MONTHS_FR[d.month - 1]} 1812" if Lang.code == "ru": return f"{d.day} {MONTHS_RU_SHORT[d.month - 1]}" if short else f"{d.day} {MONTHS_RU[d.month - 1]} 1812" return f"{d.day} {MONTHS[d.month - 1][:3]}" if short else f"{d.day} {MONTHS[d.month - 1]} 1812" # Dates at which the advance reached Minard's cities (by longitude). Sources: World History # Encyclopedia's timeline (Niemen, Vilnius, Smolensk, Borodino, Moscow), napoleon-empire.org's # day-by-day chronology (Vitebsk). Borodino is placed at Mojaisk, 12 km away. ADVANCE_DATES = [(24.0, day_of(6, 24)), (25.3, day_of(6, 28)), (30.2, day_of(7, 29)), (32.0, day_of(8, 17)), (36.0, day_of(9, 7)), (37.6, day_of(9, 14))] RETREAT_END = (24.0, LAST_DAY) # back at the Niemen, at Kowno (napoleon-empire.org) RETREAT_START = day_of(10, 18) # Minard's rivers, from Natural Earth (public domain): (lon, lat) polylines, simplified to 0.02 degrees. # In Natural Earth the Moskva is unnamed, and the Niemen above Kaunas is named "Usha". RIVERS = [ ('Niémen', [ [(22.0, 55.094), (22.076, 55.029), (22.251, 55.071), (22.662, 55.064), (23.001, 55.105), (23.412, 55.079), (23.57, 55.04), (23.631, 54.944), (23.943, 54.878), (24.081, 54.905), (24.217, 54.838), (24.221, 54.812), (24.129, 54.807), (24.092, 54.703), (24.106, 54.668), (24.064, 54.618), (23.963, 54.647), (23.972, 54.584), (23.906, 54.541), (23.95, 54.515), (24.094, 54.548), (24.052, 54.49), (24.09, 54.383), (24.039, 54.373), (23.958, 54.206), (24.169, 54.161), (24.156, 54.09), (24.044, 54.078), (23.894, 53.956), (23.809, 53.945), (23.767, 53.892), (23.83, 53.817), (23.77, 53.708), (23.974, 53.644), (24.061, 53.537), (24.187, 53.52), (24.401, 53.424), (24.707, 53.439), (25.208, 53.572), (25.426, 53.683), (25.513, 53.787), (25.767, 53.883), (26.148, 53.75), (26.184, 53.675), (26.352, 53.589), (26.479, 53.582), (26.846, 53.407), (26.957, 53.454), (27.094, 53.355), (27.146, 53.446), (27.108, 53.498), (27.192, 53.559), (27.232, 53.685), (27.205, 53.736), (27.137, 53.755), (27.13, 53.803), (26.997, 53.807)], ]), ('Wilia', [ [(27.884, 54.68), (27.788, 54.653), (27.645, 54.669), (27.543, 54.601), (27.316, 54.562), (27.227, 54.507), (26.938, 54.496), (26.746, 54.414), (26.571, 54.43), (26.404, 54.524), (26.396, 54.565), (26.445, 54.585), (26.396, 54.64), (26.411, 54.66), (26.287, 54.692), (26.301, 54.756), (26.184, 54.78), (26.191, 54.811), (26.134, 54.838), (25.914, 54.787), (25.806, 54.86), (25.753, 54.825), (25.636, 54.907), (25.512, 54.832), (25.386, 54.855), (25.365, 54.763), (25.293, 54.735), (25.294, 54.692), (25.188, 54.657), (24.978, 54.742), (24.937, 54.777), (24.971, 54.804), (24.891, 54.796), (24.896, 54.845), (24.773, 54.914), (24.458, 55.015), (24.334, 55.099), (24.128, 55.058), (24.068, 54.986), (23.837, 54.908)], ]), ('Dwina', [ [(24.05, 57.071), (24.105, 56.957), (24.259, 56.873), (24.588, 56.812), (24.992, 56.62), (25.118, 56.624), (25.235, 56.586), (25.398, 56.631), (25.579, 56.576), (25.753, 56.62), (25.748, 56.566), (25.787, 56.53), (26.055, 56.451), (26.157, 56.38), (26.193, 56.226), (26.328, 56.163), (26.377, 55.998), (26.438, 55.915), (26.527, 55.866), (26.705, 55.928), (26.736, 55.888), (26.79, 55.915), (26.869, 55.893), (26.903, 55.921), (26.941, 55.88), (27.143, 55.893), (27.438, 55.799), (27.907, 55.824), (27.935, 55.712), (28.074, 55.661), (28.138, 55.59), (28.49, 55.531), (28.626, 55.558), (28.73, 55.496), (29.016, 55.42), (29.029, 55.398), (28.985, 55.365), (29.138, 55.256), (29.207, 55.267), (29.275, 55.222), (29.329, 55.133), (29.431, 55.132), (29.459, 55.051), (29.587, 55.126), (29.72, 55.113), (29.728, 55.178), (29.844, 55.214), (29.953, 55.168), (30.169, 55.173), (30.193, 55.198), (30.117, 55.242), (30.34, 55.318), (30.506, 55.412), (30.61, 55.436), (30.678, 55.409), (30.826, 55.465), (30.999, 55.48), (31.096, 55.522), (31.116, 55.585), (31.21, 55.631), (31.478, 55.66), (31.494, 55.712), (31.406, 55.831), (31.413, 55.89), (31.345, 55.964), (31.524, 56.064), (31.666, 56.072), (31.777, 56.038), (31.992, 56.071), (31.968, 56.168), (32.101, 56.203), (32.063, 56.353), (32.094, 56.363), (32.159, 56.52), (32.258, 56.562), (32.262, 56.634), (32.317, 56.701), (32.314, 56.767), (32.216, 56.844), (32.057, 56.867), (32.078, 56.908), (32.013, 56.865), (31.845, 56.95), (31.758, 56.936)], ]), ('Dniepr', [ [(30.273, 52.6), (30.16, 52.717), (30.109, 52.728), (30.128, 52.759), (30.056, 52.844), (30.076, 52.851), (30.056, 52.96), (30.029, 52.978), (30.049, 53.001), (30.011, 53.027), (30.117, 53.125), (30.199, 53.087), (30.275, 53.105), (30.296, 53.118), (30.261, 53.125), (30.248, 53.235), (30.379, 53.262), (30.344, 53.324), (30.371, 53.352), (30.321, 53.412), (30.317, 53.485), (30.256, 53.527), (30.254, 53.598), (30.29, 53.647), (30.296, 53.718), (30.261, 53.78), (30.282, 53.869), (30.448, 53.909), (30.384, 53.968), (30.371, 54.118), (30.275, 54.277), (30.296, 54.332), (30.392, 54.411), (30.412, 54.499), (30.487, 54.557), (30.574, 54.54), (30.766, 54.638), (31.194, 54.674), (31.334, 54.713), (31.49, 54.665), (31.719, 54.762), (32.016, 54.797), (32.141, 54.801), (32.211, 54.75), (32.276, 54.749), (32.323, 54.683), (32.462, 54.66), (32.551, 54.715), (32.547, 54.746), (32.661, 54.797), (32.646, 54.852), (32.74, 54.928), (32.99, 55.01), (33.277, 54.914), (33.406, 54.92), (33.39, 54.999), (33.442, 55.051), (33.504, 55.227), (33.559, 55.243), (33.593, 55.309), (33.558, 55.442), (33.582, 55.561), (33.742, 55.541), (33.793, 55.63), (33.907, 55.719), (33.769, 55.775), (33.845, 55.811), (33.735, 55.874)], ]), ('Bérézina', [ [(28.187, 55.247), (28.207, 55.198), (28.172, 55.138), (28.07, 55.06), (28.204, 54.88), (28.186, 54.707), (28.281, 54.608), (28.421, 54.551), (28.321, 54.406), (28.333, 54.349), (28.389, 54.283), (28.824, 54.068), (28.953, 53.954), (28.99, 53.868), (28.938, 53.76), (28.961, 53.706), (28.926, 53.631), (28.964, 53.56), (28.953, 53.47), (29.072, 53.28), (29.191, 53.235), (29.261, 53.161), (29.269, 53.004), (29.373, 52.957), (29.333, 52.915), (29.356, 52.869), (29.694, 52.668), (29.978, 52.6)], ]), ('Moskowa', [ [(35.312, 55.572), (35.312, 55.633), (35.377, 55.624), (35.407, 55.661), (35.603, 55.688), (36.035, 55.517), (36.093, 55.578), (36.233, 55.625), (36.542, 55.61), (36.688, 55.708), (37.109, 55.733), (37.396, 55.832), (37.617, 55.725), (37.683, 55.662), (37.768, 55.652), (37.932, 55.567), (38.132, 55.556), (38.318, 55.417), (38.468, 55.422), (38.585, 55.386), (38.714, 55.29), (38.75, 55.134), (38.869, 55.069)], ]), ] # Where each river's name goes: the point of its course nearest to these RIVER_LABELS = {"Niémen": (23.95, 54.45), "Wilia": (26.6, 54.75), "Dwina": (29.6, 55.75), "Dniepr": (31.2, 54.55), "Bérézina": (28.7, 54.0), "Moskowa": (36.9, 55.55)} # Two slips of the dataset, against Minard's scan CITY_SPELLINGS = {"Moiodexno": "Molodexno", "Malo-Jarosewii": "Malo-jarosewli"} # On a small map, labels that would overlap are left out: these go first MAIN_CITIES = ["Moscou", "Smolensk", "Wilna", "Kowno", "Witebsk", "Polotzk", "Studienska", "Mojaisk", "Orscha", "Minsk"] # Plot coordinates: x is the longitude shrunk by cos(55°), the east-west scale at these # latitudes; y is the latitude stretched by 1.2, Minard's own stretch north-south (measured # from his cities on the 1869 map). The map keeps these proportions at any size. X_SCALE = math.cos(math.radians(55.0)) Y_STRETCH = 1.2 # Minard's scale, 1 mm for 10,000 men: on his map, the 422,000 at the Niemen are 1.21 degrees # of longitude wide (161 pixels across his band on the 2003-pixel scan; his 100,000 before Moscow # are 38: the same scale). The band is in map units, so it zooms with the map. BAND_PER_MAN = 1.21 * X_SCALE / 422000.0 # The original's edges in longitude and latitude: at the intro's end, it slides onto the redrawing, # and the comparison shows it there. They are the paper of MINARD_PLACES. ORIGINAL_LON = (22.74, 37.70) ORIGINAL_LAT = (53.01, 56.43) ORIGINAL_SCAN = (2003, 955) # the large scan's pixels, where the places below were measured # Minard's places: where his map draws each point of his troops table (by its row), measured on the scan: # the centre of that point's band. His map bends the geography; the redrawing bends with it (MinardBend), so # his band's turns, its splits and his cities fall where he drew them. The detachments' first points are not # here: where they march with the main army, they lie along its north side, as in his band (stack_detachments). MINARD_PLACES = { 1: (118, 497), 2: (219, 472), 3: (357, 492), 4: (422, 473), 5: (551, 441), 6: (679, 409), 7: (744, 400), 8: (808, 393), 9: (937, 378), 10: (968, 373), 11: (1199, 456), 12: (1406, 379), 13: (1589, 210), 14: (1725, 253), 15: (1771, 229), 16: (1918, 158), # the advance, Kowno to Moscow 17: (1928, 188), 18: (1926, 207), 19: (1905, 343), 20: (1877, 337), 21: (1681, 298), 22: (1547, 355), 23: (1404, 435), 24: (1231, 496), 25: (1027, 535), 26: (870, 594), 27: (766, 616), 28: (741, 605), 29: (640, 549), 30: (563, 590), 31: (494, 571), 32: (322, 558), 33: (215, 565), 34: (172, 567), 35: (137, 569), # the retreat, back to Kowno 39: (515, 287), 40: (640, 300), 41: (797, 316), 42: (797, 316), 43: (878, 604), 44: (768, 627), 45: (745, 618), # the detachment to Polotsk, from its turn north of Wilna 48: (214, 242), 49: (214, 242), 50: (172, 567), 51: (137, 569), # the one north of Kowno, from its turn } # Three cities he writes along the retreat's first leg, south of Moscow, away from where the bend puts them MINARD_CITY_PLACES = {"Mojaisk": (1738, 287), "Tarantino": (1930, 292), "Malo-jarosewli": (1892, 362)} # Where his temperature table draws each reading (by its row), measured on the scan: his thin lines go up from # there to the band. The 24 October one has no line: where his curve reaches zero. MINARD_READINGS_X = {1: 1926.5, 2: 1780.0, 3: 1410.0, 4: 1227.5, 5: 876.5, 6: 751.5, 7: 637.5, 8: 555.0, 9: 321.0} BEND_REACH = 0.4 # map units: how far a measured place pulls the map around it (shorter ones fold it) COMPARE_FADE = 1.2 # seconds of the fade to Minard's original and back (the comparison toggle) PREVIEW_ALPHA = 0.2 # the march to the next card, drawn ahead of the army (0: none) CARD_MARKS = True # a numbered mark on the map where each card came HAND_DRAWN_PLAY = True # the Play button's triangle and bars drawn as by hand (a trial) # The original's parts, in its height: the map above 0.745, the temperature table below 0.75, whose rows # 0 and -30 degrees Réaumur are at 0.8105 and 0.928 (measured on the 2003 x 955 scan) ORIGINAL_MAP_BOTTOM = 0.745 ORIGINAL_TABLE = (0.75, 0.976) ORIGINAL_ZERO, ORIGINAL_MINUS_30 = 0.8105, 0.928 def lerp(a: float, b: float, t: float) -> float: return a + (b - a) * t def lerp_box(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float], k: float) -> Tuple[float, float, float, float]: return lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k), lerp(a[3], b[3], k) def smooth(t: float) -> float: t = min(max(t, 0.0), 1.0) return t * t * (3.0 - 2.0 * t) # --------------------------------------------------------------------------- # Colors: Minard's paper and ink, whatever the app's theme # --------------------------------------------------------------------------- MINARD_RED = "#8E3B2E" # Minard's red, darkened: the accents def hex4(h: str, alpha: float = 1.0) -> imgui.ImVec4: h = h.lstrip("#") return imgui.ImVec4(int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255, alpha) PAPER = hex4("#F1E9D8") INK = hex4("#2E2822") INK_SOFT = hex4("#2E2822", 0.55) ADVANCE = hex4("#D2A06F") # Minard's "red", as it prints: a warm tan RETREAT = hex4("#221E1A") COLD = hex4("#3B6E9E") HIGHLIGHT = hex4("#B03A2E") # the current card's mark on the map FRAME_RED = hex4("#A0564A") # the intro's frame on the original: Minard's red, faded, as a pencil's LINK_ON_PAPER = hex4("#2A5D8F") def u32(c: imgui.ImVec4, alpha: float = 1.0) -> int: return imgui.color_convert_float4_to_u32(imgui.ImVec4(c.x, c.y, c.z, c.w * alpha)) def text_halo(dl: imgui.ImDrawList, size: float, pos: ImVec2, col: int, text: str, halo: int) -> None: """Text with a halo, readable over the band: the halo is the text drawn around itself.""" r = max(1.0, em_size(0.07)) for dx, dy in ((-r, 0), (r, 0), (0, -r), (0, r), (-r, -r), (r, r), (-r, r), (r, -r)): dl.add_text(imgui.get_font(), size, ImVec2(pos.x + dx, pos.y + dy), halo, text) dl.add_text(imgui.get_font(), size, pos, col, text) def draw_hand_drawn(dl: imgui.ImDrawList, corners: List[Tuple[float, float]], wobble: float, col: int, seed: int = 0) -> None: """A polygon as drawn with a pen: each edge bends (each its own way, always the same for a seed), the tips are soft, and a second, lighter pass of the pen runs a little beside the first.""" n = len(corners) edges = [] for i in range(n): (x0, y0), (x1, y1) = corners[i], corners[(i + 1) % n] length = math.hypot(x1 - x0, y1 - y0) nx, ny = -(y1 - y0) / length, (x1 - x0) / length bend = wobble * 0.35 * math.sin(i * 2.1 + seed * 1.3 + 0.6) edges.append((x0, y0, x1, y1, nx, ny, bend)) def edge_points(e: Tuple[float, ...]) -> List[ImVec2]: x0, y0, x1, y1, nx, ny, bend = e out: List[ImVec2] = [] for j in range(8): t = j / 8 w = bend * math.sin(t * math.pi) out.append(ImVec2(lerp(x0, x1, t) + nx * w, lerp(y0, y1, t) + ny * w)) return out fill: List[imgui.ImVec2Like] = [q for e in edges for q in edge_points(e)] dl.add_concave_poly_filled(fill, col) # A thick outline: Dear ImGui 1.93 bevels a thick stroke's sharp corners, so the tips soften as a pen's dl.add_polyline(fill, col, max(1.0, wobble * 1.4), imgui.ImDrawFlags_.closed | imgui.ImDrawFlags_.aa_line_ends) # The pen's second pass, lighter, a little off dx, dy = wobble * 0.5 * math.cos(seed * 2.0 + 0.5), wobble * 0.5 * math.sin(seed * 2.0 + 0.5) retrace: List[imgui.ImVec2Like] = [ImVec2(q.x + dx, q.y + dy) for e in edges for q in edge_points(e)] dl.add_polyline(retrace, (col & 0x00FFFFFF) | (0x70 << 24), max(1.0, wobble * 0.5), imgui.ImDrawFlags_.closed | imgui.ImDrawFlags_.aa_line_ends) def draw_hand_drawn_frame(dl: imgui.ImDrawList, p0: ImVec2, p1: ImVec2, col: int, thickness: float) -> None: """A rectangle's outline as drawn with a pencil: four strokes, each a little bent and running past the corners, and a second, lighter pass a little off the first.""" corners = [(p0.x, p0.y), (p1.x, p0.y), (p1.x, p1.y), (p0.x, p1.y)] for pass_k, (shift, alpha, width) in enumerate(((0.0, 1.0, thickness), (thickness * 0.9, 0.5, thickness * 0.6))): for i in range(4): (x0, y0), (x1, y1) = corners[i], corners[(i + 1) % 4] length = math.hypot(x1 - x0, y1 - y0) if length < 1.0: continue ux, uy = (x1 - x0) / length, (y1 - y0) / length nx, ny = -uy, ux bend = min(thickness * 2.2, length * 0.02) * math.sin(i * 2.3 + pass_k * 1.1 + 0.7) over = min(thickness * 4.0, length * 0.08) * (0.6 + 0.4 * math.sin(i * 1.7 + pass_k)) pts: List[imgui.ImVec2Like] = [] for j in range(10): t = j / 9 along = lerp(-over, length + over * 0.5, t) w = bend * math.sin(t * math.pi) + shift pts.append(ImVec2(x0 + ux * along + nx * w, y0 + uy * along + ny * w)) stroke = (col & 0x00FFFFFF) | (int(((col >> 24) & 0xFF) * alpha) << 24) dl.add_polyline(pts, stroke, width, imgui.ImDrawFlags_.aa_line_ends) def draw_play_icon(dl: imgui.ImDrawList, c: ImVec2, r: float, playing: bool, alpha: float = 1.0) -> None: """The round Play button's drawing: a disc, ink to play, Minard's red to pause, with a triangle or two bars, drawn as by hand when HAND_DRAWN_PLAY.""" disc = u32(hex4(MINARD_RED) if playing else INK, alpha) k = r * 0.38 if playing: glyphs = [[(c.x + dx * r, c.y - k), (c.x + (dx + 0.3) * r, c.y - k), (c.x + (dx + 0.3) * r, c.y + k), (c.x + dx * r, c.y + k)] for dx in (-0.45, 0.15)] else: glyphs = [[(c.x - k * 0.7, c.y - k * 1.1), (c.x + k * 1.2, c.y), (c.x - k * 0.7, c.y + k * 1.1)]] dl.add_circle_filled(c, r, disc) if not HAND_DRAWN_PLAY: for g in glyphs: dl.add_convex_poly_filled([ImVec2(x, y) for x, y in g], u32(PAPER)) return # The glyphs: a little tilted, each its own way for seed, g in enumerate(glyphs): tilt = math.radians(-2.5 if not playing else (2.0 if seed == 0 else -1.5)) gc = (sum(x for x, _ in g) / len(g), sum(y for _, y in g) / len(g)) turned = [(gc[0] + (x - gc[0]) * math.cos(tilt) - (y - gc[1]) * math.sin(tilt), gc[1] + (x - gc[0]) * math.sin(tilt) + (y - gc[1]) * math.cos(tilt)) for x, y in g] draw_hand_drawn(dl, turned, r * 0.055, u32(PAPER, alpha), seed) def install_state() -> str: """How the page can be installed as an app, as index.html tells: "prompt" (the browser's own dialog: Android), "steps" (to show: an iPhone, an iPad), "installed", or "none" (a desktop, or not in a browser).""" if sys.platform == "emscripten": import js # Pyodide's bridge to the page state = getattr(js, "minardInstallState", None) if state is not None: return str(state()) return "none" def browser_language() -> str: """The browser's language, if the app has it (navigator.language: "fr", "fr-FR", "ru-RU"...): en, fr or ru.""" if sys.platform == "emscripten": import js code = str(js.navigator.language).lower()[:2] return code if code in ("fr", "ru") else "en" return "en" def tell_page_ready() -> None: """In a browser: the app shows something, the page's loading screen can go (index.html's minardAppReady).""" if sys.platform == "emscripten": import js ready = getattr(js, "minardAppReady", None) if ready is not None: ready() def page_holds() -> bool: """In a browser: the page still shows its loading screen over the app (its walker has not arrived, and the viewer did not press Start): the intro waits, so that nobody misses its first slides.""" if sys.platform == "emscripten": import js held = getattr(js, "minardHeld", None) return bool(held()) if held is not None else False return False def install_prompt() -> None: """The browser's install dialog (Android), kept by index.html.""" if sys.platform == "emscripten": import js js.minardInstall() def draw_magnifier(dl: imgui.ImDrawList, corner: ImVec2) -> Tuple[ImVec2, ImVec2]: """A magnifier in an image's bottom right corner, a visible cue for touch screens too. Returns its rectangle.""" icon = fa.ICON_FA_SEARCH_PLUS s = imgui.calc_text_size(icon) pad = em_size(0.3) c0, c1 = ImVec2(corner.x - s.x - 3 * pad, corner.y - s.y - 3 * pad), ImVec2(corner.x - pad, corner.y - pad) dl.add_rect_filled(c0, c1, u32(INK, 0.6), em_size(0.25)) dl.add_text(ImVec2(c0.x + pad, c0.y + pad), u32(PAPER), icon) return c0, c1 def text_rotated(dl: imgui.ImDrawList, size: float, center: ImVec2, angle: float, col: int, text: str, halo: int) -> None: """Text turned by an angle (radians) around its center: drawn flat, then its vertices turned.""" w = text_width(text, size) start = dl.vtx_buffer.size() text_halo(dl, size, ImVec2(center.x - w / 2, center.y - size / 2), col, text, halo) ca, sa = math.cos(angle), math.sin(angle) for i in range(start, dl.vtx_buffer.size()): v = dl.vtx_buffer[i] x, y = v.pos.x - center.x, v.pos.y - center.y v.pos = ImVec2(center.x + x * ca - y * sa, center.y + x * sa + y * ca) def draw_quickening(dl: imgui.ImDrawList, x0: float, x1: float, y: float, t0: float, t1: float, color: int, solid_end: bool = False) -> None: """A rule whose rhythm quickens, as on the loading page: dashes from long to short, gaps closing, over the part t0..t1 of the progression (0 at 1812, 1 at 2026); the end can be a short solid line.""" thickness = max(1.0, em_size(0.06)) x = x0 while x < x1: t = t0 + (t1 - t0) * (x - x0) / (x1 - x0) if solid_end and t > 0.94: dl.add_line(ImVec2(x, y), ImVec2(x1, y), color, thickness) break dash, gap = em_size(1.6) * 0.08 ** t, em_size(1.0) * 0.2 ** t dl.add_line(ImVec2(x, y), ImVec2(min(x + dash, x1), y), color, thickness) x += dash + gap def text_width(text: str, size: float, font: Optional[imgui.ImFont] = None) -> float: """The width of a text at a size, in the current font or another one.""" if font is None: return imgui.calc_text_size(text).x * size / imgui.get_font_size() imgui.push_font(font, size) w = imgui.calc_text_size(text).x imgui.pop_font() return w # --------------------------------------------------------------------------- # Wikimedia Commons # --------------------------------------------------------------------------- def commons_path(file_name: str) -> str: """A file's path on upload.wikimedia.org comes from the MD5 of its name.""" name = file_name.replace(" ", "_") h = hashlib.md5(name.encode("utf-8")).hexdigest() return f"{h[0]}/{h[:2]}/{urllib.parse.quote(name)}" def commons_thumb_url(file_name: str, width: int = 330) -> str: """A Commons thumbnail (330 and 960 px are standard Wikimedia thumbnail widths).""" q = urllib.parse.quote(file_name.replace(" ", "_")) return f"https://upload.wikimedia.org/wikipedia/commons/thumb/{commons_path(file_name)}/{width}px-{q}" def commons_file_url(file_name: str) -> str: return f"https://upload.wikimedia.org/wikipedia/commons/{commons_path(file_name)}" def commons_page_url(file_name: str) -> str: return "https://commons.wikimedia.org/wiki/File:" + urllib.parse.quote(file_name.replace(" ", "_")) @dataclass(frozen=True) class Image: key: str title: str file: str credit: str width: int = 330 full_size_large: bool = False # enlarged, the original file rather than a 960 px thumbnail url: str = "" # a file of our own (the making-of's pictures), the same one enlarged; else a Wikimedia file @property def large_url(self) -> str: if self.url: return self.url return commons_file_url(self.file) if self.full_size_large else commons_thumb_url(self.file, 960) def source(self) -> "Source": if self.url: return Source(self.key, self.title, self.url, "", self.credit) return Source(self.key, self.title, commons_thumb_url(self.file, self.width), commons_page_url(self.file), self.credit) MINARD_MAP = Image("img:minard", "Minard's original map", "Minard.png", "C. J. Minard, 1869, public domain", 960, full_size_large=True) NIEMEN = Image("img:niemen", "Crossing the Niemen", "Napoleon Neman.jpg", "Painting: unknown artist, 1810s, public domain") LITHUANIA = Image("img:lithuania", "Napoleon's army in Lithuania", "Albrecht Adam. Napoleon's Army in Rykantai, Lithuania, 1812.jpg", "A. Adam, who marched with the army, 1812, public domain") ULA = Image("img:ula", "A bivouac at Ula, 26 July", "Uła. Ула (C. Faber du Faur, 26.07.1812).jpg", "C. W. von Faber du Faur, who marched with the army, public domain") VITEBSK = Image("img:vitebsk", "Napoleon at Vitebsk", "Reception of General Friant, appointed colonel-commander of the foot grenadiers of the Imperial " "Guard, Vitebsk, 1812.jpg", "Engraving after H. Philippoteaux, c. 1867, public domain") SMOLENSK = Image("img:smolensk", "The battle of Smolensk", "Battle of Smolensk on 18 August 1812.jpg", "Painting: A. Adam, public domain") BORODINO = Image("img:borodino", "Napoleon near Borodino", "Vereshchagin Napoleon near Borodino.jpg", "Painting: V. Vereshchagin, 1897, public domain") DEPUTATION = Image("img:deputation", "Before Moscow, waiting for the deputation of the boyars", "Перед Москвой в ожидании депутации бояр.jpg", "Painting: V. Vereshchagin, 1891-1892, public domain") BURNING = Image("img:burning", "Napoleon in burning Moscow", "Napoleon in burning Moscow - Adam Albrecht (1841).jpg", "Painting: A. Adam, 1841, public domain") PEACE = Image("img:peace", "Peace at any cost!", "Peaceatallcosts.JPG", "Painting: V. Vereshchagin, 1899-1900, public domain") LEAVING = Image("img:leaving", "The Guard leaves Moscow", "Napoleon Moskau.jpg", "C. W. von Faber du Faur, who marched with the army, public domain") GORODNYA = Image("img:gorodnya", "In Gorodnya: break through or retreat?", "В Городне - пробиваться или отступать.jpg", "Painting: V. Vereshchagin, 1887-1895, public domain") SNOW = Image("img:snow", "Napoleon's retreat from Moscow", "Jerzy Kossak Napoleon's Retreat from Moscow.jpg", "Painting: J. Kossak, 1927, public domain") BIVOUAC = Image("img:bivouac", "Night bivouac of the Great Army", "Night Bivouac of Great Army.jpg", "Painting: V. Vereshchagin, 1896-1897, public domain") ROAD = Image("img:road", "On the high road: retreat, flight", "На большой дороге. Отступление, бегство.jpg", "Painting: V. Vereshchagin, 1887-1895, public domain") STANDARDS = Image("img:standards", "Burning the standards before Napoleon", "WKossak019.jpg", "Painting: W. Kossak, 1896, public domain") SMORGONI = Image("img:smorgoni", "Napoleon at Smorgoni", "Napoleon w smorgoniach.jpg", "Painting: Z. Rozwadowski, 1930, public domain") KOWNO = Image("img:kowno", "Marshal Ney at Kowno", "Louvre-peinture-francaise-Ney-a-Kowno-p1020309.jpg", "Painting: A. Raffet, 1839, Louvre, public domain") IMAGES = [MINARD_MAP, NIEMEN, LITHUANIA, ULA, VITEBSK, SMOLENSK, BORODINO, DEPUTATION, BURNING, PEACE, LEAVING, GORODNYA, SNOW, BIVOUAC, ROAD, STANDARDS, SMORGONI, KOWNO] FRENCH.update({ # the images' titles and credits "Minard's original map": "La carte originale de Minard", "C. J. Minard, 1869, public domain": "C. J. Minard, 1869, domaine public", "Crossing the Niemen": "Le passage du Niémen", "Painting: unknown artist, 1810s, public domain": "Peinture : artiste inconnu, années 1810, domaine public", "Napoleon's army in Lithuania": "L'armée de Napoléon en Lituanie", "A. Adam, who marched with the army, 1812, public domain": "A. Adam, qui marchait avec l'armée, 1812, domaine public", "A bivouac at Ula, 26 July": "Un bivouac à Oula, le 26 juillet", "C. W. von Faber du Faur, who marched with the army, public domain": "C. W. von Faber du Faur, qui marchait avec l'armée, domaine public", "Napoleon at Vitebsk": "Napoléon à Vitebsk", "Engraving after H. Philippoteaux, c. 1867, public domain": "Gravure d'après H. Philippoteaux, vers 1867, domaine public", "The battle of Smolensk": "La bataille de Smolensk", "Painting: A. Adam, public domain": "Peinture : A. Adam, domaine public", "Napoleon near Borodino": "Napoléon près de Borodino", "Painting: V. Vereshchagin, 1897, public domain": "Peinture : V. Verechtchaguine, 1897, domaine public", "Before Moscow, waiting for the deputation of the boyars": "Devant Moscou, en attendant la députation des boyards", "Painting: V. Vereshchagin, 1891-1892, public domain": "Peinture : V. Verechtchaguine, 1891-1892, domaine public", "Napoleon in burning Moscow": "Napoléon dans Moscou en flammes", "Painting: A. Adam, 1841, public domain": "Peinture : A. Adam, 1841, domaine public", "Peace at any cost!": "La paix à tout prix !", "Painting: V. Vereshchagin, 1899-1900, public domain": "Peinture : V. Verechtchaguine, 1899-1900, domaine public", "The Guard leaves Moscow": "La Garde quitte Moscou", "In Gorodnya: break through or retreat?": "À Gorodnia : percer, ou battre en retraite ?", "Painting: V. Vereshchagin, 1887-1895, public domain": "Peinture : V. Verechtchaguine, 1887-1895, domaine public", "Napoleon's retreat from Moscow": "La retraite de Moscou", "Painting: J. Kossak, 1927, public domain": "Peinture : J. Kossak, 1927, domaine public", "Night bivouac of the Great Army": "Bivouac de nuit de la Grande Armée", "Painting: V. Vereshchagin, 1896-1897, public domain": "Peinture : V. Verechtchaguine, 1896-1897, domaine public", "On the high road: retreat, flight": "Sur la grand-route : retraite, fuite", "Burning the standards before Napoleon": "On brûle les drapeaux devant Napoléon", "Painting: W. Kossak, 1896, public domain": "Peinture : W. Kossak, 1896, domaine public", "Napoleon at Smorgoni": "Napoléon à Smorgoni", "Painting: Z. Rozwadowski, 1930, public domain": "Peinture : Z. Rozwadowski, 1930, domaine public", "Marshal Ney at Kowno": "Le maréchal Ney à Kowno", "Painting: A. Raffet, 1839, Louvre, public domain": "Peinture : A. Raffet, 1839, Louvre, domaine public", }) FRENCH.update({f"{im.title}, large": f"{FRENCH[im.title]}, grande" for im in IMAGES}) # --------------------------------------------------------------------------- # Sources: free pages a viewer can open # --------------------------------------------------------------------------- LEGEND = ("Minard's legend", commons_page_url("Minard.png")) FRIENDLY_BIO = ("Michael Friendly, Minard's life", "https://www.datavis.ca/gallery/minard/biography.pdf") FRIENDLY_2002 = ("Michael Friendly, 2002", "https://datavis.ca/papers/jebs.pdf") MAREY = ("Marey, La méthode graphique, 1878", "https://archive.org/details/lamethodegraphiq00mare") TUFTE = ("Tufte's text, on Closeread", "https://closeread.dev/gallery/examples/minards-map/") NUMBERPHILE = ("Numberphile's video: The Greatest Ever Infographic", "https://www.youtube.com/watch?v=3T7jMcstxY0&cc_load_policy=1&cc_lang_pref=en") WHE = ("World History Encyclopedia", "https://www.worldhistory.org/Napoleon%27s_Invasion_of_Russia/") WHE_TIMELINE = ("World History Encyclopedia, timeline", "https://www.worldhistory.org/timeline/Napoleon%27s_Invasion_of_Russia") FITZ = ("Fitzwilliam Museum", "https://fitzmuseum.cam.ac.uk/explore-our-collection/highlights/context/" "stories-and-histories/napoleon-in-russia") DAYBYDAY = ("napoleon-empire.org", "https://www.napoleon-empire.org/en/battles/campaign-russia-1812-day-by-day.php") FN_ADVANCE = ("Fondation Napoléon: the advance", "https://www.napoleon.org/en/history-of-the-two-empires/timelines/" "napoleons-russian-campaign-from-the-niemen-to-moscow/") FN_RETREAT = ("Fondation Napoléon: the retreat", "https://www.napoleon.org/en/history-of-the-two-empires/timelines/" "napoleons-russian-campaign-the-retreat/") BULLETIN = ("The 29th Bulletin", "https://www.napoleon.org/histoire-des-2-empires/articles/" "29e-bulletin-de-la-grande-armee-molodetchna-3-decembre-1812/") EN_WIKI = "https://en.wikipedia.org/wiki/" FR_WIKI = "https://fr.wikipedia.org/wiki/" NAPOLEON_LETTER = ("Napoleon's letter of 20 September", "https://en.wikisource.org/wiki/Napoleon%27s_Addresses/Part_V") WIKI_INVASION = ("Wikipedia: French invasion of Russia", EN_WIKI + "French_invasion_of_Russia") WIKI_WARSAW = ("Wikipedia: Duchy of Warsaw", EN_WIKI + "Duchy_of_Warsaw") WIKI_SALTANOVKA = ("Wikipedia: Battle of Saltanovka", EN_WIKI + "Battle_of_Saltanovka") WIKI_POKLONNAYA = ("Wikipedia: Poklonnaya Hill", EN_WIKI + "Poklonnaya_Hill") WIKI_OCCUPATION = ("Wikipedia: French occupation of Moscow", EN_WIKI + "French_occupation_of_Moscow") WIKI_FIRE = ("Wikipedia: Fire of Moscow", EN_WIKI + "Fire_of_Moscow_%281812%29") WIKI_MALO = ("Wikipedia: Battle of Maloyaroslavets", EN_WIKI + "Battle_of_Maloyaroslavets") WIKI_VYAZMA = ("Wikipedia: Battle of Vyazma", EN_WIKI + "Battle_of_Vyazma") WIKI_KRASNOI = ("Wikipedia: Battle of Krasnoi", EN_WIKI + "Battle_of_Krasnoi") WIKI_DAVYDOV = ("Wikipedia: Denis Davydov", EN_WIKI + "Denis_Davydov") # The Context tab's WIKI_TILSIT = ("Wikipedia: Treaties of Tilsit", EN_WIKI + "Treaties_of_Tilsit") WIKI_CONTINENTAL = ("Wikipedia: Continental System", EN_WIKI + "Continental_System") WIKI_ALEXANDER = ("Wikipedia: Alexander I of Russia", EN_WIKI + "Alexander_I_of_Russia") WIKI_VIENNA = ("Wikipedia: Congress of Vienna", EN_WIKI + "Congress_of_Vienna") WIKI_SERFDOM = ("Wikipedia: Serfdom in Russia", EN_WIKI + "Serfdom_in_Russia") WIKI_CONSCRIPTION = ("Wikipedia: Conscription in the Russian Empire", EN_WIKI + "Conscription_in_the_Russian_Empire") WIKI_RU_PATRIOTIC = ("Wikipedia: the Patriotic War of 1812", "https://ru.wikipedia.org/wiki/Отечественная_война_1812_года") WIKI_WAR_AND_PEACE = ("Wikipedia: War and Peace", EN_WIKI + "War_and_Peace") WIKI_OVERTURE = ("Wikipedia: 1812 Overture", EN_WIKI + "1812_Overture") WIKI_CATHEDRAL = ("Wikipedia: Cathedral of Christ the Saviour", EN_WIKI + "Cathedral_of_Christ_the_Saviour") WIKI_ROUBAUD = ("Wikipedia: Franz Roubaud", EN_WIKI + "Franz_Roubaud") # --------------------------------------------------------------------------- # The intro: Minard's original, one point at a time # --------------------------------------------------------------------------- def page_language(url: str) -> str: """A page's language, as a tag: EN, FR, RU.""" for code in ("fr", "ru", "en"): if f"://{code}." in url: return code.upper() return PAGE_LANGUAGES.get(url) or RUSSIAN.get("languages", {}).get(url, "EN") def reference(name: str, url: str) -> str: """A reference in the reader's language first, with its language; then the same page in the other languages, where there is one: "Wikipedia: Battle of Krasnoi (EN), in French (FR), in Russian (RU)"; then a page on the same subject in French (in Russian, for a Russian reader), if any.""" pages = {page_language(url): url} for language, table in (("FR", FRENCH_PAGES), ("RU", RUSSIAN_PAGES)): if url in table: pages[language] = table[url] if Lang.code == "ru": reading, localized = RUSSIAN["reading"].get(url), RUSSIAN["localized"] else: reading, localized = FRENCH_READING.get(url), LOCALIZED_PAGES if Lang.code == "fr" else {} first = Lang.code.upper() links = [] for k, language in enumerate(sorted(pages, key=lambda lang: (lang != first, ["EN", "FR", "RU"].index(lang)))): page = localized.get(pages[language], pages[language]) # the video: subtitles in the reader's language label = tr(name) if k == 0 else tr({"EN": "in English", "FR": "in French", "RU": "in Russian"}[language]) links.append(f"[{label}]({page}) ({page_language(page)})") if reading is not None: links.append(f"[{tr(reading[0])}]({reading[1]}) ({page_language(reading[1])})") return ", ".join(links) def source_line(name: str, url: str) -> str: """A source's line under a text.""" return f"{tr('Source: ')}{reference(name, url)}" @dataclass(frozen=True) class Slide: title: str text: str frame: Tuple[float, float, float, float] # the part of the original to show, in its unit coordinates sources: Tuple[Tuple[str, str], ...] = () title_fr: str = "" text_fr: str = "" pan: bool = False # Minard's handwriting in the frame: the camera reads along it, from left to right words: str = "" # that handwriting, typed out as he wrote it words_en: str = "" # and in English def heading(self) -> str: return in_language("slides", self.title, "title", self.title, self.title_fr) def body(self) -> str: return in_language("slides", self.title, "text", self.text, self.text_fr) def words_markdown(self) -> str: """Minard's words: in French, as he wrote them; in English and in Russian, translated, then his own.""" if Lang.code == "fr": return f"*{self.words}*" translated = in_language("slides", self.title, "words", self.words_en, self.words) return f"*{translated}*\n\n{self.words}" # The intro's camera: at most this zoom on the part of the original a slide frames; a reading pan lasts this long INTRO_ZOOM = 3.0 PAN_SECONDS = 9.0 PAN_DELAY = 1.2 # the camera first arrives at the line's start def slide_camera(s: Slide, pan_t: float) -> Tuple[float, float, float, float]: """The part of the original the intro shows for a slide, in its unit coordinates: a square there, so the scan keeps its proportions. The frame with a margin, zoomed at most INTRO_ZOOM times; for a pan, the frame is read from its left to its right, as pan_t goes from 0 to 1.""" fx0, fy0, fx1, fy1 = s.frame size = min(1.0, max(1.0 / INTRO_ZOOM, 1.15 * max(fx1 - fx0, fy1 - fy0))) cx, cy = (fx0 + fx1) / 2, (fy0 + fy1) / 2 if s.pan: size, margin = 1.0 / INTRO_ZOOM, 0.02 cx = lerp(fx0 - margin + size / 2, fx1 + margin - size / 2, smooth(pan_t)) x0, y0 = min(max(cx - size / 2, 0.0), 1.0 - size), min(max(cy - size / 2, 0.0), 1.0 - size) return x0, y0, x0 + size, y0 + size INTRO = [ Slide("Carte figurative (a figurative map), 1869", # it opens with a hook, for a viewer who never heard of the map "Many call it the best statistical graphic ever drawn: one picture tells what became of Napoleon's army " "in Russia, in 1812. Charles Joseph Minard (1781-1870), a retired inspector general of France's civil " "engineering corps, drew it in November 1869, at 88.", (0.08, 0.01, 0.76, 0.246), (TUFTE, FRIENDLY_BIO), title_fr="Carte figurative, 1869", text_fr=( "La carte figurative de Minard, créée en 1869, est souvent reconnue comme le meilleur graphique " "statistique jamais dessiné : une seule image raconte ce que devint l'armée de Napoléon en Russie, en " "1812. Charles Joseph Minard (1781-1870), inspecteur général des Ponts et Chaussées en retraite, la " "dessine en novembre 1869, à 88 ans."), pan=True, words=( "Carte Figurative des pertes successives en hommes de l'Armée Française dans la campagne de Russie " "1812-1813. Dressée par M. Minard, Inspecteur Général des Ponts et Chaussées en retraite. Paris, le 20 " "Novembre 1869. Les nombres d'hommes présents sont représentés par les largeurs des zônes colorées à " "raison d'un millimètre pour dix mille hommes ; ils sont de plus écrits en travers des zônes. Le rouge " "désigne les hommes qui entrent en Russie, le noir ceux qui en sortent. Les renseignements qui ont servi " "à dresser la carte ont été puisés dans les ouvrages de M.M. Thiers, de Ségur, de Fezensac, de Chambray " "et le journal inédit de Jacob, pharmacien de l'Armée depuis le 28 Octobre."), words_en=( "Figurative map of the successive losses in men of the French Army in the Russian campaign, 1812-1813. " "Drawn by M. Minard, Inspector General of Bridges and Roads, retired. Paris, 20 November 1869. The " "numbers of men present are shown by the widths of the coloured zones, at one millimetre for ten " "thousand men; they are also written across the zones. Red stands for the men who enter Russia, black " "for those who leave it. The information used to draw the map was taken from the works of Thiers, " "Ségur, Fezensac and Chambray, and from the unpublished diary of Jacob, the Army's pharmacist since 28 " "October.")), Slide("Why he drew it", "His legend gives his aim: to let the eye judge better how the army shrank. He had seen war himself, as " "a young engineer at Antwerp in 1813. His son-in-law wrote that the map inspires \"bitter reflections on " "the human cost of the thirst for military glory\".", (0.08, 0.183, 0.76, 0.246), (LEGEND, FRIENDLY_BIO), # his two lines, between the lines around them title_fr="Pourquoi il l'a dessinée", text_fr=( "Sa légende donne son but : « pour mieux faire juger à l'œil la diminution de l'armée ». Jeune " "ingénieur, il avait lui-même vu la guerre, à Anvers en 1813. Son gendre écrit que la carte inspire " "d'amères réflexions sur le coût humain de la soif de gloire militaire."), pan=True, words=( "Pour mieux faire juger à l'œil la diminution de l'armée, j'ai supposé que les corps du Prince Jérôme " "et du Maréchal Davoust qui avaient été détachés sur Minsk et Mobilow et ont rejoint vers Orscha et " "Witebsk, avaient toujours marché avec l'armée."), words_en=( "To let the eye judge better how the army shrank, I have assumed that the corps of Prince Jérôme and of " "Marshal Davout, which had been detached toward Minsk and Mogilev and rejoined near Orsha and Vitebsk, " "had always marched with the army.")), Slide("The width is the number of men", "One millimetre for 10,000 men, says the legend. In June 1812, 422,000 cross the Niemen into Russia: " "the tan band (red, in Minard's words).", (0.03, 0.33, 0.20, 0.62), (LEGEND,), title_fr="La largeur, c'est le nombre d'hommes", text_fr=( "Un millimètre pour 10 000 hommes, dit la légende. En juin 1812, 422 000 hommes passent le Niémen et " "entrent en Russie : la bande beige, mais rouge dans les mots de Minard.")), Slide("A map: the army's position", "The band runs where the army marched, west to east, from the Niemen to Moscow: its position, " "longitude and latitude, is two more variables. The towns it passed and the rivers it crossed are named.", (0.02, 0.25, 0.995, 0.745), title_fr="Une carte : la position de l'armée", text_fr=( "La bande suit la marche de l'armée, d'ouest en est, du Niémen à Moscou : sa position, longitude et " "latitude, donne deux variables de plus. Les villes traversées et les rivières franchies sont " "nommées.")), Slide("100,000 reach Moscow", "The band thins all the way to Moscow, in September. Its color gives the direction: tan going in, " "black for the way back.", (0.80, 0.08, 0.995, 0.40), title_fr="100 000 hommes atteignent Moscou", text_fr=( "La bande s'amincit jusqu'à Moscou, en septembre. Sa couleur donne la direction : beige à l'aller, " "noire au retour.")), Slide("10,000 come back", "In December, the black band crosses the Niemen again: 10,000 men of the 422,000, by Minard's count.", (0.03, 0.55, 0.30, 0.66), (LEGEND,), title_fr="10 000 hommes reviennent", text_fr="En décembre, la bande noire repasse le Niémen : 10 000 hommes sur 422 000, selon Minard."), Slide("The cold, and the dates", "Below, the temperatures of the retreat, in degrees Réaumur, each dated and tied to the map by a thin " "line: down to -30° (-37 °C) on 6 December. They are the only dates of the campaign on the map.", (0.0, 0.745, 0.995, 0.975), title_fr="Le froid, et les dates", text_fr=( "En bas, les températures de la retraite, en degrés Réaumur, chacune datée et reliée à la carte par un " "fin trait : jusqu'à -30 °R, c'est-à-dire -37 °C, le 6 décembre. Ce sont les seules dates de la campagne sur la " "carte.")), Slide("Why it is famous", "Six variables in one picture: the army's size, its position (longitude and latitude), its direction, " "the temperature and the dates. In 1878, the physiologist Étienne-Jules Marey wrote that its \"brutal " "eloquence\" seems to defy the historian's pen. In 1983, Edward Tufte: \"It may well be the best " "statistical graphic ever drawn.\"", (0.0, 0.0, 1.0, 1.0), (MAREY, TUFTE, NUMBERPHILE), title_fr="Pourquoi cette carte est célèbre", text_fr=( "Six variables en une seule image : la taille de l'armée, sa position (longitude et latitude), sa " "direction, la température et les dates. En 1878, le physiologiste Étienne-Jules Marey écrit que sa " "« brutale éloquence » semble défier la plume de l'historien. En 1983, Edward Tufte : « It may well " "be the best statistical graphic ever drawn » (c'est peut-être le meilleur graphique statistique " "jamais dessiné).")), Slide("Now, with time", "Minard's map has almost no dates. This app adds them, and replays the campaign day by day.", (0.0, 0.0, 1.0, 1.0), title_fr="Maintenant, avec le temps", text_fr=( "La carte de Minard n'a presque pas de dates. Cette application les ajoute, et rejoue la campagne " "jour après jour.")), ] # --------------------------------------------------------------------------- # The story: one card per moment, each fact with its source # --------------------------------------------------------------------------- @dataclass(frozen=True) class Event: start: int # the day the card comes; it stays until the next one title: str text: str sources: Tuple[Tuple[str, str], ...] image: Optional[Image] = None title_fr: str = "" text_fr: str = "" def heading(self) -> str: return in_language("events", self.title, "title", self.title, self.title_fr) def body(self) -> str: return in_language("events", self.title, "text", self.text, self.text_fr) def markdown(self) -> str: parts = [f"**{self.heading()}**", self.body()] parts += [source_line(name, url) for name, url in self.sources] return "\n\n".join(parts) def reading_seconds(self) -> float: """How long playback stops at the card: enough to read it, at 4.5 words a second (the mouse on the card holds it longer).""" words = len(self.heading().split()) + len(self.body().split()) return min(10.0, max(4.0, 1.5 + words / 4.5)) EVENTS = [ # The advance Event(0, "24 June: across the Niemen", f"In Minard's figures, 422,000 men cross the [Niemen]({EN_WIKI}Neman) into Russia at " f"[Kowno]({EN_WIKI}Kaunas): the main army and two detachments. Fewer than half of Napoleon's soldiers " "are French: Poles, Germans, Italians, Swiss and others serve with them, from some twenty nations.", (LEGEND, WHE, FITZ), NIEMEN, title_fr="24 juin : le passage du Niémen", text_fr=( f"Selon Minard, 422 000 hommes passent le [Niémen]({FR_WIKI}Niémen) et entrent en Russie à " f"[Kowno]({FR_WIKI}Kaunas) : l'armée principale et deux détachements. Moins de la moitié des " "soldats de Napoléon sont français : des Polonais, des Allemands, des Italiens, des Suisses et " "d'autres servent avec eux, d'une vingtaine de nations.")), Event(day_of(6, 28), "28 June: Vilna", f"The army takes [Vilna]({EN_WIKI}Vilnius) without a fight. Napoleon stays there until 16 July, while " "the Russian armies fall back to the east. The same day, the Poles of the " f"[Duchy of Warsaw]({EN_WIKI}Duchy_of_Warsaw) proclaim their kingdom restored; Napoleon does not " "commit himself.", (DAYBYDAY, FN_ADVANCE, WIKI_WARSAW), LITHUANIA, title_fr="28 juin : Vilna", text_fr=( f"L'armée prend [Vilna]({FR_WIKI}Vilnius) sans combat. Napoléon y reste jusqu'au 16 juillet, " "tandis que les armées russes se replient vers l'est. Le même jour, les Polonais du " f"[duché de Varsovie]({FR_WIKI}Duché_de_Varsovie) proclament le rétablissement de leur royaume ; " "Napoléon ne s'engage pas.")), Event(day_of(7, 8), "July: the losses before the battles", f"Heat, storms, [typhus]({EN_WIKI}Epidemic_typhus), dysentery and desertion: before its first big " "battle, the army has lost about 100,000 men. Minard's main band falls from 340,000 at the Niemen to " "175,000 at Vitebsk.", (WIKI_INVASION, WHE), title_fr="Juillet : les pertes avant les batailles", text_fr=( f"Chaleur, orages, [typhus]({FR_WIKI}Typhus_exanthématique), dysenterie et désertion : avant sa " "première grande bataille, l'armée a perdu environ 100 000 hommes. La bande principale de Minard " "passe de 340 000 hommes au Niémen à 175 000 à Vitebsk.")), Event(day_of(7, 17), "17 July: the Russians keep retreating", "They leave their camp at Drissa and fall back again, destroying crops, bridges and depots behind them, " f"and avoiding open battle: [scorched earth]({EN_WIKI}Scorched_earth).", (FN_ADVANCE, WHE), ULA, title_fr="17 juillet : les Russes reculent encore", text_fr=( "Ils quittent leur camp de Drissa et reculent de nouveau, en détruisant derrière eux récoltes, " "ponts et dépôts, et en évitant la bataille rangée : la " f"[terre brûlée]({FR_WIKI}Politique_de_la_terre_brûlée).")), Event(day_of(7, 23), "23 July: Mogilev, and Minard's simplification", f"In the south, [Davout]({EN_WIKI}Louis-Nicolas_Davout) stops the army of " f"[Bagration]({EN_WIKI}Pyotr_Bagration) at Saltanovka: it cannot join " f"[Barclay]({EN_WIKI}Michael_Andreas_Barclay_de_Tolly)'s at Vitebsk. Minard does not draw their " "detour: he makes them march with the army.", (WIKI_SALTANOVKA, LEGEND), title_fr="23 juillet : Mohilev, et la simplification de Minard", text_fr=( f"Au sud, [Davout]({FR_WIKI}Louis_Nicolas_Davout) arrête l'armée de " f"[Bagration]({FR_WIKI}Piotr_Ivanovitch_Bagration) à Saltanovka : elle ne peut rejoindre celle de " f"[Barclay]({FR_WIKI}Michel_Barclay_de_Tolly) à Vitebsk. Minard ne dessine pas leur " "détour : il les fait marcher avec l'armée.")), Event(day_of(7, 28), "28 July: Vitebsk", f"After a first big clash at [Ostrovno]({EN_WIKI}Battle_of_Ostrovno), the Russians slip away again. " "In heat of about 30 °C, Napoleon rests his exhausted troops, and thinks of ending the year's campaign " "here.", (FN_ADVANCE,), VITEBSK, title_fr="28 juillet : Vitebsk", text_fr=( f"Après un premier grand choc à [Ostrovno]({FR_WIKI}Bataille_d%27Ostrovno), les Russes se dérobent " "encore. Par une chaleur d'environ 30 °C, Napoléon fait reposer ses troupes épuisées, et pense " "terminer là la campagne de l'année.")), Event(day_of(8, 15), "16-18 August: Smolensk", f"Napoleon takes the burning city after [a two-day battle]({EN_WIKI}Battle_of_Smolensk_%281812%29), but " "the Russian army withdraws once more. In the north, the detachment of Minard's upper band fights its " f"own battle, at [Polotsk]({EN_WIKI}First_Battle_of_Polotsk).", (FN_ADVANCE,), SMOLENSK, title_fr="16-18 août : Smolensk", text_fr=( f"Napoléon prend la ville en flammes après [deux jours de bataille]({FR_WIKI}" "Bataille_de_Smolensk_%281812%29), mais l'armée russe se retire une fois de plus. Au nord, le " "détachement de la bande supérieure de Minard livre sa propre bataille, à " f"[Polotsk]({FR_WIKI}Première_bataille_de_Polotsk).")), Event(day_of(8, 29), "29 August: Kutuzov", f"[Kutuzov]({EN_WIKI}Mikhail_Kutuzov) takes command of the retreating Russian armies. Nine days later, " "he gives battle.", (FN_ADVANCE, DAYBYDAY), title_fr="29 août : Koutouzov", text_fr=( f"[Koutouzov]({FR_WIKI}Mikhaïl_Koutouzov) prend le commandement des armées russes en retraite. " "Neuf jours plus tard, il livre bataille.")), Event(day_of(9, 4), "7 September: Borodino", f"The two armies finally meet at [Borodino]({EN_WIKI}Battle_of_Borodino), 72 miles west of Moscow: " "about 133,000 men under Napoleon against 120,000 under the Tsar. Around the battle, Minard's band " "falls from 127,000 to 100,000: his sources count differently.", (WHE_TIMELINE, FITZ), BORODINO, title_fr="7 septembre : Borodino", text_fr=( f"Les deux armées se rencontrent enfin à Borodino ([la Moskova]({FR_WIKI}Bataille_de_la_Moskova), " "pour les Français), à 115 km à l'ouest de Moscou : environ 133 000 hommes sous Napoléon, contre " "120 000 sous le tsar. Autour de la bataille, la bande de Minard passe de 127 000 à 100 000 " "hommes : ses sources ne comptent pas de la même façon.")), Event(day_of(9, 14), "14 September: Moscow", f"On [Poklonnaya Hill]({EN_WIKI}Poklonnaya_Hill), outside the city, Napoleon waits in vain for the keys " f"of the Kremlin: Moscow is almost deserted. His troops [occupy it]({EN_WIKI}French_occupation_of_Moscow)" " that day; he enters on the 15th.", (WIKI_POKLONNAYA, FN_ADVANCE), DEPUTATION, title_fr="14 septembre : Moscou", text_fr=( f"Sur le [mont Poklonnaïa]({FR_WIKI}Mont_Poklonnaïa), devant la ville, Napoléon attend en vain les " f"clés du Kremlin : Moscou est presque déserte. Ses troupes [l'occupent]({FR_WIKI}Prise_de_Moscou) " "ce jour-là ; lui y entre le 15.")), Event(day_of(9, 16), "14-18 September: the fire", "Fires break out across the city and burn for days, destroying about three quarters of it. On the 16th, " "Napoleon has to flee the Kremlin for the Petrovsky Palace; he comes back on the 18th.", (WIKI_FIRE, FN_ADVANCE), BURNING, title_fr="14-18 septembre : l'incendie", text_fr=( "Des incendies éclatent dans toute la ville et brûlent des jours durant ; ils en détruisent environ " "les trois quarts. Le 16, Napoléon doit fuir le Kremlin pour le palais Petrovski ; il y revient le " "18.")), Event(day_of(9, 20), "Five weeks in Moscow", "On 20 September, Napoleon writes to Tsar Alexander, blaming the fire on Moscow's governor, " f"[Rostopchin]({EN_WIKI}Fyodor_Rostopchin). On 4 October he sends " f"[Lauriston]({EN_WIKI}Jacques_Lauriston) to Kutuzov, who refuses to negotiate. No answer ever comes.", (NAPOLEON_LETTER, WIKI_OCCUPATION, FN_ADVANCE), PEACE, title_fr="Cinq semaines à Moscou", text_fr=( "Le 20 septembre, Napoléon écrit au tsar Alexandre, et accuse de l'incendie le gouverneur de " f"Moscou, [Rostopchine]({FR_WIKI}Fédor_Rostopchine). Le 4 octobre, il envoie " f"[Lauriston]({FR_WIKI}Jacques_Alexandre_Law_de_Lauriston) auprès de Koutouzov, qui refuse de " "négocier. Aucune réponse ne viendra.")), Event(day_of(10, 5), "The Russian side", "In Russia, this is the Patriotic War of 1812. Peasants and Cossacks harry the invaders, and partisans " f"such as [Denis Davydov]({EN_WIKI}Denis_Davydov) and [Alexander Figner]({EN_WIKI}Aleksandr_Figner) " "raid their supply trains. Along the route, civilians die as much as soldiers, perhaps more.", (WIKI_INVASION, WIKI_DAVYDOV), title_fr="Le côté russe", text_fr=( "En Russie, c'est la guerre patriotique de 1812. Paysans et cosaques harcèlent les envahisseurs, " f"et des partisans comme [Denis Davydov]({FR_WIKI}Denis_Davydov) et " f"[Alexandre Figner]({FR_WIKI}Alexandre_Samoïlovitch_Figner) attaquent leurs convois. Le long de la " "route, les civils meurent autant que les soldats, peut-être plus.")), # The retreat Event(day_of(10, 18), "18-19 October: the retreat", f"On the 18th, Kutuzov beats Murat at [Vinkovo]({EN_WIKI}Battle_of_Tarutino), and Napoleon decides to " "leave. The army leaves Moscow on the 19th (Minard's temperatures start on the 18th).", (DAYBYDAY, FN_RETREAT), LEAVING, title_fr="18-19 octobre : la retraite", text_fr=( f"Le 18, Koutouzov bat Murat à [Vinkovo]({FR_WIKI}Bataille_de_Winkowo), et Napoléon décide de " "partir. L'armée quitte Moscou le 19 (les températures de Minard commencent le 18).")), Event(day_of(10, 24), "24 October: Maloyaroslavets", f"[The town]({EN_WIKI}Battle_of_Maloyaroslavets) changes hands eight times. The French keep it, but " "Kutuzov bars the road to the south: the army must go back by the road it stripped bare on the way in.", (WIKI_MALO,), GORODNYA, title_fr="24 octobre : Maloïaroslavets", text_fr=( f"[La ville]({FR_WIKI}Bataille_de_Maloïaroslavets) change huit fois de mains. Les Français la " "gardent, mais Koutouzov barre la route du sud : l'armée doit revenir par la route qu'elle a " "dévastée à l'aller.")), Event(day_of(11, 3), "3 November: Vyazma", f"[Miloradovich]({EN_WIKI}Mikhail_Miloradovich)'s troops attack the rear guard and inflict heavy " "losses.", (WIKI_VYAZMA,), title_fr="3 novembre : Viazma", text_fr=( f"Les troupes de [Miloradovitch]({FR_WIKI}Mikhaïl_Andreïevitch_Miloradovitch) attaquent " "l'arrière-garde et lui infligent de lourdes pertes.")), Event(day_of(11, 6), "6 November: winter", f"A blizzard: [winter]({EN_WIKI}Russian_Winter) has come (Napoleon's own bulletin dates the cold from " "the 7th). In a few days, more than 30,000 horses die: cavalry and guns are left without them.", (WIKI_INVASION, BULLETIN), SNOW, title_fr="6 novembre : l'hiver", text_fr=( f"Une tempête de neige : [l'hiver]({FR_WIKI}Général_Hiver) est là (le bulletin de Napoléon fait " "commencer le froid le 7). En quelques jours, plus de 30 000 chevaux meurent : la cavalerie et les " "canons en sont privés.")), Event(day_of(11, 9), "9 November: Smolensk again", "Napoleon reaches Smolensk. Figures differ: about 60,000 fighting men for one source, 37,000 in " "Minard's main band. On 14 November, Minard notes -21 degrees Réaumur (-26 °C).", (WHE_TIMELINE, LEGEND), BIVOUAC, title_fr="9 novembre : Smolensk de nouveau", text_fr=( "Napoléon atteint Smolensk. Les chiffres diffèrent : environ 60 000 combattants selon une source, " "37 000 dans la bande principale de Minard. Le 14 novembre, Minard note -21 degrés Réaumur " "(-26 °C).")), Event(day_of(11, 16), "15-18 November: Krasnoi", f"Kutuzov strikes the strung-out columns at [Krasnoi]({EN_WIKI}Battle_of_Krasnoi) and takes about " f"26,500 prisoners. Cut off, [Ney]({EN_WIKI}Michel_Ney)'s rear guard escapes across the ice of the " f"[Dnieper]({EN_WIKI}Dnieper).", (WIKI_KRASNOI,), ROAD, title_fr="15-18 novembre : Krasnoï", text_fr=( f"Koutouzov frappe les colonnes étirées à [Krasnoï]({FR_WIKI}Bataille_de_Krasnoï) et fait environ " f"26 500 prisonniers. Coupée du reste, l'arrière-garde de [Ney]({FR_WIKI}Michel_Ney) s'échappe sur " f"la glace du [Dniepr]({FR_WIKI}Dniepr).")), Event(day_of(11, 25), "26-29 November: the Berezina", f"Harried by Russian armies, the remnants [cross the river]({EN_WIKI}Battle_of_Berezina) on bridges " f"built by [Eblé]({EN_WIKI}Jean_Baptiste_Eblé)'s pontoneers, and narrowly escape destruction.", (WHE_TIMELINE,), STANDARDS, title_fr="26-29 novembre : la Bérézina", text_fr=( f"Harcelés par les armées russes, les survivants [passent la rivière]({FR_WIKI}" f"Bataille_de_la_Bérézina) sur les ponts des pontonniers d'[Éblé]({FR_WIKI}Jean-Baptiste_Éblé), et " "échappent de peu à la destruction.")), Event(day_of(12, 3), "3 December: the 29th Bulletin", "At Molodechno, Napoleon admits the disaster in his 29th Bulletin, read in Paris two weeks later. It " "ends: \"His Majesty's health has never been better.\"", (BULLETIN, FN_RETREAT), title_fr="3 décembre : le 29e Bulletin", text_fr=( f"À Molodetchno, Napoléon reconnaît le désastre dans son [29e Bulletin]({FR_WIKI}" "29e_Bulletin_de_la_Grande_Armée), lu à Paris deux semaines plus tard. Il se termine ainsi : « La " "santé de Sa Majesté n'a jamais été meilleure. »")), Event(day_of(12, 5), "5 December: Napoleon leaves", f"At [Smorgoni]({EN_WIKI}Smarhon), Napoleon leaves the army by sleigh. He is in Paris on 18 December. On " "the 7th, at Vilna, Minard records -26 degrees Réaumur: -32 °C.", (FN_RETREAT, LEGEND), SMORGONI, title_fr="5 décembre : Napoléon s'en va", text_fr=( f"À [Smorgoni]({FR_WIKI}Smarhon), Napoléon quitte l'armée en traîneau. Il est à Paris le 18 " "décembre. Le 7, à Vilna, Minard relève -26 degrés Réaumur : -32 °C.")), Event(day_of(12, 12), "14 December: back across the Niemen", f"[Ney]({EN_WIKI}Michel_Ney) leads the rear guard back across the river with barely a thousand men. In " "Minard's figures, about 10,000 of the 422,000 return. Counting both sides, soldiers and civilians, the " "campaign caused about a million deaths.", (WIKI_INVASION, LEGEND, WHE_TIMELINE), KOWNO, title_fr="14 décembre : le Niémen repassé", text_fr=( f"[Ney]({FR_WIKI}Michel_Ney) ramène l'arrière-garde de l'autre côté du fleuve, avec à peine un " "millier d'hommes. Selon Minard, environ 10 000 des 422 000 reviennent. En comptant les deux camps, " "soldats et civils, la campagne a fait environ un million de morts.")), Event(LAST_DAY, "Why Minard drew it", "His legend says: to let the eye judge how the army shrank. His son-in-law wrote that the map " "\"inspires bitter reflections on the human cost of the thirst for military glory\". Michael Friendly " "reads it as a statement against war.", (LEGEND, FRIENDLY_BIO, FRIENDLY_2002), MINARD_MAP, title_fr="Pourquoi Minard l'a dessinée", text_fr=( "Sa légende le dit : « pour mieux faire juger à l'œil la diminution de l'armée ». Son gendre écrit " "qu'elle inspire d'amères réflexions sur le coût humain de la soif de gloire militaire. Michael " "Friendly y lit une prise de position contre la guerre.")), ] @dataclass(frozen=True) class Section: """A section of the Context tab.""" title: str text: str sources: Tuple[Tuple[str, str], ...] title_fr: str = "" text_fr: str = "" def heading(self) -> str: return in_language("sections", self.title, "title", self.title, self.title_fr) def body(self) -> str: return in_language("sections", self.title, "text", self.text, self.text_fr) CONTEXT = [ Section("Europe in 1812", "Napoleon rules or dominates most of the continent. At " f"[Tilsit]({EN_WIKI}Treaties_of_Tilsit), on 25 June 1807, he and Tsar Alexander met on a raft in the " "middle of the Niemen; in July they made peace, and Russia joined his " f"[Continental System]({EN_WIKI}Continental_System), a blockade of British trade. On 31 December " "1810, a ukase of Alexander reopened Russia's ports to neutral ships and taxed French goods: a way out " "of the blockade, without a declared break. The usual account of 1812: Napoleon invades to force Russia back into the " "blockade. He also called the campaign the \"Second Polish War\", to win over the Poles, without " "committing himself to restoring Poland.", (WIKI_TILSIT, WIKI_CONTINENTAL, WIKI_INVASION, WIKI_WARSAW), title_fr="L'Europe en 1812", text_fr=( "Napoléon domine la plus grande partie du continent. À " f"[Tilsit]({FR_WIKI}Traités_de_Tilsit), le 25 juin 1807, il rencontre le tsar Alexandre sur un " "radeau au milieu du Niémen ; en juillet, ils font la paix, et la Russie rejoint son " f"[Blocus continental]({FR_WIKI}Blocus_continental) contre le commerce britannique. Le 31 " "décembre 1810, un oukase d'Alexandre rouvre les ports russes aux navires neutres et taxe les " "produits français : une sortie du blocus, sans rupture déclarée. Le récit habituel de 1812 : Napoléon envahit la Russie " "pour la forcer à revenir au blocus. Il parle aussi de « seconde guerre de Pologne », pour rallier " "les Polonais, sans s'engager à rétablir la Pologne.")), Section("Tsar Alexander I", f"[Alexander I]({EN_WIKI}Alexander_I_of_Russia) (1777-1825) became emperor in 1801, when his father " "was murdered. The loss of Moscow did not make him sue for peace, and Kutuzov turned Napoleon's envoy " "away. With his allies, he beat Napoleon at Leipzig in October 1813, and on 31 March 1814 he rode into " f"[Paris]({EN_WIKI}Battle_of_Paris_%281814%29) at their head. He then took part in the " f"[Congress of Vienna]({EN_WIKI}Congress_of_Vienna), and in 1815 he also became King of Poland.", (WIKI_ALEXANDER, WIKI_OCCUPATION, WIKI_VIENNA), title_fr="Le tsar Alexandre Ier", text_fr=( f"[Alexandre Ier]({FR_WIKI}Alexandre_Ier_%28empereur_de_Russie%29) (1777-1825) devient empereur " "en 1801, à l'assassinat de son père. La perte de Moscou ne le fait pas demander la paix, et " "Koutouzov éconduit l'envoyé de Napoléon. Avec ses alliés, il bat Napoléon à Leipzig en octobre " f"1813, et le 31 mars 1814 il entre dans [Paris]({FR_WIKI}Bataille_de_Paris_%281814%29) à leur " f"tête. Il prend ensuite part au [congrès de Vienne]({FR_WIKI}Congrès_de_Vienne), et devient en " "1815 roi de Pologne.")), Section("Russia in 1812", "Most Russians are peasants, and many of them are " f"[serfs]({EN_WIKI}Serfdom_in_Russia), bound to the land and to their lords: as late as 1857, private " "serfs were 23 million of 62.5 million people. The soldiers are " f"[conscripts]({EN_WIKI}Conscription_in_the_Russian_Empire) taken from these peasants, for 25 years. " "1812 saw three levies, and the nobles raised a militia of over 400,000 men from their serfs. The " "officers come mostly from the nobility.", (WIKI_SERFDOM, WIKI_CONSCRIPTION, WIKI_RU_PATRIOTIC, WIKI_INVASION), title_fr="La Russie en 1812", text_fr=( "La plupart des Russes sont des paysans, et beaucoup d'entre eux sont des " f"[serfs]({FR_WIKI}Servage_en_Russie), attachés à la terre et à leurs seigneurs : en 1857 " "encore, les serfs privés étaient 23 millions sur 62,5 millions d'habitants. Les soldats sont des " "conscrits pris parmi ces paysans, pour 25 ans. L'année 1812 voit trois levées, et les nobles " "forment avec leurs serfs une milice de plus de 400 000 hommes. Les officiers viennent surtout de " "la noblesse.")), Section("1812 in Russian culture", f"Tolstoy's [War and Peace]({EN_WIKI}War_and_Peace) tells of 1812; the complete novel appeared in 1869, " f"the year of Minard's map. Tchaikovsky wrote his [1812 Overture]({EN_WIKI}1812_Overture) in 1880; it " "was first played in Moscow in 1882, by the " f"[Cathedral of Christ the Saviour]({EN_WIKI}Cathedral_of_Christ_the_Saviour), then being finished. " "Alexander had vowed that cathedral in December 1812: consecrated in 1883, it was dynamited in 1931 and " f"rebuilt by 2000. For the centenary of 1912, [Franz Roubaud]({EN_WIKI}Franz_Roubaud) painted the " "Borodino Panorama, still shown in Moscow.", (WIKI_WAR_AND_PEACE, WIKI_OVERTURE, WIKI_CATHEDRAL, WIKI_ROUBAUD), title_fr="1812 dans la culture russe", text_fr=( f"[Guerre et Paix]({FR_WIKI}Guerre_et_Paix), de Tolstoï, raconte 1812 ; le roman complet paraît " "en 1869, l'année de la carte de Minard. Tchaïkovski écrit son " f"[Ouverture 1812]({FR_WIKI}Ouverture_solennelle_1812) en 1880 ; elle est jouée pour la première " "fois à Moscou en 1882, près de la " f"[cathédrale du Christ-Sauveur]({FR_WIKI}Cathédrale_du_Christ-Sauveur_de_Moscou), alors en " "achèvement. Alexandre avait fait vœu de la construire en décembre 1812 : consacrée en 1883, elle " "est dynamitée en 1931 et reconstruite en 2000. Pour le centenaire de 1912, " f"[Franz Roubaud]({FR_WIKI}Franz_Roubaud) peint le panorama de Borodino, toujours exposé à " "Moscou.")), Section("The cost", "About a million dead, soldiers and civilians, roughly as many on each side; along the route, " "civilians died as much as soldiers, perhaps more. Estimates of the Grande Armée run from 400,000 to 685,000 men: about " "380,000 of them died, went missing or were taken prisoner, and disease caused about half of these " "losses. Of some 110,000 prisoners counted by the Russians, more than half had died by February 1813. " "Minard counts 10,000 men back across the Niemen; historians count more survivors, up to about " "120,000, depending on whom they count.", (WIKI_INVASION,), title_fr="Le bilan", text_fr=( "Environ un million de morts, soldats et civils, à peu près autant de chaque côté ; le long de la " "route, les civils meurent autant que les soldats, peut-être plus. Les estimations de la Grande Armée " "vont de 400 000 " "à 685 000 hommes : environ 380 000 d'entre eux sont morts, disparus ou prisonniers, et la maladie " "cause près de la moitié de ces pertes. Sur quelque 110 000 prisonniers comptés par les Russes, " "plus de la moitié sont morts en février 1813. Minard compte 10 000 hommes qui repassent le " "Niémen ; les historiens comptent plus de survivants, jusqu'à 120 000 environ, selon qui ils " "comptent.")), ] FRENCH.update({"The twenty-four steps": "Les vingt-quatre étapes"}) FRENCH.update({ # the sources' names "Minard's legend": "La légende de Minard", "Michael Friendly, Minard's life": "Michael Friendly, la vie de Minard", "Marey, La méthode graphique, 1878": "Marey, La méthode graphique, 1878", "Tufte's text, on Closeread": "Le texte de Tufte, sur Closeread", "World History Encyclopedia, timeline": "World History Encyclopedia, chronologie", "Fondation Napoléon: the advance": "Fondation Napoléon : l'avance", "Fondation Napoléon: the retreat": "Fondation Napoléon : la retraite", "The 29th Bulletin": "Le 29e Bulletin", "Wikipedia: French invasion of Russia": "Wikipédia : la campagne de Russie", "Wikipedia: Battle of Saltanovka": "Wikipédia : la bataille de Moguilev", "Wikipedia: Poklonnaya Hill": "Wikipédia : le mont Poklonnaïa", "Wikipedia: Duchy of Warsaw": "Wikipédia : le duché de Varsovie", "Wikipedia: French occupation of Moscow": "Wikipédia : la prise de Moscou", "Napoleon's letter of 20 September": "La lettre de Napoléon du 20 septembre", "Wikipedia: Treaties of Tilsit": "Wikipédia : les traités de Tilsit", "Wikipedia: Continental System": "Wikipédia : le Blocus continental", "Wikipedia: Alexander I of Russia": "Wikipédia : Alexandre Ier", "Wikipedia: Congress of Vienna": "Wikipédia : le congrès de Vienne", "Wikipedia: Serfdom in Russia": "Wikipédia : le servage en Russie", "Wikipedia: Conscription in the Russian Empire": "Wikipédia : la conscription dans l'Empire russe", "Wikipedia: the Patriotic War of 1812": "Wikipédia : la guerre patriotique de 1812", "Wikipedia: War and Peace": "Wikipédia : Guerre et Paix", "Wikipedia: 1812 Overture": "Wikipédia : l'Ouverture 1812", "Wikipedia: Cathedral of Christ the Saviour": "Wikipédia : la cathédrale du Christ-Sauveur", "Wikipedia: Franz Roubaud": "Wikipédia : Franz Roubaud", "Michael Friendly, 2002": "Michael Friendly, 2002", "Numberphile's video: The Greatest Ever Infographic": "La vidéo de Numberphile : The Greatest Ever Infographic", "World History Encyclopedia": "World History Encyclopedia", "Fitzwilliam Museum": "Fitzwilliam Museum", "napoleon-empire.org": "napoleon-empire.org", "Wikipedia: Fire of Moscow": "Wikipédia : l'incendie de Moscou", "Wikipedia: Battle of Maloyaroslavets": "Wikipédia : la bataille de Maloïaroslavets", "Wikipedia: Battle of Vyazma": "Wikipédia : la bataille de Viazma", "Wikipedia: Battle of Krasnoi": "Wikipédia : la bataille de Krasnoï", "Wikipedia: Denis Davydov": "Wikipédia : Denis Davydov", }) # The French version of a page, where there is one (the video: its subtitles in French) NUMBERPHILE_FR_SUBTITLES = "https://www.youtube.com/watch?v=3T7jMcstxY0&cc_load_policy=1&cc_lang_pref=fr&hl=fr" # The same page, set for French readers (the video's French subtitles) LOCALIZED_PAGES = {NUMBERPHILE[1]: NUMBERPHILE_FR_SUBTITLES} FRENCH_PAGES = { EN_WIKI + en: FR_WIKI + fr for en, fr in [ ("French_invasion_of_Russia", "Campagne_de_Russie"), ("Duchy_of_Warsaw", "Duché_de_Varsovie"), ("Battle_of_Saltanovka", "Bataille_de_Moguilev"), ("Poklonnaya_Hill", "Mont_Poklonnaïa"), ("French_occupation_of_Moscow", "Prise_de_Moscou"), ("Fire_of_Moscow_%281812%29", "Incendie_de_Moscou_%281812%29"), ("Battle_of_Maloyaroslavets", "Bataille_de_Maloïaroslavets"), ("Battle_of_Vyazma", "Bataille_de_Viazma"), ("Battle_of_Krasnoi", "Bataille_de_Krasnoï"), ("Denis_Davydov", "Denis_Davydov"), ("Treaties_of_Tilsit", "Traités_de_Tilsit"), ("Continental_System", "Blocus_continental"), ("Alexander_I_of_Russia", "Alexandre_Ier_%28empereur_de_Russie%29"), ("Congress_of_Vienna", "Congrès_de_Vienne"), ("Serfdom_in_Russia", "Servage_en_Russie"), ("War_and_Peace", "Guerre_et_Paix"), ("1812_Overture", "Ouverture_solennelle_1812"), ("Cathedral_of_Christ_the_Saviour", "Cathédrale_du_Christ-Sauveur_de_Moscou"), ("Franz_Roubaud", "Franz_Roubaud"), ] } NAPOLEON_FR = "https://www.napoleon.org/histoire-des-2-empires/" FRENCH_PAGES.update({ # Friendly's text, translated (the site is gone: its archived copy) FRIENDLY_BIO[1]: "https://web.archive.org/web/20170329051132/http://www.19e.org/biographies/M/minard.htm", WHE[1]: "https://www.worldhistory.org/trans/fr/1-22111/campagne-de-russie/", FN_ADVANCE[1]: NAPOLEON_FR + "chronologies/1812-la-campagne-de-russie-2-de-borodino-a-moscou/", DAYBYDAY[1]: "https://www.napoleon-empire.org/batailles/campagne-de-russie-au-jour-le-jour.php", # The letter in its original French: Correspondance de Napoléon Ier, volume 24, letter 19213 NAPOLEON_LETTER[1]: "https://archive.org/details/correspondancede24napouoft/page/221/mode/1up", }) # The same page in Russian, where there is one (Russian Wikipedia's article, a translation): for every reader RU_WIKI = "https://ru.wikipedia.org/wiki/" RUSSIAN_PAGES = { EN_WIKI + en: RU_WIKI + urllib.parse.quote(ru) for en, ru in [ ("1812_Overture", "1812_год_(увертюра)"), ("Aleksandr_Figner", "Фигнер,_Александр_Самойлович"), ("Alexander_I_of_Russia", "Александр_I"), ("Battle_of_Berezina", "Сражение_на_Березине"), ("Battle_of_Borodino", "Бородинское_сражение"), ("Battle_of_Krasnoi", "Сражение_под_Красным"), ("Battle_of_Maloyaroslavets", "Сражение_под_Малоярославцем"), ("Battle_of_Ostrovno", "Бой_под_Островно"), ("Battle_of_Paris_%281814%29", "Взятие_Парижа_(1814)"), ("Battle_of_Saltanovka", "Бой_под_Салтановкой"), ("Battle_of_Smolensk_%281812%29", "Смоленское_сражение_(1812)"), ("Battle_of_Tarutino", "Тарутинский_бой"), ("Battle_of_Vyazma", "Сражение_под_Вязьмой_(1812)"), ("Cathedral_of_Christ_the_Saviour", "Храм_Христа_Спасителя"), ("Congress_of_Vienna", "Венский_конгресс"), ("Conscription_in_the_Russian_Empire", "Рекрутская_повинность"), ("Continental_System", "Континентальная_блокада"), ("Denis_Davydov", "Давыдов,_Денис_Васильевич"), ("Dnieper", "Днепр"), ("Duchy_of_Warsaw", "Варшавское_герцогство"), ("Epidemic_typhus", "Эпидемический_сыпной_тиф"), ("Fire_of_Moscow_%281812%29", "Московский_пожар_(1812)"), ("First_Battle_of_Polotsk", "Первое_сражение_под_Полоцком"), ("Franz_Roubaud", "Рубо,_Франц_Алексеевич"), ("French_invasion_of_Russia", "Отечественная_война_1812_года"), ("French_occupation_of_Moscow", "Французская_оккупация_Москвы"), ("Fyodor_Rostopchin", "Ростопчин,_Фёдор_Васильевич"), ("Jacques_Lauriston", "Лористон,_Жак_Александр_Ло_де"), ("Jean_Baptiste_Eblé", "Эбле,_Жан-Батист"), ("Kaunas", "Каунас"), ("Louis-Nicolas_Davout", "Даву,_Луи_Никола"), ("Michael_Andreas_Barclay_de_Tolly", "Барклай_де_Толли,_Михаил_Богданович"), ("Michel_Ney", "Ней,_Мишель"), ("Mikhail_Kutuzov", "Кутузов,_Михаил_Илларионович"), ("Mikhail_Miloradovich", "Милорадович,_Михаил_Андреевич"), ("Neman", "Неман"), ("Poklonnaya_Hill", "Поклонная_гора"), ("Pyotr_Bagration", "Багратион,_Пётр_Иванович"), ("Russian_Winter", "Генерал_Мороз"), ("Scorched_earth", "Тактика_выжженной_земли"), ("Serfdom_in_Russia", "Крепостное_право_в_России"), ("Smarhon", "Сморгонь"), ("Treaties_of_Tilsit", "Тильзитский_мир"), ("Vilnius", "Вильнюс"), ("War_and_Peace", "Война_и_мир"), ] } RUSSIAN_PAGES.update({ # The letter, translated in a 1912 Moscow edition (pre-reform spelling) NAPOLEON_LETTER[1]: "https://ru.wikisource.org/wiki/" + urllib.parse.quote("Письмо_Наполеона_Александру_I_из_Москвы_(Наполеон)/ДО"), # The bulletin, as the Russian press printed it in January 1813 BULLETIN[1]: "https://runivers.ru/russianhistory/khroniki-otechestvennoy-voyny-1812-goda/calendar-sobytiy/12/" "hostilities/29-y-byulleten-velikoy-armii-molodechno-21-noyabrya-dekabrya-3-dnya-1812-453943/", }) # A French page on the same subject, where a source has no French version: (name, url) FRENCH_READING = { FRIENDLY_2002[1]: ("Éric Mauvière, Minard's \"brutal eloquence\"", "https://www.icem7.fr/datavisualisation/la-brutale-eloquence-de-charles-joseph-minard/"), TUFTE[1]: ("Sandra Rendgen, Minard's system, on Visionscarto", "https://www.visionscarto.net/le-systeme-minard"), WHE_TIMELINE[1]: ("Fondation Napoléon: the chronology of 1812", NAPOLEON_FR + "chronologies/chronologie-de-la-correspondance-generale-de-napoleon-bonaparte-" "tome-12-la-campagne-de-russie-1812/"), FN_RETREAT[1]: ("Paul Ganière, The retreat from Russia", NAPOLEON_FR + "articles/la-retraite-de-russie/"), FITZ[1]: ("L'Histoire par l'image: the Russian campaign", "https://histoire-image.org/etudes/campagne-russie-1812"), WIKI_CONSCRIPTION[1]: ("Wikipedia: the Russian army, its recruitment", FR_WIKI + "Armée_russe_dans_les_guerres_de_la_Révolution_et_de_l%27Empire#Recrutement"), } FRENCH.update({ "Éric Mauvière, Minard's \"brutal eloquence\"": "Éric Mauvière, la « brutale éloquence » de Minard", "Sandra Rendgen, Minard's system, on Visionscarto": "Sandra Rendgen, le système Minard, sur Visionscarto", "Fondation Napoléon: the chronology of 1812": "Fondation Napoléon : la chronologie de 1812", "Paul Ganière, The retreat from Russia": "Paul Ganière, La retraite de Russie", "L'Histoire par l'image: the Russian campaign": "L'Histoire par l'image : la campagne de Russie", "Wikipedia: the Russian army, its recruitment": "Wikipédia : l'armée russe, le recrutement", }) # The language of the pages that are not on a xx.wikipedia.org (EN by default) PAGE_LANGUAGES = {LEGEND[1]: "FR", MAREY[1]: "FR", BULLETIN[1]: "FR", NUMBERPHILE_FR_SUBTITLES: "EN, sous-titres FR"} PAGE_LANGUAGES.update({url: "FR" for url in FRENCH_PAGES.values()}) PAGE_LANGUAGES.update({url: "FR" for _, url in FRENCH_READING.values()}) PAGE_LANGUAGES.update({url: "RU" for url in RUSSIAN_PAGES.values()}) DETACHMENTS_NOTE = ( "**The detachments** are corps that marched apart from the main army. On Minard's map, two bands go " "north: one to Polotsk, and a short one north of Kowno. The corps sent south, toward Minsk and Mogilev, " "he drew inside the main band, as if they had always marched with it (his legend says so)." ) DETACHMENTS_NOTE_FR = ( "**Les détachements** sont des corps qui marchent à l'écart de l'armée principale. Sur la carte de Minard, " "deux bandes montent vers le nord : l'une vers Polotsk, l'autre, courte, au nord de Kowno. Les corps envoyés " "au sud, vers Minsk et Mohilev, il les a dessinés dans la bande principale, comme s'ils avaient toujours " "marché avec elle (sa légende le dit)." ) ABOUT = ( "Charles Joseph Minard drew this in 1869. The band's width is the number of men; the line below, the " "temperatures of the retreat. The animation adds time: the advance is placed at sourced dates for the cities " "it passed, the retreat at Minard's own dated readings, and the detachments' timing is approximate.\n\n" "The redrawing places Minard's figures where his own map does: it bends the geography as he did, from his " "band's turns and splits measured on the 1869 sheet. The rivers come from Natural Earth (public domain), " "simplified, and bend with it.\n\n" "Space plays or pauses; the arrow keys go from card to card.\n\n" "He printed it on one sheet with a map of Hannibal's march across the Alps, in 218 BC: 96,000 men at the " "start, 26,000 at the end. The two were meant to be compared." ) ABOUT_FR = ( "Charles Joseph Minard a dessiné cette carte en 1869. La largeur de la bande, c'est le nombre d'hommes ; la " "ligne du bas, les températures de la retraite. L'animation ajoute le temps : l'avance est placée aux dates " "connues des villes traversées, la retraite aux relevés datés de Minard lui-même, et la chronologie des " "détachements est approximative.\n\n" "Le redessin place les chiffres de Minard là où sa propre carte les place : il infléchit la géographie comme " "lui, d'après les tournants et les séparations de sa bande, mesurés sur la feuille de 1869. Les fleuves " "viennent de Natural Earth (domaine public), simplifiés, et s'infléchissent avec elle.\n\n" "Espace lance ou arrête la lecture ; les flèches passent d'une étape à l'autre.\n\n" "Il l'a imprimée sur une même feuille qu'une carte de la marche d'Hannibal à travers les Alpes, en 218 av. " "J.-C. : 96 000 hommes au départ, 26 000 à l'arrivée. Les deux étaient faites pour être comparées." ) # The author's note, after the last card and at the end of About: a thin rule, the note in italics, then the signature AUTHOR_NOTE = ( "May past wars remind us, in troubled times, that war is never inevitable, and that our history binds us more " "than it divides us. We owe it to those who marched, and to those who will." ) AUTHOR_NOTE_FR = ( "Que les guerres passées nous rappellent, en des temps inquiets, que la guerre n'est jamais une fatalité, et que " "notre histoire nous relie plus qu'elle ne nous sépare. Nous le devons à ceux qui ont marché et à ceux qui " "marcheront." ) AUTHOR_SIGNATURE, AUTHOR_SIGNATURE_FR = "Pascal Thomet, October 2026", "Pascal Thomet, octobre 2026" # The note fades in a while after its page: under the last card, once the reader is into Minard's words NOTE_DELAY, NOTE_DELAY_ABOUT, NOTE_FADE = 3.0, 1.5, 1.8 def gui_author_note(seconds: float, delay: float) -> bool: """The author's note, set apart: a box on a slightly deeper tone of the page, Minard's tan as a bar at its left, the note in italics, the signature at the right. It keeps its room from the start, and fades in from delay seconds after its page came (seconds): no words shift under the reader's eye. Returns whether the signature was clicked: it is the way to About.""" text = in_language("notes", "author", "text", AUTHOR_NOTE, AUTHOR_NOTE_FR) signature = in_language("notes", "author", "signature", AUTHOR_SIGNATURE, AUTHOR_SIGNATURE_FR) ink = imgui.get_style().color_(imgui.Col_.text) pad, bar = em_size(0.9), em_size(0.18) imgui.dummy(ImVec2(0.0, em_size(0.7))) imgui.push_style_var(imgui.StyleVar_.alpha, imgui.get_style().alpha * smooth((seconds - delay) / NOTE_FADE)) imgui.push_style_color(imgui.Col_.child_bg, imgui.ImVec4(ink.x, ink.y, ink.z, 0.06)) imgui.push_style_var(imgui.StyleVar_.child_rounding, em_size(0.35)) imgui.push_style_var(imgui.StyleVar_.window_padding, ImVec2(pad + bar, pad * 0.8)) imgui.begin_child("author_note", ImVec2(0.0, 0.0), imgui.ChildFlags_.always_use_window_padding | imgui.ChildFlags_.auto_resize_y) imgui.pop_style_var(2) imgui.pop_style_color() top, bottom = imgui.get_window_pos(), imgui.get_window_pos() + imgui.get_window_size() imgui.get_window_draw_list().add_rect_filled(top, ImVec2(top.x + bar, bottom.y), imgui.get_color_u32(ADVANCE), em_size(0.35), imgui.ImDrawFlags_.round_left) imgui_md.render_unindented(f"*{text}*") imgui.push_font(None, imgui.get_font_size() * 0.85) imgui.set_cursor_pos_x(imgui.get_cursor_pos_x() + max(0.0, imgui.get_content_region_avail().x - imgui.calc_text_size(signature).x)) imgui.text_disabled(signature) clicked = imgui.is_item_clicked() if imgui.is_item_hovered(): imgui.set_tooltip(tr("About")) lo, hi = imgui.get_item_rect_min(), imgui.get_item_rect_max() imgui.get_window_draw_list().add_line(ImVec2(lo.x, hi.y), ImVec2(hi.x, hi.y), imgui.get_color_u32(ADVANCE), max(1.0, em_size(0.05))) imgui.pop_font() imgui.end_child() imgui.pop_style_var() return clicked imgui.dummy(ImVec2(0.0, em_size(0.3))) INSTALL_STEPS = ( "**Install it as an app**\n\n" "On your Home Screen, it opens as an app, with the whole screen for the map.\n\n" "In Safari, open the Share menu (on a recent iPhone, under the ... button), then choose \"Add to Home Screen\" " "(sometimes under More)." ) FRENCH.update({ INSTALL_STEPS: ( "**L'installer comme une app**\n\n" "Sur l'écran d'accueil, elle s'ouvre comme une app, avec tout l'écran pour la carte.\n\n" "Dans Safari, ouvrez le menu Partager (sur un iPhone récent, sous le bouton ...), puis choisissez « Sur " "l'écran d'accueil » (parfois sous Plus)."), "Install it as an app": "L'installer comme une app", }) ENPC_ORIGINAL = ("Minard's original, at the École des ponts", "https://heritage.ecoledesponts.fr/ark:/12148/btv1b10484829h/f52.item") # sheet 28 READ_MORE_FRENCH = ( ("Sandra Rendgen, Minard's fifty maps", "https://www.visionscarto.net/charles-joseph-minard-cinquante-cartes"), ("Institut de France: the Russian campaign, four podcasts (Jean Tulard...)", "https://podcasts.institutdefrance.fr/emissions/napoleon-et-lempire/la-campagne-de-russie"), ("Natalie Petiteau, The Russian campaign of 1812: myths and realities", "https://www.persee.fr/doc/slave_0080-2557_2012_num_83_4_8293"), ) READ_MORE = (ENPC_ORIGINAL, ("Michael Friendly, Re-Visions of Minard", "http://datavis.ca/gallery/re-minard.html"), NUMBERPHILE) + READ_MORE_FRENCH FRENCH.update({"Minard's original, at the École des ponts": "L'original de Minard, à l'École des ponts", "Michael Friendly, Re-Visions of Minard": "Michael Friendly, Re-Visions of Minard", "Sandra Rendgen, Minard's fifty maps": "Sandra Rendgen, les cinquante cartes de Minard", "Institut de France: the Russian campaign, four podcasts (Jean Tulard...)": "Institut de France : la campagne de Russie, quatre podcasts (Jean Tulard...)", "Natalie Petiteau, The Russian campaign of 1812: myths and realities": "Natalie Petiteau, La campagne de Russie de 1812 : mythes et réalités"}) PAGE_LANGUAGES.update({url: "FR" for _, url in (ENPC_ORIGINAL,) + READ_MORE_FRENCH}) def curly(text: str) -> str: """French typography: the curly apostrophe (l’armée), which Old Standard TT has, and a no-break space before ":", ";", "!", "?" and inside « », so that a line never starts with them.""" text = re.sub(r"(?<=\w)'(?=\w)", "’", text) return re.sub(r" ([:;!?»])", "\u00a0\\1", text).replace("« ", "«\u00a0") FRENCH.update({english: curly(french) for english, french in FRENCH.items()}) INTRO = [replace(s, title_fr=curly(s.title_fr), text_fr=curly(s.text_fr), words=curly(s.words)) for s in INTRO] EVENTS = [replace(ev, title_fr=curly(ev.title_fr), text_fr=curly(ev.text_fr)) for ev in EVENTS] CONTEXT = [replace(s, title_fr=curly(s.title_fr), text_fr=curly(s.text_fr)) for s in CONTEXT] DETACHMENTS_NOTE_FR, ABOUT_FR, AUTHOR_NOTE_FR = curly(DETACHMENTS_NOTE_FR), curly(ABOUT_FR), curly(AUTHOR_NOTE_FR) # --------------------------------------------------------------------------- # Live data # --------------------------------------------------------------------------- REPO = "vincentarelbundock/Rdatasets" BASE_URL = f"https://raw.githubusercontent.com/{REPO}/master/csv/HistData/" def app_file_url(name: str) -> str: """A file beside the app's code (in a browser, index.html tells where it found the code): the Russian texts, the making-of, the fonts.""" if sys.platform == "emscripten": import js return urllib.parse.urljoin(str(getattr(js, "minardCodeUrl", js.location.href)), name) return (pathlib.Path(__file__).parent / name).resolve().as_uri() # The fonts, from the Google Fonts repository (SIL Open Font License). The text: Old Standard TT, a revival # of the type of late-19th-century books. The map's lettering: IM Fell DW Pica Italic, close to Minard's # slanted city names. OLD_STANDARD = ("https://fonts.google.com/specimen/Old+Standard+TT", "Old Standard TT, by Alexey Kryukov") FONTS = [ # (key, title, url, file name on disk, page and credit) # The text and the map's lettering: the Google Fonts files, subset to Latin, Greek and Cyrillic # (tools/text_fonts.py), beside the app, whose site compresses them: about 330 kB on the wire, instead of 1 MB # uncompressed from GitHub ("font:text", "Text font", app_file_url("fonts/OldStandard-Regular.ttf"), "OldStandard-Regular.ttf", OLD_STANDARD), ("font:bold", "Text font, bold", app_file_url("fonts/OldStandard-Bold.ttf"), "OldStandard-Bold.ttf", OLD_STANDARD), ("font:italic", "Text font, italic", app_file_url("fonts/OldStandard-Italic.ttf"), "OldStandard-RegularItalic.ttf", OLD_STANDARD), ("font:map", "The map's lettering", app_file_url("fonts/IMFePIit28P.ttf"), "MinardMap.ttf", ("https://fonts.google.com/specimen/IM+Fell+DW+Pica", "IM Fell, by Igino Marini")), # The two years of the title, each in the type of its time, as on the loading page: the digits alone, subset # from the Google Fonts files (tools/digit_fonts.py), beside the app ("font:1812", "The year 1812's type", app_file_url("fonts/BodoniModa-digits.ttf"), "BodoniModa-digits.ttf", ("https://fonts.google.com/specimen/Bodoni+Moda", "Bodoni Moda, by indestructible type*")), ("font:2026", "The year 2026's type", app_file_url("fonts/SpaceGrotesk-digits.ttf"), "SpaceGrotesk-digits.ttf", ("https://fonts.google.com/specimen/Space+Grotesk", "Space Grotesk, by Florian Karsten")), ] def human_bytes(n: float) -> str: for unit in ("B", "kB", "MB"): if n < 1000: return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" if n < 10 else f"{n:.0f} {unit}" n /= 1000 return f"{n:.1f} GB" @dataclass class Source: key: str title: str url: str page_url: str = "" # for images: where to credit them credit: str = "" state: str = "waiting" # waiting, queued, fetching, ready, failed size: int = 0 rows: int = 0 seconds: float = 0.0 started: float = 0.0 error: str = "" @property def is_image(self) -> bool: return self.key.startswith("img:") @property def is_font(self) -> bool: return self.key.startswith("font:") @property def is_table(self) -> bool: return self.url.endswith(".csv") @property def file_name(self) -> str: return urllib.parse.unquote(self.url.split("/")[-1].split("px-", 1)[-1]) RUSSIAN_KEY = "text:ru" LANGUAGE_NAMES = {"en": "English", "fr": "Français", "ru": "Русский"} # each in its own language def russian_url() -> str: return app_file_url("minard_1812_ru.json") MAKING_OF_KEY = "text:making_of" PAGE_COLUMN_EM = 38.0 # the reading column of a page open over the whole window (the making-of, About) PAGE_TOC_EM = 16.0 # the room of the table of contents beside it (RichMd's document), on a wide screen MAKING_OF_EXTRACT = """ **How this animation came to be** In 1869, Charles Joseph Minard drew what had become of Napoleon's army in Russia. This animation gives his map back its time, day after day, and adds what Minard could not put on a sheet: the paintings, the sources, the faces. Here is how it was made, in ten days, between one person, Pascal, and an AI he had named Minard. The simplest is to let the conversations speak. """ MAKING_OF_EXTRACT_FR = """ **Comment est née cette animation** En 1869, Charles Joseph Minard dessine ce qu'est devenue l'armée de Napoléon en Russie. Cette animation lui rend le temps, jour après jour, et lui ajoute ce que Minard n'a pas pu mettre sur une feuille : les tableaux, les sources, les visages. Voici comment elle s'est faite, en dix jours, entre une personne, Pascal, et une IA à qui il avait donné le nom de Minard. Le plus simple est de laisser parler les conversations. """ DATA_SOURCES = """ Everything the animation shows is fetched live, from where it lives: - **Minard's figures**, as Michael Friendly digitized them for his *Re-Visions of Minard*: three tables (the army's path and strength, the cities, the temperatures of the retreat), from the [Rdatasets](https://vincentarelbundock.github.io/Rdatasets/) collection (the R package HistData). - **Minard's original map** and **the paintings**, from [Wikimedia Commons](https://commons.wikimedia.org/): public domain, each credited at its step, with a link to its page. - **The rivers**, from [Natural Earth](https://www.naturalearthdata.com/) (public domain), simplified, and bent with the map. - **The fonts**, from Google Fonts (SIL Open Font License): Old Standard TT for the text, IM Fell for the map's lettering, Bodoni Moda and Space Grotesk for the two years of the title. The states of the downloads, and a way to fetch them again: *Data and downloads*, under the gear. """ DATA_SOURCES_FR = """ Tout ce que l'animation montre est téléchargé en direct, là où il vit : - **Les chiffres de Minard**, tels que Michael Friendly les a numérisés pour ses *Re-Visions of Minard* : trois tables (le chemin et l'effectif de l'armée, les villes, les températures de la retraite), depuis la collection [Rdatasets](https://vincentarelbundock.github.io/Rdatasets/) (le paquet R HistData). - **La carte originale de Minard** et **les tableaux**, depuis [Wikimedia Commons](https://commons.wikimedia.org/) : domaine public, chacun crédité à son étape, avec un lien vers sa page. - **Les fleuves**, depuis [Natural Earth](https://www.naturalearthdata.com/) (domaine public), simplifiés, et infléchis avec la carte. - **Les polices**, depuis Google Fonts (licence SIL Open Font) : Old Standard TT pour le texte, IM Fell pour l'écriture de la carte, Bodoni Moda et Space Grotesk pour les deux années du titre. L'état des téléchargements, et de quoi les relancer : *Données et téléchargements*, sous la roue dentée. """ LICENSE = """ © 2026 Pascal Thomet. The texts, the drawings, the voice and the animation are under the [Creative Commons BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/) license: share them and show them, with credit; no commercial use, no changes. The code is all rights reserved: readable, not reusable. Minard's map, his figures and the paintings are in the public domain. """ LICENSE_FR = """ © 2026 Pascal Thomet. Les textes, les dessins, la voix et l'animation sont sous licence [Creative Commons BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/deed.fr) : partagez-les et montrez-les, en citant l'auteur ; sans usage commercial, sans modification. Le code est tous droits réservés : lisible, pas réutilisable. La carte de Minard, ses chiffres et les tableaux sont dans le domaine public. """ NARRATED_LANGUAGES = ("fr",) # the languages with a narration (voice//, made by plans/voice/generate_narration.py) VOICE_KEY = "voice:manifest" class Narrator: """The narration: the cards and the intro's slides read by the author's voice (a clone of it), when the reader turns it on. The files are beside the app (voice//.mp3); manifest.json gives their lengths, so that the animation waits for the end of a reading. In a browser, the page's Audio plays them; on the desktop, miniaudio.""" def __init__(self) -> None: self.on = False self.seconds: Dict[str, float] = {} # from the manifest self.current = "" # the key playing self.queue: List[str] = [] self.ended = True self.failed = False # the current file could not play (missing): the animation keeps its own timing self.audio: Any = None # the page's Audio element self.device: Any = None # miniaudio's playback device self.proxies: List[Any] = [] # the callbacks given to the page, kept alive def available(self) -> bool: return Lang.code in NARRATED_LANGUAGES def length(self, keys: List[str]) -> float: return sum(self.seconds.get(k, 0.0) for k in keys) def play(self, keys: List[str]) -> None: """Reads these, one after the other (a card, then the note).""" self.stop() self.queue = list(keys) self.update() def update(self) -> None: """Each frame: the next reading once the current one has ended.""" if self.ended and self.queue: self.start(self.queue.pop(0)) def reading(self) -> bool: """A reading is going on (or about to): the animation waits for its end.""" return self.on and (not self.ended or bool(self.queue)) def just_ended(self) -> bool: """The readings asked for have all ended, and played: the animation can go on at once.""" return self.on and self.ended and not self.queue and not self.failed and self.current != "" def start(self, key: str) -> None: self.current, self.ended, self.failed = key, False, False url = app_file_url(f"voice/{Lang.code}/{key}.mp3") try: if sys.platform == "emscripten": import js from pyodide.ffi import create_proxy self.audio = js.Audio.new(url) on_ended = create_proxy(lambda event: self.on_ended()) self.proxies.append(on_ended) self.audio.onended = on_ended on_error = create_proxy(lambda event: self.on_error()) self.proxies.append(on_error) self.audio.onerror = on_error # a missing file: the next reading, or the animation's own timing self.audio.play() else: import miniaudio # type: ignore[import-untyped] stream = miniaudio.stream_with_callbacks(miniaudio.stream_file(urllib.request.url2pathname( urllib.parse.urlparse(url).path)), None, None, self.on_ended) next(stream) self.device = miniaudio.PlaybackDevice() self.device.start(stream) except Exception as e: # no sound: the animation goes on with its own timing print(f"The voice could not play {key}: {e}") self.on_error() def on_ended(self) -> None: self.ended = True def on_error(self) -> None: self.ended, self.failed = True, True def stop(self) -> None: self.queue = [] if self.audio is not None: self.audio.pause() self.audio = None if self.device is not None: self.device.close() self.device = None self.ended, self.current, self.failed = True, "", False def pause(self) -> None: if self.audio is not None: self.audio.pause() elif self.device is not None: self.device.stop() def resume(self) -> None: if self.audio is not None: self.audio.play() elif self.device is not None: self.device.start() GUESTBOOK_API = "https://imgui-bundle-api.pthomet.workers.dev/minard_guestbook" # the Worker in cloudflare/julia_points GUESTBOOK_MAX_NAME, GUESTBOOK_MAX_PLACE, GUESTBOOK_MAX_MESSAGE = 40, 60, 1000 # the server's limits MAKING_OF_WITNESS = "00_la_chatte.jpg" # the making-of's tab shows her, the first evening's witness, before the button def making_of_url(name: str) -> str: """Where the making-of is: the folder making_of/ beside the app's code (made by tools/making_of_copy.py).""" if sys.platform == "emscripten": import js return urllib.parse.urljoin(str(getattr(js, "minardCodeUrl", js.location.href)), "making_of/" + name) return (pathlib.Path(__file__).with_name("making_of") / name).resolve().as_uri() @dataclass(frozen=True) class Block: """A piece of the making-of: a stretch of Markdown (anchor: the slug of the chapter it opens), a picture (narrow: the photos), a note (a word of the trade explained, in small type), or the summary (set apart in a box).""" markdown: str = "" image: Optional[Image] = None narrow: bool = False anchor: str = "" note: bool = False summary: bool = False def slug(text: str) -> str: """A text as an address fragment: "5. Le russe" gives "le-russe", "7 September: Borodino" gives "borodino".""" text = text.split(": ", 1)[-1] text = re.sub(r"^\d+\.\s*", "", text).replace("\u2019", "'") # a curly apostrophe (« Livre d'or ») counts as one plain = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() return re.sub(r"[^a-z0-9]+", "-", plain.lower()).strip("-") def page_fragment() -> str: """Where to open, from the address: "making-of/le-russe", "about", "intro/3", "step/9" or "borodino", "data". On the web, the address's fragment; on the desktop, an argument starting with #.""" if sys.platform == "emscripten": import js return urllib.parse.unquote(str(js.location.hash)).lstrip("#") return sys.argv[1].lstrip("#") if len(sys.argv) > 1 and sys.argv[1].startswith("#") else "" def set_page_fragment(fragment: str, push: bool) -> None: """The address follows the reader: copied, it brings someone else to the same place. A move by hand (push) is an entry of the browser's history, so that Back returns to the previous view; the animation's own moves replace the entry, so as not to bury the history under one card every few seconds.""" if sys.platform == "emscripten": import js address = js.location.pathname + js.location.search + ("#" + fragment if fragment else "") if push: js.history.pushState(None, "", address) else: js.history.replaceState(None, "", address) IMAGE_MD = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)\)\s*$") IMAGE_HTML = re.compile(r'^([^\s*$') NOTE_HTML = re.compile(r"^(.*)\s*$") def parse_making_of(text: str) -> List[Block]: """The making-of's Markdown, cut at its pictures, its notes (a paragraph) and its chapters: the text keeps its headings, quotes and captions; a chapter's block carries its anchor.""" blocks: List[Block] = [] lines: List[str] = [] anchor = "" def flush() -> None: if any(line.strip() for line in lines): blocks.append(Block(markdown="\n".join(lines), anchor=anchor)) lines.clear() for line in text.splitlines(): if line.startswith("## "): flush() anchor = slug(line[3:]) md, html, note = IMAGE_MD.match(line), IMAGE_HTML.match(line), NOTE_HTML.match(line) if note is not None: flush() blocks.append(Block(markdown=note[1], note=True)) continue if line.startswith("**En résumé.**"): flush() blocks.append(Block(markdown=line, summary=True)) continue if md is not None: alt, name, narrow = md[1], md[2], False elif html is not None: alt, name, narrow = html[2], html[1], True else: lines.append(line) continue flush() blocks.append(Block(image=Image("img:making_of:" + name, alt, name, "", url=making_of_url(name)), narrow=narrow)) flush() return blocks def pref_get(name: str) -> str: """A lasting choice of the reader (the language, the voice, the turn hint): in a browser, the page's local storage under "minard/" (hello_imgui's lasting prefs do not reach it from Pyodide); on the desktop, hello_imgui's lasting prefs, beside the ini file. "" when never saved.""" if sys.platform == "emscripten": import js try: return str(js.localStorage.getItem("minard/" + name) or "") except Exception: # the site's storage blocked return "" return hello_imgui.load_lasting_pref(name) def pref_set(name: str, value: str) -> None: if sys.platform == "emscripten": import js try: js.localStorage.setItem("minard/" + name, value) except Exception: pass else: hello_imgui.save_lasting_pref(name, value) def local_setting(name: str) -> str: """A setting of this machine: an environment variable on the desktop; in a browser, the page's local storage (localStorage.setItem(name, value) in the console). The guestbook's admin token lives there, and nowhere else.""" if sys.platform == "emscripten": import js return str(js.localStorage.getItem(name) or "") return os.environ.get(name, "") @dataclass class Entry: id: int name: str place: str message: str lang: str created: str # ISO 8601: the date, then the time def entry_from_json(d: Dict[str, Any]) -> Entry: return Entry(int(d["id"]), str(d["name"]), str(d["place"]), str(d["message"]), str(d["lang"]), str(d["created"])) class Guestbook: """The guestbook, the last page of the making-of: the words of those who walked with them. A word waits for Pascal's reading before it is shown. With the admin token (MINARD_GUESTBOOK_ADMIN, see local_setting), the page also lists the words waiting, and buttons publish, hide or delete them.""" def __init__(self) -> None: self.api = local_setting("MINARD_GUESTBOOK_API") or GUESTBOOK_API # a local Worker, for the tests self.admin_token = local_setting("MINARD_GUESTBOOK_ADMIN") self.entries: List[Entry] = [] self.pending: List[Entry] = [] self.loaded = False self.loading: Optional[immapp.Download] = None self.loading_pending: Optional[immapp.Download] = None self.error = "" self.name, self.place, self.message = "", "", "" self.sending: Optional[immapp.Download] = None self.sent = False self.send_error = "" self.acting: Dict[int, Tuple[immapp.Download, str]] = {} # the admin's requests under way, by entry def admin_headers(self) -> Optional[Dict[str, str]]: return {"Authorization": f"Bearer {self.admin_token}"} if self.admin_token else None def load(self) -> None: self.loaded, self.error = True, "" self.loading = immapp.start_download(self.api) if self.admin_token: self.loading_pending = immapp.start_download(self.api + "/pending", headers=self.admin_headers()) def poll(self) -> None: """At each frame: the answers that arrived.""" if self.loading is not None and self.loading.done: if self.loading.error: self.error = self.loading.error else: self.entries = [entry_from_json(d) for d in json.loads(self.loading.data)] self.loading = None if self.loading_pending is not None and self.loading_pending.done: if not self.loading_pending.error: self.pending = [entry_from_json(d) for d in json.loads(self.loading_pending.data)] self.loading_pending = None if self.sending is not None and self.sending.done: if self.sending.error: self.send_error = self.sending.error else: self.sent, self.name, self.place, self.message = True, "", "", "" if self.admin_token: self.load() # the admin sees their own word waiting at once self.sending = None for entry_id, (download, status) in list(self.acting.items()): if download.done: del self.acting[entry_id] if download.error: self.error = download.error continue moved = next((e for e in self.pending + self.entries if e.id == entry_id), None) self.pending = [e for e in self.pending if e.id != entry_id] self.entries = [e for e in self.entries if e.id != entry_id] if moved is not None and status == "shown": self.entries.insert(0, moved) def send(self) -> None: body = {"name": self.name.strip(), "place": self.place.strip(), "message": self.message.strip(), "lang": Lang.code, "website": ""} self.sending, self.send_error = immapp.start_download(self.api, "POST", body), "" def set_status(self, entry: Entry, status: str) -> None: self.acting[entry.id] = (immapp.start_download(f"{self.api}/{entry.id}/status", "POST", {"status": status}, headers=self.admin_headers()), status) def delete(self, entry: Entry) -> None: self.acting[entry.id] = (immapp.start_download(f"{self.api}/{entry.id}", "DELETE", headers=self.admin_headers()), "deleted") def use_russian(data: bytes) -> None: """Reads the Russian texts. A page they link to is in Russian, unless they say otherwise.""" texts = json.loads(data.decode("utf-8")) missing = [k for k in ("ui", "slides", "events", "sections", "notes", "reading", "localized") if k not in texts] if missing: raise KeyError(", ".join(missing)) linked = [url for _, url in texts["reading"].values()] linked += [url for _, url in texts.get("read_more", [])] + list(texts["localized"].values()) texts["languages"] = {url: "RU" for url in linked} | texts.get("languages", {}) RUSSIAN.update(texts) def scan_place(u: float, v: float) -> Tuple[float, float]: """A point of the large scan (its pixels), in plot coordinates: where the comparison shows it.""" w, h = ORIGINAL_SCAN lon = ORIGINAL_LON[0] + u / w * (ORIGINAL_LON[1] - ORIGINAL_LON[0]) lat = ORIGINAL_LAT[1] - v / h * (ORIGINAL_LAT[1] - ORIGINAL_LAT[0]) return lon * X_SCALE, lat * Y_STRETCH def solve(a: List[List[float]], b: List[List[float]]) -> List[List[float]]: """x such that a x = b (Gauss-Jordan, with pivoting); b and x have one column per right-hand side.""" n = len(a) m = [row[:] + rhs[:] for row, rhs in zip(a, b, strict=True)] for col in range(n): pivot = max(range(col, n), key=lambda r: abs(m[r][col])) m[col], m[pivot] = m[pivot], m[col] for r in range(n): if r != col: f = m[r][col] / m[col][col] m[r] = [x - f * y for x, y in zip(m[r], m[col], strict=True)] return [[x / m[r][r] for x in m[r][n:]] for r in range(n)] class MinardBend: """(lon, lat) to plot coordinates, bent as Minard's map is: an affine part (his scale and his shift), and around each of his places a Gaussian bump that carries the point there. Far from his places, the affine part alone. Fitted on his troops table's points and their places (MINARD_PLACES).""" def __init__(self, points: List[Tuple[float, float]], places: List[Tuple[float, float]]) -> None: merged: Dict[Tuple[float, float], List[Tuple[float, float]]] = {} for p, q in zip(points, places, strict=True): # a point listed twice (a turn back) is one point merged.setdefault(p, []).append(q) self.points = list(merged) targets = [(sum(q[0] for q in qs) / len(qs), sum(q[1] for q in qs) / len(qs)) for qs in merged.values()] rows = [(1.0, x, y) for x, y in self.points] normal = [[sum(r[i] * r[j] for r in rows) for j in range(3)] for i in range(3)] self.affine = solve(normal, [[sum(r[i] * t[k] for r, t in zip(rows, targets, strict=True)) for k in range(2)] for i in range(3)]) left = [[t[0] - a[0], t[1] - a[1]] for t, a in zip(targets, map(self.affine_at, self.points), strict=True)] kernel = [[self.bump(p, q) + (1e-3 if p == q else 0.0) for q in self.points] for p in self.points] self.weights = solve(kernel, left) def affine_at(self, p: Tuple[float, float]) -> Tuple[float, float]: (x0, y0), (xx, yx), (xy, yy) = self.affine return x0 + xx * p[0] + xy * p[1], y0 + yx * p[0] + yy * p[1] @staticmethod def bump(p: Tuple[float, float], q: Tuple[float, float]) -> float: return math.exp(-((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2) / (2.0 * BEND_REACH ** 2)) def __call__(self, lon: float, lat: float) -> Tuple[float, float]: p = (lon * X_SCALE, lat * Y_STRETCH) x, y = self.affine_at(p) for q, (wx, wy) in zip(self.points, self.weights, strict=True): b = self.bump(p, q) x, y = x + b * wx, y + b * wy return x, y @dataclass class Path: """One group's march: points in plot coordinates, with men and a day for each.""" group: int x: List[float] y: List[float] men: List[int] retreat: List[bool] day: List[float] @dataclass class Campaign: paths: List[Path] = field(default_factory=list) cities: List[Tuple[str, float, float]] = field(default_factory=list) temps: List[Tuple[float, float, int]] = field(default_factory=list) # (x, degrees Réaumur, day) rivers: List[Tuple[str, List[List[Tuple[float, float]]]]] = field(default_factory=list) # bent, as his map bend: Optional[MinardBend] = None kowno_x: float = 24.0 * X_SCALE # where the retreat ends: Minard's note on the Niemen class DataStore: """Fetches Minard's tables and the images, keeping every request visible.""" IMAGE_GAP_SECONDS = 0.5 # between two image requests: Wikimedia refuses bursts (HTTP 429) def __init__(self) -> None: self.t0 = time.perf_counter() self.log: List[Tuple[float, str]] = [] self.sources: Dict[str, Source] = {} for key, title in (("troops", "Troops: path and men"), ("cities", "Cities"), ("temp", "Temperatures")): self.sources[key] = Source(key, title, BASE_URL + f"Minard.{key}.csv") for im in IMAGES: self.sources[im.key] = im.source() for key, title, url, _, (page, credit) in FONTS: self.sources[key] = Source(key, title, url, page, credit) self.raw: Dict[str, bytes] = {} self.textures: Dict[str, hello_imgui.TextureGpu] = {} self.campaign: Optional[Campaign] = None self.started = False self.image_lock: Optional[asyncio.Lock] = None # made in the event loop, at the first image def say(self, text: str) -> None: self.log.append((time.perf_counter() - self.t0, text)) @property def busy(self) -> bool: return any(s.state in ("queued", "fetching") for s in self.sources.values()) def start(self) -> None: self.say(f"Data: github.com/{REPO}; images: commons.wikimedia.org") self.started = True self.request_first() def request_first(self) -> None: """The tables and Minard's map: the fonts came before the window opened, and each painting comes when its card does.""" for key, src in self.sources.items(): if src.is_table or key == MINARD_MAP.key: self.request(key) def request(self, key: str, retry: bool = False) -> None: """Fetches a file once; again only on a retry, after a failure.""" src = self.sources[key] if not (src.state == "waiting" or (retry and src.state == "failed")): return try: asyncio.get_running_loop() except RuntimeError: # no event loop (a test harness, say): nothing can be fetched now return src.state = "queued" asyncio.ensure_future(self.fetch(key)) def request_russian(self) -> None: """The Russian texts, fetched once: minard_1812_ru.json, beside the app (in a browser: beside its code).""" if RUSSIAN_KEY not in self.sources: self.sources[RUSSIAN_KEY] = Source(RUSSIAN_KEY, "Russian texts", russian_url()) self.request(RUSSIAN_KEY) def request_voice(self) -> None: """The narration's manifest (the readings' lengths), fetched once the voice is on.""" if VOICE_KEY not in self.sources: self.sources[VOICE_KEY] = Source(VOICE_KEY, "The voice's manifest", app_file_url(f"voice/{Lang.code}/manifest.json")) self.request(VOICE_KEY) def request_making_of(self) -> None: """The making-of's text, fetched once, when its tab opens; its pictures come with its blocks.""" if MAKING_OF_KEY not in self.sources: self.sources[MAKING_OF_KEY] = Source(MAKING_OF_KEY, "The making-of's text", making_of_url("making_of.md")) self.request(MAKING_OF_KEY) def add_image(self, im: Image) -> None: if im.key not in self.sources: self.sources[im.key] = im.source() def request_large(self, im: Image) -> str: """Fetches the large version of an image, once: the enlarged view shows it when it comes.""" key = im.key + ":large" if key not in self.sources: self.sources[key] = Source(key, im.title + ", large", im.large_url, "" if im.url else commons_page_url(im.file), im.credit) self.request(key) return key def refetch_all(self) -> None: self.say("Fetching everything again") self.raw.clear() self.textures.clear() self.campaign = None for k, s in self.sources.items(): self.sources[k] = Source(k, s.title, s.url, s.page_url, s.credit) self.request_first() async def fetch(self, key: str) -> None: """Images one at a time, spaced, with two retries: Wikimedia limits the requests' rate.""" src = self.sources[key] if not src.is_image: await self.fetch_once(src) return if self.image_lock is None: self.image_lock = asyncio.Lock() async with self.image_lock: for attempt in range(3): if attempt > 0: self.say(f"Retrying {src.title} in {4 * attempt} s") await asyncio.sleep(4 * attempt) await self.fetch_once(src) if src.state == "ready": break await asyncio.sleep(self.IMAGE_GAP_SECONDS) async def fetch_once(self, src: Source) -> None: key = src.key src.state, src.error, src.started = "fetching", "", time.perf_counter() host = src.url.split("/")[2] self.say(f"GET {host}: {src.file_name if src.is_image else src.url.split('/')[-1]}") try: data = await immapp.download_url_bytes_async(src.url) except Exception as e: # a network error: the panel says so data, src.error = b"", str(e) src.seconds = time.perf_counter() - src.started if not data: src.state, src.error = "failed", src.error or "No answer from the server (offline?)" self.say(f"FAILED {src.title}: {src.error}") return src.size = len(data) self.raw[key] = data if src.is_table: src.rows = max(0, data.decode("utf-8").count("\n") - 1) src.state = "ready" self.say(f"{src.title}: {human_bytes(src.size)}{f', {src.rows} rows' if src.is_table else ''}, " f"in {src.seconds:.2f} s") if key == RUSSIAN_KEY: try: use_russian(data) except (ValueError, KeyError) as e: # not the file the app expects src.state, src.error = "failed", f"Not the Russian texts: {e}" self.say(f"FAILED {src.title}: {src.error}") if all(k in self.raw for k in ("troops", "cities", "temp")) and self.campaign is None: try: self.campaign = parse_campaign(self.raw["troops"], self.raw["cities"], self.raw["temp"]) self.say("Paths dated: the advance by city dates, the retreat by Minard's temperature dates") except Exception as e: # a changed file format: say so rather than crash self.say(f"FAILED to read Minard's tables: {e}") def texture(self, key: str) -> Optional[hello_imgui.TextureGpu]: """An image as a GPU texture, made from its bytes on first use (on the GUI thread).""" if key in self.textures: return self.textures[key] src = self.sources.get(key) if src is None or src.state != "ready": return None try: tex = hello_imgui.create_texture_gpu_from_rgba_data(immapp.decode_image(self.raw[key], 4)) except ValueError as e: # the server sent something else (an error page) src.state, src.error = "failed", f"Not an image: {e}" return None self.textures[key] = tex return tex def parse_campaign(troops: bytes, cities: bytes, temp: bytes) -> Campaign: c = Campaign() # Minard's dated temperature readings: they date the retreat. One date is missing in the # table; the "days" column (days since the previous reading) gives it: 24 November. month_of = {m[:3]: i + 1 for i, m in enumerate(MONTHS)} readings = [] prev_day = 0 for r in csv.DictReader(io.StringIO(temp.decode("utf-8"))): d = r["date"].strip() day = day_of(month_of[d[:3]], int(d[3:])) if d else prev_day + int(r["days"]) readings.append((float(r["long"]), float(r["temp"]), day)) # On the map, where his table draws it (his longitudes are rounded) c.temps.append((scan_place(MINARD_READINGS_X[int(r["rownames"])], 0.0)[0], float(r["temp"]), day)) prev_day = day retreat_anchors = sorted([(lon, d) for lon, _, d in readings] + [RETREAT_END]) advance_anchors = sorted(ADVANCE_DATES) def interp(lon: float, anchors: List[Tuple[float, int]]) -> float: if lon <= anchors[0][0]: return anchors[0][1] for (l0, d0), (l1, d1) in zip(anchors, anchors[1:], strict=False): if lon <= l1: return lerp(d0, d1, (lon - l0) / (l1 - l0)) return anchors[-1][1] rows = list(csv.DictReader(io.StringIO(troops.decode("utf-8")))) placed = [r for r in rows if int(r["rownames"]) in MINARD_PLACES] bend = c.bend = MinardBend([(float(r["long"]) * X_SCALE, float(r["lat"]) * Y_STRETCH) for r in placed], [scan_place(*MINARD_PLACES[int(r["rownames"])]) for r in placed]) groups: Dict[int, Path] = {} main_at: Dict[float, int] = {} # the main army's points on the way in, by longitude stacked: List[Tuple[Path, int, float]] = [] # the detachments' points along it: (path, index, longitude) for r in rows: g = int(r["group"]) p = groups.setdefault(g, Path(g, [], [], [], [], [])) lon, retreat = float(r["long"]), r["direction"].strip() == "R" t = interp(lon, retreat_anchors if retreat else advance_anchors) if retreat and p.retreat and not p.retreat[-1]: # Where a march turns back, it halts until its retreat begins (five weeks in Moscow) p.x.append(p.x[-1]) p.y.append(p.y[-1]) p.men.append(p.men[-1]) p.retreat.append(False) p.day.append(max(p.day[-1], t - 1.0)) x, y = 0.0, 0.0 if int(r["rownames"]) in MINARD_PLACES: x, y = bend(lon, float(r["lat"])) else: stacked.append((p, len(p.x), lon)) # placed below, on the main army's band if g == 1 and not retreat: main_at[lon] = len(p.x) p.x.append(x) p.y.append(y) p.men.append(int(float(r["survivors"]))) p.retreat.append(retreat) p.day.append(max(t, p.day[-1]) if p.day else t) # time only moves forward c.paths = [groups[g] for g in sorted(groups)] stack_detachments(groups[1], main_at, stacked) c.kowno_x = groups[1].x[-1] for r in csv.DictReader(io.StringIO(cities.decode("utf-8"))): name = CITY_SPELLINGS.get(r["city"], r["city"]) x, y = scan_place(*MINARD_CITY_PLACES[name]) if name in MINARD_CITY_PLACES else bend(float(r["long"]), float(r["lat"])) c.cities.append((name, x, y)) c.rivers = [(name, [[bend(lon, lat) for lon, lat in line] for line in lines]) for name, lines in RIVERS] return c def stack_detachments(main: Path, main_at: Dict[float, int], stacked: List[Tuple[Path, int, float]]) -> None: """Where a detachment marches with the main army (the same longitude, on the way in), its band lies along the main band's north side, after those already there: Minard's band, 422,000 wide at Kowno, is all of them.""" above: Dict[float, float] = {} # the men already along the main band's north side, by longitude for p, k, lon in stacked: j = main_at[lon] normals = [] # of the main band's segments at that point: their mean, as its miter for a, b in ((j - 1, j), (j, j + 1)): if a >= 0 and b < len(main.x): dx, dy = main.x[b] - main.x[a], main.y[b] - main.y[a] length = math.hypot(dx, dy) normals.append((-dy / length, dx / length)) # north of a band going east nx, ny = sum(n[0] for n in normals), sum(n[1] for n in normals) m = math.hypot(nx, ny) nx, ny = nx / m, ny / m scale = 1.0 / max(nx * normals[-1][0] + ny * normals[-1][1], 0.4) # the width kept round the turn offset = scale * BAND_PER_MAN * (main.men[j] / 2 + above.get(lon, 0.0) + p.men[k] / 2) p.x[k], p.y[k] = main.x[j] + nx * offset, main.y[j] + ny * offset above[lon] = above.get(lon, 0.0) + p.men[k] def rejoined(p: Path, day: float) -> bool: """A detachment whose path ends well before the campaign does has joined the main army.""" return p.day[-1] < LAST_DAY - 5 and day > p.day[-1] def position(p: Path, day: float) -> Optional[Tuple[int, float]]: """Where a path is on a given day: (index of the last point passed, fraction of the next segment), or None before it starts.""" if day < p.day[0]: return None for i in range(len(p.day) - 1): if day < p.day[i + 1]: span = p.day[i + 1] - p.day[i] return i, (day - p.day[i]) / span if span > 0 else 1.0 return len(p.day) - 1, 0.0 def heading(p: Path, at: Tuple[int, float]) -> str: """Where a march is going: "advancing", "retreating", or "" at a halt.""" i, j = at[0], min(at[0] + 1, len(p.x) - 1) if (p.x[i], p.y[i]) == (p.x[j], p.y[j]): return "" return "retreating" if p.retreat[j] else "advancing" # --------------------------------------------------------------------------- # The app # --------------------------------------------------------------------------- class App: SPEEDS = [5.0, 10.0, 20.0] FADE_SECONDS = 0.35 def __init__(self) -> None: self.data = DataStore() self.day = 0.0 self.playing = False self.autoplay_done = False self.speed_idx = 1 self.show_events = True # Playback stops at each card for its reading time; the mouse over the card holds it longer self.hold = 0.0 self.hold_total = 0.0 self.card_hovered = False self.card_event: Optional[Event] = None self.card_alpha = 0.0 self.card_seconds = 0.0 # since the card came: the author's note arrives a while after the last one self.about_seconds, self.about_frame = 0.0, -2 # the same under About, since the tab was opened self.making_of: Optional[List[Block]] = None # the making-of's text, parsed once it has come self.page_open = "" # "making_of" or "about": that one over the whole page, the map put away self.page_resume = False # whether the animation was playing when the page opened self.page_paused_intro = False # the intro's tour, paused while the page is open self.page_chapter = "" # the making-of's chapter asked for, kept in the address self.page_anchor_until = 0.0 # until then, the page scrolls to that chapter (the pictures above it come late) self.anchor_y: Optional[float] = None # where the chapter asked for starts, in the column, this frame self.guestbook = Guestbook() self.show_data = False # the window of the data and the downloads (the gear, or a click on the warning) self.narrator = Narrator() self.voice_pref: Optional[bool] = None # read once, at the first frame self.voiced_slide = -1 # the intro's slide read last self.voiced_card: Optional[Event] = None # the card read last self.start_at = page_fragment() # where the address says to open, applied at the first frame self.fragment = "" # the address's fragment, as last set self.by_hand = False # this frame's move was the reader's (an arrow, a click, a page): the history gets it self.popped = False # this frame's move came from the browser's Back or Forward: the address is theirs self.last_side_tab = "" if sys.platform == "emscripten": # the browser's Back and Forward apply the address they bring import js from pyodide.ffi import create_proxy self.on_popstate_proxy = create_proxy(lambda event: self.on_popstate()) js.window.addEventListener("popstate", self.on_popstate_proxy) # The intro: a slide index, its fade and countdown, then the crossfade to the animation self.intro_slide = 0 self.intro_alpha = 0.0 self.intro_hold = 0.0 self.intro_auto = True # it advances by itself until the viewer navigates, or pauses it self.intro_paused = False # paused with the round button (or Space): the reading pan waits too self.intro_hold_total = 0.0 self.intro_cover = 1.0 # the original's opacity over the animation: 1 during the intro, 0 after self.intro_leaving = False self.resume_playing = False self.frame = INTRO[0].frame self.camera = (0.0, 0.0, 1.0, 1.0) # the part of the original the intro shows (slide_camera) self.pan_clock = 0.0 # seconds of the slide's reading pan self.show_words = False # Minard's words, typed out, under a slide that frames his handwriting self.goto_slide(0, auto=True) # The enlarged image self.lightbox: Optional[Image] = None self.lightbox_key = "" self.lightbox_pending = False self.lightbox_frame = 0 # Its zoom: 1 fits the image in the view; 0 until its first frame. A zoom asked by a button: (zoom, at) self.lightbox_zoom = 0.0 self.lightbox_start_zoom = 1.0 self.lightbox_scroll = ImVec2(0, 0) self.lightbox_request: Optional[Tuple[float, ImVec2]] = None self.lightbox_press_beside = False # the last press was beside the image, on nothing self.lightbox_mouse = ImVec2(0, 0) # the last known mouse position # The look, chosen in the settings: the theme and the text font self.themed = False self.period_font = True self.text_size = 100 # percent self.map_font: Optional[imgui.ImFont] = None # these three, set by use_fonts() self.year_fonts: Dict[str, imgui.ImFont] = {} # the title's 1812 and 2026, each in the type of its time self.text_font: Optional[imgui.ImFont] = None self.plain_font: Optional[imgui.ImFont] = None # The markdown contexts: ImmApp's, and one with the plain font self.md_period: Optional[imgui_md.Context] = None self.md_plain: Optional[imgui_md.Context] = None self.select_tab = "intro" # a tab to select at the next frame ("intro", "story"), or "" self.original_on_map: Optional[Tuple[ImVec2, ImVec2]] = None # where the original lies on the map self.map_box: Optional[Tuple[ImVec2, ImVec2]] = None # the map's plot area: position, size # The comparison toggle: Minard's original over the redrawing, faded in and out; playback waits meanwhile self.compare = False self.compare_level = 0.0 # 0: the redrawing, 1: the original self.compare_day = 0.0 # the day it was turned on: a move in time turns it off self.reset_view = True # The map's and the temperatures' heights, kept: ImPlot updates them on resize self.ratios = implot.SubplotsRowColRatios([3.0, 1.0], [1.0]) self.ratios_narrow = implot.SubplotsRowColRatios([2.2, 1.3], [1.0]) self.narrow = False self.small_story = False self.counts_h = 0.0 # the height of the date's and the counts' lines, last drawn self.temperatures_h = 0.0 self.row_y: Optional[float] = None # the y of the frame items beside the round Play button self.side_tab = "intro_tab" # the tab shown beside the map self.phone_upright = False # a narrow screen held upright self.map_in_view = True # on a phone, the map can be scrolled away: playback and the slides then wait self.compare_inline = False # the comparison toggle ends the controls' second row (a phone) # A move to a card by hand glides there: (from day, to day, seconds since it began, duration, card) self.glide: Optional[Tuple[float, float, float, float, int]] = None self.turn_hint: Optional[bool] = None # the hint to turn the phone: None until the pref is read self.page_told = False # in a browser, the loading screen stays until the intro can show the original self.turn_hint_shown = False @property def in_intro(self) -> bool: return self.intro_cover > 0.0 # ----- per frame --------------------------------------------------------------- def gui(self) -> None: d = self.data dt = min(imgui.get_io().delta_time, 0.1) if not d.started: d.start() if Lang.wanted == "ru" and RUSSIAN: # the Russian texts have come Lang.code, Lang.wanted = "ru", "" elif Lang.wanted == "ru" and d.sources[RUSSIAN_KEY].state == "failed": # the Data tab says why Lang.wanted = "" if self.voice_pref is None: # the first frame: the reader's lasting choices, the language and the voice saved = pref_get("language") if saved in LANGUAGE_NAMES and saved != Lang.code: self.choose_language(saved, remember=False) self.voice_pref = pref_get("voice") == "1" if self.voice_pref: self.narrator.on = True d.request_voice() if not self.page_told: original = d.sources[MINARD_MAP.key] if original.state in ("ready", "failed") or imgui.get_time() > 5.0: self.page_told = True tell_page_ready() self.update_look() plain = not self.period_font and self.plain_font is not None if plain: imgui.push_font(self.plain_font, 0.0) self.update_glide(dt) if self.compare and self.day != self.compare_day: self.set_compare(False) # back to the animated view step = dt / COMPARE_FADE level = self.compare_level + (step if self.compare else -step) self.compare_level = min(1.0, max(0.0, level)) self.update_intro(dt) if self.start_at: self.apply_start_at() if d.campaign is not None and not self.in_intro and not self.autoplay_done: self.autoplay_done = True self.playing = True self.goto_event(0) if self.playing: self.advance(dt) self.handle_keys() note_arriving = ((self.card_event is EVENTS[-1] and self.card_seconds < NOTE_DELAY + NOTE_FADE) or (self.side_tab == "about" and self.about_seconds < NOTE_DELAY_ABOUT + NOTE_FADE)) moving = (self.playing or self.in_intro or self.card_alpha < 1.0 or note_arriving or self.lightbox is not None or 0.0 < self.compare_level < 1.0 or self.glide is not None) hello_imgui.get_runner_params().fps_idling.enable_idling = not (moving or d.busy) display = imgui.get_io().display_size self.phone_upright = display.x < 600 and display.y > 1.2 * display.x self.gui_header() self.gui_turn_hint() avail = imgui.get_content_region_avail() self.narrow = avail.x < em_size(52.0) if self.page_open: self.gui_page() elif self.narrow: imgui.begin_child("story", ImVec2(0, 0)) w = imgui.get_content_region_avail().x self.gui_story(ImVec2(w, max(em_size(20.0), avail.y * 0.62))) self.gui_side() imgui.end_child() else: panel_w = max(em_size(19.0), avail.x * 0.28) imgui.begin_child("story", ImVec2(avail.x - panel_w - imgui.get_style().item_spacing.x, avail.y)) self.gui_story(imgui.get_content_region_avail()) imgui.end_child() imgui.same_line() imgui.begin_child("panel", ImVec2(0, avail.y)) self.gui_side() imgui.end_child() self.narrator.update() if self.narrator.on and not self.narrator.seconds and d.sources.get(VOICE_KEY) is not None \ and d.sources[VOICE_KEY].state == "ready": manifest = json.loads(d.raw[VOICE_KEY].decode("utf-8")) self.narrator.seconds = {k: float(v["seconds"]) for k, v in manifest.items()} self.gui_data_window() self.gui_lightbox() self.update_fragment() if plain: imgui.pop_font() def on_popstate(self) -> None: self.start_at, self.popped = page_fragment() or "/", True def apply_start_at(self) -> None: """Opens where the address says: a page (and its chapter), an intro slide, a card, a tab; "/" is the bare address (Back to where it all started). Not a move by hand: the address is already the browser's.""" target, self.start_at = self.start_at, "" self.open_target(target) self.by_hand = False def open_target(self, target: str) -> None: name, _, rest = target.partition("/") if self.page_open and name not in ("making-of", "about"): self.close_page() if name in ("making-of", "about"): self.open_page(name.replace("-", "_"), rest) return if name == "map": if not self.in_intro: self.show_original() self.goto_slide(int(rest) - 1 if rest.isdigit() else 0) self.intro_paused = True return if name == "context": self.leave_intro_now() self.select_tab = name return if name == "data": self.show_data = True return index = int(rest) - 1 if name == "step" and rest.isdigit() else -1 if name != "step": index = next((i for i, ev in enumerate(EVENTS) if slug(ev.title) == name), -1) if 0 <= index < len(EVENTS): self.leave_intro_now() self.goto_event(index) self.playing = False def leave_intro_now(self) -> None: """Straight to the animation, without the intro's fade: the address asked for a card or a tab.""" self.intro_cover, self.intro_leaving, self.autoplay_done = 0.0, True, True self.select_tab = "story" def update_fragment(self) -> None: """The address's fragment follows the reader: the open page and its chapter, the intro's slide, the tab, the card.""" if self.page_open: fragment = self.page_open.replace("_", "-") + ("/" + self.page_chapter if self.page_chapter else "") elif self.in_intro: fragment = f"map/{self.intro_slide + 1}" if self.intro_slide > 0 else "" elif self.side_tab == "context": fragment = self.side_tab elif self.data.campaign is not None and self.autoplay_done: fragment = slug(EVENTS[self.current_index()].title) else: fragment = "" if self.side_tab != self.last_side_tab: self.by_hand = self.by_hand or self.last_side_tab != "" self.last_side_tab = self.side_tab if fragment != self.fragment: self.fragment = fragment set_page_fragment(fragment, push=self.by_hand and not self.popped) self.by_hand = self.popped = False def gui_turn_hint(self) -> None: """Once: on a phone held upright, a line suggesting to turn it. A tap on OK, or turning the phone, puts it away for good (a lasting pref: on the web, the browser's local storage).""" if self.turn_hint is None: self.turn_hint = pref_get("turn_hint_seen") != "1" if not self.turn_hint: return display = imgui.get_io().display_size if display.x > display.y and self.turn_hint_shown: # the phone was turned: the hint did its job self.dismiss_turn_hint() return if not self.phone_upright: return self.turn_hint_shown = True imgui.push_style_color(imgui.Col_.child_bg, PAPER) flags = (imgui.ChildFlags_.borders | imgui.ChildFlags_.auto_resize_y | imgui.ChildFlags_.always_use_window_padding) imgui.begin_child("turn_hint", ImVec2(0, 0), flags) imgui.pop_style_color() text = f"{fa.ICON_FA_MOBILE} {tr('Turn your phone for a larger map.')}" ok_w = imgui.calc_text_size("OK").x + 2 * imgui.get_style().frame_padding.x imgui.push_text_wrap_pos(imgui.get_content_region_avail().x - ok_w - em_size(0.8)) imgui.align_text_to_frame_padding() imgui.text_wrapped(text) imgui.pop_text_wrap_pos() imgui.same_line(imgui.get_window_width() - ok_w - imgui.get_style().window_padding.x) if imgui.button("OK###turn_hint_ok"): self.dismiss_turn_hint() imgui.end_child() def dismiss_turn_hint(self) -> None: self.turn_hint = False pref_set("turn_hint_seen", "1") def update_look(self) -> None: """Minard's paper and ink, and the text font and size chosen in the settings.""" if not self.themed: apply_theme() self.themed = True imgui.get_style().font_scale_main = self.text_size / 100.0 # The text: ImmApp's markdown context has the period font; another one, made when first needed, the plain one if self.md_period is None: self.md_period = imgui_md.get_current_context() plain = not self.period_font and self.text_font is not None if plain and self.md_plain is None: self.md_plain = imgui_md.create_context(markdown_options()) imgui_md.set_current_context(self.md_plain if plain and self.md_plain is not None else self.md_period) def before_exit(self) -> None: """Destroys the second markdown context while the renderer lives: ImmApp destroys its own.""" if self.md_plain is not None and self.md_period is not None: imgui_md.set_current_context(self.md_period) imgui_md.destroy_context(self.md_plain) self.md_plain = None def push_map_font(self) -> None: """The map's lettering, a little larger: its letters are smaller than the app's.""" imgui.push_font(self.map_font, imgui.get_font_size() * (1.25 if self.map_font is not None else 1.0)) def voice_drives(self) -> bool: """With the voice on, the voice alone sets the pace: no countdown, no hold for the map out of view or the mouse on the card; a pause is the reader's, by the Play button (Pascal, Oct 23, 21:18).""" return self.narrator.on and self.narrator.available() def advance(self, dt: float) -> None: """Moves the playhead, stopping at each card for its reading time, or for its reading by the voice.""" if self.lightbox is not None or self.glide is not None or (not self.map_in_view and not self.voice_drives()): return # the viewer is looking at an image, going to a card, or reading below the map if self.voice_drives(): if self.voiced_card is not self.card_event or self.narrator.reading(): return # the card's reading has not started, or goes on if self.narrator.just_ended(): self.hold = 0.0 # it ended: the next card now (a missing file leaves the timed pace) if self.hold > 0.0: if not self.card_hovered: self.hold -= dt return new_day = self.day + dt * self.SPEEDS[self.speed_idx] if self.show_events: reached = [i for i, ev in enumerate(EVENTS) if self.day < ev.start <= new_day] if reached: self.goto_event(reached[0]) if self.side_tab != "story": self.select_tab = "story" # the card must not go unseen behind another tab if reached[0] == len(EVENTS) - 1: self.set_compare(True) # the campaign is over: Minard's original return self.day = new_day if self.day >= LAST_DAY: self.day, self.playing = float(LAST_DAY), False def set_compare(self, on: bool) -> None: """Fades to Minard's original (playback waits), or back to the animated view.""" self.compare = on if on: self.playing = False self.compare_day = self.day self.data.request_large(MINARD_MAP) # sharper than the thumbnail, if it comes in time def goto_event(self, i: int, glide: bool = False) -> None: """Goes to a card; by hand (glide), the band grows or shrinks to it in a fraction of a second.""" ev = EVENTS[i] self.by_hand = self.by_hand or glide target = float(ev.start) self.hold = self.hold_total = ev.reading_seconds() if self.show_events else 0.0 if glide and abs(target - self.day) > 0.5: duration = 0.35 + 0.45 * min(1.0, abs(target - self.day) / 60.0) self.glide = (self.day, target, 0.0, duration, i) else: self.glide = None self.day = target def update_glide(self, dt: float) -> None: if self.glide is None: return start, target, t, duration, i = self.glide t += dt self.day = lerp(start, target, smooth(t / duration)) self.glide = None if t >= duration else (start, target, t, duration, i) def handle_keys(self) -> None: if imgui.get_io().want_text_input or self.lightbox is not None: return if self.page_open: if imgui.is_key_pressed(imgui.Key.escape, False): self.close_page() return if imgui.is_key_pressed(imgui.Key.space, False): if self.in_intro: self.toggle_slides() else: self.toggle_play() step = (imgui.is_key_pressed(imgui.Key.right_arrow) - imgui.is_key_pressed(imgui.Key.left_arrow)) if step == 0: return if self.in_intro: if step < 0: self.slide_back() else: self.goto_slide(self.intro_slide + 1) elif self.data.campaign is not None: if step < 0: self.card_back(self.current_index()) else: self.goto_event(min(self.current_index() + 1, len(EVENTS) - 1), glide=True) def toggle_play(self) -> None: if not self.playing and self.day >= LAST_DAY: self.goto_event(0) # at the end, Play starts over self.playing = not self.playing if self.playing and self.compare: self.set_compare(False) # back to the animated view if self.playing: self.narrator.resume() else: self.narrator.pause() def toggle_voice(self) -> None: """The author's voice, on or off; on, the current slide or card is read at once.""" n = self.narrator n.on = not n.on pref_set("voice", "1" if n.on else "0") if not n.on: n.stop() return self.data.request_voice() self.voiced_slide, self.voiced_card = -1, None # read again, from here def read_slide(self, i: int) -> None: """The intro's slide i, read; the tour waits for the end of the reading.""" n = self.narrator self.voiced_slide = i n.play([f"intro_{i + 1:02d}"]) def read_card(self, i: int) -> None: """Card i, read, and the author's note after the last one; the animation waits for the end.""" n = self.narrator self.voiced_card = EVENTS[i] n.play([f"card_{i + 1:02d}"] + (["note"] if i == len(EVENTS) - 1 else [])) # ----- the intro ------------------------------------------------------------------- def update_intro(self, dt: float) -> None: if not self.in_intro: return ready = self.data.texture(MINARD_MAP.key) is not None if self.data.sources[MINARD_MAP.key].state == "failed" and not self.intro_leaving: self.skip_intro() # no original to show: straight to the animation if self.intro_leaving: self.intro_cover = max(0.0, self.intro_cover - dt / 1.8) k = 1.0 - math.exp(-dt * 12.0) # quickly back to the whole map, which slides onto the redrawing self.camera = lerp_box(self.camera, (0.0, 0.0, 1.0, 1.0), k) return if not ready: return self.data.request_large(MINARD_MAP) # the full scan, for the zoom (once) self.intro_alpha = min(1.0, self.intro_alpha + dt / 0.6) s = INTRO[self.intro_slide] k = 1.0 - math.exp(-dt * 6.0) # the frame glides to the slide's part of the map self.frame = lerp_box(self.frame, s.frame, k) if page_holds(): # the loading screen is still over the app: the slides and the pan wait return if self.narrator.on and self.narrator.available() and self.voiced_slide != self.intro_slide \ and self.intro_auto and not self.intro_paused: # the tour runs: the play button says so self.read_slide(self.intro_slide) if self.lightbox is None and self.map_in_view and not self.intro_paused: self.pan_clock += dt pan_t = min(1.0, max(0.0, (self.pan_clock - PAN_DELAY) / PAN_SECONDS)) k = 1.0 - math.exp(-dt * 4.0) # the camera follows, a little slower self.camera = lerp_box(self.camera, slide_camera(s, pan_t), k) if self.intro_auto and self.lightbox is None and (self.map_in_view or self.voice_drives()): if self.voice_drives() and (self.voiced_slide != self.intro_slide or self.narrator.reading()): pass # the voice alone sets the pace: the slide's reading has not started, or goes on elif self.voice_drives() and self.narrator.just_ended(): self.intro_hold = 0.0 else: self.intro_hold -= dt if self.intro_hold <= 0.0: if self.intro_slide == len(INTRO) - 1: self.skip_intro() else: self.goto_slide(self.intro_slide + 1, auto=True) def goto_slide(self, i: int, auto: bool = False) -> None: self.by_hand = self.by_hand or not auto self.intro_slide = min(max(i, 0), len(INTRO) - 1) self.intro_alpha = 0.0 s = INTRO[self.intro_slide] self.intro_hold = min(12.0, max(4.5, 2.0 + (len(s.heading().split()) + len(s.body().split())) / 3.5)) if s.pan: # the reading pan goes to the line's end first self.intro_hold = max(self.intro_hold, PAN_DELAY + PAN_SECONDS + 2.0) self.intro_hold_total = self.intro_hold self.pan_clock, self.intro_paused = 0.0, False self.intro_auto = self.intro_auto and auto def toggle_slides(self) -> None: """The intro's tour: paused (the reading pan too, and the voice), or going on from this slide.""" self.intro_auto = not self.intro_auto self.intro_paused = not self.intro_auto if self.intro_paused: self.narrator.pause() else: self.narrator.resume() def skip_intro(self) -> None: self.narrator.stop() self.intro_leaving = True self.select_tab = "story" if self.autoplay_done: # back from the original: the animation goes on where it was self.playing = self.resume_playing def restart_campaign(self) -> None: """June 1812: the chronology from its first step, playing (the step read again), from wherever one is.""" if self.in_intro: self.leave_intro_now() self.narrator.stop() self.set_compare(False) self.glide = None self.goto_event(0) self.card_alpha, self.card_seconds, self.voiced_card = 0.0, 0.0, None # the first step, read again self.playing = True self.select_tab = "story" self.by_hand = True def goto_end(self) -> None: """The end: the last card, the animation paused, and the author's note that comes with it.""" if self.in_intro: self.leave_intro_now() self.narrator.stop() self.set_compare(False) self.glide = None self.goto_event(len(EVENTS) - 1) self.playing = False self.select_tab = "story" self.by_hand = True def restart_intro(self) -> None: """The intro from its first slide, the tour running (the voice with it, when on); the animation goes on where it was once the intro ends.""" self.show_original() self.intro_auto, self.intro_paused = True, False self.by_hand = True def show_original(self) -> None: """Minard's original again, with the intro's slides; leaving it, the animation resumes.""" self.resume_playing = self.playing self.playing = False self.narrator.stop() self.voiced_slide = -1 self.intro_cover, self.intro_leaving, self.intro_auto = 1.0, False, False self.select_tab = "intro" self.goto_slide(0) def draw_intro(self, p0: ImVec2, size: ImVec2) -> None: """Minard's original on its paper, a frame on the part being told, and the slide's text.""" dl = imgui.get_window_draw_list() cover = smooth(self.intro_cover) p1 = ImVec2(p0.x + size.x, p0.y + size.y) dl.add_rect_filled(p0, p1, u32(PAPER, cover)) tex = self.data.texture(MINARD_MAP.key + ":large") or self.data.texture(MINARD_MAP.key) # sharper, zoomed if tex is None: text = tr("Fetching Minard's map...") dl.add_text(ImVec2(p0.x + (size.x - imgui.calc_text_size(text).x) / 2, p0.y + size.y / 2), u32(INK, cover), text) return m = em_size(0.8) wide = size.x > em_size(40.0) text_h = self.intro_text_height(size.x - 2 * m, wide) aspect = tex.width / tex.height img_w = min(size.x - 2 * m, (size.y - text_h - 2 * m) * aspect) beside_text = scroll_text = False if img_w < 0.6 * (size.x - 2 * m): # no room for both scroll_text = True # the text scrolls if long if size.x > 1.5 * size.y: # a short, wide area (a phone held sideways): the text beside the image beside_text = True img_w = min(0.55 * (size.x - 3 * m), (size.y - 2 * m) * aspect) text_h = size.y - 2 * m else: # a tall, narrow one (a phone held upright): the image on top, the text below img_w = min(size.x - 2 * m, size.y * 0.45 * aspect) text_h = size.y - img_w / aspect - 2.5 * m img_h = img_w / aspect if beside_text: i0 = ImVec2(p0.x + m, p0.y + (size.y - img_h) / 2) else: block_h = img_h + text_h + m * 0.5 i0 = ImVec2(p0.x + (size.x - img_w) / 2, p0.y + max(m, (size.y - block_h) / 2)) i1 = ImVec2(i0.x + img_w, i0.y + img_h) image_alpha = cover if self.intro_leaving and self.original_on_map is not None: # First half: the original slides onto the redrawing; second half: it fades t = 1.0 - self.intro_cover k = smooth(t * 2.0) a0, a1 = self.original_on_map i0 = ImVec2(lerp(i0.x, a0.x, k), lerp(i0.y, a0.y, k)) i1 = ImVec2(lerp(i1.x, a1.x, k), lerp(i1.y, a1.y, k)) image_alpha = 1.0 - smooth((t - 0.5) * 2.0) cx0, cy0, cx1, cy1 = self.camera # the part of the original shown def on_screen(u: float, v: float) -> ImVec2: return ImVec2(i0.x + (u - cx0) / (cx1 - cx0) * (i1.x - i0.x), i0.y + (v - cy0) / (cy1 - cy0) * (i1.y - i0.y)) dl.push_clip_rect(p0, p1, True) dl.add_image(imgui.ImTextureRef(tex.texture_id()), i0, i1, ImVec2(cx0, cy0), ImVec2(cx1, cy1), u32(imgui.ImVec4(1, 1, 1, 1), image_alpha)) dl.pop_clip_rect() if self.intro_leaving: return # The part being told: the rest is dimmed, and a frame goes round it fx0, fy0, fx1, fy1 = self.frame f0, f1 = on_screen(fx0, fy0), on_screen(fx1, fy1) x0, x1 = min(max(f0.x, i0.x), i1.x), min(max(f1.x, i0.x), i1.x) # the frame may run past the view y0, y1 = min(max(f0.y, i0.y), i1.y), min(max(f1.y, i0.y), i1.y) dim = u32(INK, 0.30) dl.add_rect_filled(i0, ImVec2(i1.x, y0), dim) dl.add_rect_filled(ImVec2(i0.x, y1), i1, dim) dl.add_rect_filled(ImVec2(i0.x, y0), ImVec2(x0, y1), dim) dl.add_rect_filled(ImVec2(x1, y0), ImVec2(i1.x, y1), dim) if fx1 - fx0 < 0.99: dl.push_clip_rect(i0, i1, True) draw_hand_drawn_frame(dl, f0, f1, u32(FRAME_RED), max(2.0, em_size(0.13))) dl.pop_clip_rect() c0, c1 = draw_magnifier(dl, i1) # the original, large back = imgui.get_cursor_screen_pos() imgui.set_cursor_screen_pos(c0) if imgui.invisible_button("###intro_zoom", ImVec2(c1.x - c0.x, c1.y - c0.y)): self.open_lightbox(MINARD_MAP) if imgui.is_item_hovered(): imgui.set_mouse_cursor(imgui.MouseCursor_.hand) imgui.set_cursor_screen_pos(back) # The slide's text s = INTRO[self.intro_slide] if beside_text: t0 = ImVec2(i1.x + m, p0.y + m) width = p1.x - m - t0.x else: t0 = ImVec2(p0.x + m, i1.y + m * 0.5) width = size.x - 2 * m back = imgui.get_cursor_screen_pos() imgui.set_cursor_screen_pos(ImVec2(t0.x, t0.y)) imgui.push_style_var(imgui.StyleVar_.alpha, self.intro_alpha) imgui.push_style_color(imgui.Col_.text, INK) imgui.push_style_color(imgui.Col_.text_link, LINK_ON_PAPER) imgui.push_style_color(imgui.Col_.child_bg, imgui.ImVec4(0, 0, 0, 0)) scrolls = scroll_text or (self.show_words and bool(s.words)) # his words are not in the text's height imgui.begin_child("intro_text", ImVec2(width, text_h), imgui.ChildFlags_.always_use_window_padding, 0 if scrolls else imgui.WindowFlags_.no_scrollbar) large = wide and not beside_text # beside the image, the text has a narrow column imgui.push_font(None, em_size(1.3 if large else 1.05)) imgui_md.render_unindented(f"**{s.heading()}**") imgui.pop_font() imgui.push_font(None, em_size(1.1 if large else 0.92)) md = s.body() + "".join("\n\n" + source_line(name, url) for name, url in s.sources) imgui_md.render_unindented(md) if s.words: # a button opens them; reading them stops the slides' tour arrow = fa.ICON_FA_CARET_DOWN if self.show_words else fa.ICON_FA_CARET_RIGHT imgui.push_item_flag(imgui.ItemFlags_.no_nav, True) label = tr("Minard's words, typed out") if imgui.small_button(f"{arrow} {label}###minard_words"): self.show_words = not self.show_words if self.show_words: self.intro_auto = False imgui.set_scroll_here_y(0.0) # the button at the top of the text: his words below it imgui.pop_item_flag() if self.show_words: imgui_md.render_unindented(s.words_markdown()) imgui.pop_font() imgui.end_child() imgui.pop_style_color(3) imgui.pop_style_var() imgui.set_cursor_screen_pos(back) @staticmethod def intro_text_height(width: float, wide: bool) -> float: """The height the longest slide's text needs at this width: none is clipped, and the image does not move from one slide to the next. An estimate of the markdown's layout, with a margin.""" style = imgui.get_style() inner = width - 2 * style.window_padding.x title_k, body_k = (1.3, 1.1) if wide else (1.05, 0.92) source_k = body_k * 0.82 # def height(text: str, k: float) -> float: return imgui.calc_text_size(text, False, inner / k).y * k tallest = 0.0 for s in INTRO: h = height(s.heading(), title_k) + height(s.body(), body_k) h += sum(height(tr("Source: ") + tr(name), source_k) for name, _ in s.sources) h += (1 + len(s.sources)) * em_size(1.0) # the space between paragraphs if s.words: # the button that opens Minard's words (the words themselves scroll) h += em_size(1.8) tallest = max(tallest, h) return tallest + 2 * style.window_padding.y + em_size(1.5) def gui_intro_controls(self) -> None: """The round button at the left plays or pauses the points (as it does the chronology, once there); the points' navigation at the right, whose last step starts the chronology (the outline has the button too).""" imgui.push_item_flag(imgui.ItemFlags_.no_nav, True) ring = (self.intro_hold / max(self.intro_hold_total, 1e-3) if self.intro_auto and not self.voice_drives() else 0.0) # with the voice, no countdown if self.play_button(self.intro_auto, ring): self.toggle_slides() if imgui.is_item_hovered(): imgui.set_tooltip(tr("Pause the points (Space)" if self.intro_auto else "Play the points (Space)")) style = imgui.get_style() after_play = imgui.get_item_rect_max().x - imgui.get_window_pos().x + style.item_spacing.x last = self.intro_slide == len(INTRO) - 1 next_label = f"{fa.ICON_FA_PLAY} {self.start_label()}" if last else f"{tr('Next')} {fa.ICON_FA_CHEVRON_RIGHT}" count = f"{self.intro_slide + 1} / {len(INTRO)}" def row_width() -> float: return (em_size(2.6) + imgui.calc_text_size(count).x + imgui.calc_text_size(next_label).x + 2 * style.frame_padding.x + 2 * style.item_spacing.x) right = imgui.get_window_width() - style.window_padding.x if after_play + row_width() > right: # a narrow phone, the last point: the count goes, the words stay count = "" self.same_line_in_row(max(after_play, right - row_width())) if imgui.button(f"{fa.ICON_FA_CHEVRON_LEFT}###back", em_to_vec2(2.6, 0)): self.slide_back() if count: self.same_line_in_row() imgui.align_text_to_frame_padding() imgui.text_disabled(count) self.same_line_in_row() if imgui.button(f"{next_label}###next"): if last: self.skip_intro() else: self.goto_slide(self.intro_slide + 1) imgui.pop_item_flag() def start_label(self) -> str: return tr("Back to the chronology" if self.autoplay_done else "Start the chronology") # ----- header ---------------------------------------------------------------------- def gui_header(self) -> None: """The title, and two buttons at most: the author's voice (where a narration exists), and the gear, whose menu holds the language, the text's font and size, the install, the data (Pascal, Oct 23: fewer buttons).""" compact = imgui.get_window_width() < em_size(32.0) pad = imgui.get_style().frame_padding.x w = imgui.calc_text_size(fa.ICON_FA_COG).x + 2 * pad voiced = self.narrator.available() if voiced: w += imgui.calc_text_size(fa.ICON_FA_VOLUME_UP).x + 2 * pad + imgui.get_style().item_spacing.x # A phone held upright: the buttons on a row of their own, the title on the whole row below viewport = imgui.get_main_viewport().size two_rows = compact and viewport.y > viewport.x if two_rows: imgui.set_cursor_pos_x(imgui.get_window_width() - w - imgui.get_style().window_padding.x) else: self.gui_title(imgui.get_content_region_avail().x - w - em_size(0.5)) imgui.same_line(imgui.get_window_width() - w - imgui.get_style().window_padding.x) if voiced: icon = fa.ICON_FA_VOLUME_UP if self.narrator.on else fa.ICON_FA_VOLUME_OFF if imgui.small_button(f"{icon}###voice"): self.toggle_voice() if imgui.is_item_hovered(): imgui.set_tooltip(tr("The author's voice")) imgui.same_line() if imgui.small_button(f"{fa.ICON_FA_COG}###settings"): imgui.open_popup("settings") if imgui.is_item_hovered(): imgui.set_tooltip(tr("Settings")) self.gui_settings() if two_rows: self.gui_title(imgui.get_content_region_avail().x) # The data's status, only while it matters: at the start, or after a failure d = self.data first = [s for k, s in d.sources.items() if not s.is_image or k == MINARD_MAP.key] if any(s.state != "ready" and s.state != "failed" for s in first): text = tr("Fetching live data...") elif any(s.state == "failed" for s in d.sources.values()): text = f"{fa.ICON_FA_EXCLAMATION_TRIANGLE} {tr('Some files failed: see Data and downloads, under the gear')}" else: return imgui.text_disabled(text) if imgui.is_item_clicked(): self.show_data = True def gui_title(self, room: float) -> None: """The title on one line, as the loading page's cartouche: 1812, a full dot, dashes that quicken, the title, dashes, an empty circle, 2026. The full title where it fits, else the map's name (a phone, or a longer language); the dashes take what is left, up to 8 em each. The years keep their size: where room is short, the name alone gets smaller, down to the text's size; below that, the name shows without the years.""" font = self.map_font full_size = size = em_size(1.6 if font is not None else 1.35) year_size = size * 0.8 dot_r, gap = em_size(0.2), em_size(0.5) pad_x, pad_y = em_size(0.4), em_size(0.12) # the medallions' room around the digits def year_font(year: str) -> Optional[imgui.ImFont]: return self.year_fonts.get(year, font) fixed = (text_width("1812", year_size, year_font("1812")) + text_width("2026", year_size, year_font("2026")) + 4 * pad_x + 2 * (2 * dot_r + 3 * gap)) # the years in their medallions, the dots, the gaps title = tr("The Russian campaign, after Minard: a homage in motion") title_w = text_width(title, size, font) years = True if fixed + title_w + 2 * em_size(1.5) > room: # the map's name, smaller if need be title = tr("Minard's figurative map (1869)") title_w = text_width(title, size, font) if fixed + title_w > room: size = max(em_size(1.0), size * (room - fixed) / max(title_w, 1.0)) title_w = text_width(title, size, font) if fixed + title_w > room: # still too wide (a phone held upright): the name alone, at the width there is years, fixed = False, 0.0 full_w = text_width(title, full_size, font) size = max(em_size(0.8), min(full_size, full_size * room / max(full_w, 1.0))) title_w = text_width(title, size, font) if title_w > room: # even at its smallest (a large text size): the short name title = tr("Minard (1869)") full_w = text_width(title, full_size, font) size = max(em_size(0.8), min(full_size, full_size * room / max(full_w, 1.0))) title_w = text_width(title, size, font) if title_w > room: # still too wide: cut, with an ellipsis while len(title) > 1 and text_width(title + "…", size, font) > room: title = title[:-1] title = title.rstrip() + "…" title_w = text_width(title, size, font) run = min(em_size(8.0), (room - fixed - title_w) / 2) if years else 0.0 if run < em_size(1.0): run = 0.0 dl = imgui.get_window_draw_list() ink, soft = imgui.get_color_u32(INK), imgui.get_color_u32(INK_SOFT) start = imgui.get_cursor_screen_pos() x, y, mid = start.x, start.y, start.y + size * 0.55 if not years: imgui.push_font(font, size) imgui.text(title) imgui.pop_font() return for year in ("1812", "2026"): if year == "2026": if run > 0.0: draw_quickening(dl, x, x + run, mid, 0.5, 1.0, soft, solid_end=True) x += run + gap dl.add_circle(ImVec2(x + dot_r, mid), dot_r, ink, 0, max(1.0, em_size(0.07))) x += 2 * dot_r + gap # Each year in a medallion, a thin ellipse of ink: the two ends of the thread, which is the chronology: # 1812 its first step, 2026 its end. Tan under the mouse. The thread's dots stay beside them. imgui.push_font(year_font(year), year_size) yw = text_width(year, year_size, year_font(year)) left, top = x, y + size - year_size imgui.set_cursor_screen_pos(ImVec2(left, top - pad_y)) if imgui.invisible_button(f"###year_{year}", ImVec2(yw + 2 * pad_x, year_size + 2 * pad_y)): if year == "1812": self.restart_campaign() else: self.goto_end() hovered = imgui.is_item_hovered() if hovered: # the year's font has the digits only: the tooltip in the text's font imgui.push_font(self.text_font or self.plain_font, 0.0) imgui.set_tooltip(tr("June 1812: the start of the campaign" if year == "1812" else "December 1812, and the note of 2026")) imgui.pop_font() center = ImVec2(left + pad_x + yw / 2, top + year_size / 2) radius = ImVec2(yw / 2 + pad_x, year_size / 2 + pad_y) color = imgui.get_color_u32(ADVANCE) if hovered else ink if hovered: dl.add_ellipse_filled(center, radius, u32(ADVANCE, 0.16)) dl.add_ellipse(center, radius, color, 0.0, 0, max(1.0, em_size(0.05))) dl.add_text(imgui.get_font(), year_size, ImVec2(left + pad_x, top), color, year) imgui.pop_font() x += yw + 2 * pad_x + gap if year == "1812": dl.add_circle_filled(ImVec2(x + dot_r, mid), dot_r, ink) x += 2 * dot_r + gap if run > 0.0: draw_quickening(dl, x, x + run, mid, 0.0, 0.5, soft) x += run + gap imgui.push_font(font, size) imgui.set_cursor_screen_pos(ImVec2(x, y)) imgui.text(title) imgui.pop_font() x += title_w + gap def choose_language(self, code: str, remember: bool = True) -> None: """English and French at once; Russian once its texts have come (fetched the first time). The choice is kept for the next visit (a lasting pref), unless it is that pref being applied.""" if remember: pref_set("language", code) if code == "ru" and not RUSSIAN: Lang.wanted = "ru" self.data.request_russian() else: Lang.code, Lang.wanted = code, "" def gui_settings(self) -> None: # An ink border: the pop-up must stand out from the paper it covers (the border is drawn by begin_popup) imgui.push_style_color(imgui.Col_.border, imgui.ImVec4(INK.x, INK.y, INK.z, 0.55)) opened = imgui.begin_popup("settings") imgui.pop_style_color() if not opened: return imgui.push_style_var(imgui.StyleVar_.frame_border_size, 1.0) # an empty radio button, visible on the paper imgui.text_disabled(tr("Language")) for code, name in LANGUAGE_NAMES.items(): if imgui.radio_button(name, code == (Lang.wanted or Lang.code)): self.choose_language(code) imgui.spacing() imgui.text_disabled(tr("Text font")) imgui.begin_disabled(self.text_font is None) # not downloaded: the default font only if imgui.radio_button(tr("Old Standard TT, a 19th-century book type"), self.period_font): self.period_font = True imgui.end_disabled() if imgui.radio_button(tr("Default font"), not self.period_font or self.text_font is None): self.period_font = False imgui.text_disabled(tr("The map keeps Minard's lettering.")) imgui.spacing() imgui.text_disabled(tr("Text size")) imgui.push_item_flag(imgui.ItemFlags_.no_nav, True) if imgui.button("A-###smaller") and self.text_size > 70: self.text_size -= 10 imgui.same_line() imgui.text(f"{self.text_size} %") imgui.same_line() if imgui.button("A+###larger") and self.text_size < 160: self.text_size += 10 imgui.pop_item_flag() imgui.spacing() install = install_state() # in a browser: a way to install it as an app, until it is if install in ("prompt", "steps"): if imgui.button(f"{fa.ICON_FA_DOWNLOAD} {tr('Install it as an app')}###install"): if install == "prompt": install_prompt() else: imgui.open_popup("install") imgui.set_next_window_size(ImVec2(min(em_size(22.0), imgui.get_main_viewport().size.x - em_size(2.0)), 0)) if imgui.begin_popup("install"): imgui_md.render_unindented(tr(INSTALL_STEPS)) imgui.end_popup() if imgui.button(f"{fa.ICON_FA_DATABASE} {tr('Data and downloads')}###data_button"): self.show_data = True imgui.close_current_popup() imgui.pop_style_var() imgui.end_popup() def gui_data_window(self) -> None: """The data and the downloads, in a window of their own: where each file comes from, its state, a retry.""" if not self.show_data: return display = imgui.get_io().display_size size = ImVec2(min(em_size(30.0), display.x - em_size(1.0)), min(em_size(34.0), display.y * 0.85)) imgui.set_next_window_size(size, imgui.Cond_.appearing) imgui.set_next_window_pos(ImVec2(display.x / 2, display.y / 2), imgui.Cond_.appearing, ImVec2(0.5, 0.5)) imgui.push_style_color(imgui.Col_.border, imgui.ImVec4(INK.x, INK.y, INK.z, 0.55)) shown, still_open = imgui.begin(f"{tr('Data and downloads')}###data_window", self.show_data, imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_saved_settings) self.show_data = bool(still_open) imgui.pop_style_color() if shown: self.gui_data() imgui.end() # ----- the map, the temperatures and the timeline ------------------------------------ def gui_story(self, size: ImVec2) -> None: c = self.data.campaign self.small_story = size.x < em_size(34.0) # a phone's: the legend above the map, the controls on two lines self.gui_counts(c) rows = 2 if self.small_story else 1 spacing = imgui.get_style().item_spacing.y controls_h = self.play_d + spacing + (rows - 1) * (imgui.get_frame_height() + spacing) plot_size = ImVec2(size.x, max(em_size(14.0), imgui.get_content_region_avail().y - controls_h)) if self.narrow: # The map's content at Minard's proportions (about 3.2 to 1), with a margin, and room for the legend # when it is on the map; the temperatures take the rest of the subplots' height map_h = size.x / 3.0 + (0.0 if self.small_story else em_size(3.5)) ratios_narrow = self.ratios_narrow.row_ratios plot_size.y = max(em_size(14.0), map_h * sum(ratios_narrow) / ratios_narrow[0]) p0 = imgui.get_cursor_screen_pos() if self.in_intro and not self.intro_leaving: if self.narrow: # the original and the slide's text, no more; the Play button stays on screen text_h = self.intro_text_height(size.x - em_size(1.6), size.x > em_size(40.0)) room = imgui.get_content_region_avail().y - controls_h plot_size.y = max(min(em_size(14.0), room), min(size.x / 2.1 + text_h + em_size(2.0), room)) self.note_map_in_view(p0, plot_size) # The original alone: a click goes to the next slide (its magnifier, drawn later, takes its own clicks) imgui.set_next_item_allow_overlap() if imgui.invisible_button("##intro", plot_size) and self.intro_slide < len(INTRO) - 1: self.goto_slide(self.intro_slide + 1) self.draw_intro(p0, plot_size) self.gui_intro_controls() return self.note_map_in_view(p0, plot_size) implot.push_style_color(implot.Col_.plot_bg, PAPER) implot.push_style_color(implot.Col_.plot_border, INK_SOFT) flags = implot.SubplotFlags_.link_cols | implot.SubplotFlags_.no_title | implot.SubplotFlags_.no_menus ratios = self.ratios_narrow if self.narrow else self.ratios self.temperatures_h = plot_size.y * ratios.row_ratios[1] / sum(ratios.row_ratios) if implot.begin_subplots("##minard", 2, 1, plot_size, flags, ratios): self.gui_map(c) self.gui_temperatures(c) implot.end_subplots() implot.pop_style_color(2) if self.in_intro: self.draw_intro(p0, plot_size) # the original fading out over the animation else: if not self.small_story: self.gui_compare(over_map=True) if self.map_zoomed: self.gui_whole_map_word() self.gui_controls() if self.small_story and not self.in_intro and not self.compare_inline: self.gui_compare(over_map=False) def note_map_in_view(self, p0: ImVec2, size: ImVec2) -> None: """Whether at least half of the map is in its window: on a phone, the reader may scroll down to the card.""" top = imgui.get_window_pos().y bottom = top + imgui.get_window_size().y seen = min(bottom, p0.y + size.y) - max(top, p0.y) self.map_in_view = seen > 0.5 * size.y def gui_whole_map_word(self) -> None: """While the map is zoomed or panned: a faint word in its bottom right corner, which says how to get the whole map back, and does it when clicked (a tap is surer than a double tap).""" if self.map_box is None: return touch = imgui.get_io().mouse_source == imgui.MouseSource.touch_screen text = tr("Double tap: the whole map" if touch else "Double-click: the whole map") pos, size = self.map_box w, h = imgui.calc_text_size(text).x, imgui.get_text_line_height() p = ImVec2(pos.x + size.x - w - em_size(0.8), pos.y + size.y - h - em_size(0.6)) back = imgui.get_cursor_screen_pos() m = em_size(0.35) dl = imgui.get_window_draw_list() dl.add_rect_filled(ImVec2(p.x - m, p.y - m), ImVec2(p.x + w + m, p.y + h + m), u32(PAPER, 0.88), em_size(0.3)) imgui.set_cursor_screen_pos(ImVec2(p.x - m, p.y - m)) if imgui.invisible_button("###whole_map", ImVec2(w + 2 * m, h + 2 * m)): self.reset_view = True color = u32(ADVANCE) if imgui.is_item_hovered() else u32(INK_SOFT) dl.add_text(p, color, text) imgui.set_cursor_screen_pos(back) @staticmethod def compare_width() -> float: return (imgui.get_frame_height() * 1.62 + imgui.get_style().item_inner_spacing.x + imgui.calc_text_size(tr("Compare with the original")).x) def gui_compare(self, over_map: bool) -> None: """The comparison toggle: over the map's top right corner, or (on a phone) beside or under the controls.""" label = tr("Compare with the original") w = self.compare_width() back = imgui.get_cursor_screen_pos() if over_map: if self.map_box is None: return pos, size = self.map_box p = ImVec2(pos.x + size.x - w - em_size(0.8), pos.y + em_size(0.6)) m = em_size(0.35) imgui.get_window_draw_list().add_rect_filled(ImVec2(p.x - m, p.y - m), ImVec2(p.x + w + m, p.y + imgui.get_frame_height() + m), u32(PAPER, 0.88), em_size(0.3)) imgui.set_cursor_screen_pos(p) # The toggle takes the theme's colors: on, the check mark's (Minard's red). Over the map, its label is ink. if over_map: imgui.push_style_color(imgui.Col_.text, INK) flags = imgui_toggle.ToggleFlags_.animated | imgui_toggle.ToggleFlags_.bordered_frame changed, on = imgui_toggle.toggle(f"{label}###compare", self.compare, flags) if over_map: imgui.pop_style_color() if changed: self.set_compare(on) if imgui.is_item_hovered(): imgui.set_tooltip(tr("Minard's original over the redrawing, which bends the geography where his map " "does: his band and his cities fall on ours. Play, or a move in time, fades back to " "the animated map.")) if over_map: imgui.set_cursor_screen_pos(back) def gui_counts(self, c: Optional[Campaign]) -> None: """The date and the counts, above the map. None during the intro: the original takes their room. When the animation starts, they come in from the top, and the map slides down under them.""" if self.in_intro and not self.intro_leaving: return reveal = smooth(2.0 * (1.0 - self.intro_cover)) # with the original sliding onto the map: the first half p0 = imgui.get_cursor_screen_pos() if reveal < 1.0: right = imgui.get_window_pos().x + imgui.get_window_size().x imgui.push_clip_rect(ImVec2(imgui.get_window_pos().x, p0.y), ImVec2(right, p0.y + reveal * self.counts_h), True) imgui.push_style_var(imgui.StyleVar_.alpha, imgui.get_style().alpha * reveal) self.gui_counts_lines(c) self.counts_h = imgui.get_cursor_screen_pos().y - p0.y if reveal < 1.0: imgui.pop_style_var() imgui.pop_clip_rect() imgui.set_cursor_screen_pos(ImVec2(p0.x, p0.y + reveal * self.counts_h)) def gui_counts_lines(self, c: Optional[Campaign]) -> None: """The date, and the men left: the main army, and the detachments while they last.""" line_w = imgui.get_content_region_avail().x imgui.push_font(None, em_size(1.2)) # The line breaks depend on the widest texts, not on today's: the map below must not jump date_w = imgui.calc_text_size("30 September 1812").x imgui.text(date_label(self.day)) imgui.pop_font() if c is None: return main, detached, back, going = 0, 0, False, "" for p in c.paths: pos = position(p, self.day) if pos is None or rejoined(p, self.day): continue # not started, or joined the main army men = p.men[pos[0]] if p.group == 1: main, going = men, heading(p, pos) back = p.retreat[pos[0]] or going == "retreating" else: detached += men if going: main_text = tr("Main army, {going}: {men} men").format(going=tr(going), men=num(main)) else: main_text = tr("Main army, at a halt: {men} men").format(men=num(main)) main_w = imgui.calc_text_size(tr("Main army, retreating: 000,000 men")).x detached_w = imgui.calc_text_size(tr("Detachments: 00,000")).x + em_size(1.6) used = 0.0 if date_w + em_size(1.5) + main_w <= line_w: imgui.same_line(0, em_size(1.5)) used = date_w + em_size(1.5) if main_w <= line_w: imgui.text(main_text) else: # a small phone, in French or in Russian imgui.text_wrapped(main_text) used += main_w if used + em_size(1.0) + detached_w <= line_w: imgui.same_line(used + em_size(1.0) + imgui.get_style().window_padding.x) # otherwise on its own line imgui.text_disabled(tr("Detachments: {men}").format(men=num(detached))) imgui.same_line(0, em_size(0.3)) note = in_language("notes", "detachments", "text", DETACHMENTS_NOTE, DETACHMENTS_NOTE_FR) self.gui_info_button("detachments", note) if self.small_story: self.gui_legend_line(back) @staticmethod def gui_info_button(id_: str, markdown: str) -> None: """An (i) that opens a short explanation: a click, so that it works on a phone too.""" imgui.push_id(id_) imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(0, 0, 0, 0)) if imgui.small_button(fa.ICON_FA_INFO_CIRCLE): imgui.open_popup("info") imgui.pop_style_color() imgui.set_next_window_size(ImVec2(min(em_size(22.0), imgui.get_main_viewport().size.x - em_size(2.0)), 0)) if imgui.begin_popup("info"): imgui_md.render_unindented(markdown) imgui.end_popup() imgui.pop_id() def gui_legend_line(self, back: bool) -> None: """Minard's colors, on a line of their own: a phone's map is too small to hold them.""" dl = imgui.get_window_draw_list() h = imgui.get_text_line_height() rows = ((ADVANCE, tr("Going in"), 1.0), (RETREAT, tr("Coming out: the retreat"), 1.0 if back else 0.4)) item_w = [em_size(2.2) + imgui.calc_text_size(text).x for _, text, _ in rows] one_line = sum(item_w) + em_size(1.2) <= imgui.get_content_region_avail().x # else one item per line for k, (col, text, alpha) in enumerate(rows): if k > 0 and not one_line: imgui.new_line() p = imgui.get_cursor_screen_pos() dl.add_rect_filled(p, ImVec2(p.x + em_size(1.8), p.y + h), u32(PAPER), em_size(0.2)) dl.add_rect_filled(ImVec2(p.x + em_size(0.2), p.y + h * 0.3), ImVec2(p.x + em_size(1.6), p.y + h * 0.7), u32(col, alpha)) imgui.dummy(ImVec2(em_size(1.8), h)) imgui.same_line(0, em_size(0.4)) if alpha < 1.0: imgui.text_disabled(text) else: imgui.text(text) imgui.same_line(0, em_size(1.2)) imgui.new_line() def setup_canvas(self, y_label: bool) -> None: """An invisible canvas: no grid, no ticks, and the plot's own mouse text off.""" x_flags = implot.AxisFlags_.no_decorations y_flags = implot.AxisFlags_.no_grid_lines if y_label else implot.AxisFlags_.no_decorations implot.setup_axes("", "", x_flags, y_flags) def gui_map(self, c: Optional[Campaign]) -> None: flags = implot.Flags_.no_mouse_text | implot.Flags_.no_legend | implot.Flags_.no_menus | implot.Flags_.equal if not implot.begin_plot("##map", ImVec2(-1, 0), flags): return self.setup_canvas(False) cond = imgui.Cond_.always if self.reset_view else imgui.Cond_.once implot.setup_axes_limits(23.0 * X_SCALE, 38.4 * X_SCALE, 53.75 * Y_STRETCH, 56.0 * Y_STRETCH, cond) self.reset_view = False self.original_on_map = (implot.plot_to_pixels(ORIGINAL_LON[0] * X_SCALE, ORIGINAL_LAT[1] * Y_STRETCH), implot.plot_to_pixels(ORIGINAL_LON[1] * X_SCALE, ORIGINAL_LAT[0] * Y_STRETCH)) if c is None: self.draw_waiting() else: self.draw_campaign(c) if implot.is_plot_hovered() and imgui.is_mouse_double_clicked(0): self.reset_view = True # Zoomed in or out, or panned (the wheel, a drag): the view is not the whole map, and a word says how to get # it back. The whole map: the asked rectangle in view, and tight on one axis at least (the equal aspect widens # the other, at the reset and at each resize); zoomed out, both axes are loose. lim, tol = implot.get_plot_limits(), 0.01 x0, x1, y0, y1 = 23.0 * X_SCALE, 38.4 * X_SCALE, 53.75 * Y_STRETCH, 56.0 * Y_STRETCH tx, ty = tol * X_SCALE, tol * Y_STRETCH inside = lim.x.min <= x0 + tx and lim.x.max >= x1 - tx and lim.y.min <= y0 + ty and lim.y.max >= y1 - ty tight = ((abs(lim.x.min - x0) <= tx and abs(lim.x.max - x1) <= tx) or (abs(lim.y.min - y0) <= ty and abs(lim.y.max - y1) <= ty)) self.map_zoomed = not (inside and tight) implot.end_plot() def draw_waiting(self) -> None: pos, size = implot.get_plot_pos(), implot.get_plot_size() center = ImVec2(pos.x + size.x / 2, pos.y + size.y / 2) failed = [s for s in self.data.sources.values() if s.is_table and s.state == "failed"] text = tr("Minard's tables could not be fetched: see Data and downloads, under the gear" if failed else "Fetching Minard's tables...") w = imgui.calc_text_size(text).x implot.get_plot_draw_list().add_text(ImVec2(center.x - w / 2, center.y), u32(INK), text) def draw_campaign(self, c: Campaign) -> None: dl = implot.get_plot_draw_list() implot.push_plot_clip_rect() self.push_map_font() pos, size = implot.get_plot_pos(), implot.get_plot_size() halo = u32(PAPER, 0.85) # The day, large and faint, like a watermark; not on a phone held upright, where it would cross the small map if not self.phone_upright: label = date_label(self.day) big = min(em_size(4.0), 0.85 * size.x / (imgui.calc_text_size(label).x / em_size(1.0))) dl.add_text(imgui.get_font(), big, ImVec2(pos.x + (size.x - text_width(label, big)) / 2, pos.y + size.y - big * 1.25), u32(INK, 0.08), label) # Rivers, thin, under the band, as Minard draws them for _, lines in c.rivers: for line in lines: pts: List[imgui.ImVec2Like] = [implot.plot_to_pixels(x, y) for x, y in line] dl.add_polyline(pts, u32(INK, 0.45), max(1.0, em_size(0.07)), 0) # The band: Minard's width (men) in map units; advance first, retreat on top lim = implot.get_plot_limits() half_per_man = 0.5 * BAND_PER_MAN * size.x / (lim.x.max - lim.x.min) upcoming = [ev.start for ev in EVENTS if ev.start > self.day + 1e-3] if self.show_events and upcoming and PREVIEW_ALPHA > 0.0: # faint, ahead: the march to the next card for retreat_pass in (False, True): for p in c.paths: self.draw_path(dl, p, half_per_man, retreat_pass, upcoming[0], PREVIEW_ALPHA) for retreat_pass in (False, True): for p in c.paths: self.draw_path(dl, p, half_per_man, retreat_pass) # The main army's labels first: the cities' names leave them room small_map = size.x < em_size(30.0) # a phone's taken = [] if self.small_story else [self.draw_legend(dl, pos, size)] # else: above the map labels: List[Tuple[ImVec2, float, float, str]] = [] # (where, size, alpha, text), drawn last heads: List[ImVec2] = [] for p in c.paths: at = position(p, self.day) if at is None or rejoined(p, self.day): continue hp = implot.plot_to_pixels(*self.head(p, at)) heads.append(hp) if p.group != 1: continue men = num(p.men[at[0]]) mw = text_width(men, em_size(1.0)) lx = hp.x + em_size(0.5) if hp.x + em_size(0.5) + mw <= pos.x + size.x else hp.x - em_size(0.5) - mw labels.append((ImVec2(lx, hp.y + em_size(0.4)), em_size(1.0), 1.0, men)) retreat_label = None if small_map else self.retreat_label(hp, pos.x + size.x) if retreat_label is not None: labels.append(retreat_label) for where, text_size, _, text in labels: taken.append((where.x, where.y, where.x + text_width(text, text_size), where.y + text_size)) # Cities, written over the band as Minard does small = em_size(0.68 if small_map else 0.8) order = sorted(c.cities, key=lambda city: MAIN_CITIES.index(city[0]) if city[0] in MAIN_CITIES else 99) for name, x, y in order: cp = implot.plot_to_pixels(x, y) dl.add_circle_filled(cp, em_size(0.16), u32(INK_SOFT)) tw = text_width(name, small) lx = cp.x + em_size(0.25) if cp.x + em_size(0.25) + tw < pos.x + size.x else cp.x - em_size(0.25) - tw r = (lx, cp.y - small * 1.2, lx + tw, cp.y - small * 0.1) if any(r[0] < t[2] and t[0] < r[2] and r[1] < t[3] and t[1] < r[3] for t in taken): continue taken.append(r) text_halo(dl, small, ImVec2(r[0], r[1]), u32(INK, 0.8), name, halo) if not small_map: self.draw_numbers(dl, c, half_per_man, taken) self.draw_river_names(dl, c, taken) # The head of each march, then the main army's labels for hp in heads: dl.add_circle_filled(hp, em_size(0.3), u32(INK)) dl.add_circle(hp, em_size(0.3), u32(PAPER), 0, 1.5) for where, text_size, alpha, text in labels: text_halo(dl, text_size, where, u32(INK, alpha), text, u32(PAPER, 0.9 * alpha)) if self.show_events and CARD_MARKS and not self.phone_upright: # held upright, a phone's map is too small self.draw_card_marks(dl, c, taken) fade = smooth(self.compare_level) if fade > 0.0: # Minard's original, over the redrawing, which fades out: none of it peeks out around the scan dl.add_rect_filled(pos, ImVec2(pos.x + size.x, pos.y + size.y), u32(PAPER, fade)) self.draw_original_map(dl, fade) caption = tr("Minard's original, 1869") text_halo(dl, em_size(1.0), ImVec2(pos.x + size.x - text_width(caption, em_size(1.0)) - em_size(0.8), pos.y + size.y - em_size(1.8)), u32(INK, fade), caption, u32(PAPER, 0.9 * fade)) self.map_box = (pos, size) imgui.pop_font() implot.pop_plot_clip_rect() def draw_numbers(self, dl: imgui.ImDrawList, c: Campaign, half_per_man: float, taken: List[Tuple[float, float, float, float]]) -> None: """Minard's figures, written across the band where the number of men changes: the advance's on its north side, the retreat's on its south side. A figure that would overlap a label is left out.""" size = em_size(0.72) pos, plot_size = implot.get_plot_pos(), implot.get_plot_size() for p in c.paths: at = position(p, self.day) if at is None: continue for k in range(min(at[0], len(p.x) - 2) + 1): if k > 0 and p.men[k] == p.men[k - 1]: continue a = implot.plot_to_pixels(p.x[k], p.y[k]) b = implot.plot_to_pixels(p.x[k + 1], p.y[k + 1]) length = math.hypot(b.x - a.x, b.y - a.y) if length < 1.0: continue nx, ny = -(b.y - a.y) / length, (b.x - a.x) / length # the band's normal retreat = p.retreat[k + 1] if (ny > 0) != retreat: # the advance's figures go north (up), the retreat's south nx, ny = -nx, -ny text = num(p.men[k]) w = text_width(text, size) gap = half_per_man * p.men[k] + em_size(0.3) + w / 2 m = ImVec2((a.x + b.x) / 2, (a.y + b.y) / 2) # the middle of the segment center = ImVec2(m.x + nx * gap, m.y + ny * gap) angle = math.atan2(ny, nx) if angle > math.pi / 2: # never upside down angle -= math.pi elif angle < -math.pi / 2: angle += math.pi hx = abs(w / 2 * math.cos(angle)) + abs(size / 2 * math.sin(angle)) hy = abs(w / 2 * math.sin(angle)) + abs(size / 2 * math.cos(angle)) r = (center.x - hx, center.y - hy, center.x + hx, center.y + hy) if r[0] < pos.x or r[1] < pos.y or r[2] > pos.x + plot_size.x or r[3] > pos.y + plot_size.y: continue # it would be cut at the map's edge if any(r[0] < t[2] and t[0] < r[2] and r[1] < t[3] and t[1] < r[3] for t in taken): continue taken.append(r) text_rotated(dl, size, center, angle, u32(INK, 0.75), text, u32(PAPER, 0.85)) def draw_river_names(self, dl: imgui.ImDrawList, c: Campaign, taken: List[Tuple[float, float, float, float]]) -> None: size = em_size(0.7) for name, lines in RIVERS: ax, ay = RIVER_LABELS[name] lon, lat = min((pt for line in lines for pt in line), key=lambda q: (q[0] - ax) ** 2 + (q[1] - ay) ** 2) assert c.bend is not None cp = implot.plot_to_pixels(*c.bend(lon, lat)) text = f"{name} R." # as Minard writes them r = (cp.x + em_size(0.2), cp.y, cp.x + em_size(0.2) + text_width(text, size), cp.y + size) if any(r[0] < t[2] and t[0] < r[2] and r[1] < t[3] and t[1] < r[3] for t in taken): continue taken.append(r) text_halo(dl, size, ImVec2(r[0], r[1]), u32(INK, 0.55), text, u32(PAPER, 0.7)) def draw_card_marks(self, dl: imgui.ImDrawList, c: Campaign, taken: List[Tuple[float, float, float, float]]) -> None: """A numbered mark above where the main army was when each card came, for the cards met so far. Cards at the same place share a mark ("10-13", or "8, 16"). A mark moves up, or else down, off the labels (taken). The mouse on a mark shows its cards; a click (or a touch) goes to its first one.""" main = c.paths[0] r = em_size(0.55) current = self.current_index() groups: List[Tuple[ImVec2, List[int]]] = [] # (foot, cards) for i, ev in enumerate(EVENTS): if ev.start > self.day + 1e-3: break at = position(main, ev.start) if at is None: continue foot = implot.plot_to_pixels(*self.head(main, at)) near = next((g for g in groups if math.hypot(g[0].x - foot.x, g[0].y - foot.y) < 1.5 * r), None) if near is not None: near[1].append(i) else: groups.append((foot, [i])) placed: List[Tuple[float, float, float, float]] = [] mouse, hovered = imgui.get_mouse_pos(), None size = em_size(0.72) pos, plot_size = implot.get_plot_pos(), implot.get_plot_size() def box(q: ImVec2, half_w: float) -> Tuple[float, float, float, float]: return (q.x - half_w, q.y - r, q.x + half_w, q.y + r) def overlap(b: Tuple[float, float, float, float], others: List[Tuple[float, float, float, float]]) -> float: return sum(max(0.0, min(b[2] + 2, o[2]) - max(b[0] - 2, o[0])) * max(0.0, min(b[3], o[3]) - max(b[1], o[1])) for o in others) def free(b: Tuple[float, float, float, float], others: List[Tuple[float, float, float, float]]) -> bool: inside = pos.x <= b[0] and b[2] <= pos.x + plot_size.x and pos.y <= b[1] and b[3] <= pos.y + plot_size.y return inside and overlap(b, others) == 0.0 for foot, cards in groups: consecutive = cards == list(range(cards[0], cards[-1] + 1)) label = (f"{cards[0] + 1}" if len(cards) == 1 else f"{cards[0] + 1}-{cards[-1] + 1}" if consecutive else ", ".join(str(k + 1) for k in cards)) half_w = max(r, text_width(label, size) / 2 + em_size(0.3)) # Up from the army, then down, a step at a time, straight or a little aside, until off the labels and # the other marks; else (a small map) off the other marks, over as little text as possible spots = [ImVec2(foot.x + dx, foot.y + sign * (em_size(1.8) + k * 2.1 * r)) for sign in (-1, 1) for k in range(4) for dx in (0.0, -2.2 * half_w, 2.2 * half_w)] q = next((s for s in spots if free(box(s, half_w), placed + taken)), None) if q is None: apart = [s for s in spots if free(box(s, half_w), placed)] or spots q = min(apart, key=lambda s: overlap(box(s, half_w), taken)) b = box(q, half_w) placed.append(b) dl.add_line(foot, ImVec2(q.x, q.y + r if q.y < foot.y else q.y - r), u32(INK, 0.35), 1.0) dl.add_rect_filled(ImVec2(b[0], b[1]), ImVec2(b[2], b[3]), u32(PAPER), r) ring = HIGHLIGHT if current in cards else INK dl.add_rect(ImVec2(b[0], b[1]), ImVec2(b[2], b[3]), u32(ring, 0.9), r, max(1.0, em_size(0.09))) dl.add_text(imgui.get_font(), size, ImVec2(q.x - text_width(label, size) / 2, q.y - size / 2), u32(INK), label) grow = em_size(0.4) if imgui.get_io().mouse_source == imgui.MouseSource.touch_screen else 0.0 # a finger if (implot.is_plot_hovered() and b[0] - grow <= mouse.x <= b[2] + grow and b[1] - grow <= mouse.y <= b[3] + grow): hovered = cards if hovered is None: return imgui.set_mouse_cursor(imgui.MouseCursor_.hand) imgui.begin_tooltip() imgui.push_text_wrap_pos(em_size(22.0)) for i in hovered: imgui.text(f"{i + 1}. {EVENTS[i].heading()}") if len(hovered) == 1: imgui.text_disabled(EVENTS[i].body()) imgui.pop_text_wrap_pos() imgui.end_tooltip() if imgui.is_mouse_clicked(0): self.playing = False self.goto_event(hovered[0], glide=True) self.select_tab = "story" def original_texture(self) -> Optional[hello_imgui.TextureGpu]: """Minard's original, to compare: the full scan if it came, else the thumbnail.""" return self.data.texture(MINARD_MAP.key + ":large") or self.data.texture(MINARD_MAP.key) def draw_original_map(self, dl: imgui.ImDrawList, alpha: float) -> None: """The original's map part, aligned on Minard's cities; tinted, so that the scan's white becomes the paper.""" tex = self.original_texture() if tex is None or self.original_on_map is None: return a, b = self.original_on_map b = ImVec2(b.x, a.y + (b.y - a.y) * ORIGINAL_MAP_BOTTOM) dl.add_image(imgui.ImTextureRef(tex.texture_id()), a, b, ImVec2(0, 0), ImVec2(1, ORIGINAL_MAP_BOTTOM), u32(PAPER, alpha)) def draw_original_table(self, dl: imgui.ImDrawList, alpha: float) -> None: """The original's temperature table, aligned by longitude and by degree.""" tex = self.original_texture() if tex is None: return top, bottom = ORIGINAL_TABLE per_height = -30.0 / (ORIGINAL_MINUS_30 - ORIGINAL_ZERO) # degrees per unit of the scan's height a = implot.plot_to_pixels(ORIGINAL_LON[0] * X_SCALE, per_height * (top - ORIGINAL_ZERO)) b = implot.plot_to_pixels(ORIGINAL_LON[1] * X_SCALE, per_height * (bottom - ORIGINAL_ZERO)) dl.add_image(imgui.ImTextureRef(tex.texture_id()), a, b, ImVec2(0, top), ImVec2(1, bottom), u32(PAPER, alpha)) def draw_legend(self, dl: imgui.ImDrawList, pos: ImVec2, size: ImVec2) -> Tuple[float, float, float, float]: """Minard's colors, top left. Returns the rectangle it takes.""" back = self.day >= RETREAT_START small = em_size(0.85) x, y = pos.x + em_size(0.7), pos.y + em_size(0.6) rows = [(ADVANCE, tr("Going in (Minard's red)"), 1.0), (RETREAT, tr("Coming out: the retreat"), 1.0 if back else 0.4)] note = tr("Width: 1 mm for 10,000 men") right = x + max(em_size(2.0) + text_width(rows[1][1], small), text_width(note, small * 0.88)) bottom = y + 2 * small * 1.35 + small m = em_size(0.3) # a sheet of paper behind it: a band passing there does not cross the text dl.add_rect_filled(ImVec2(x - m, y - m), ImVec2(right + m, bottom + m), u32(PAPER, 0.85), em_size(0.3)) for col, text, alpha in rows: dl.add_rect_filled(ImVec2(x, y + small * 0.25), ImVec2(x + em_size(1.6), y + small * 0.8), u32(col, alpha)) text_halo(dl, small, ImVec2(x + em_size(2.0), y), u32(INK, alpha), text, u32(PAPER, 0.85)) y += small * 1.35 text_halo(dl, small * 0.88, ImVec2(x, y), u32(INK_SOFT), note, u32(PAPER, 0.85)) return (pos.x, pos.y, right + m, bottom + m) def retreat_label(self, head: ImVec2, right: float) -> Optional[Tuple[ImVec2, float, float, str]]: """For a few days once the retreat has begun, a label under the main army's count.""" since = self.day - RETREAT_START alpha = 1.0 - smooth((since - 12.0) / 3.0) # shown from the retreat's card on if since < 0.0 or alpha <= 0.0: return None text, small = tr("The retreat begins: black"), em_size(0.85) x = min(head.x + em_size(0.5), right - text_width(text, small) - em_size(0.5)) return ImVec2(x, head.y + em_size(1.9)), small, alpha, text @staticmethod def head(p: Path, at: Tuple[int, float]) -> Tuple[float, float]: i, f = at if i >= len(p.x) - 1: return p.x[-1], p.y[-1] return lerp(p.x[i], p.x[i + 1], f), lerp(p.y[i], p.y[i + 1], f) def draw_path(self, dl: imgui.ImDrawList, p: Path, half_per_man: float, retreat_pass: bool, day: Optional[float] = None, alpha: float = 1.0) -> None: """The band as quads with mitered joins, like Minard's: adjacent segments share the corner edge, so where the men decrease the width steps down crisply at the vertex. Up to today, or another day.""" at = position(p, self.day if day is None else day) if at is None: return last, _ = at pts = [implot.plot_to_pixels(p.x[k], p.y[k]) for k in range(last + 1)] if last < len(p.x) - 1: pts.append(implot.plot_to_pixels(*self.head(p, at))) n = len(pts) # The unit normal of each segment; None where it has no length (the army staying put) normals: List[Optional[Tuple[float, float]]] = [] for k in range(n - 1): dx, dy = pts[k + 1].x - pts[k].x, pts[k + 1].y - pts[k].y length = math.hypot(dx, dy) normals.append((-dy / length, dx / length) if length >= 0.5 else None) def miter(k: int) -> Tuple[float, float]: """The offset direction at vertex k, scaled so the band keeps its width round the turn.""" n_in = next((normals[j] for j in range(k - 1, -1, -1) if normals[j] is not None), None) n_out = next((normals[j] for j in range(k, n - 1) if normals[j] is not None), None) if n_in is None or n_out is None: return n_in or n_out or (0.0, 1.0) mx, my = n_in[0] + n_out[0], n_in[1] + n_out[1] m = math.hypot(mx, my) if m < 1e-6: return n_out mx, my = mx / m, my / m scale = 1.0 / max(mx * n_out[0] + my * n_out[1], 0.4) # capped: no long spikes at sharp turns return mx * scale, my * scale for k in range(n - 1): seg_retreat = p.retreat[min(k + 1, len(p.retreat) - 1)] if seg_retreat != retreat_pass or normals[k] is None: continue h = max(1.0, half_per_man * p.men[k]) (ax, ay), (bx, by) = miter(k), miter(k + 1) a, b = pts[k], pts[k + 1] dl.add_quad_filled(ImVec2(a.x + ax * h, a.y + ay * h), ImVec2(b.x + bx * h, b.y + by * h), ImVec2(b.x - bx * h, b.y - by * h), ImVec2(a.x - ax * h, a.y - ay * h), u32(RETREAT if seg_retreat else ADVANCE, alpha)) def gui_temperatures(self, c: Optional[Campaign]) -> None: flags = implot.Flags_.no_mouse_text | implot.Flags_.no_legend | implot.Flags_.no_menus if not implot.begin_plot("##temperatures", ImVec2(-1, 0), flags): return self.setup_canvas(True) implot.setup_axis_limits(implot.ImAxis_.y1, -34.0, 6.0, imgui.Cond_.always) if self.temperatures_h < em_size(4.5): # a phone held sideways implot.setup_axis_ticks(implot.ImAxis_.y1, [-30.0, 0.0], ["-30°", "0°"]) elif self.small_story: implot.setup_axis_ticks(implot.ImAxis_.y1, [-30.0, -15.0, 0.0], ["-30°", "-15°", "0°"]) else: implot.setup_axis_ticks(implot.ImAxis_.y1, [-30.0, -20.0, -10.0, 0.0], ["-30°", "-20°", "-10°", "0°"]) if c is not None: dl = implot.get_plot_draw_list() implot.push_plot_clip_rect() self.push_map_font() pos, size = implot.get_plot_pos(), implot.get_plot_size() shown = [(x, t, d) for x, t, d in c.temps if d <= self.day] pts = [implot.plot_to_pixels(x, t) for x, t, _ in shown] # Minard's thin lines from each reading up to the map for p in pts: dl.add_line(ImVec2(p.x, pos.y), p, u32(INK, 0.25), 1.0) # The line through the readings, extended to today along the next segment if shown and len(shown) < len(c.temps): x0, t0, d0 = shown[-1] x1, t1, d1 = c.temps[len(shown)] f = min(1.0, (self.day - d0) / max(d1 - d0, 1)) pts.append(implot.plot_to_pixels(lerp(x0, x1, f), lerp(t0, t1, f))) for a, b in zip(pts, pts[1:], strict=False): dl.add_line(a, b, u32(COLD), 2.0) small = em_size(0.78) placed: List[Tuple[float, float, float, float]] = [] coldest = min(range(len(shown)), key=lambda k: shown[k][1], default=-1) if (coldest >= 0 and shown[coldest][1] > -5.0) or self.temperatures_h < em_size(4.5): coldest = -1 # no cold yet worth a label of its own, or no room for it for i, ((_x, t, d), p) in enumerate(zip(shown, pts, strict=False)): dl.add_circle_filled(p, em_size(0.18), u32(COLD)) if size.x < em_size(30.0) and i not in (coldest, len(shown) - 1): continue # a phone's: only the last reading and the coldest label = f"{t:+.0f}° {date_label(d, True)}" if d == day_of(10, 24): label += tr(", rain") # Minard: "Pluie 24 8bre" if i == len(shown) - 1 and t != 0: label += f" ({t * 1.25:+.0f} °C)" lw, gap = text_width(label, small), em_size(0.3) # Below the point at its right, else at its left, else above: the first spot inside the chart that # no other label takes spots = [(p.x + gap, p.y + em_size(0.2)), (p.x - gap - lw, p.y + em_size(0.2)), (p.x + gap, p.y - small - gap), (p.x - gap - lw, p.y - small - gap)] rects = [(x, y, x + lw, y + small) for x, y in spots] inside = [r for r in rects if pos.x <= r[0] and r[2] <= pos.x + size.x and r[3] <= pos.y + size.y] r = next((r for r in inside if not any(r[0] < o[2] and o[0] < r[2] and r[1] < o[3] and o[1] < r[3] for o in placed)), (inside or rects)[0]) placed.append(r) latest = i == len(shown) - 1 # in red, as the current card's mark: the reading of the day, with its °C dl.add_text(imgui.get_font(), small, ImVec2(r[0], r[1]), u32(HIGHLIGHT if latest else INK), label) note = tr("Temperatures of the retreat, degrees Réaumur (x 1.25 = °C)") if size.x < em_size(30.0): note = "Réaumur" elif text_width(note, small) > size.x - em_size(1.0): note = tr("Degrees Réaumur (x 1.25 = °C)") dl.add_text(imgui.get_font(), small, ImVec2(pos.x + em_size(0.5), pos.y + em_size(0.3)), u32(INK_SOFT), note) if not shown: # the advance: an empty chart must not look like a bug waiting, big = tr("Minard's temperature readings start with the retreat, on 18 October"), em_size(1.1) if text_width(waiting, big) > size.x - em_size(1.0): waiting = tr("Temperature readings from 18 October") if text_width(waiting, big) > size.x - em_size(1.0): big = small dl.add_text(imgui.get_font(), big, ImVec2(pos.x + (size.x - text_width(waiting, big)) / 2, pos.y + (size.y - big) / 2), u32(INK_SOFT), waiting) if self.compare_level > 0.0: fade = smooth(self.compare_level) dl.add_rect_filled(pos, ImVec2(pos.x + size.x, pos.y + size.y), u32(PAPER, fade)) self.draw_original_table(dl, fade) if self.day >= LAST_DAY - 1 and size.x > em_size(30.0): # Minard's note, by Kowno cossacks = tr("The Cossacks gallop across the frozen Niemen (Minard's note)") kx = implot.plot_to_pixels(c.kowno_x, 0.0).x dl.add_text(imgui.get_font(), small, ImVec2(kx + em_size(0.3), pos.y + em_size(1.4)), u32(INK), cossacks) imgui.pop_font() implot.pop_plot_clip_rect() implot.end_plot() # ----- timeline ----------------------------------------------------------------- def play_button(self, playing: bool, ring: float = 0.0) -> bool: """A round button: a triangle to play, two bars to pause (on Minard's red). A ring empties around it (ring, from 1 to 0): the time left before playback goes on. The next items are centered on it.""" d = self.play_d p = imgui.get_cursor_screen_pos() clicked = imgui.invisible_button("###play", ImVec2(d, d)) hovered = imgui.is_item_hovered() dl = imgui.get_window_draw_list() c, r = ImVec2(p.x + d / 2, p.y + d / 2), d / 2 draw_play_icon(dl, c, r, playing, 0.85 if hovered else 1.0) if ring > 0.0: a0 = -math.pi / 2 dl.path_arc_to(c, r - em_size(0.17), a0, a0 + 2 * math.pi * min(ring, 1.0), 32) dl.path_stroke(u32(PAPER, 0.9), em_size(0.2)) imgui.same_line() self.row_y = imgui.get_cursor_pos_y() + (d - imgui.get_frame_height()) / 2 imgui.set_cursor_pos_y(self.row_y) return clicked def same_line_in_row(self, offset_from_start_x: float = 0.0, spacing: float = -1.0) -> None: """same_line(), then the y of the row beside the round Play button: same_line() alone would go back to the top of the line, the button's.""" imgui.same_line(offset_from_start_x, spacing) if self.row_y is not None: imgui.set_cursor_pos_y(self.row_y) @staticmethod def room_after_item() -> float: """The width left on the row after the last item (the cursor is on the next line then).""" right = imgui.get_cursor_screen_pos().x + imgui.get_content_region_avail().x return right - imgui.get_item_rect_max().x - imgui.get_style().item_spacing.x @property def play_d(self) -> float: return em_size(2.3) def gui_controls(self) -> None: imgui.push_item_flag(imgui.ItemFlags_.no_nav, True) # Space must not also "click" it stop = (self.hold / max(self.hold_total, 1e-3) if self.playing and self.hold > 0.0 and not self.voice_drives() else 0.0) if self.play_button(self.playing, stop): self.toggle_play() imgui.pop_item_flag() if imgui.is_item_hovered(): imgui.set_tooltip(tr("Play or pause (Space). The arrow keys go from step to step.")) two_lines = self.small_story checkboxes_w = imgui.calc_text_size(tr("Steps")).x + imgui.get_frame_height() + em_size(1.0) keys = f"{tr('Keys:')} {tr('Space')}, {fa.ICON_FA_ARROW_LEFT} {fa.ICON_FA_ARROW_RIGHT}" show_keys = not two_lines and imgui.get_io().mouse_source == imgui.MouseSource.mouse # not on a phone keys_w = imgui.calc_text_size(keys).x + em_size(0.5) if show_keys else 0.0 right_w = 0.0 if two_lines else em_size(7.5) + checkboxes_w + keys_w imgui.set_next_item_width(max(em_size(8.0), imgui.get_content_region_avail().x - right_w)) imgui.push_style_var(imgui.StyleVar_.grab_min_size, em_size(0.35)) changed, self.day = imgui.slider_float("##day", self.day, 0.0, float(LAST_DAY), date_label(self.day)) if changed: self.playing, self.hold, self.glide = False, 0.0, None self.by_hand = True self.draw_timeline_marks() imgui.pop_style_var() if two_lines: self.row_y = None # a line of its own else: self.same_line_in_row() imgui.set_next_item_width(em_size(7.0)) labels = [tr("{n} days/s").format(n=f"{s:.0f}") for s in self.SPEEDS] _, self.speed_idx = imgui.combo("##speed", self.speed_idx, labels) self.same_line_in_row() changed, self.show_events = imgui.checkbox(f"{tr('Steps')}###events", self.show_events) if changed and not self.show_events: self.hold = 0.0 # On two rows (a phone), the comparison toggle ends the second one if it fits, else it takes a row below self.compare_inline = two_lines and self.room_after_item() > self.compare_width() + em_size(1.0) if self.compare_inline and not self.in_intro: self.same_line_in_row() self.gui_compare(over_map=False) if show_keys: self.same_line_in_row() imgui.text_disabled(keys) if imgui.is_item_hovered(): imgui.set_tooltip(tr("Play or pause (Space). The arrow keys go from step to step.")) def draw_timeline_marks(self) -> None: """A tick for each card, with its title on hover; then the playhead, over the ticks. The date written in the slider stays readable: the ticks under it are short, and it is written again over the playhead.""" p0, p1 = imgui.get_item_rect_min(), imgui.get_item_rect_max() grab = imgui.get_style().grab_min_size x0, x1 = p0.x + 2.0 + 0.5 * grab, p1.x - 2.0 - 0.5 * grab dl = imgui.get_window_draw_list() x = x0 + self.day / LAST_DAY * (x1 - x0) head0, head1 = ImVec2(x - em_size(0.28), p0.y + 1), ImVec2(x + em_size(0.28), p1.y - 1) label = date_label(self.day) # as the slider writes it, centered size = imgui.calc_text_size(label) t0 = ImVec2((p0.x + p1.x - size.x) / 2, (p0.y + p1.y - size.y) / 2) text_left, text_right = t0.x - em_size(0.3), t0.x + size.x + em_size(0.3) if self.show_events: mark = hex4("#E69F00") stub = 0.25 * (p1.y - p0.y) mouse, hovered_ev = imgui.get_mouse_pos(), None for ev in EVENTS: a = x0 + ev.start / LAST_DAY * (x1 - x0) if text_left <= a <= text_right: # under the date: its two ends only dl.add_line(ImVec2(a, p0.y + 2), ImVec2(a, p0.y + stub), u32(mark, 0.9), 2.0) dl.add_line(ImVec2(a, p1.y - stub), ImVec2(a, p1.y - 2), u32(mark, 0.9), 2.0) else: dl.add_line(ImVec2(a, p0.y + 2), ImVec2(a, p1.y - 2), u32(mark, 0.9), 2.0) if imgui.is_item_hovered() and abs(mouse.x - a) <= 4: hovered_ev = ev if hovered_ev is not None and not imgui.is_item_active(): imgui.set_tooltip(hovered_ev.heading()) self.draw_playhead(dl, head0, head1) if head1.x > text_left and head0.x < text_right: frame = imgui.get_style().color_(imgui.Col_.frame_bg) text_halo(dl, imgui.get_font_size(), t0, u32(imgui.get_style().color_(imgui.Col_.text)), label, u32(frame)) @staticmethod def draw_playhead(dl: imgui.ImDrawList, p0: ImVec2, p1: ImVec2) -> None: dl.add_rect_filled(p0, p1, u32(imgui.get_style().color_(imgui.Col_.text)), em_size(0.12)) dl.add_rect(p0, p1, u32(imgui.get_style().color_(imgui.Col_.frame_bg)), em_size(0.12), 1.5) # ----- the card ----------------------------------------------------------------- def current_index(self) -> int: if self.glide is not None: return self.glide[4] # the card being gone to, not those passed on the way return max((i for i, ev in enumerate(EVENTS) if ev.start <= self.day + 1e-3), default=0) def gui_side(self) -> None: """The tabs beside the map (below it on a phone): the story's card, about the map, the data.""" self.card_hovered = False if not imgui.begin_tab_bar("side"): return def flags(tab: str) -> int: return imgui.TabItemFlags_.set_selected if self.select_tab == tab else 0 intro_flags, story_flags = flags("intro"), flags("story") wanted, self.select_tab = self.select_tab, "" # a tab asked for shows on the next frame: this one still draws the old # Where the five labels do not fit (a phone), the tabs not selected show their icon only tabs = [("intro_tab", fa.ICON_FA_MAP, "The map"), ("story", fa.ICON_FA_BOOKMARK, "Chronology"), ("context", fa.ICON_FA_GLOBE, "Context"), ("about", fa.ICON_FA_INFO_CIRCLE, "About"), ("making_of", fa.ICON_FA_FILM, "Making-of")] style = imgui.get_style() full_w = sum(imgui.calc_text_size(f"{icon} {tr(name)}").x + 2 * style.frame_padding.x + style.item_inner_spacing.x for _, icon, name in tabs) compact = full_w > imgui.get_content_region_avail().x label = {tab_id: f"{icon}###{tab_id}" if compact and tab_id != self.side_tab else f"{icon} {tr(name)}###{tab_id}" for tab_id, icon, name in tabs} tips = {"intro_tab": "The map explained, in nine points", "story": "The chronology of the campaign, in twenty-four steps", "context": "The context: what the map does not say", "about": "About: the author, the note, the sources", "making_of": "The making-of: how this animation came to be"} def tab(tab_id: str, flags: int = 0) -> bool: """A tab, with what it holds as its tooltip (under the mouse: a finger gets the name once it is chosen).""" shown = imgui.begin_tab_item_simple(label[tab_id], flags) if imgui.is_item_hovered(): imgui.set_tooltip(tr(tips[tab_id])) return shown if tab("intro_tab", intro_flags): self.side_tab = "intro_tab" self.gui_intro_outline() imgui.end_tab_item() if tab("story", story_flags): self.side_tab = "story" if self.in_intro and not self.intro_leaving and wanted != "intro": # chosen during the intro: it starts self.skip_intro() # the chronology (not when the intro was just asked for from here) if self.in_intro and not self.intro_leaving: pass # the first step comes with the fade elif not self.show_events: imgui.text_wrapped(tr("The steps are off: tick Steps, below the map.")) elif self.data.campaign is None: imgui.text_disabled(tr("Fetching Minard's tables...")) else: self.gui_card(imgui.get_content_region_avail().x) imgui.end_tab_item() if tab("context"): self.side_tab = "context" self.gui_context() imgui.end_tab_item() if tab("about"): self.side_tab = "about" self.gui_about() imgui.end_tab_item() if tab("making_of"): self.side_tab = "making_of" self.gui_making_of_tab() imgui.end_tab_item() imgui.end_tab_bar() def gui_context(self) -> None: """The twenty-four steps (a click goes there), then the background of the campaign.""" if imgui.collapsing_header(f"{tr('The twenty-four steps')}###whole_story", imgui.TreeNodeFlags_.default_open): current = self.current_index() for i, ev in enumerate(EVENTS): clicked, _ = imgui.selectable(f"{ev.heading()}###card{i}", i == current and not self.in_intro) if clicked: if self.in_intro: self.skip_intro() self.goto_event(i, glide=True) self.select_tab = "story" for k, section in enumerate(CONTEXT): if imgui.collapsing_header(f"{section.heading()}###context{k}"): imgui_md.render_unindented(section.body()) for name, url in section.sources: imgui_md.render_unindented(source_line(name, url)) def gui_intro_outline(self) -> None: """The points: a click shows one, from the chronology too. Under them, during the map explained, the button that starts the chronology: the list's natural end.""" imgui.text_disabled(tr("Minard's original, explained in {n} points").format(n=len(INTRO))) for i, s in enumerate(INTRO): current = self.in_intro and i == self.intro_slide clicked, _ = imgui.selectable(f"{i + 1}. {s.heading()}###slide{i}", current) if clicked: if not self.in_intro: self.show_original() self.goto_slide(i) if self.in_intro: imgui.spacing() if imgui.button(f"{fa.ICON_FA_PLAY} {self.start_label()}###start"): self.skip_intro() def gui_card(self, width: float) -> None: """The current card: its image, its text and sources, the countdown, and the way to the other cards.""" i = self.current_index() ev = EVENTS[i] upcoming = EVENTS[i + 1].image if i + 1 < len(EVENTS) else None for im in (ev.image, upcoming): # this card's painting first, then the next card's if im is not None: self.data.request(im.key) dt = min(imgui.get_io().delta_time, 0.1) if ev is not self.card_event: self.card_event, self.card_alpha, self.card_seconds = ev, 0.0, 0.0 if self.narrator.on and self.narrator.available() and self.voiced_card is not ev and not self.in_intro \ and self.playing: # the animation plays: the play button says so; Play reads the card shown self.read_card(i) self.card_alpha = min(1.0, self.card_alpha + dt / self.FADE_SECONDS) self.card_seconds += dt flags = (imgui.ChildFlags_.borders | imgui.ChildFlags_.always_use_window_padding | imgui.ChildFlags_.auto_resize_y) # A sheet of Minard's paper; with the host's theme, its own background (its text is not ink) imgui.push_style_color(imgui.Col_.child_bg, PAPER) # a sheet of Minard's paper imgui.begin_child("card", ImVec2(width, 0.0), flags) imgui.pop_style_color() # A mouse on the card holds playback; a finger's last touch would hold it forever on_mouse = imgui.get_io().mouse_source == imgui.MouseSource.mouse self.card_hovered = on_mouse and imgui.is_window_hovered(imgui.HoveredFlags_.child_windows) self.gui_card_arrows(i) # on top: they stay in place, whatever the card's length if i == 0: # the first step: the way back to the map explained (the last point has the way here) if imgui.button(f"{fa.ICON_FA_CHEVRON_LEFT} {tr('The map, explained')}###card_map"): self.restart_intro() imgui.push_style_var(imgui.StyleVar_.alpha, imgui.get_style().alpha * smooth(self.card_alpha)) inner_w = imgui.get_content_region_avail().x if ev.image is not None: # on a phone, small: the map must keep the room (the magnifier enlarges it) self.gui_image(ev.image, inner_w, em_size(7.0) if self.small_story else None) imgui_md.render_unindented(ev.markdown()) if i == len(EVENTS) - 1 and gui_author_note(self.card_seconds, NOTE_DELAY): self.open_page("about") imgui.pop_style_var() imgui.end_child() def card_back(self, i: int) -> None: """Back: to the card's start (its reading again), or to the card before within its first three seconds, as a music player does.""" if self.card_seconds < 3.0 and i > 0: self.goto_event(i - 1, glide=True) else: self.goto_event(i, glide=True) self.card_alpha, self.card_seconds, self.voiced_card = 0.0, 0.0, None def slide_back(self) -> None: """The same for the intro's slides: the slide again, or the one before within its first three seconds.""" if self.pan_clock < 3.0 and self.intro_slide > 0: self.goto_slide(self.intro_slide - 1) else: self.goto_slide(self.intro_slide) self.voiced_slide = -1 def gui_card_arrows(self, i: int) -> None: imgui.push_item_flag(imgui.ItemFlags_.no_nav, True) if imgui.button(f"{fa.ICON_FA_CHEVRON_LEFT}###prev_card", em_to_vec2(2.2, 0)): self.card_back(i) imgui.same_line() imgui.text_disabled(f"{i + 1} / {len(EVENTS)}") imgui.same_line() imgui.begin_disabled(i == len(EVENTS) - 1) if imgui.button(f"{fa.ICON_FA_CHEVRON_RIGHT}###next_card", em_to_vec2(2.2, 0)): self.goto_event(i + 1, glide=True) imgui.end_disabled() imgui.pop_item_flag() # Beside them, the countdown before playback goes on: it greys while the mouse holds it if self.playing and self.hold > 0.0 and not self.voice_drives(): # the countdown bar: not with the voice imgui.same_line() w, h = imgui.get_content_region_avail().x, em_size(0.4) p = imgui.get_cursor_screen_pos() p = ImVec2(p.x + em_size(0.3), p.y + (imgui.get_frame_height() - h) / 2) w -= em_size(0.3) frac = max(0.0, self.hold / max(self.hold_total, 1e-3)) dl = imgui.get_window_draw_list() muted = imgui.get_style().color_(imgui.Col_.text_disabled) dl.add_rect_filled(p, ImVec2(p.x + w, p.y + h), u32(muted, 0.25), h / 2) fill = u32(muted) if self.card_hovered else u32(imgui.get_style().color_(imgui.Col_.slider_grab_active)) dl.add_rect_filled(p, ImVec2(p.x + max(h, w * frac), p.y + h), fill, h / 2) imgui.new_line() def gui_image(self, im: Image, width: float, max_h: Optional[float] = None) -> None: """An image at the given width (at most max_h high), with its credit; a click on it enlarges it.""" self.data.request(im.key) tex = self.data.texture(im.key) src = self.data.sources[im.key] if tex is None: text = tr("Image not available" if src.state == "failed" else "Fetching the image...") imgui.text_disabled(text) else: if max_h is None: max_h = em_size(15.0) if self.narrow else width * 1.25 # a portrait painting must not fill a phone size = ImVec2(width, width * tex.height / tex.width) if size.y > max_h: size = ImVec2(max_h * tex.width / tex.height, max_h) imgui.set_cursor_pos_x(imgui.get_cursor_pos_x() + (width - size.x) / 2) imgui.image(imgui.ImTextureRef(tex.texture_id()), size) if imgui.is_item_hovered(): imgui.set_mouse_cursor(imgui.MouseCursor_.hand) if imgui.is_item_clicked(): self.open_lightbox(im) draw_magnifier(imgui.get_window_draw_list(), imgui.get_item_rect_max()) if im.credit: imgui_md.render_unindented(f"[{tr(im.credit)}]({src.page_url})") # ----- the enlarged image ------------------------------------------------------- def open_lightbox(self, im: Image) -> None: self.lightbox = im self.lightbox_frame = imgui.get_frame_count() self.lightbox_key = self.data.request_large(im) self.lightbox_pending = True # opened at the window's level, in gui_lightbox() self.lightbox_zoom, self.lightbox_request, self.lightbox_press_beside = 0.0, None, False def gui_lightbox(self) -> None: """The image over the whole window. It opens fitted (a wide image on a phone held upright: as high as the view, to slide along). The wheel, a double click (a double tap) or the buttons zoom; a drag moves it (a finger: hello_imgui's touch scroll). Esc, Close or a click beside it closes it.""" if self.lightbox_pending: imgui.open_popup("##lightbox") self.lightbox_pending = False if self.lightbox is None: return vp = imgui.get_main_viewport() imgui.set_next_window_pos(vp.pos) imgui.set_next_window_size(vp.size) imgui.push_style_color(imgui.Col_.popup_bg, imgui.ImVec4(0.06, 0.06, 0.06, 0.93)) imgui.push_style_var(imgui.StyleVar_.window_border_size, 0.0) imgui.push_style_var(imgui.StyleVar_.window_rounding, 0.0) flags = imgui.WindowFlags_.no_decoration | imgui.WindowFlags_.no_move | imgui.WindowFlags_.no_saved_settings opened, _ = imgui.begin_popup_modal("##lightbox", None, flags) imgui.pop_style_var(2) imgui.pop_style_color() if not opened: self.lightbox = None return im = self.lightbox large = self.data.texture(self.lightbox_key) tex = large or self.data.texture(im.key) m = em_size(1.0) io = imgui.get_io() wide = vp.size.x > em_size(40.0) caption_h = ((2 if wide else 4) * imgui.get_text_line_height_with_spacing() + imgui.get_frame_height() + imgui.get_style().item_spacing.y) view_pos = ImVec2(vp.pos.x + m, vp.pos.y + m) view = ImVec2(vp.size.x - 2 * m, vp.size.y - 2 * m - caption_h) # The last known position: on a touch screen, the press of a double tap's second tap comes with none if imgui.is_mouse_pos_valid(): self.lightbox_mouse = ImVec2(io.mouse_pos.x, io.mouse_pos.y) mouse = self.lightbox_mouse in_view = view_pos.x <= mouse.x <= view_pos.x + view.x and view_pos.y <= mouse.y <= view_pos.y + view.y if tex is not None: aspect = tex.width / tex.height fit_w = min(view.x, view.y * aspect) # the image's width when it fits the view if self.lightbox_zoom == 0.0: upright_wide = view.y > 1.2 * view.x and aspect > 1.3 self.lightbox_start_zoom = view.y * aspect / fit_w if upright_wide else 1.0 self.lightbox_zoom = self.lightbox_start_zoom zoom = self.lightbox_zoom top = max(4.0, 3.0 * self.lightbox_start_zoom) def image_size(z: float) -> ImVec2: return ImVec2(fit_w * z, fit_w * z / aspect) def pad(s: ImVec2) -> ImVec2: # the margins that center an image smaller than the view return ImVec2(max(0.0, (view.x - s.x) / 2), max(0.0, (view.y - s.y) / 2)) # A zoom keeps the point under the mouse (or the view's center) in place; a mouse drag moves the image new_zoom, at = zoom, ImVec2(view_pos.x + view.x / 2, view_pos.y + view.y / 2) if self.lightbox_request is not None: new_zoom, at = self.lightbox_request self.lightbox_request = None elif in_view and io.mouse_wheel != 0.0: new_zoom, at = zoom * 1.15 ** io.mouse_wheel, mouse elif in_view and imgui.is_mouse_double_clicked(0): new_zoom, at = (self.lightbox_start_zoom if zoom > 1.05 * self.lightbox_start_zoom else 2.5 * self.lightbox_start_zoom), mouse new_zoom = min(top, max(min(1.0, self.lightbox_start_zoom), new_zoom)) s0, s1 = image_size(zoom), image_size(new_zoom) p0, p1 = pad(s0), pad(s1) scroll = self.lightbox_scroll if new_zoom != zoom: fx = (scroll.x + at.x - view_pos.x - p0.x) / s0.x fy = (scroll.y + at.y - view_pos.y - p0.y) / s0.y scroll = ImVec2(fx * s1.x + p1.x - (at.x - view_pos.x), fy * s1.y + p1.y - (at.y - view_pos.y)) self.lightbox_zoom = new_zoom elif io.mouse_source == imgui.MouseSource.mouse and in_view and imgui.is_mouse_dragging(0, 0.0): scroll = ImVec2(scroll.x - io.mouse_delta.x, scroll.y - io.mouse_delta.y) if scroll.x != self.lightbox_scroll.x or scroll.y != self.lightbox_scroll.y: limit = ImVec2(max(0.0, s1.x - view.x), max(0.0, s1.y - view.y)) imgui.set_next_window_scroll(ImVec2(min(limit.x, max(0.0, scroll.x)), min(limit.y, max(0.0, scroll.y)))) imgui.set_next_window_content_size(ImVec2(max(s1.x, view.x), max(s1.y, view.y))) imgui.set_cursor_screen_pos(view_pos) imgui.push_style_var(imgui.StyleVar_.window_padding, ImVec2(0, 0)) imgui.push_style_color(imgui.Col_.child_bg, imgui.ImVec4(0, 0, 0, 0)) # It scrolls (a finger drags it through hello_imgui's touch scroll); the image claims the wheel, to zoom imgui.begin_child("##lightbox_view", view, 0, imgui.WindowFlags_.no_scrollbar) imgui.set_cursor_pos(p1) imgui.image(imgui.ImTextureRef(tex.texture_id()), s1) if imgui.is_item_hovered(): imgui.set_item_key_owner(imgui.Key.mouse_wheel_y) # the wheel zooms the image if new_zoom > 1.0: imgui.set_mouse_cursor(imgui.MouseCursor_.resize_all) self.lightbox_scroll = ImVec2(imgui.get_scroll_x(), imgui.get_scroll_y()) imgui.end_child() imgui.pop_style_color() imgui.pop_style_var() # The caption, then the buttons imgui.set_cursor_screen_pos(ImVec2(vp.pos.x + m, vp.pos.y + vp.size.y - m - caption_h)) imgui.push_style_color(imgui.Col_.text, imgui.ImVec4(0.92, 0.92, 0.92, 1.0)) imgui.text_wrapped(f"{tr(im.title)}. {tr(im.credit)}.") imgui.pop_style_color() imgui.set_cursor_screen_pos(ImVec2(vp.pos.x + m, imgui.get_cursor_screen_pos().y)) center = ImVec2(view_pos.x + view.x / 2, view_pos.y + view.y / 2) zoom = self.lightbox_zoom if imgui.button(f"{fa.ICON_FA_SEARCH_MINUS}###zoom_out", em_to_vec2(2.6, 0)): self.lightbox_request = (zoom / 1.5, center) imgui.same_line() if imgui.button(f"{fa.ICON_FA_SEARCH_PLUS}###zoom_in", em_to_vec2(2.6, 0)): self.lightbox_request = (zoom * 1.5, center) imgui.same_line() if imgui.button(f"{fa.ICON_FA_COMPRESS}###zoom_fit", em_to_vec2(2.6, 0)): self.lightbox_request = (self.lightbox_start_zoom, center) imgui.same_line() close = imgui.button(f"{fa.ICON_FA_TIMES} {tr('Close')}###close") loading = large is None and self.data.sources[self.lightbox_key].state == "fetching" touch = io.mouse_source == imgui.MouseSource.touch_screen hint = ("Fetching the large version..." if loading else "Drag to move it; a double tap zooms." if touch else "Drag to move it; the wheel or a double click zooms. Esc closes.") if wide: # beside the buttons; else (a phone) below them imgui.same_line() imgui.align_text_to_frame_padding() else: imgui.set_cursor_screen_pos(ImVec2(vp.pos.x + m, imgui.get_cursor_screen_pos().y)) imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled)) imgui.text_wrapped(tr(hint)) imgui.pop_style_color() # A click beside the image closes (not the one that opened it, nor the end of a drag). Where it was is taken # at the press: when a finger lifts, the mouse has no position if imgui.is_mouse_clicked(0): self.lightbox_press_beside = not in_view and not imgui.is_any_item_hovered() beside = (imgui.is_mouse_released(0) and self.lightbox_press_beside and not imgui.internal.is_mouse_drag_past_threshold(0) and imgui.get_frame_count() > self.lightbox_frame + 1) if imgui.is_key_pressed(imgui.Key.escape) or close or beside: imgui.close_current_popup() self.lightbox = None imgui.end_popup() # ----- side panel --------------------------------------------------------------- def gui_about(self) -> None: """The About tab: the title, the author's note, and the way to the rest, over the whole page.""" imgui_md.render_unindented(f"**1812 · {tr('The Russian campaign, after Minard: a homage in motion')} · 2026**") frame = imgui.get_frame_count() if frame != self.about_frame + 1: # the tab was just opened self.about_seconds = 0.0 self.about_frame = frame self.about_seconds += min(imgui.get_io().delta_time, 0.1) gui_author_note(self.about_seconds, NOTE_DELAY_ABOUT) imgui.dummy(em_to_vec2(0, 0.3)) if self.gui_rule_button(tr("Read more"), "about_more"): self.open_page("about") imgui.dummy(em_to_vec2(0, 0.6)) if imgui.button(f"{fa.ICON_FA_UNDO} {tr('See the map explained again')}"): self.show_original() def making_of_blocks(self) -> Optional[List[Block]]: """The making-of's text, fetched when first needed, parsed once it has come; None until then (a line says why).""" d = self.data d.request_making_of() src = d.sources[MAKING_OF_KEY] if self.making_of is None: if src.state == "failed": imgui.text_disabled(tr("Not available: see Data and downloads, under the gear")) return None if src.state != "ready": imgui.text_disabled(tr("Fetching the text...")) return None self.making_of = parse_making_of(d.raw[MAKING_OF_KEY].decode("utf-8")) for block in self.making_of: if block.image is not None: d.add_image(block.image) return self.making_of def gui_making_of_tab(self) -> None: """The making-of's tab: an extract, the first evening's witness, and the button that opens the whole text.""" width = imgui.get_content_region_avail().x if Lang.code != "fr": imgui.text_wrapped(tr("The making-of exists in French only, for now.")) imgui_md.render_unindented(in_language("notes", "making_of", "extract", MAKING_OF_EXTRACT, MAKING_OF_EXTRACT_FR)) witness = Image("img:making_of:" + MAKING_OF_WITNESS, "La chatte, témoin du premier soir, devant le code", MAKING_OF_WITNESS, "", url=making_of_url(MAKING_OF_WITNESS)) self.data.add_image(witness) self.gui_image(witness, min(width, em_size(14.0))) imgui.dummy(em_to_vec2(0, 0.3)) if self.gui_rule_button(tr("Read the making-of"), "open_making_of"): self.open_page("making_of") imgui.push_style_color(imgui.Col_.text, imgui.get_style_color_vec4(imgui.Col_.text_disabled)) imgui.text_wrapped(tr("Nine chapters, about twenty pictures, a quarter of an hour.")) imgui.pop_style_color() def gui_rule_button(self, label: str, id_: str) -> bool: """The button of the loading page's Start: the words, and under them the quickening rule ending in an arrow.""" style = imgui.get_style() size = ImVec2(imgui.calc_text_size(label).x + em_size(3.0), imgui.get_text_line_height() + em_size(1.3)) imgui.push_style_var(imgui.StyleVar_.button_text_align, ImVec2(0.5, 0.22)) imgui.push_style_color(imgui.Col_.button, imgui.get_style_color_vec4(imgui.Col_.frame_bg)) pressed = imgui.button(f"{label}###{id_}", size) imgui.pop_style_color() imgui.pop_style_var() lo, hi = imgui.get_item_rect_min(), imgui.get_item_rect_max() dl = imgui.get_window_draw_list() color = imgui.get_color_u32(imgui.Col_.text_disabled) y = hi.y - em_size(0.55) x0, x1 = lo.x + style.frame_padding.x + em_size(0.6), hi.x - style.frame_padding.x - em_size(0.9) draw_quickening(dl, x0, x1, y, 0.35, 1.0, color, solid_end=True) a = em_size(0.22) # the arrow's head dl.add_line(ImVec2(x1, y), ImVec2(x1 - a, y - a), color, max(1.0, em_size(0.06))) dl.add_line(ImVec2(x1, y), ImVec2(x1 - a, y + a), color, max(1.0, em_size(0.06))) return pressed def open_page(self, name: str, chapter: str = "") -> None: """A page (the making-of, the rest of About) over the whole window, at a chapter if asked; the animation, or the intro's tour, waits while one reads.""" self.page_resume, self.playing = self.playing, False if self.in_intro and self.intro_auto: self.intro_auto, self.intro_paused, self.page_paused_intro = False, True, True self.page_open, self.page_chapter = name, chapter self.page_anchor_until = imgui.get_time() + 3.0 if chapter else 0.0 self.by_hand = True if name == "making_of" and not self.guestbook.loaded: self.guestbook.load() def close_page(self) -> None: self.select_tab = self.page_open self.page_open = self.page_chapter = "" self.playing = self.page_resume self.by_hand = True if self.page_paused_intro: self.intro_auto, self.intro_paused, self.page_paused_intro = True, False, False def gui_page(self) -> None: """The open page over the whole window, the map put away: the way back, then the text as a RichMd document, centered: its table of contents beside it (above it on a phone), its search on Ctrl+F (a magnifier on a touch screen).""" if imgui.small_button(f"{fa.ICON_FA_CHEVRON_LEFT} {tr('Back to the map')}###close_page"): self.close_page() avail_w = imgui.get_content_region_avail().x width = min(avail_w, em_size(PAGE_COLUMN_EM + PAGE_TOC_EM)) imgui.set_cursor_pos_x(imgui.get_cursor_pos_x() + (avail_w - width) / 2) options = imgui_md.DocumentOptions() options.toc_min_headings = 3 imgui_md.begin_document("page_" + self.page_open, ImVec2(width, 0), options) imgui.dummy(em_to_vec2(0, 0.8)) self.anchor_y = None col_w = imgui.get_content_region_avail().x if self.page_open == "making_of": self.gui_making_of_page(col_w) else: self.gui_about_page(col_w) imgui.dummy(em_to_vec2(0, 3.0)) if self.anchor_y is not None: # the chapter asked for, at the top; again while the pictures above it come imgui.set_scroll_y(self.anchor_y - em_size(0.6)) imgui_md.end_document() def gui_making_of_page(self, width: float) -> None: blocks = self.making_of_blocks() if blocks is None: return for block in blocks: if (block.anchor == self.page_chapter and block.anchor and self.anchor_y is None and imgui.get_time() < self.page_anchor_until): self.anchor_y = imgui.get_cursor_pos().y if block.summary: # the same box as the author's note: a deeper tone of the page, the tan bar at the left ink, pad, bar = imgui.get_style().color_(imgui.Col_.text), em_size(0.9), em_size(0.18) imgui.push_style_color(imgui.Col_.child_bg, imgui.ImVec4(ink.x, ink.y, ink.z, 0.06)) imgui.push_style_var(imgui.StyleVar_.child_rounding, em_size(0.35)) imgui.push_style_var(imgui.StyleVar_.window_padding, ImVec2(pad + bar, pad * 0.8)) imgui.begin_child("summary", ImVec2(0.0, 0.0), imgui.ChildFlags_.always_use_window_padding | imgui.ChildFlags_.auto_resize_y) imgui.pop_style_var(2) imgui.pop_style_color() top, bottom = imgui.get_window_pos(), imgui.get_window_pos() + imgui.get_window_size() imgui.get_window_draw_list().add_rect_filled(top, ImVec2(top.x + bar, bottom.y), imgui.get_color_u32(ADVANCE), em_size(0.35), imgui.ImDrawFlags_.round_left) imgui_md.render_unindented(block.markdown) imgui.end_child() imgui.dummy(em_to_vec2(0, 0.6)) elif block.note: # a word explained: small, soft, the size pushed before the render so that the italic follows imgui.push_font(None, em_size(0.9)) imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled)) imgui_md.render_unindented(block.markdown) imgui.pop_style_color() imgui.pop_font() elif block.image is None: imgui_md.render_unindented(block.markdown) else: self.gui_image(block.image, min(width, em_size(14.0)) if block.narrow else width, width * 2.0) self.gui_guestbook(width) def gui_guestbook(self, width: float) -> None: """The last page of the making-of: the guestbook. The form, then the words published; for the admin, the words waiting, with their buttons.""" gb = self.guestbook gb.poll() if (self.page_chapter in ("guestbook", slug(tr("Guestbook"))) and self.anchor_y is None and imgui.get_time() < self.page_anchor_until): self.anchor_y = imgui.get_cursor_pos().y imgui_md.document_heading(2, tr("Guestbook")) imgui_md.render_unindented(f"**{tr('You walked with them.')}** " + tr("Leave a word: where you write from, what you keep. " "The words are read before they are published.")) field_w = min(width, em_size(24.0)) imgui.set_next_item_width(field_w) _, gb.name = imgui.input_text_with_hint("##gb_name", tr("Your name"), gb.name) imgui.set_next_item_width(field_w) _, gb.place = imgui.input_text_with_hint("##gb_place", tr("Where you write from (optional)"), gb.place) imgui.text_disabled(tr("Your word")) _, gb.message = imgui.input_text_multiline("##gb_message", gb.message, ImVec2(width, em_size(6.0))) gb.name, gb.place = gb.name[:GUESTBOOK_MAX_NAME], gb.place[:GUESTBOOK_MAX_PLACE] gb.message = gb.message[:GUESTBOOK_MAX_MESSAGE] imgui.begin_disabled(gb.sending is not None or not gb.name.strip() or not gb.message.strip()) if imgui.button(f"{tr('Send')}###gb_send"): gb.send() imgui.end_disabled() if gb.sending is not None: imgui.same_line() imgui.text_disabled(tr("Sending...")) elif gb.sent: imgui.same_line() imgui.text_wrapped(tr("Thank you. Your word will be published once read.")) if gb.send_error: imgui.text_wrapped(f"{tr('Could not send')}: {gb.send_error}") imgui.dummy(em_to_vec2(0, 0.6)) if gb.admin_token and gb.pending: imgui_md.render_unindented(f"### {tr('Waiting for a reading')}") for entry in list(gb.pending): self.gui_entry(entry, width, admin=True) if gb.error: imgui.text_wrapped(f"{tr('Could not load the guestbook')}: {gb.error}") elif gb.loading is not None: imgui.text_disabled(tr("Fetching the text...")) elif not gb.entries: imgui.text_disabled(tr("Nobody has written yet.")) for entry in list(gb.entries): self.gui_entry(entry, width, admin=bool(gb.admin_token)) def gui_entry(self, entry: Entry, width: float, admin: bool) -> None: """A word of the guestbook, as plain text (never markdown: it is a stranger's).""" gb = self.guestbook imgui.push_id(entry.id) imgui.separator() where = f", {entry.place}" if entry.place else "" imgui.text(f"{entry.name}{where}") imgui.same_line() imgui.text_disabled(entry.created[:10]) imgui.push_text_wrap_pos(imgui.get_cursor_pos_x() + width) imgui.text_wrapped(entry.message) imgui.pop_text_wrap_pos() if admin: imgui.begin_disabled(entry.id in gb.acting) if entry in gb.pending: if imgui.small_button(tr("Approve")): gb.set_status(entry, "shown") imgui.same_line() if imgui.small_button(tr("Hide")): gb.set_status(entry, "hidden") imgui.same_line() if imgui.small_button(tr("Delete")): gb.delete(entry) imgui.end_disabled() imgui.pop_id() def gui_about_page(self, width: float) -> None: """The rest of About: the text, the original, the readings, the credit.""" imgui_md.render_unindented(f"# {tr('The Russian campaign, after Minard: a homage in motion')}") imgui_md.render_unindented(in_language("notes", "about", "text", ABOUT, ABOUT_FR)) imgui_md.render_unindented(source_line(*FRIENDLY_2002)) self.gui_image(MINARD_MAP, width) more = READ_MORE if Lang.code != "ru" else READ_MORE[:-len(READ_MORE_FRENCH)] + tuple(RUSSIAN["read_more"]) for name, url in more: imgui_md.render_unindented(f"{reference(name, url)}") imgui_md.render_unindented(f"{tr('Made with')} [Dear ImGui Bundle](https://pthom.github.io/imgui_bundle/)" "") imgui_md.render_unindented(f"## {tr('Where the data comes from')}\n" + in_language("notes", "data_sources", "text", DATA_SOURCES, DATA_SOURCES_FR)) imgui_md.render_unindented(f"## {tr('License')}\n" + in_language("notes", "license", "text", LICENSE, LICENSE_FR)) def gui_data(self) -> None: d = self.data imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled)) imgui.text_wrapped(tr("Where the data comes from: every file is fetched live")) imgui.pop_style_color() for s in list(d.sources.values()): imgui.push_id(s.key) if s.state == "fetching": imspinner.spinner_ang_triple("##sp", em_size(0.32), em_size(0.24), em_size(0.16), 2.0, imgui.ImColor(imgui.get_style().color_(imgui.Col_.text)), imgui.ImColor(imgui.get_style().color_(imgui.Col_.text_disabled)), imgui.ImColor(imgui.get_style().color_(imgui.Col_.text)), 6.0) elif s.state == "ready": imgui.text_colored(hex4("#009E73"), fa.ICON_FA_CHECK) elif s.state == "failed": imgui.text_colored(hex4("#D55E00"), fa.ICON_FA_TIMES) else: imgui.text_disabled(fa.ICON_FA_CLOCK) # waiting (a painting: for its card), or queued imgui.same_line() imgui.text_wrapped(tr(s.title)) if s.state == "failed": imgui.same_line() if imgui.small_button(tr("Retry")): d.request(s.key, retry=True) # The second line: where from, and what came (or what went wrong) host = s.url.split("/")[2] if s.state == "ready": rows = f", {s.rows} rows" if s.is_table else "" detail = f"{host}: {human_bytes(s.size)}{rows}, {s.seconds:.2f} s" elif s.state == "failed": detail = f"{host}: {s.error}" else: detail = host imgui.indent(em_size(1.4)) imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled)) imgui.text_wrapped(detail) imgui.pop_style_color() if imgui.is_item_hovered(): imgui.set_tooltip(s.url) if s.page_url: imgui.same_line(0, em_size(0.5)) imgui.text_link_open_url(tr("its page"), s.page_url) imgui.unindent(em_size(1.4)) imgui.pop_id() if imgui.button(f"{fa.ICON_FA_SYNC} {tr('Fetch it all again')}"): d.refetch_all() imgui.text_disabled(tr("Requests")) imgui.begin_child("log", ImVec2(0, em_size(7.0)), imgui.ChildFlags_.borders) for t, line in d.log: imgui.text_disabled(f"{t:5.2f} s") imgui.same_line() imgui.text_wrapped(line) if imgui.get_scroll_y() >= imgui.get_scroll_max_y() - 1: imgui.set_scroll_here_y(1.0) imgui.end_child() # --------------------------------------------------------------------------- # Running it # --------------------------------------------------------------------------- def markdown_options() -> imgui_md.MarkdownOptions: """Markdown with a tag, for credits and sources.""" options = imgui_md.MarkdownOptions() def on_html_span(tag: str, opening: bool) -> bool: if tag != "small": return False if opening: imgui.push_font(None, em_size(0.82)) imgui.push_style_color(imgui.Col_.text, imgui.get_style().color_(imgui.Col_.text_disabled)) else: imgui.pop_style_color() imgui.pop_font() return True options.callbacks.on_html_span = on_html_span return options def apply_theme() -> None: """Minard's paper and ink for the whole window, whatever the host's theme.""" style = imgui.get_style() c = imgui.Col_ red = MINARD_RED for col, (h, alpha) in { c.text: ("#2E2822", 1.0), c.text_disabled: ("#7A6E60", 1.0), c.window_bg: ("#E6DCC6", 1.0), c.child_bg: ("#E6DCC6", 0.0), c.popup_bg: ("#F4EEE2", 1.0), c.border: ("#A8977A", 0.6), c.frame_bg: ("#F1E9D8", 1.0), c.frame_bg_hovered: ("#EADFC8", 1.0), c.frame_bg_active: ("#E0CFAE", 1.0), c.title_bg: ("#D8C8A8", 1.0), c.title_bg_active: ("#CDB894", 1.0), c.menu_bar_bg: ("#DDD0B4", 1.0), c.scrollbar_bg: ("#E6DCC6", 0.0), c.scrollbar_grab: ("#C4B293", 1.0), c.scrollbar_grab_hovered: ("#B39E7B", 1.0), c.scrollbar_grab_active: ("#A08860", 1.0), c.check_mark: (red, 1.0), c.checkbox_selected_bg: ("#F1E9D8", 1.0), c.slider_grab: ("#8C6E4B", 1.0), c.slider_grab_active: (red, 1.0), c.button: ("#D8C6A2", 1.0), c.button_hovered: ("#CDB48A", 1.0), c.button_active: ("#BFA06E", 1.0), c.header: ("#DCCBA9", 1.0), c.header_hovered: ("#D2BC94", 1.0), c.header_active: ("#C6AA78", 1.0), c.separator: ("#A8977A", 0.6), c.separator_hovered: ("#8C6E4B", 1.0), c.separator_active: (red, 1.0), c.resize_grip: ("#C4B293", 0.5), c.resize_grip_hovered: ("#B39E7B", 1.0), c.resize_grip_active: (red, 1.0), c.tab: ("#D6C5A3", 1.0), c.tab_hovered: ("#EADFC8", 1.0), c.tab_selected: ("#F1E9D8", 1.0), c.tab_selected_overline: (red, 1.0), c.tab_dimmed: ("#D6C5A3", 1.0), c.tab_dimmed_selected: ("#F1E9D8", 1.0), c.tab_dimmed_selected_overline: (red, 0.6), c.plot_lines: ("#3B6E9E", 1.0), c.plot_histogram: (red, 1.0), c.text_selected_bg: ("#D2A06F", 0.5), c.text_link: ("#2F5F8A", 1.0), c.nav_cursor: (red, 1.0), c.modal_window_dim_bg: ("#2E2822", 0.35), }.items(): style.set_color_(col, hex4(h, alpha)) style.frame_rounding = 3.0 style.tab_rounding = 6.0 style.popup_rounding = 4.0 style.child_rounding = 4.0 style.grab_rounding = 2.0 async def fetch_fonts(data: DataStore) -> None: """The fonts come before the window opens: the text's renderer loads its fonts at the start.""" await asyncio.gather(*(data.fetch_once(data.sources[key]) for key, *_ in FONTS)) def use_fonts(app: App, params: hello_imgui.RunnerParams) -> Optional[str]: """Old Standard for the text and the widgets, IM Fell for the map; the defaults for a font that failed. Returns the markdown fonts' base path, for addons_for().""" d = app.data folder = os.path.join(tempfile.gettempdir(), "minard_1812_fonts") paths: Dict[str, str] = {} try: os.makedirs(folder, exist_ok=True) for key, _, _, name, _ in FONTS: if d.sources[key].state == "ready": with open(os.path.join(folder, name), "wb") as f: f.write(d.raw[key]) paths[key] = os.path.join(folder, name) text = all(k in paths for k in ("font:text", "font:bold", "font:italic")) if text: # the markdown renderer wants a bold italic too: Old Standard has none shutil.copy(paths["font:bold"], os.path.join(folder, "OldStandard-BoldItalic.ttf")) except OSError as e: # no file system to write to: the default fonts d.say(f"The fonts could not be written: {e}") paths, text = {}, False def load_fonts() -> None: if text: # the first font loaded is the default one app.text_font = hello_imgui.load_font_ttf_with_font_awesome_icons(paths["font:text"], 16.0) app.plain_font = hello_imgui.load_font_ttf_with_font_awesome_icons("fonts/DroidSans.ttf", 15.0) else: hello_imgui.imgui_default_settings.load_default_font_with_font_awesome_icons() if "font:map" in paths: app.map_font = hello_imgui.load_font_ttf(paths["font:map"], 20.0) if text: # IM Fell has no Cyrillic: Old Standard's italic draws the letters it lacks merge = hello_imgui.FontLoadingParams(merge_to_last_font=True, inside_assets=False) hello_imgui.load_font(paths["font:italic"], 20.0, merge) for year in ("1812", "2026"): if f"font:{year}" in paths: app.year_fonts[year] = hello_imgui.load_font(paths[f"font:{year}"], 20.0, hello_imgui.FontLoadingParams(inside_assets=False)) params.callbacks.load_additional_fonts = load_fonts return os.path.join(folder, "OldStandard") if text else None def addons_for(app: App, markdown_font_base: Optional[str] = None) -> immapp.AddOnsParams: addons = immapp.AddOnsParams() addons.with_implot = True options = markdown_options() if markdown_font_base is not None: options.font_options.font_base_path = markdown_font_base addons.with_markdown_options = options return addons async def run_app() -> None: app = App() params = hello_imgui.RunnerParams() params.app_window_params.window_title = "1812 – The Russian campaign, after Minard: a homage in motion – 2026" params.app_window_params.window_geometry.size = (1400, 860) params.callbacks.show_gui = app.gui params.callbacks.before_exit = app.before_exit params.fps_idling.fps_idle = 10.0 if Lang.wanted == "ru": # a browser in Russian: its texts come with the fonts, before the first frame app.data.request_russian() await fetch_fonts(app.data) while Lang.wanted == "ru" and app.data.sources[RUSSIAN_KEY].state in ("queued", "fetching"): await asyncio.sleep(0.05) await immapp.run_async(params, addons_for(app, use_fonts(app, params))) def main() -> None: if sys.platform == "emscripten": # in a browser (Pyodide): the page's event loop runs it language = browser_language() # the button still switches Lang.code, Lang.wanted = ("en", "ru") if language == "ru" else (language, "") asyncio.ensure_future(run_app()) else: asyncio.run(run_app()) if __name__ == "__main__": main()