|
|
@@ -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("&", "&")
|
|
|
+ .replaceAll("<", "<")
|
|
|
+ .replaceAll(">", ">")
|
|
|
+ .replaceAll("\"", """)
|
|
|
+ .replaceAll("'", "'");
|
|
|
+}
|
|
|
+
|
|
|
+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();
|
|
|
+ }
|
|
|
+});
|