script.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  1. const mapContainer = d3.select("#map");
  2. const modal = document.getElementById("country-modal");
  3. const countryName = document.getElementById("country-name");
  4. const countryCategory = document.getElementById("country-category");
  5. const countryData = document.getElementById("country-data");
  6. const closeModalButton = document.getElementById("close-modal");
  7. const legendContainer = document.getElementById("category-legend");
  8. const countrySearchInputDesktop = document.getElementById("country-search");
  9. const countryListContainerDesktop = document.getElementById("country-list");
  10. const countryListCountDesktop = document.getElementById("country-list-count");
  11. const countrySearchInputMobile = document.getElementById("country-search-mobile");
  12. const countryListContainerMobile = document.getElementById("country-list-mobile");
  13. const countryListCountMobile = document.getElementById("country-list-count-mobile");
  14. const openMobileCountryListButton = document.getElementById("open-mobile-country-list");
  15. const closeCountryListModalButton = document.getElementById("close-country-list-modal");
  16. const countryListModal = document.getElementById("country-list-modal");
  17. const legendToggleButton = document.getElementById("toggle-mobile-legend");
  18. const mapTooltip = document.getElementById("map-tooltip");
  19. const worldGeoJsonUrl =
  20. "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_map_units.geojson";
  21. const rawPolicyDataUrl = "./znep_gta_pays_etrangers.txt";
  22. const legacyColorByCode = {
  23. VERT: "#22c55e",
  24. BLEU: "#3b82f6",
  25. ORANGE: "#f59e0b",
  26. ROUGE: "#ef4444",
  27. };
  28. const colorByAuthorizationCode = {
  29. "10": "#22c55e",
  30. "21": "#facc15",
  31. "22": "#f59e0b",
  32. "23": "#ef4444",
  33. "30": "#0a0a0a",
  34. "99": "#ef4444",
  35. };
  36. const explicitCategoryTitleByCode = {
  37. "10": "Libre circulation (sans formalité)",
  38. "21": "Autorisation ministérielle renforcée",
  39. "22": "Autorisation préalable + avis PSD",
  40. "23": "Autorisation CFA / autorité assimilée",
  41. "30": "Interdiction de séjour",
  42. "99": "Autorisation selon zone de destination",
  43. };
  44. const procedureSummaryByAuthorizationCode = {
  45. "10": "Déplacement possible sans autorisation préalable spécifique.",
  46. "21": "Autorisation ministérielle obligatoire avant départ.",
  47. "22": "Autorisation préalable obligatoire avec avis PSD.",
  48. "23": "Autorisation CFA (ou autorité assimilée) obligatoire avant départ.",
  49. "30": "Destination interdite : déplacement non autorisé.",
  50. "99": "Autorisation variable selon la zone/pays de destination.",
  51. };
  52. const noteServiceUpdateDate = "20260316";
  53. const noteServiceProcedureReference = `ANNEXE II à la circulaire n" l 1 400/ARM/SGA/DRH-MD/SR-RH/SDFM/FM5
  54. du 30 juillet 2024.
  55. Message n"5}l397lARM/CMlAIP du 13 mars2026`;
  56. const defaultCountryColor = "#6b7280";
  57. const forcedPolicyIsoByMatchedParentIso = {
  58. GB: ["GI"],
  59. NO: ["BV"],
  60. US: ["UM"],
  61. };
  62. const mapIsoToPolicyIsoAlias = {
  63. "CN-TW": "TW",
  64. TL: "TP",
  65. CW: "AN",
  66. SX: "AN",
  67. BQ: "AN",
  68. };
  69. const mapNameToPolicyIsoAlias = {
  70. antigua: "AG",
  71. barbuda: "AG",
  72. "federation of bosnia and herzegovina": "BA",
  73. "republic srpska": "BA",
  74. "flemish region": "BE",
  75. "walloon region": "BE",
  76. brussels: "BE",
  77. gaza: "PS",
  78. "west bank": "PS",
  79. "united states minor outlying islands": "UM",
  80. "autonomous region of bougainville": "PG",
  81. bougainville: "PG",
  82. azores: "PT",
  83. madeira: "PT",
  84. wales: "GB",
  85. scotland: "GB",
  86. england: "GB",
  87. "northern ireland": "GB",
  88. "nouvelle caledonie": "FR",
  89. "new caledonia": "FR",
  90. "polynesie francaise": "FR",
  91. "french polynesia": "FR",
  92. "saint barthelemy": "FR",
  93. "saint martin": "FR",
  94. "saint pierre et miquelon": "FR",
  95. "saint pierre and miquelon": "FR",
  96. "terres australes et antarctiques francaises": "FR",
  97. "french southern and antarctic lands": "FR",
  98. "wallis et futuna": "FR",
  99. "wallis and futuna": "FR",
  100. "chypre du nord": "CY",
  101. "northern cyprus": "CY",
  102. somaliland: "SO",
  103. "glacier de siachen": "IN",
  104. "siachen glacier": "IN",
  105. };
  106. let width = 0;
  107. let height = 0;
  108. let countries = [];
  109. let countryShadowPaths = null;
  110. let countryPaths = null;
  111. let countryPolicyByIso2 = new Map();
  112. let countryPolicyByName = new Map();
  113. let categoryByCode = new Map();
  114. let lastAnalysis = null;
  115. let countryEntries = [];
  116. let countryByKey = new Map();
  117. const svg = mapContainer.append("svg").attr("aria-hidden", "true");
  118. const zoomLayer = svg.append("g");
  119. const countryShadowLayer = zoomLayer
  120. .append("g")
  121. .attr("class", "country-shadow-layer")
  122. .attr("transform", "translate(1.8,1.8)");
  123. const countryLayer = zoomLayer.append("g");
  124. const projection = d3.geoRobinson ? d3.geoRobinson() : d3.geoEqualEarth();
  125. const path = d3.geoPath(projection);
  126. let currentTransform = d3.zoomIdentity;
  127. const zoom = d3
  128. .zoom()
  129. .scaleExtent([1, 40])
  130. .on("start", () => {
  131. hideCountryTooltip();
  132. })
  133. .on("zoom", (event) => {
  134. currentTransform = event.transform;
  135. zoomLayer.attr("transform", currentTransform);
  136. });
  137. function normalizeCountryName(name) {
  138. return String(name || "")
  139. .normalize("NFD")
  140. .replace(/[\u0300-\u036f]/g, "")
  141. .toLowerCase()
  142. .replace(/[^a-z0-9]+/g, " ")
  143. .trim();
  144. }
  145. function getCountryLabel(feature) {
  146. return (
  147. feature.properties?.NAME_FR ||
  148. feature.properties?.NAME_EN ||
  149. feature.properties?.NAME ||
  150. "Pays inconnu"
  151. );
  152. }
  153. function getCountryIso2(feature) {
  154. const rawIso2 = getCountryIso2Raw(feature);
  155. if (!rawIso2) {
  156. return "";
  157. }
  158. return mapIsoToPolicyIsoAlias[rawIso2] || rawIso2;
  159. }
  160. function getCountryIso2Raw(feature) {
  161. const direct = String(feature.properties?.ISO_A2 || "").toUpperCase().trim();
  162. if (direct && direct !== "-99") {
  163. return direct;
  164. }
  165. const fallback = String(feature.properties?.ISO_A2_EH || "")
  166. .toUpperCase()
  167. .trim();
  168. if (fallback && fallback !== "-99") {
  169. return fallback;
  170. }
  171. return "";
  172. }
  173. function getFlagEmojiFromIso2(iso2) {
  174. const code = String(iso2 || "").toUpperCase();
  175. if (!/^[A-Z]{2}$/.test(code)) {
  176. return "🏳️";
  177. }
  178. const first = code.codePointAt(0) + 127397;
  179. const second = code.codePointAt(1) + 127397;
  180. return String.fromCodePoint(first, second);
  181. }
  182. function htmlToText(htmlString) {
  183. const parser = new DOMParser();
  184. const doc = parser.parseFromString(String(htmlString || ""), "text/html");
  185. return (doc.body.textContent || "").replace(/\s+/g, " ").trim();
  186. }
  187. function formatDate(yyyymmdd) {
  188. const value = String(yyyymmdd || "");
  189. if (!/^\d{8}$/.test(value)) {
  190. return value;
  191. }
  192. if (value === "99991231") {
  193. return "Sans fin";
  194. }
  195. const year = value.slice(0, 4);
  196. const month = value.slice(4, 6);
  197. const day = value.slice(6, 8);
  198. return `${day}/${month}/${year}`;
  199. }
  200. function escapeHtml(value) {
  201. return String(value)
  202. .replaceAll("&", "&")
  203. .replaceAll("<", "&lt;")
  204. .replaceAll(">", "&gt;")
  205. .replaceAll("\"", "&quot;")
  206. .replaceAll("'", "&#39;");
  207. }
  208. function getCategoryDisplayLabel(code, fallbackLabel) {
  209. return (
  210. explicitCategoryTitleByCode[String(code || "")] ||
  211. fallbackLabel ||
  212. `Catégorie ${code}`
  213. );
  214. }
  215. function getCategoryColor(code, fallbackColorCode) {
  216. return (
  217. colorByAuthorizationCode[String(code || "")] ||
  218. legacyColorByCode[String(fallbackColorCode || "")] ||
  219. defaultCountryColor
  220. );
  221. }
  222. function summarizeProcedureText(rawText, categoryCode) {
  223. const code = String(categoryCode || "");
  224. const fallbackSummary = procedureSummaryByAuthorizationCode[code] || "";
  225. const cleaned = String(rawText || "").replace(/\s+/g, " ").trim();
  226. if (!cleaned) {
  227. return fallbackSummary || "Non renseignée";
  228. }
  229. const fragments = cleaned
  230. .split(/[.;]\s+|\n+/)
  231. .map((value) => value.replace(/^[\s\-•\d).]+/, "").trim())
  232. .filter(Boolean);
  233. const keywords =
  234. /(autorisation|autorise|interdit|interdiction|avis|accord|demande|validation|cfa|psd|minist[eè]re|depart|départ|avant)/i;
  235. const selected = [];
  236. for (let i = 0; i < fragments.length; i += 1) {
  237. const fragment = fragments[i];
  238. if (fragment.length < 18) {
  239. continue;
  240. }
  241. if (!keywords.test(fragment) && selected.length > 0) {
  242. continue;
  243. }
  244. selected.push(fragment);
  245. if (selected.length >= 2) {
  246. break;
  247. }
  248. }
  249. if (selected.length === 0 && fragments.length > 0) {
  250. selected.push(fragments[0]);
  251. }
  252. let literalSummary = selected.join(" / ");
  253. if (literalSummary.length > 210) {
  254. literalSummary = `${literalSummary.slice(0, 207)}...`;
  255. }
  256. if (!fallbackSummary) {
  257. return literalSummary || "Non renseignée";
  258. }
  259. return literalSummary ? `${fallbackSummary} ${literalSummary}` : fallbackSummary;
  260. }
  261. function isConcernedByMarch2026Note(policy) {
  262. return (
  263. String(policy?.beginDate || "") === noteServiceUpdateDate &&
  264. String(policy?.updateDate || "") === noteServiceUpdateDate
  265. );
  266. }
  267. function parseFlatTable(flatTable) {
  268. if (!Array.isArray(flatTable) || flatTable.length < 2) {
  269. return [];
  270. }
  271. const columnCount = Number(flatTable[0]);
  272. if (!Number.isInteger(columnCount) || columnCount <= 0) {
  273. return [];
  274. }
  275. const headers = flatTable.slice(1, columnCount + 1);
  276. const rows = [];
  277. for (let index = columnCount + 1; index < flatTable.length; index += columnCount) {
  278. const row = {};
  279. for (let col = 0; col < columnCount; col += 1) {
  280. row[headers[col]] = flatTable[index + col] ?? "";
  281. }
  282. rows.push(row);
  283. }
  284. return rows;
  285. }
  286. function isBetterCountryRow(candidate, current) {
  287. const candidateActive = candidate.ENDDA === "99991231" ? 1 : 0;
  288. const currentActive = current.ENDDA === "99991231" ? 1 : 0;
  289. if (candidateActive !== currentActive) {
  290. return candidateActive > currentActive;
  291. }
  292. const candidateBegda = String(candidate.BEGDA || "");
  293. const currentBegda = String(current.BEGDA || "");
  294. if (candidateBegda !== currentBegda) {
  295. return candidateBegda > currentBegda;
  296. }
  297. const candidateAedtm = String(candidate.AEDTM || "");
  298. const currentAedtm = String(current.AEDTM || "");
  299. return candidateAedtm > currentAedtm;
  300. }
  301. function parsePolicyModel(rawText) {
  302. const parsedJson = JSON.parse(rawText);
  303. const rawCountryRows = parseFlatTable(parsedJson.modelmasterListData);
  304. const rawCategoryRows = parseFlatTable(parsedJson.modelmasterListCatData);
  305. const countryRowsByIso = new Map();
  306. rawCountryRows.forEach((row) => {
  307. const iso2 = String(row.LAND1 || "").toUpperCase().trim();
  308. if (!iso2) {
  309. return;
  310. }
  311. const current = countryRowsByIso.get(iso2);
  312. if (!current || isBetterCountryRow(row, current)) {
  313. countryRowsByIso.set(iso2, row);
  314. }
  315. });
  316. const countriesData = Array.from(countryRowsByIso.values()).map((row) => ({
  317. iso2: String(row.LAND1 || "").toUpperCase(),
  318. countryNameFr: String(row.LANDX50 || ""),
  319. categoryCode: String(row.CATEGORIE || ""),
  320. categoryLabel: String(row.LBL_CATEGORIE || ""),
  321. colorCode: String(row.COLOR || ""),
  322. beginDate: String(row.BEGDA || ""),
  323. endDate: String(row.ENDDA || ""),
  324. beginDateText: String(row.BEGDA_TXT || ""),
  325. endDateText: String(row.ENDDA_TXT || ""),
  326. updateDate: String(row.AEDTM || ""),
  327. iconPath: String(row.DESCRIPTIF || ""),
  328. }));
  329. const categoriesData = rawCategoryRows.map((row) => ({
  330. code: String(row.CATEGORIE || ""),
  331. label: String(row.LBL_CATEGORIE || ""),
  332. colorCode: String(row.COLOR || ""),
  333. beginDate: String(row.BEGDA || ""),
  334. endDate: String(row.ENDDA || ""),
  335. descriptionHtml: String(row.DESCRIPTIF_WDA || row.DESCRIPTIF || ""),
  336. descriptionText: htmlToText(row.DESCRIPTIF_WDA || row.DESCRIPTIF || ""),
  337. }));
  338. return {
  339. countriesData,
  340. categoriesData,
  341. };
  342. }
  343. function getCountryPolicyForFeature(feature) {
  344. const iso2 = getCountryIso2(feature);
  345. if (iso2 && countryPolicyByIso2.has(iso2)) {
  346. return countryPolicyByIso2.get(iso2);
  347. }
  348. const names = [
  349. feature.properties?.NAME_FR,
  350. feature.properties?.NAME_EN,
  351. feature.properties?.NAME,
  352. feature.properties?.ADMIN,
  353. feature.properties?.NAME_LONG,
  354. ];
  355. for (let i = 0; i < names.length; i += 1) {
  356. const normalized = normalizeCountryName(names[i]);
  357. if (normalized && countryPolicyByName.has(normalized)) {
  358. return countryPolicyByName.get(normalized);
  359. }
  360. const aliasIso = mapNameToPolicyIsoAlias[normalized];
  361. if (aliasIso && countryPolicyByIso2.has(aliasIso)) {
  362. return countryPolicyByIso2.get(aliasIso);
  363. }
  364. }
  365. return null;
  366. }
  367. function getCountryColor(feature) {
  368. const policy = getCountryPolicyForFeature(feature);
  369. if (!policy) {
  370. return defaultCountryColor;
  371. }
  372. return getCategoryColor(policy.categoryCode, policy.colorCode);
  373. }
  374. function moveCountryTooltip(event) {
  375. if (!mapTooltip.classList.contains("visible")) {
  376. return;
  377. }
  378. const [pointerX, pointerY] = d3.pointer(event, document.body);
  379. const rawX = Number.isFinite(event?.clientX) ? event.clientX : pointerX;
  380. const rawY = Number.isFinite(event?.clientY) ? event.clientY : pointerY;
  381. const offset = 14;
  382. const tooltipWidth = mapTooltip.offsetWidth || 160;
  383. const tooltipHeight = mapTooltip.offsetHeight || 36;
  384. const maxX = window.innerWidth - tooltipWidth - 8;
  385. const maxY = window.innerHeight - tooltipHeight - 8;
  386. const x = Math.max(8, Math.min(maxX, rawX + offset));
  387. const y = Math.max(8, Math.min(maxY, rawY + offset));
  388. mapTooltip.style.left = `${x}px`;
  389. mapTooltip.style.top = `${y}px`;
  390. }
  391. function showCountryTooltip(event, feature) {
  392. mapTooltip.textContent = getCountryLabel(feature);
  393. mapTooltip.classList.add("visible");
  394. mapTooltip.setAttribute("aria-hidden", "false");
  395. moveCountryTooltip(event);
  396. }
  397. function hideCountryTooltip() {
  398. mapTooltip.classList.remove("visible");
  399. mapTooltip.setAttribute("aria-hidden", "true");
  400. }
  401. function buildCountryEntries() {
  402. countryByKey = new Map(countries.map((feature) => [featureKey(feature), feature]));
  403. countryEntries = countries
  404. .map((feature) => {
  405. const policy = getCountryPolicyForFeature(feature);
  406. const mapIso2 = getCountryIso2Raw(feature);
  407. const iso2 = mapIso2 || policy?.iso2 || getCountryIso2(feature);
  408. return {
  409. key: featureKey(feature),
  410. name: getCountryLabel(feature),
  411. iso2,
  412. flag: getFlagEmojiFromIso2(iso2),
  413. };
  414. })
  415. .sort((a, b) => {
  416. return a.name.localeCompare(b.name, "fr", { sensitivity: "base" });
  417. });
  418. }
  419. function renderCountryList(filterValue = "") {
  420. const normalizedFilter = normalizeCountryName(filterValue);
  421. const filtered = countryEntries.filter((entry) => {
  422. const byName = normalizeCountryName(entry.name).includes(normalizedFilter);
  423. const byIso = String(entry.iso2 || "")
  424. .toLowerCase()
  425. .includes(normalizedFilter);
  426. return byName || byIso;
  427. });
  428. const countText = `${filtered.length} / ${countryEntries.length} pays`;
  429. countryListCountDesktop.textContent = countText;
  430. countryListCountMobile.textContent = countText;
  431. let listHtml = "";
  432. if (filtered.length === 0) {
  433. listHtml = '<li class="country-list-empty">Aucun pays trouvé.</li>';
  434. } else {
  435. listHtml = filtered
  436. .map((entry) => {
  437. return `<li><button type="button" class="country-list-item" data-country-key="${escapeHtml(
  438. entry.key
  439. )}"><span class="country-list-flag">${entry.flag}</span><span>${escapeHtml(
  440. entry.name
  441. )}</span></button></li>`;
  442. })
  443. .join("");
  444. }
  445. countryListContainerDesktop.innerHTML = listHtml;
  446. countryListContainerMobile.innerHTML = listHtml;
  447. }
  448. function focusCountryOnMap(feature) {
  449. const bounds = path.bounds(feature);
  450. const x0 = bounds?.[0]?.[0];
  451. const y0 = bounds?.[0]?.[1];
  452. const x1 = bounds?.[1]?.[0];
  453. const y1 = bounds?.[1]?.[1];
  454. if (![x0, y0, x1, y1].every(Number.isFinite)) {
  455. return;
  456. }
  457. const featureWidth = Math.max(1, x1 - x0);
  458. const featureHeight = Math.max(1, y1 - y0);
  459. const centerX = (x0 + x1) / 2;
  460. const centerY = (y0 + y1) / 2;
  461. // Zoom cible plus fort au clic, ajusté à la taille du pays.
  462. const autoScale = 0.62 / Math.max(featureWidth / width, featureHeight / height);
  463. const targetScale = Math.min(20, Math.max(currentTransform.k, 4.5, autoScale));
  464. const translateX = width / 2 - targetScale * centerX;
  465. const translateY = height / 2 - targetScale * centerY;
  466. const targetTransform = d3.zoomIdentity
  467. .translate(translateX, translateY)
  468. .scale(targetScale);
  469. svg.transition().duration(420).call(zoom.transform, targetTransform);
  470. }
  471. function openCountryListModal() {
  472. countryListModal.classList.add("open");
  473. countryListModal.setAttribute("aria-hidden", "false");
  474. countrySearchInputMobile.focus();
  475. }
  476. function closeCountryListModal() {
  477. countryListModal.classList.remove("open");
  478. countryListModal.setAttribute("aria-hidden", "true");
  479. }
  480. function closeLegendOnMobile() {
  481. legendContainer.classList.remove("mobile-open");
  482. legendToggleButton.setAttribute("aria-expanded", "false");
  483. }
  484. function handleCountryListClick(event) {
  485. const button = event.target.closest("button[data-country-key]");
  486. if (!button) {
  487. return;
  488. }
  489. const feature = countryByKey.get(button.dataset.countryKey);
  490. if (!feature) {
  491. return;
  492. }
  493. focusCountryOnMap(feature);
  494. openModalForFeature(feature);
  495. closeCountryListModal();
  496. }
  497. function initCountryListEvents() {
  498. const syncAndRender = (value, source) => {
  499. const nextValue = value || "";
  500. if (source !== "desktop" && countrySearchInputDesktop.value !== nextValue) {
  501. countrySearchInputDesktop.value = nextValue;
  502. }
  503. if (source !== "mobile" && countrySearchInputMobile.value !== nextValue) {
  504. countrySearchInputMobile.value = nextValue;
  505. }
  506. renderCountryList(nextValue);
  507. };
  508. countrySearchInputDesktop.addEventListener("input", (event) => {
  509. syncAndRender(event.target.value, "desktop");
  510. });
  511. countrySearchInputMobile.addEventListener("input", (event) => {
  512. syncAndRender(event.target.value, "mobile");
  513. });
  514. countryListContainerDesktop.addEventListener("click", handleCountryListClick);
  515. countryListContainerMobile.addEventListener("click", handleCountryListClick);
  516. openMobileCountryListButton.addEventListener("click", openCountryListModal);
  517. closeCountryListModalButton.addEventListener("click", closeCountryListModal);
  518. countryListModal.addEventListener("click", (event) => {
  519. if (event.target === countryListModal) {
  520. closeCountryListModal();
  521. }
  522. });
  523. legendToggleButton.addEventListener("click", () => {
  524. const isOpen = legendContainer.classList.toggle("mobile-open");
  525. legendToggleButton.setAttribute("aria-expanded", String(isOpen));
  526. });
  527. }
  528. function updateLegend() {
  529. if (!lastAnalysis) {
  530. legendContainer.innerHTML = "";
  531. return;
  532. }
  533. const categories = [...lastAnalysis.categoryCounts.entries()].sort((a, b) => {
  534. if (a[0] === "30" && b[0] !== "30") {
  535. return 1;
  536. }
  537. if (b[0] === "30" && a[0] !== "30") {
  538. return -1;
  539. }
  540. return Number(a[0]) - Number(b[0]);
  541. });
  542. const items = categories
  543. .map(([code, count]) => {
  544. const category = categoryByCode.get(code);
  545. const label = getCategoryDisplayLabel(code, category?.label);
  546. const hex = getCategoryColor(code, category?.colorCode);
  547. return `<li><span class="legend-swatch" style="background:${hex}"></span><span>Code ${code} - ${label} (${count})</span></li>`;
  548. })
  549. .join("");
  550. legendContainer.innerHTML = `<h3>Légende catégories</h3><ul>${items}</ul>`;
  551. }
  552. function setCountryDataHtml(lines) {
  553. const rows = lines
  554. .map(
  555. ([label, value]) =>
  556. `<div class="country-data-row"><strong>${escapeHtml(
  557. label
  558. )}</strong><span>${escapeHtml(value)}</span></div>`
  559. )
  560. .join("");
  561. countryData.innerHTML = rows;
  562. }
  563. function openModalForFeature(feature) {
  564. const mapLabel = getCountryLabel(feature);
  565. const policy = getCountryPolicyForFeature(feature);
  566. const mapIso2 = getCountryIso2Raw(feature);
  567. const iso2 = mapIso2 || policy?.iso2 || getCountryIso2(feature);
  568. const flag = getFlagEmojiFromIso2(iso2);
  569. countryName.textContent = `${flag} ${mapLabel}`;
  570. if (!policy) {
  571. countryCategory.textContent = "Aucune donnée de catégorie pour ce pays.";
  572. setCountryDataHtml([
  573. ["Source", "Carte Natural Earth"],
  574. ["Code ISO2 carte", getCountryIso2(feature) || "N/A"],
  575. ]);
  576. } else {
  577. const category = categoryByCode.get(policy.categoryCode);
  578. const categoryLabel = getCategoryDisplayLabel(
  579. policy.categoryCode,
  580. category?.label || policy.categoryLabel
  581. );
  582. countryCategory.textContent = `${categoryLabel} (code ${policy.categoryCode})`;
  583. const procedureText = summarizeProcedureText(
  584. category?.descriptionText || "",
  585. policy.categoryCode
  586. );
  587. const fullProcedureText = isConcernedByMarch2026Note(policy)
  588. ? `${procedureText}\n\n${noteServiceProcedureReference}`
  589. : procedureText;
  590. setCountryDataHtml([
  591. ["Pays (donnée source)", policy.countryNameFr || mapLabel],
  592. ["Code ISO2", policy.iso2],
  593. ["Début validité", formatDate(policy.beginDate)],
  594. ["Fin validité", formatDate(policy.endDate)],
  595. ["Dernière maj", formatDate(policy.updateDate)],
  596. ["Procédure", fullProcedureText || "Non renseignée"],
  597. ]);
  598. }
  599. modal.classList.add("open");
  600. modal.setAttribute("aria-hidden", "false");
  601. }
  602. function closeModal() {
  603. modal.classList.remove("open");
  604. modal.setAttribute("aria-hidden", "true");
  605. }
  606. function handleCountryClick(event, feature) {
  607. if (event.defaultPrevented) {
  608. return;
  609. }
  610. if (event.currentTarget && typeof event.currentTarget.blur === "function") {
  611. event.currentTarget.blur();
  612. }
  613. focusCountryOnMap(feature);
  614. openModalForFeature(feature);
  615. }
  616. function featureKey(feature) {
  617. const iso3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || "-";
  618. const name = feature.properties?.NAME_EN || feature.properties?.NAME || "unknown";
  619. return `${iso3}:${name}`;
  620. }
  621. function updateProjectionAndDrawing() {
  622. if (!countryPaths || !countryShadowPaths || countries.length === 0) {
  623. return;
  624. }
  625. const rect = mapContainer.node().getBoundingClientRect();
  626. width = Math.max(320, rect.width);
  627. height = Math.max(240, rect.height);
  628. svg.attr("viewBox", `0 0 ${width} ${height}`);
  629. projection.fitExtent(
  630. [
  631. [10, 10],
  632. [width - 10, height - 10],
  633. ],
  634. { type: "FeatureCollection", features: countries }
  635. );
  636. countryShadowPaths.attr("d", path);
  637. countryPaths.attr("d", path);
  638. zoom
  639. .extent([
  640. [0, 0],
  641. [width, height],
  642. ])
  643. .translateExtent([
  644. [-width, -height],
  645. [width * 2, height * 2],
  646. ]);
  647. svg.call(zoom).call(zoom.transform, currentTransform);
  648. }
  649. function analyzeMatching() {
  650. const categoryCounts = new Map();
  651. const matchedIso2 = new Set();
  652. let matchedMapCountries = 0;
  653. countryPolicyByIso2.forEach((policy) => {
  654. const categoryCode = policy.categoryCode || "N/A";
  655. categoryCounts.set(categoryCode, (categoryCounts.get(categoryCode) || 0) + 1);
  656. });
  657. countries.forEach((feature) => {
  658. const policy = getCountryPolicyForFeature(feature);
  659. if (!policy) {
  660. return;
  661. }
  662. matchedMapCountries += 1;
  663. matchedIso2.add(policy.iso2);
  664. });
  665. Object.entries(forcedPolicyIsoByMatchedParentIso).forEach(
  666. ([parentIso, forcedIsoList]) => {
  667. if (!matchedIso2.has(parentIso)) {
  668. return;
  669. }
  670. forcedIsoList.forEach((forcedIso) => {
  671. matchedIso2.add(forcedIso);
  672. });
  673. }
  674. );
  675. const totalPolicyCountries = countryPolicyByIso2.size;
  676. const unmatchedMapCountries = countries.filter((feature) => !getCountryPolicyForFeature(feature)).length;
  677. const unmatchedIso2 = [...countryPolicyByIso2.keys()].filter((iso2) => !matchedIso2.has(iso2));
  678. lastAnalysis = {
  679. totalPolicyCountries,
  680. matchedMapCountries,
  681. unmatchedMapCountries,
  682. categoryCounts,
  683. unmatchedIso2,
  684. };
  685. }
  686. Promise.all([d3.json(worldGeoJsonUrl), d3.text(rawPolicyDataUrl)])
  687. .then(([geoJson, rawPolicyText]) => {
  688. const parsedPolicy = parsePolicyModel(rawPolicyText);
  689. countryPolicyByIso2 = new Map(parsedPolicy.countriesData.map((item) => [item.iso2, item]));
  690. countryPolicyByName = new Map(
  691. parsedPolicy.countriesData
  692. .map((item) => [normalizeCountryName(item.countryNameFr), item])
  693. .filter(([name]) => Boolean(name))
  694. );
  695. categoryByCode = new Map(parsedPolicy.categoriesData.map((item) => [item.code, item]));
  696. countries = geoJson.features.filter(
  697. (feature) => feature.properties?.NAME_EN !== "Antarctica"
  698. );
  699. countryShadowPaths = countryShadowLayer
  700. .selectAll("path")
  701. .data(countries, featureKey)
  702. .join("path")
  703. .attr("class", "country-shadow");
  704. countryPaths = countryLayer
  705. .selectAll("path")
  706. .data(countries, featureKey)
  707. .join("path")
  708. .attr("class", "country")
  709. .attr("fill", getCountryColor)
  710. .attr("tabindex", 0)
  711. .on("pointerenter", (event, feature) => {
  712. showCountryTooltip(event, feature);
  713. })
  714. .on("pointermove", (event) => {
  715. moveCountryTooltip(event);
  716. })
  717. .on("pointerleave", () => {
  718. hideCountryTooltip();
  719. })
  720. .on("mouseenter", (event, feature) => {
  721. showCountryTooltip(event, feature);
  722. })
  723. .on("mouseover", (event, feature) => {
  724. showCountryTooltip(event, feature);
  725. })
  726. .on("mouseenter", (event, feature) => {
  727. showCountryTooltip(event, feature);
  728. })
  729. .on("mousemove", (event) => {
  730. moveCountryTooltip(event);
  731. })
  732. .on("mouseleave", () => {
  733. hideCountryTooltip();
  734. })
  735. .on("focus", (event, feature) => {
  736. showCountryTooltip(event, feature);
  737. })
  738. .on("click", handleCountryClick)
  739. .on("keydown", (event, feature) => {
  740. if (event.key === "Enter" || event.key === " ") {
  741. event.preventDefault();
  742. openModalForFeature(feature);
  743. }
  744. })
  745. .on("blur", () => {
  746. hideCountryTooltip();
  747. });
  748. countryPaths
  749. .selectAll("title")
  750. .data((feature) => [feature])
  751. .join("title")
  752. .text((feature) => getCountryLabel(feature));
  753. buildCountryEntries();
  754. renderCountryList();
  755. analyzeMatching();
  756. updateLegend();
  757. updateProjectionAndDrawing();
  758. })
  759. .catch((error) => {
  760. mapContainer
  761. .append("p")
  762. .style("color", "#b91c1c")
  763. .style("margin", "1rem")
  764. .text("Erreur lors du chargement de la carte ou des données.");
  765. // eslint-disable-next-line no-console
  766. console.error(error);
  767. });
  768. window.addEventListener("resize", () => {
  769. updateProjectionAndDrawing();
  770. hideCountryTooltip();
  771. if (window.innerWidth > 760) {
  772. closeCountryListModal();
  773. closeLegendOnMobile();
  774. }
  775. });
  776. initCountryListEvents();
  777. closeModalButton.addEventListener("click", closeModal);
  778. modal.addEventListener("click", (event) => {
  779. if (event.target === modal) {
  780. closeModal();
  781. }
  782. });
  783. document.addEventListener("keydown", (event) => {
  784. if (event.key !== "Escape") {
  785. return;
  786. }
  787. if (countryListModal.classList.contains("open")) {
  788. closeCountryListModal();
  789. return;
  790. }
  791. if (legendContainer.classList.contains("mobile-open")) {
  792. closeLegendOnMobile();
  793. return;
  794. }
  795. if (modal.classList.contains("open")) {
  796. closeModal();
  797. }
  798. });