Sfoglia il codice sorgente

Initial world map app with policy data, UI, docs and bot-blocking

benjamin 7 mesi fa
commit
1d23ebf022
7 ha cambiato i file con 1740 aggiunte e 0 eliminazioni
  1. 31 0
      .gitignore
  2. 115 0
      README.md
  3. 120 0
      index.html
  4. 20 0
      robots.txt
  5. 930 0
      script.js
  6. 524 0
      styles.css
  7. 0 0
      znep_gta_pays_etrangers.txt

+ 31 - 0
.gitignore

@@ -0,0 +1,31 @@
+# OS
+.DS_Store
+Thumbs.db
+
+# Editors / IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+pnpm-debug.log*
+
+# Env / local secrets
+.env
+.env.*
+!.env.example
+
+# Build / cache / reports
+dist/
+build/
+coverage/
+.cache/
+tmp/
+*.tmp
+
+# Package managers
+node_modules/

+ 115 - 0
README.md

@@ -0,0 +1,115 @@
+# CarteVoyageGie
+
+Carte du monde interactive plein écran pour visualiser des destinations de voyage et leur niveau d'autorisation, avec affichage détaillé par pays.
+
+## Objectif
+
+L'application affiche une carte mondiale cliquable, colorée par catégorie d'autorisation (codes métier), avec :
+
+- zoom/pan,
+- recherche pays,
+- liste triée alphabétiquement,
+- modal de détail au clic,
+- version adaptée desktop et mobile.
+
+## Fonctionnalités
+
+- Carte monde interactive (D3 + GeoJSON Natural Earth).
+- Coloration des pays par code d'autorisation.
+- Modal pays au clic avec :
+  - drapeau + nom,
+  - catégorie explicite,
+  - période de validité,
+  - procédure synthétisée.
+- Tooltip au survol.
+- Zoom sur le pays sélectionné.
+- Légende des catégories.
+- Liste flottante des pays avec recherche (desktop).
+- Boutons mobiles :
+  - loupe pour ouvrir la recherche en modal,
+  - bouton pour ouvrir/fermer la légende.
+- Thème dark.
+
+## Codes d'autorisation et couleurs
+
+- `10` : vert
+- `21`, `22`, `23` : jaune
+- `99` : rouge
+- `30` : noir (affiché en dernier dans la légende)
+- sans donnée : gris neutre
+
+## Données
+
+### Source locale
+
+- `znep_gta_pays_etrangers.txt` : données brutes des pays/catégories.
+
+### Source cartographique
+
+- GeoJSON Natural Earth chargé depuis :
+  - `https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_map_units.geojson`
+
+### Traitement
+
+- Parsing du fichier brut côté navigateur.
+- Appariement par ISO2 en priorité.
+- Fallback par normalisation de nom (accents/variantes).
+- Redirections de certains territoires vers pays parent (exemple : Nouvelle-Calédonie -> France).
+- Synthèse des libellés procédure pour affichage court en modal.
+
+## Interface
+
+### Desktop
+
+- Carte plein écran.
+- Titre en haut à gauche.
+- Liste pays flottante à gauche.
+- Légende à droite.
+- Footer bas-centre avec lien dépôt.
+
+### Mobile
+
+- Carte plein écran.
+- Bouton `🔍` pour la recherche des pays (modal).
+- Bouton `Légende` pour afficher/masquer la légende.
+- Champ recherche en `font-size: 16px` pour limiter les zooms iOS.
+
+## Structure du projet
+
+- `index.html` : structure UI.
+- `styles.css` : thème, responsive, composants visuels.
+- `script.js` : logique carte, parsing données, interactions.
+- `znep_gta_pays_etrangers.txt` : dataset métier brut.
+- `robots.txt` : blocage robots/crawlers.
+- `.gitignore` : exclusions locales.
+
+## Lancer en local
+
+Depuis la racine du projet :
+
+```bash
+python3 -m http.server 8008
+```
+
+Puis ouvrir :
+
+- `http://localhost:8008`
+
+## Protection anti-robots
+
+La protection anti-indexation/crawling est configurée par :
+
+- `robots.txt` avec `Disallow: /` (global + quelques user agents IA),
+- meta tags `robots`, `googlebot`, `bingbot` en `noindex,nofollow,...` dans `index.html`.
+
+Important : ces mécanismes reposent sur la bonne volonté des robots.  
+Pour renforcer côté production, ajouter des en-têtes HTTP (`X-Robots-Tag`) au niveau du serveur web/reverse proxy.
+
+## Dépôt Git
+
+- URL : `git@git.goutfer.fr:benjamin/CarteVoyageGie.git`
+- Lien web : `https://git.goutfer.fr/benjamin/CarteVoyageGie`
+
+## Auteur
+
+- Benjamin GOUTFER (MDC)

+ 120 - 0
index.html

@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html lang="fr">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta
+      name="robots"
+      content="noindex, nofollow, noarchive, nosnippet, noimageindex, notranslate"
+    />
+    <meta
+      name="googlebot"
+      content="noindex, nofollow, noarchive, nosnippet, noimageindex"
+    />
+    <meta
+      name="bingbot"
+      content="noindex, nofollow, noarchive, nosnippet, noimageindex"
+    />
+    <title>Carte du monde interactive</title>
+    <link rel="stylesheet" href="styles.css" />
+  </head>
+  <body>
+    <main class="app">
+      <header class="map-header">
+        <h1>Carte des destinations de voyage</h1>
+        <p>Militaires de la Gendarmerie</p>
+      </header>
+
+      <button
+        id="open-mobile-country-list"
+        class="mobile-country-button"
+        type="button"
+        aria-label="Ouvrir la recherche de pays"
+      >
+        🔍
+      </button>
+
+      <button
+        id="toggle-mobile-legend"
+        class="mobile-legend-button"
+        type="button"
+        aria-controls="category-legend"
+        aria-expanded="false"
+      >
+        Légende
+      </button>
+
+      <aside class="country-sidebar" aria-label="Liste des pays">
+        <h3>Pays</h3>
+        <input
+          id="country-search"
+          class="country-search"
+          type="search"
+          placeholder="Rechercher un pays..."
+          autocomplete="off"
+        />
+        <p id="country-list-count" class="country-list-count"></p>
+        <ul id="country-list" class="country-list"></ul>
+      </aside>
+
+      <div id="map" class="map" role="img" aria-label="Carte du monde interactive"></div>
+      <section id="category-legend" class="legend" aria-live="polite"></section>
+      <p class="map-footer">
+        Création MDC GOUTFER Benjamin -
+        <a
+          href="https://git.goutfer.fr/benjamin/CarteVoyageGie"
+          target="_blank"
+          rel="noopener noreferrer"
+          aria-label="Lien dépot GIT"
+          >Lien dépot GIT</a
+        >
+      </p>
+    </main>
+    <div id="map-tooltip" class="map-tooltip" aria-hidden="true"></div>
+
+    <div
+      id="country-list-modal"
+      class="country-list-modal"
+      aria-hidden="true"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="country-list-modal-title"
+    >
+      <div class="country-list-modal-content">
+        <div class="country-list-modal-head">
+          <h2 id="country-list-modal-title">Rechercher un pays</h2>
+          <button
+            id="close-country-list-modal"
+            class="close"
+            aria-label="Fermer la recherche de pays"
+          >
+            ×
+          </button>
+        </div>
+        <input
+          id="country-search-mobile"
+          class="country-search"
+          type="search"
+          placeholder="Rechercher un pays..."
+          autocomplete="off"
+        />
+        <p id="country-list-count-mobile" class="country-list-count"></p>
+        <ul id="country-list-mobile" class="country-list"></ul>
+      </div>
+    </div>
+
+    <div id="country-modal" class="modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modal-title">
+      <div class="modal-content">
+        <button id="close-modal" class="close" aria-label="Fermer la fenêtre">×</button>
+        <h2 id="modal-title">Pays sélectionné</h2>
+        <p id="country-name"></p>
+        <p id="country-category" class="country-category"></p>
+        <div id="country-data" class="country-data"></div>
+      </div>
+    </div>
+
+    <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/d3-geo-projection@4/dist/d3-geo-projection.min.js"></script>
+    <script src="script.js"></script>
+  </body>
+</html>

+ 20 - 0
robots.txt

@@ -0,0 +1,20 @@
+User-agent: *
+Disallow: /
+
+User-agent: GPTBot
+Disallow: /
+
+User-agent: ChatGPT-User
+Disallow: /
+
+User-agent: CCBot
+Disallow: /
+
+User-agent: ClaudeBot
+Disallow: /
+
+User-agent: anthropic-ai
+Disallow: /
+
+User-agent: Google-Extended
+Disallow: /

+ 930 - 0
script.js

@@ -0,0 +1,930 @@
+const mapContainer = d3.select("#map");
+const modal = document.getElementById("country-modal");
+const countryName = document.getElementById("country-name");
+const countryCategory = document.getElementById("country-category");
+const countryData = document.getElementById("country-data");
+const closeModalButton = document.getElementById("close-modal");
+const legendContainer = document.getElementById("category-legend");
+const countrySearchInputDesktop = document.getElementById("country-search");
+const countryListContainerDesktop = document.getElementById("country-list");
+const countryListCountDesktop = document.getElementById("country-list-count");
+const countrySearchInputMobile = document.getElementById("country-search-mobile");
+const countryListContainerMobile = document.getElementById("country-list-mobile");
+const countryListCountMobile = document.getElementById("country-list-count-mobile");
+const openMobileCountryListButton = document.getElementById("open-mobile-country-list");
+const closeCountryListModalButton = document.getElementById("close-country-list-modal");
+const countryListModal = document.getElementById("country-list-modal");
+const legendToggleButton = document.getElementById("toggle-mobile-legend");
+const mapTooltip = document.getElementById("map-tooltip");
+
+const worldGeoJsonUrl =
+  "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_map_units.geojson";
+const rawPolicyDataUrl = "./znep_gta_pays_etrangers.txt";
+
+const legacyColorByCode = {
+  VERT: "#22c55e",
+  BLEU: "#3b82f6",
+  ORANGE: "#f59e0b",
+  ROUGE: "#ef4444",
+};
+
+const colorByAuthorizationCode = {
+  "10": "#22c55e",
+  "21": "#facc15",
+  "22": "#facc15",
+  "23": "#facc15",
+  "30": "#0a0a0a",
+  "99": "#ef4444",
+};
+
+const explicitCategoryTitleByCode = {
+  "10": "Libre circulation (sans formalité)",
+  "21": "Autorisation ministérielle renforcée",
+  "22": "Autorisation préalable + avis PSD",
+  "23": "Autorisation CFA / autorité assimilée",
+  "30": "Interdiction de séjour",
+  "99": "Autorisation selon zone de destination",
+};
+
+const procedureSummaryByAuthorizationCode = {
+  "10": "Déplacement possible sans autorisation préalable spécifique.",
+  "21": "Autorisation ministérielle obligatoire avant départ.",
+  "22": "Autorisation préalable obligatoire avec avis PSD.",
+  "23": "Autorisation CFA (ou autorité assimilée) obligatoire avant départ.",
+  "30": "Destination interdite : déplacement non autorisé.",
+  "99": "Autorisation variable selon la zone/pays de destination.",
+};
+
+const defaultCountryColor = "#6b7280";
+
+const forcedPolicyIsoByMatchedParentIso = {
+  GB: ["GI"],
+  NO: ["BV"],
+  US: ["UM"],
+};
+
+const mapIsoToPolicyIsoAlias = {
+  "CN-TW": "TW",
+  TL: "TP",
+  CW: "AN",
+  SX: "AN",
+  BQ: "AN",
+};
+
+const mapNameToPolicyIsoAlias = {
+  antigua: "AG",
+  barbuda: "AG",
+  "federation of bosnia and herzegovina": "BA",
+  "republic srpska": "BA",
+  "flemish region": "BE",
+  "walloon region": "BE",
+  brussels: "BE",
+  gaza: "PS",
+  "west bank": "PS",
+  "united states minor outlying islands": "UM",
+  "autonomous region of bougainville": "PG",
+  bougainville: "PG",
+  azores: "PT",
+  madeira: "PT",
+  wales: "GB",
+  scotland: "GB",
+  england: "GB",
+  "northern ireland": "GB",
+  "nouvelle caledonie": "FR",
+  "new caledonia": "FR",
+  "polynesie francaise": "FR",
+  "french polynesia": "FR",
+  "saint barthelemy": "FR",
+  "saint martin": "FR",
+  "saint pierre et miquelon": "FR",
+  "saint pierre and miquelon": "FR",
+  "terres australes et antarctiques francaises": "FR",
+  "french southern and antarctic lands": "FR",
+  "wallis et futuna": "FR",
+  "wallis and futuna": "FR",
+  "chypre du nord": "CY",
+  "northern cyprus": "CY",
+  somaliland: "SO",
+  "glacier de siachen": "IN",
+  "siachen glacier": "IN",
+};
+
+let width = 0;
+let height = 0;
+let countries = [];
+let countryShadowPaths = null;
+let countryPaths = null;
+let countryPolicyByIso2 = new Map();
+let countryPolicyByName = new Map();
+let categoryByCode = new Map();
+let lastAnalysis = null;
+let countryEntries = [];
+let countryByKey = new Map();
+
+const svg = mapContainer.append("svg").attr("aria-hidden", "true");
+const zoomLayer = svg.append("g");
+const countryShadowLayer = zoomLayer
+  .append("g")
+  .attr("class", "country-shadow-layer")
+  .attr("transform", "translate(1.8,1.8)");
+const countryLayer = zoomLayer.append("g");
+
+const projection = d3.geoRobinson ? d3.geoRobinson() : d3.geoEqualEarth();
+const path = d3.geoPath(projection);
+
+let currentTransform = d3.zoomIdentity;
+const zoom = d3
+  .zoom()
+  .scaleExtent([1, 40])
+  .on("start", () => {
+    hideCountryTooltip();
+  })
+  .on("zoom", (event) => {
+    currentTransform = event.transform;
+    zoomLayer.attr("transform", currentTransform);
+  });
+
+function normalizeCountryName(name) {
+  return String(name || "")
+    .normalize("NFD")
+    .replace(/[\u0300-\u036f]/g, "")
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, " ")
+    .trim();
+}
+
+function getCountryLabel(feature) {
+  return (
+    feature.properties?.NAME_FR ||
+    feature.properties?.NAME_EN ||
+    feature.properties?.NAME ||
+    "Pays inconnu"
+  );
+}
+
+function getCountryIso2(feature) {
+  const rawIso2 = getCountryIso2Raw(feature);
+  if (!rawIso2) {
+    return "";
+  }
+
+  return mapIsoToPolicyIsoAlias[rawIso2] || rawIso2;
+}
+
+function getCountryIso2Raw(feature) {
+  const direct = String(feature.properties?.ISO_A2 || "").toUpperCase().trim();
+  if (direct && direct !== "-99") {
+    return direct;
+  }
+
+  const fallback = String(feature.properties?.ISO_A2_EH || "")
+    .toUpperCase()
+    .trim();
+  if (fallback && fallback !== "-99") {
+    return fallback;
+  }
+
+  return "";
+}
+
+function getFlagEmojiFromIso2(iso2) {
+  const code = String(iso2 || "").toUpperCase();
+  if (!/^[A-Z]{2}$/.test(code)) {
+    return "🏳️";
+  }
+
+  const first = code.codePointAt(0) + 127397;
+  const second = code.codePointAt(1) + 127397;
+  return String.fromCodePoint(first, second);
+}
+
+function htmlToText(htmlString) {
+  const parser = new DOMParser();
+  const doc = parser.parseFromString(String(htmlString || ""), "text/html");
+  return (doc.body.textContent || "").replace(/\s+/g, " ").trim();
+}
+
+function formatDate(yyyymmdd) {
+  const value = String(yyyymmdd || "");
+  if (!/^\d{8}$/.test(value)) {
+    return value;
+  }
+
+  if (value === "99991231") {
+    return "Sans fin";
+  }
+
+  const year = value.slice(0, 4);
+  const month = value.slice(4, 6);
+  const day = value.slice(6, 8);
+  return `${day}/${month}/${year}`;
+}
+
+function escapeHtml(value) {
+  return String(value)
+    .replaceAll("&", "&amp;")
+    .replaceAll("<", "&lt;")
+    .replaceAll(">", "&gt;")
+    .replaceAll("\"", "&quot;")
+    .replaceAll("'", "&#39;");
+}
+
+function getCategoryDisplayLabel(code, fallbackLabel) {
+  return (
+    explicitCategoryTitleByCode[String(code || "")] ||
+    fallbackLabel ||
+    `Catégorie ${code}`
+  );
+}
+
+function getCategoryColor(code, fallbackColorCode) {
+  return (
+    colorByAuthorizationCode[String(code || "")] ||
+    legacyColorByCode[String(fallbackColorCode || "")] ||
+    defaultCountryColor
+  );
+}
+
+function summarizeProcedureText(rawText, categoryCode) {
+  const code = String(categoryCode || "");
+  const fallbackSummary = procedureSummaryByAuthorizationCode[code] || "";
+  const cleaned = String(rawText || "").replace(/\s+/g, " ").trim();
+
+  if (!cleaned) {
+    return fallbackSummary || "Non renseignée";
+  }
+
+  const fragments = cleaned
+    .split(/[.;]\s+|\n+/)
+    .map((value) => value.replace(/^[\s\-•\d).]+/, "").trim())
+    .filter(Boolean);
+
+  const keywords =
+    /(autorisation|autorise|interdit|interdiction|avis|accord|demande|validation|cfa|psd|minist[eè]re|depart|départ|avant)/i;
+
+  const selected = [];
+  for (let i = 0; i < fragments.length; i += 1) {
+    const fragment = fragments[i];
+    if (fragment.length < 18) {
+      continue;
+    }
+    if (!keywords.test(fragment) && selected.length > 0) {
+      continue;
+    }
+    selected.push(fragment);
+    if (selected.length >= 2) {
+      break;
+    }
+  }
+
+  if (selected.length === 0 && fragments.length > 0) {
+    selected.push(fragments[0]);
+  }
+
+  let literalSummary = selected.join(" / ");
+  if (literalSummary.length > 210) {
+    literalSummary = `${literalSummary.slice(0, 207)}...`;
+  }
+
+  if (!fallbackSummary) {
+    return literalSummary || "Non renseignée";
+  }
+
+  return literalSummary ? `${fallbackSummary} ${literalSummary}` : fallbackSummary;
+}
+
+function parseFlatTable(flatTable) {
+  if (!Array.isArray(flatTable) || flatTable.length < 2) {
+    return [];
+  }
+
+  const columnCount = Number(flatTable[0]);
+  if (!Number.isInteger(columnCount) || columnCount <= 0) {
+    return [];
+  }
+
+  const headers = flatTable.slice(1, columnCount + 1);
+  const rows = [];
+
+  for (let index = columnCount + 1; index < flatTable.length; index += columnCount) {
+    const row = {};
+
+    for (let col = 0; col < columnCount; col += 1) {
+      row[headers[col]] = flatTable[index + col] ?? "";
+    }
+
+    rows.push(row);
+  }
+
+  return rows;
+}
+
+function isBetterCountryRow(candidate, current) {
+  const candidateActive = candidate.ENDDA === "99991231" ? 1 : 0;
+  const currentActive = current.ENDDA === "99991231" ? 1 : 0;
+
+  if (candidateActive !== currentActive) {
+    return candidateActive > currentActive;
+  }
+
+  const candidateBegda = String(candidate.BEGDA || "");
+  const currentBegda = String(current.BEGDA || "");
+  if (candidateBegda !== currentBegda) {
+    return candidateBegda > currentBegda;
+  }
+
+  const candidateAedtm = String(candidate.AEDTM || "");
+  const currentAedtm = String(current.AEDTM || "");
+  return candidateAedtm > currentAedtm;
+}
+
+function parsePolicyModel(rawText) {
+  const parsedJson = JSON.parse(rawText);
+
+  const rawCountryRows = parseFlatTable(parsedJson.modelmasterListData);
+  const rawCategoryRows = parseFlatTable(parsedJson.modelmasterListCatData);
+
+  const countryRowsByIso = new Map();
+  rawCountryRows.forEach((row) => {
+    const iso2 = String(row.LAND1 || "").toUpperCase().trim();
+    if (!iso2) {
+      return;
+    }
+
+    const current = countryRowsByIso.get(iso2);
+    if (!current || isBetterCountryRow(row, current)) {
+      countryRowsByIso.set(iso2, row);
+    }
+  });
+
+  const countriesData = Array.from(countryRowsByIso.values()).map((row) => ({
+    iso2: String(row.LAND1 || "").toUpperCase(),
+    countryNameFr: String(row.LANDX50 || ""),
+    categoryCode: String(row.CATEGORIE || ""),
+    categoryLabel: String(row.LBL_CATEGORIE || ""),
+    colorCode: String(row.COLOR || ""),
+    beginDate: String(row.BEGDA || ""),
+    endDate: String(row.ENDDA || ""),
+    beginDateText: String(row.BEGDA_TXT || ""),
+    endDateText: String(row.ENDDA_TXT || ""),
+    updateDate: String(row.AEDTM || ""),
+    iconPath: String(row.DESCRIPTIF || ""),
+  }));
+
+  const categoriesData = rawCategoryRows.map((row) => ({
+    code: String(row.CATEGORIE || ""),
+    label: String(row.LBL_CATEGORIE || ""),
+    colorCode: String(row.COLOR || ""),
+    beginDate: String(row.BEGDA || ""),
+    endDate: String(row.ENDDA || ""),
+    descriptionHtml: String(row.DESCRIPTIF_WDA || row.DESCRIPTIF || ""),
+    descriptionText: htmlToText(row.DESCRIPTIF_WDA || row.DESCRIPTIF || ""),
+  }));
+
+  return {
+    countriesData,
+    categoriesData,
+  };
+}
+
+function getCountryPolicyForFeature(feature) {
+  const iso2 = getCountryIso2(feature);
+  if (iso2 && countryPolicyByIso2.has(iso2)) {
+    return countryPolicyByIso2.get(iso2);
+  }
+
+  const names = [
+    feature.properties?.NAME_FR,
+    feature.properties?.NAME_EN,
+    feature.properties?.NAME,
+    feature.properties?.ADMIN,
+    feature.properties?.NAME_LONG,
+  ];
+
+  for (let i = 0; i < names.length; i += 1) {
+    const normalized = normalizeCountryName(names[i]);
+    if (normalized && countryPolicyByName.has(normalized)) {
+      return countryPolicyByName.get(normalized);
+    }
+
+    const aliasIso = mapNameToPolicyIsoAlias[normalized];
+    if (aliasIso && countryPolicyByIso2.has(aliasIso)) {
+      return countryPolicyByIso2.get(aliasIso);
+    }
+  }
+
+  return null;
+}
+
+function getCountryColor(feature) {
+  const policy = getCountryPolicyForFeature(feature);
+  if (!policy) {
+    return defaultCountryColor;
+  }
+
+  return getCategoryColor(policy.categoryCode, policy.colorCode);
+}
+
+function moveCountryTooltip(event) {
+  if (!mapTooltip.classList.contains("visible")) {
+    return;
+  }
+
+  const [pointerX, pointerY] = d3.pointer(event, document.body);
+  const rawX = Number.isFinite(event?.clientX) ? event.clientX : pointerX;
+  const rawY = Number.isFinite(event?.clientY) ? event.clientY : pointerY;
+
+  const offset = 14;
+  const tooltipWidth = mapTooltip.offsetWidth || 160;
+  const tooltipHeight = mapTooltip.offsetHeight || 36;
+  const maxX = window.innerWidth - tooltipWidth - 8;
+  const maxY = window.innerHeight - tooltipHeight - 8;
+  const x = Math.max(8, Math.min(maxX, rawX + offset));
+  const y = Math.max(8, Math.min(maxY, rawY + offset));
+
+  mapTooltip.style.left = `${x}px`;
+  mapTooltip.style.top = `${y}px`;
+}
+
+function showCountryTooltip(event, feature) {
+  mapTooltip.textContent = getCountryLabel(feature);
+  mapTooltip.classList.add("visible");
+  mapTooltip.setAttribute("aria-hidden", "false");
+  moveCountryTooltip(event);
+}
+
+function hideCountryTooltip() {
+  mapTooltip.classList.remove("visible");
+  mapTooltip.setAttribute("aria-hidden", "true");
+}
+
+function buildCountryEntries() {
+  countryByKey = new Map(countries.map((feature) => [featureKey(feature), feature]));
+
+  countryEntries = countries
+    .map((feature) => {
+      const policy = getCountryPolicyForFeature(feature);
+      const mapIso2 = getCountryIso2Raw(feature);
+      const iso2 = mapIso2 || policy?.iso2 || getCountryIso2(feature);
+      return {
+        key: featureKey(feature),
+        name: getCountryLabel(feature),
+        iso2,
+        flag: getFlagEmojiFromIso2(iso2),
+      };
+    })
+    .sort((a, b) => {
+      return a.name.localeCompare(b.name, "fr", { sensitivity: "base" });
+    });
+}
+
+function renderCountryList(filterValue = "") {
+  const normalizedFilter = normalizeCountryName(filterValue);
+  const filtered = countryEntries.filter((entry) => {
+    const byName = normalizeCountryName(entry.name).includes(normalizedFilter);
+    const byIso = String(entry.iso2 || "")
+      .toLowerCase()
+      .includes(normalizedFilter);
+    return byName || byIso;
+  });
+
+  const countText = `${filtered.length} / ${countryEntries.length} pays`;
+  countryListCountDesktop.textContent = countText;
+  countryListCountMobile.textContent = countText;
+
+  let listHtml = "";
+  if (filtered.length === 0) {
+    listHtml = '<li class="country-list-empty">Aucun pays trouvé.</li>';
+  } else {
+    listHtml = filtered
+      .map((entry) => {
+        return `<li><button type="button" class="country-list-item" data-country-key="${escapeHtml(
+          entry.key
+        )}"><span class="country-list-flag">${entry.flag}</span><span>${escapeHtml(
+          entry.name
+        )}</span></button></li>`;
+      })
+      .join("");
+  }
+
+  countryListContainerDesktop.innerHTML = listHtml;
+  countryListContainerMobile.innerHTML = listHtml;
+}
+
+function focusCountryOnMap(feature) {
+  const bounds = path.bounds(feature);
+  const x0 = bounds?.[0]?.[0];
+  const y0 = bounds?.[0]?.[1];
+  const x1 = bounds?.[1]?.[0];
+  const y1 = bounds?.[1]?.[1];
+
+  if (![x0, y0, x1, y1].every(Number.isFinite)) {
+    return;
+  }
+
+  const featureWidth = Math.max(1, x1 - x0);
+  const featureHeight = Math.max(1, y1 - y0);
+  const centerX = (x0 + x1) / 2;
+  const centerY = (y0 + y1) / 2;
+
+  // Zoom cible plus fort au clic, ajusté à la taille du pays.
+  const autoScale = 0.62 / Math.max(featureWidth / width, featureHeight / height);
+  const targetScale = Math.min(20, Math.max(currentTransform.k, 4.5, autoScale));
+  const translateX = width / 2 - targetScale * centerX;
+  const translateY = height / 2 - targetScale * centerY;
+  const targetTransform = d3.zoomIdentity
+    .translate(translateX, translateY)
+    .scale(targetScale);
+
+  svg.transition().duration(420).call(zoom.transform, targetTransform);
+}
+
+function openCountryListModal() {
+  countryListModal.classList.add("open");
+  countryListModal.setAttribute("aria-hidden", "false");
+  countrySearchInputMobile.focus();
+}
+
+function closeCountryListModal() {
+  countryListModal.classList.remove("open");
+  countryListModal.setAttribute("aria-hidden", "true");
+}
+
+function closeLegendOnMobile() {
+  legendContainer.classList.remove("mobile-open");
+  legendToggleButton.setAttribute("aria-expanded", "false");
+}
+
+function handleCountryListClick(event) {
+  const button = event.target.closest("button[data-country-key]");
+  if (!button) {
+    return;
+  }
+
+  const feature = countryByKey.get(button.dataset.countryKey);
+  if (!feature) {
+    return;
+  }
+
+  focusCountryOnMap(feature);
+  openModalForFeature(feature);
+  closeCountryListModal();
+}
+
+function initCountryListEvents() {
+  const syncAndRender = (value, source) => {
+    const nextValue = value || "";
+    if (source !== "desktop" && countrySearchInputDesktop.value !== nextValue) {
+      countrySearchInputDesktop.value = nextValue;
+    }
+    if (source !== "mobile" && countrySearchInputMobile.value !== nextValue) {
+      countrySearchInputMobile.value = nextValue;
+    }
+    renderCountryList(nextValue);
+  };
+
+  countrySearchInputDesktop.addEventListener("input", (event) => {
+    syncAndRender(event.target.value, "desktop");
+  });
+
+  countrySearchInputMobile.addEventListener("input", (event) => {
+    syncAndRender(event.target.value, "mobile");
+  });
+
+  countryListContainerDesktop.addEventListener("click", handleCountryListClick);
+  countryListContainerMobile.addEventListener("click", handleCountryListClick);
+
+  openMobileCountryListButton.addEventListener("click", openCountryListModal);
+  closeCountryListModalButton.addEventListener("click", closeCountryListModal);
+
+  countryListModal.addEventListener("click", (event) => {
+    if (event.target === countryListModal) {
+      closeCountryListModal();
+    }
+  });
+
+  legendToggleButton.addEventListener("click", () => {
+    const isOpen = legendContainer.classList.toggle("mobile-open");
+    legendToggleButton.setAttribute("aria-expanded", String(isOpen));
+  });
+}
+
+function updateLegend() {
+  if (!lastAnalysis) {
+    legendContainer.innerHTML = "";
+    return;
+  }
+
+  const categories = [...lastAnalysis.categoryCounts.entries()].sort((a, b) => {
+    if (a[0] === "30" && b[0] !== "30") {
+      return 1;
+    }
+    if (b[0] === "30" && a[0] !== "30") {
+      return -1;
+    }
+    return Number(a[0]) - Number(b[0]);
+  });
+
+  const items = categories
+    .map(([code, count]) => {
+      const category = categoryByCode.get(code);
+      const label = getCategoryDisplayLabel(code, category?.label);
+      const hex = getCategoryColor(code, category?.colorCode);
+      return `<li><span class="legend-swatch" style="background:${hex}"></span><span>Code ${code} - ${label} (${count})</span></li>`;
+    })
+    .join("");
+
+  legendContainer.innerHTML = `<h3>Légende catégories</h3><ul>${items}</ul>`;
+}
+
+function setCountryDataHtml(lines) {
+  const rows = lines
+    .map(
+      ([label, value]) =>
+        `<div class="country-data-row"><strong>${escapeHtml(
+          label
+        )}</strong><span>${escapeHtml(value)}</span></div>`
+    )
+    .join("");
+
+  countryData.innerHTML = rows;
+}
+
+function openModalForFeature(feature) {
+  const mapLabel = getCountryLabel(feature);
+  const policy = getCountryPolicyForFeature(feature);
+  const mapIso2 = getCountryIso2Raw(feature);
+  const iso2 = mapIso2 || policy?.iso2 || getCountryIso2(feature);
+  const flag = getFlagEmojiFromIso2(iso2);
+
+  countryName.textContent = `${flag} ${mapLabel}`;
+
+  if (!policy) {
+    countryCategory.textContent = "Aucune donnée de catégorie pour ce pays.";
+    setCountryDataHtml([
+      ["Source", "Carte Natural Earth"],
+      ["Code ISO2 carte", getCountryIso2(feature) || "N/A"],
+    ]);
+  } else {
+    const category = categoryByCode.get(policy.categoryCode);
+    const categoryLabel = getCategoryDisplayLabel(
+      policy.categoryCode,
+      category?.label || policy.categoryLabel
+    );
+
+    countryCategory.textContent = `${categoryLabel} (code ${policy.categoryCode})`;
+
+    const procedureText = summarizeProcedureText(
+      category?.descriptionText || "",
+      policy.categoryCode
+    );
+
+    setCountryDataHtml([
+      ["Pays (donnée source)", policy.countryNameFr || mapLabel],
+      ["Code ISO2", policy.iso2],
+      ["Début validité", formatDate(policy.beginDate)],
+      ["Fin validité", formatDate(policy.endDate)],
+      ["Dernière maj", formatDate(policy.updateDate)],
+      ["Procédure", procedureText || "Non renseignée"],
+    ]);
+  }
+
+  modal.classList.add("open");
+  modal.setAttribute("aria-hidden", "false");
+}
+
+function closeModal() {
+  modal.classList.remove("open");
+  modal.setAttribute("aria-hidden", "true");
+}
+
+function handleCountryClick(event, feature) {
+  if (event.defaultPrevented) {
+    return;
+  }
+
+  if (event.currentTarget && typeof event.currentTarget.blur === "function") {
+    event.currentTarget.blur();
+  }
+
+  focusCountryOnMap(feature);
+  openModalForFeature(feature);
+}
+
+function featureKey(feature) {
+  const iso3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || "-";
+  const name = feature.properties?.NAME_EN || feature.properties?.NAME || "unknown";
+  return `${iso3}:${name}`;
+}
+
+function updateProjectionAndDrawing() {
+  if (!countryPaths || !countryShadowPaths || countries.length === 0) {
+    return;
+  }
+
+  const rect = mapContainer.node().getBoundingClientRect();
+  width = Math.max(320, rect.width);
+  height = Math.max(240, rect.height);
+
+  svg.attr("viewBox", `0 0 ${width} ${height}`);
+
+  projection.fitExtent(
+    [
+      [10, 10],
+      [width - 10, height - 10],
+    ],
+    { type: "FeatureCollection", features: countries }
+  );
+
+  countryShadowPaths.attr("d", path);
+  countryPaths.attr("d", path);
+
+  zoom
+    .extent([
+      [0, 0],
+      [width, height],
+    ])
+    .translateExtent([
+      [-width, -height],
+      [width * 2, height * 2],
+    ]);
+
+  svg.call(zoom).call(zoom.transform, currentTransform);
+}
+
+function analyzeMatching() {
+  const categoryCounts = new Map();
+  const matchedIso2 = new Set();
+  let matchedMapCountries = 0;
+
+  countryPolicyByIso2.forEach((policy) => {
+    const categoryCode = policy.categoryCode || "N/A";
+    categoryCounts.set(categoryCode, (categoryCounts.get(categoryCode) || 0) + 1);
+  });
+
+  countries.forEach((feature) => {
+    const policy = getCountryPolicyForFeature(feature);
+    if (!policy) {
+      return;
+    }
+
+    matchedMapCountries += 1;
+    matchedIso2.add(policy.iso2);
+  });
+
+  Object.entries(forcedPolicyIsoByMatchedParentIso).forEach(
+    ([parentIso, forcedIsoList]) => {
+      if (!matchedIso2.has(parentIso)) {
+        return;
+      }
+
+      forcedIsoList.forEach((forcedIso) => {
+        matchedIso2.add(forcedIso);
+      });
+    }
+  );
+
+  const totalPolicyCountries = countryPolicyByIso2.size;
+  const unmatchedMapCountries = countries.filter((feature) => !getCountryPolicyForFeature(feature)).length;
+
+  const unmatchedIso2 = [...countryPolicyByIso2.keys()].filter((iso2) => !matchedIso2.has(iso2));
+
+  lastAnalysis = {
+    totalPolicyCountries,
+    matchedMapCountries,
+    unmatchedMapCountries,
+    categoryCounts,
+    unmatchedIso2,
+  };
+}
+
+Promise.all([d3.json(worldGeoJsonUrl), d3.text(rawPolicyDataUrl)])
+  .then(([geoJson, rawPolicyText]) => {
+    const parsedPolicy = parsePolicyModel(rawPolicyText);
+
+    countryPolicyByIso2 = new Map(parsedPolicy.countriesData.map((item) => [item.iso2, item]));
+    countryPolicyByName = new Map(
+      parsedPolicy.countriesData
+        .map((item) => [normalizeCountryName(item.countryNameFr), item])
+        .filter(([name]) => Boolean(name))
+    );
+
+    categoryByCode = new Map(parsedPolicy.categoriesData.map((item) => [item.code, item]));
+
+    countries = geoJson.features.filter(
+      (feature) => feature.properties?.NAME_EN !== "Antarctica"
+    );
+
+    countryShadowPaths = countryShadowLayer
+      .selectAll("path")
+      .data(countries, featureKey)
+      .join("path")
+      .attr("class", "country-shadow");
+
+    countryPaths = countryLayer
+      .selectAll("path")
+      .data(countries, featureKey)
+      .join("path")
+      .attr("class", "country")
+      .attr("fill", getCountryColor)
+      .attr("tabindex", 0)
+      .on("pointerenter", (event, feature) => {
+        showCountryTooltip(event, feature);
+      })
+      .on("pointermove", (event) => {
+        moveCountryTooltip(event);
+      })
+      .on("pointerleave", () => {
+        hideCountryTooltip();
+      })
+      .on("mouseenter", (event, feature) => {
+        showCountryTooltip(event, feature);
+      })
+      .on("mouseover", (event, feature) => {
+        showCountryTooltip(event, feature);
+      })
+      .on("mouseenter", (event, feature) => {
+        showCountryTooltip(event, feature);
+      })
+      .on("mousemove", (event) => {
+        moveCountryTooltip(event);
+      })
+      .on("mouseleave", () => {
+        hideCountryTooltip();
+      })
+      .on("focus", (event, feature) => {
+        showCountryTooltip(event, feature);
+      })
+      .on("click", handleCountryClick)
+      .on("keydown", (event, feature) => {
+        if (event.key === "Enter" || event.key === " ") {
+          event.preventDefault();
+          openModalForFeature(feature);
+        }
+      })
+      .on("blur", () => {
+        hideCountryTooltip();
+      });
+
+    countryPaths
+      .selectAll("title")
+      .data((feature) => [feature])
+      .join("title")
+      .text((feature) => getCountryLabel(feature));
+
+    buildCountryEntries();
+    renderCountryList();
+    analyzeMatching();
+    updateLegend();
+    updateProjectionAndDrawing();
+  })
+  .catch((error) => {
+    mapContainer
+      .append("p")
+      .style("color", "#b91c1c")
+      .style("margin", "1rem")
+      .text("Erreur lors du chargement de la carte ou des données.");
+
+    // eslint-disable-next-line no-console
+    console.error(error);
+  });
+
+window.addEventListener("resize", () => {
+  updateProjectionAndDrawing();
+  hideCountryTooltip();
+
+  if (window.innerWidth > 760) {
+    closeCountryListModal();
+    closeLegendOnMobile();
+  }
+});
+
+initCountryListEvents();
+
+closeModalButton.addEventListener("click", closeModal);
+
+modal.addEventListener("click", (event) => {
+  if (event.target === modal) {
+    closeModal();
+  }
+});
+
+document.addEventListener("keydown", (event) => {
+  if (event.key !== "Escape") {
+    return;
+  }
+
+  if (countryListModal.classList.contains("open")) {
+    closeCountryListModal();
+    return;
+  }
+
+  if (legendContainer.classList.contains("mobile-open")) {
+    closeLegendOnMobile();
+    return;
+  }
+
+  if (modal.classList.contains("open")) {
+    closeModal();
+  }
+});

+ 524 - 0
styles.css

@@ -0,0 +1,524 @@
+:root {
+  color-scheme: dark;
+  --bg: #1f2328;
+  --text: #ecf0f4;
+  --muted-text: #bcc3cc;
+  --panel: rgba(39, 44, 51, 0.9);
+  --panel-strong: #323841;
+  --card: #2d333b;
+  --border: rgba(236, 240, 244, 0.18);
+  --border-strong: rgba(236, 240, 244, 0.32);
+  --focus: #d7dde5;
+  --shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  height: 100%;
+}
+
+body {
+  margin: 0;
+  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  overflow: hidden;
+}
+
+.app {
+  position: fixed;
+  inset: 0;
+}
+
+.map {
+  width: 100%;
+  height: 100%;
+  background: var(--bg);
+}
+
+.map-header {
+  position: fixed;
+  top: 1rem;
+  left: 1rem;
+  z-index: 3;
+  padding: 0.75rem 0.9rem;
+  border-radius: 12px;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(6px);
+}
+
+.map-header h1 {
+  margin: 0;
+  font-size: clamp(1rem, 1.9vw, 1.35rem);
+  line-height: 1.15;
+}
+
+.map-header p {
+  margin: 0.22rem 0 0;
+  font-size: clamp(0.76rem, 1.2vw, 0.9rem);
+  color: var(--muted-text);
+}
+
+.mobile-country-button,
+.mobile-legend-button {
+  display: none;
+  align-items: center;
+  justify-content: center;
+  position: fixed;
+  z-index: 4;
+  border: 1px solid var(--border);
+  background: var(--panel);
+  color: var(--text);
+  border-radius: 999px;
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(6px);
+  cursor: pointer;
+}
+
+.mobile-country-button {
+  width: 2.7rem;
+  height: 2.7rem;
+  font-size: 1.25rem;
+}
+
+.mobile-legend-button {
+  height: 2.7rem;
+  padding: 0 0.9rem;
+  font-size: 0.92rem;
+  font-weight: 600;
+}
+
+.country-sidebar {
+  position: fixed;
+  top: 6.35rem;
+  left: 1rem;
+  height: min(58vh, 520px);
+  width: min(320px, calc(100vw - 2rem));
+  display: flex;
+  flex-direction: column;
+  gap: 0.55rem;
+  padding: 0.8rem;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 14px;
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(6px);
+  z-index: 2;
+}
+
+.country-sidebar h3 {
+  margin: 0;
+  font-size: 1rem;
+}
+
+.country-search {
+  border: 1px solid var(--border);
+  border-radius: 9px;
+  padding: 0.52rem 0.62rem;
+  font-size: 0.9rem;
+  color: var(--text);
+  background: var(--panel-strong);
+  outline: none;
+}
+
+.country-search:focus {
+  border-color: var(--focus);
+  box-shadow: 0 0 0 3px rgba(236, 240, 244, 0.2);
+}
+
+.country-search::placeholder {
+  color: var(--muted-text);
+}
+
+.country-list-count {
+  margin: 0;
+  font-size: 0.78rem;
+  color: var(--muted-text);
+}
+
+.country-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  overflow: auto;
+  min-height: 0;
+  flex: 1;
+  display: grid;
+  gap: 0.28rem;
+  align-content: start;
+}
+
+.country-list-item {
+  width: 100%;
+  border: 1px solid var(--border);
+  background: var(--card);
+  border-radius: 8px;
+  padding: 0.38rem 0.5rem;
+  display: flex;
+  align-items: center;
+  gap: 0.45rem;
+  font-size: 0.86rem;
+  color: var(--text);
+  cursor: pointer;
+  text-align: left;
+}
+
+.country-list-item:hover,
+.country-list-item:focus-visible {
+  background: #3a414a;
+  border-color: var(--border-strong);
+  outline: none;
+}
+
+.country-list-flag {
+  width: 1.3rem;
+  text-align: center;
+}
+
+.country-list-empty {
+  margin: 0.4rem 0;
+  font-size: 0.84rem;
+  color: var(--muted-text);
+}
+
+.country-list-modal {
+  position: fixed;
+  inset: 0;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  padding: 0.8rem;
+  background: rgba(0, 0, 0, 0.64);
+  z-index: 11;
+}
+
+.country-list-modal.open {
+  display: flex;
+}
+
+.country-list-modal-content {
+  width: min(700px, 96vw);
+  height: min(88vh, 800px);
+  background: var(--card);
+  border: 1px solid var(--border);
+  color: var(--text);
+  border-radius: 14px;
+  padding: 0.85rem;
+  box-shadow: var(--shadow);
+  display: flex;
+  flex-direction: column;
+  gap: 0.55rem;
+}
+
+.country-list-modal-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.5rem;
+}
+
+.country-list-modal-head h2 {
+  margin: 0;
+  font-size: 1.04rem;
+}
+
+.country-list-modal-head .close {
+  position: static;
+  font-size: 1.35rem;
+  line-height: 1;
+}
+
+.country-list-modal .country-list {
+  flex: 1;
+}
+
+.map-footer {
+  position: fixed;
+  left: 50%;
+  bottom: 0.55rem;
+  transform: translateX(-50%);
+  margin: 0;
+  z-index: 2;
+  font-size: 0.78rem;
+  color: var(--muted-text);
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  padding: 0.34rem 0.75rem;
+  backdrop-filter: blur(6px);
+  white-space: nowrap;
+}
+
+.map-footer a {
+  color: #e8ecf2;
+}
+
+.map-tooltip {
+  position: fixed;
+  left: 0;
+  top: 0;
+  z-index: 40;
+  pointer-events: none;
+  opacity: 0;
+  transform: translate(8px, 8px);
+  transition: none;
+  background: rgba(17, 20, 24, 0.96);
+  color: #f8fafc;
+  border-radius: 8px;
+  padding: 0.3rem 0.5rem;
+  font-size: 0.8rem;
+  line-height: 1.2;
+  white-space: nowrap;
+  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.55);
+}
+
+.map-tooltip.visible {
+  opacity: 1;
+}
+
+svg {
+  width: 100%;
+  height: 100%;
+  display: block;
+  touch-action: none;
+  cursor: grab;
+}
+
+svg:active {
+  cursor: grabbing;
+}
+
+.country-shadow-layer {
+  box-shadow: var(--shadow);
+  filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.38));
+}
+
+.country-shadow {
+  fill: rgba(0, 0, 0, 0.31);
+  filter: blur(2.5px);
+  stroke: none;
+  pointer-events: none;
+}
+
+.country {
+  stroke: rgba(21, 24, 28, 0.95);
+  stroke-width: 0.85;
+  vector-effect: non-scaling-stroke;
+  stroke-linejoin: round;
+  filter: none;
+  outline: none !important;
+  -webkit-tap-highlight-color: transparent;
+  cursor: pointer;
+  transition: opacity 0.15s ease;
+}
+
+.country:hover,
+.country:focus,
+.country:focus-visible {
+  opacity: 0.75;
+  outline: none !important;
+}
+
+svg [tabindex]:focus {
+  outline: none !important;
+}
+
+.modal {
+  position: fixed;
+  inset: 0;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  padding: 1rem;
+  background: rgba(0, 0, 0, 0.66);
+}
+
+.modal.open {
+  display: flex;
+}
+
+.modal-content {
+  width: min(360px, 95vw);
+  background: var(--card);
+  border: 1px solid var(--border);
+  color: var(--text);
+  border-radius: 14px;
+  padding: 1.2rem 1.2rem 1rem;
+  box-shadow: var(--shadow);
+  position: relative;
+}
+
+.modal-content h2 {
+  margin: 0;
+  font-size: 1.15rem;
+}
+
+.modal-content p {
+  margin: 0.8rem 0 0;
+  font-size: 1.05rem;
+}
+
+.close {
+  position: absolute;
+  top: 0.55rem;
+  right: 0.55rem;
+  border: none;
+  background: transparent;
+  font-size: 1.45rem;
+  line-height: 1;
+  cursor: pointer;
+  color: var(--muted-text);
+}
+
+.close:hover,
+.close:focus-visible {
+  color: var(--text);
+}
+
+
+.legend {
+  position: fixed;
+  right: 1rem;
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 0.7rem 0.8rem;
+  width: min(330px, calc(100vw - 2rem));
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(6px);
+}
+
+.legend {
+  top: 1rem;
+}
+
+.legend h3 {
+  margin: 0 0 0.45rem;
+  font-size: 0.95rem;
+}
+
+.legend ul {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  display: grid;
+  gap: 0.32rem;
+}
+
+.legend li {
+  display: flex;
+  align-items: center;
+  gap: 0.45rem;
+  font-size: 0.86rem;
+}
+
+.legend-swatch {
+  width: 12px;
+  height: 12px;
+  border-radius: 2px;
+  border: 1px solid rgba(226, 232, 240, 0.24);
+  flex: 0 0 12px;
+}
+
+.country-category {
+  margin-top: 0.45rem;
+  margin-bottom: 0.2rem;
+  font-weight: 600;
+  color: var(--text);
+}
+
+.country-data {
+  margin-top: 0.45rem;
+  max-height: min(48vh, 360px);
+  overflow: auto;
+  padding-right: 0.25rem;
+}
+
+.country-data-row {
+  display: grid;
+  grid-template-columns: 132px 1fr;
+  gap: 0.5rem;
+  margin-bottom: 0.42rem;
+  font-size: 0.92rem;
+  line-height: 1.3;
+}
+
+.country-data-row strong {
+  color: #c8ced6;
+}
+
+.country-data-row span {
+  color: var(--text);
+}
+
+@media (max-width: 760px) {
+  .country-search {
+    font-size: 16px;
+  }
+
+  .map-header {
+    top: 0.6rem;
+    left: 0.6rem;
+    right: 0.6rem;
+  }
+
+  .mobile-country-button,
+  .mobile-legend-button {
+    display: inline-flex;
+    top: 5.1rem;
+  }
+
+  .mobile-country-button {
+    left: 0.6rem;
+  }
+
+  .mobile-legend-button {
+    right: 0.6rem;
+  }
+
+  .country-sidebar {
+    display: none;
+  }
+
+  .legend {
+    right: 0.6rem;
+    width: min(290px, calc(100vw - 1.2rem));
+    top: 8.5rem;
+    max-height: min(58vh, 430px);
+    overflow: auto;
+    display: none;
+  }
+
+  .legend.mobile-open {
+    display: block;
+  }
+
+  .country-list-modal {
+    padding: 0.6rem;
+  }
+
+  .country-list-modal-content {
+    width: 100%;
+    height: min(90vh, 820px);
+  }
+
+  .map-footer {
+    max-width: calc(100vw - 1.2rem);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 0.72rem;
+  }
+
+  .country-data-row {
+    grid-template-columns: 1fr;
+    gap: 0.15rem;
+  }
+
+}

File diff suppressed because it is too large
+ 0 - 0
znep_gta_pays_etrangers.txt


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