script.js 26 KB

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