script.js 71 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239
  1. const mapCanvas = document.getElementById("map-canvas");
  2. const mapShell = document.getElementById("map-shell");
  3. const mapTooltip = document.getElementById("map-tooltip");
  4. const mapStatus = document.getElementById("map-status");
  5. const entitySidebar = document.getElementById("entity-sidebar");
  6. const entityListTitle = document.getElementById("entity-list-title");
  7. const communeName = document.getElementById("commune-name");
  8. const communeTerritory = document.getElementById("commune-territory");
  9. const communeData = document.getElementById("commune-data");
  10. const communeModal = document.getElementById("commune-modal");
  11. const closeModalButton = document.getElementById("close-modal");
  12. const modalOpenTerritoryButton = document.getElementById("modal-open-territory");
  13. const modalTitle = document.getElementById("modal-title");
  14. const communeSearchInputDesktop = document.getElementById("commune-search");
  15. const communeSearchInputMobile = document.getElementById("commune-search-mobile");
  16. const communeListContainerDesktop = document.getElementById("commune-list");
  17. const communeListContainerMobile = document.getElementById("commune-list-mobile");
  18. const communeListCountDesktop = document.getElementById("commune-list-count");
  19. const communeListCountMobile = document.getElementById("commune-list-count-mobile");
  20. const openMobileCommuneListButton = document.getElementById("open-mobile-commune-list");
  21. const closeCommuneListModalButton = document.getElementById("close-commune-list-modal");
  22. const communeListModal = document.getElementById("commune-list-modal");
  23. const communeListModalTitle = document.getElementById("commune-list-modal-title");
  24. const territoryPanel = document.getElementById("territory-panel");
  25. const territorySummary = document.getElementById("territory-summary");
  26. const territoryList = document.getElementById("territory-list");
  27. const territoryToggleButton = document.getElementById("toggle-mobile-territories");
  28. const maskingDisclaimer = document.getElementById("masking-disclaimer");
  29. const maskingDisclaimerText = document.getElementById("masking-disclaimer-text");
  30. const openMaskingModalButton = document.getElementById("open-masking-modal");
  31. const maskingModal = document.getElementById("masking-modal");
  32. const closeMaskingModalButton = document.getElementById("close-masking-modal");
  33. const maskingModalSummary = document.getElementById("masking-modal-summary");
  34. const maskingModalData = document.getElementById("masking-modal-data");
  35. const COMMUNE_INDEX_URL = "./data/communes-index.json";
  36. const TERRITORIES_URL = "./data/territories.json";
  37. const MAX_SEARCH_RESULTS = 160;
  38. const BASE_PADDING = 28;
  39. const FILL_SEAL_WIDTH = 1.2;
  40. const BORDER_LINE_WIDTH = 0.85;
  41. const VECTOR_RENDER_SCALE_THRESHOLD = 2.2;
  42. const UNIT_MARKER_SIZE = 10.5;
  43. const UNIT_MARKER_CORNER_RADIUS = 2.8;
  44. const UNIT_MARKER_HOVER_SIZE = 12;
  45. const UNIT_MARKER_HIT_RADIUS = 10;
  46. const UNIT_FOCUS_SCALE = 14;
  47. const TERRITORY_DISPLAY_SCALE = {};
  48. const REGION_ENTITY_TYPE = "region";
  49. const DEPARTMENT_ENTITY_TYPE = "departement";
  50. const COMMUNE_ENTITY_TYPE = "commune";
  51. const UNIT_SEARCH_KIND = "unit";
  52. const ROUTE_TERRITORY_SEGMENT = "territoire";
  53. const ROUTE_ENTITY_SEGMENT = "entite";
  54. const ROUTE_UNIT_SEGMENT = "unite";
  55. const TERRITORY_BADGE_ASSETS = {
  56. "region-ile-de-france": {
  57. src: "./assets/territory-badges/region-ile-de-france.svg",
  58. },
  59. "region-centre-val-de-loire": {
  60. src: "./assets/territory-badges/region-centre-val-de-loire.svg",
  61. },
  62. "region-bourgogne-franche-comte": {
  63. src: "./assets/territory-badges/region-bourgogne-franche-comte.png",
  64. },
  65. "region-normandie": {
  66. src: "./assets/territory-badges/region-normandie.svg",
  67. },
  68. "region-hauts-de-france": {
  69. src: "./assets/territory-badges/region-hauts-de-france.svg",
  70. },
  71. "region-grand-est": {
  72. src: "./assets/territory-badges/region-grand-est.svg",
  73. },
  74. "region-pays-de-la-loire": {
  75. src: "./assets/territory-badges/region-pays-de-la-loire.png",
  76. },
  77. "region-bretagne": {
  78. src: "./assets/territory-badges/region-bretagne.svg",
  79. },
  80. "region-nouvelle-aquitaine": {
  81. src: "./assets/territory-badges/region-nouvelle-aquitaine.svg",
  82. },
  83. "region-occitanie": {
  84. src: "./assets/territory-badges/region-occitanie.svg",
  85. },
  86. "region-auvergne-rhone-alpes": {
  87. src: "./assets/territory-badges/region-auvergne-rhone-alpes.svg",
  88. },
  89. "region-provence-alpes-cote-d-azur": {
  90. src: "./assets/territory-badges/region-provence-alpes-cote-d-azur.svg",
  91. },
  92. "region-corse": {
  93. src: "./assets/territory-badges/region-corse.svg",
  94. },
  95. guadeloupe: {
  96. src: "./assets/territory-badges/guadeloupe.png",
  97. },
  98. martinique: {
  99. src: "./assets/territory-badges/martinique.png",
  100. },
  101. guyane: {
  102. src: "./assets/territory-badges/guyane.png",
  103. },
  104. "la-reunion": {
  105. src: "./assets/territory-badges/la-reunion.png",
  106. },
  107. mayotte: {
  108. src: "./assets/territory-badges/mayotte.png",
  109. },
  110. "saint-pierre-et-miquelon": {
  111. src: "./assets/territory-badges/saint-pierre-et-miquelon.png",
  112. },
  113. "saint-barthelemy": {
  114. src: "./assets/territory-badges/saint-barthelemy.svg",
  115. className: "territory-badge-flag",
  116. },
  117. "saint-martin": {
  118. src: "./assets/territory-badges/saint-martin.svg",
  119. className: "territory-badge-flag",
  120. },
  121. "polynesie-francaise": {
  122. src: "./assets/territory-badges/polynesie-francaise.png",
  123. },
  124. "nouvelle-caledonie": {
  125. src: "./assets/territory-badges/nouvelle-caledonie.png",
  126. },
  127. "wallis-et-futuna": {
  128. src: "./assets/territory-badges/wallis-et-futuna.png",
  129. },
  130. };
  131. const mapContext = mapCanvas.getContext("2d", { alpha: false });
  132. const hitCanvas = document.createElement("canvas");
  133. const hitContext = hitCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
  134. const baseCanvas = document.createElement("canvas");
  135. const baseContext = baseCanvas.getContext("2d", { alpha: false });
  136. const themeStyles = getComputedStyle(document.documentElement);
  137. const themeColors = {
  138. background: themeStyles.getPropertyValue("--bg").trim(),
  139. communeFill: themeStyles.getPropertyValue("--commune-fill").trim(),
  140. communeHover: themeStyles.getPropertyValue("--commune-hover").trim(),
  141. communeSelected: themeStyles.getPropertyValue("--commune-selected").trim(),
  142. communeStroke: themeStyles.getPropertyValue("--commune-stroke").trim(),
  143. unitMarkerFill: themeStyles.getPropertyValue("--unit-marker-fill").trim() || "#394149",
  144. unitMarkerStroke: themeStyles.getPropertyValue("--unit-marker-stroke").trim() || "#07090b",
  145. unitMarkerHover: themeStyles.getPropertyValue("--unit-marker-hover").trim() || "#5a6672",
  146. };
  147. const state = {
  148. pixelRatio: window.devicePixelRatio || 1,
  149. width: 0,
  150. height: 0,
  151. communesIndex: [],
  152. communePostalCodesByCode: new Map(),
  153. searchEntries: [],
  154. territories: [],
  155. territoryByKey: new Map(),
  156. territoryDataByKey: new Map(),
  157. activeTerritoryKey: null,
  158. activeTerritoryData: null,
  159. hoveredPartKey: null,
  160. hoveredUnitKey: null,
  161. selectedCommuneId: null,
  162. selectedPartKey: null,
  163. selectedUnitKey: null,
  164. transform: d3.zoomIdentity,
  165. renderQueued: false,
  166. baseDirty: true,
  167. hitDirty: true,
  168. resizeTimer: null,
  169. routeHash: "",
  170. routeApplying: false,
  171. };
  172. const zoom = d3
  173. .zoom()
  174. .scaleExtent([1, 80])
  175. .on("start", () => hideTooltip())
  176. .on("zoom", (event) => {
  177. state.transform = event.transform;
  178. requestRender();
  179. });
  180. d3.select(mapCanvas).call(zoom).on("dblclick.zoom", null);
  181. function normalizeText(value) {
  182. return String(value || "")
  183. .normalize("NFD")
  184. .replace(/[\u0300-\u036f]/g, "")
  185. .toLowerCase()
  186. .replace(/[^a-z0-9]+/g, " ")
  187. .trim();
  188. }
  189. function compareEntriesAlphabetically(left, right) {
  190. if (left.searchName !== right.searchName) {
  191. return left.searchName.localeCompare(right.searchName, "fr", { sensitivity: "base" });
  192. }
  193. if (left.name !== right.name) {
  194. return left.name.localeCompare(right.name, "fr", { sensitivity: "base" });
  195. }
  196. if (left.code !== right.code) {
  197. return left.code.localeCompare(right.code, "fr", { sensitivity: "base" });
  198. }
  199. return left.rawCode.localeCompare(right.rawCode, "fr", { sensitivity: "base" });
  200. }
  201. function compareLabelsAlphabetically(left, right) {
  202. return String(left || "").localeCompare(String(right || ""), "fr", { sensitivity: "base" });
  203. }
  204. function escapeHtml(value) {
  205. return String(value)
  206. .replaceAll("&", "&")
  207. .replaceAll("<", "&lt;")
  208. .replaceAll(">", "&gt;")
  209. .replaceAll('"', "&quot;")
  210. .replaceAll("'", "&#39;");
  211. }
  212. function hasTerritoryBadge(territoryKey) {
  213. return territoryKey === "france" || Boolean(TERRITORY_BADGE_ASSETS[territoryKey]);
  214. }
  215. function getTerritoryBadgeMarkup(territory, extraClass = "") {
  216. const classNames = ["territory-badge", extraClass];
  217. if (territory.key === "france") {
  218. classNames.push("territory-badge-flag");
  219. return `<span class="${classNames.filter(Boolean).join(" ")}" aria-hidden="true">🇫🇷</span>`;
  220. }
  221. const badge = TERRITORY_BADGE_ASSETS[territory.key];
  222. if (!badge) {
  223. return `<span class="territory-list-marker" aria-hidden="true">▦</span>`;
  224. }
  225. if (badge.className) {
  226. classNames.push(badge.className);
  227. }
  228. return `
  229. <span class="${classNames.filter(Boolean).join(" ")}" aria-hidden="true">
  230. <img class="territory-badge-image" src="${badge.src}" alt="" loading="lazy" decoding="async" />
  231. </span>
  232. `;
  233. }
  234. function getTerritoryBadgeMarkupByKey(territoryKey, extraClass = "") {
  235. if (!territoryKey || !hasTerritoryBadge(territoryKey)) {
  236. return "";
  237. }
  238. return getTerritoryBadgeMarkup(getTerritoryMeta(territoryKey) || { key: territoryKey }, extraClass);
  239. }
  240. function formatBadgeLabelMarkup(label, territoryKey, extraClass = "") {
  241. if (territoryKey === "france") {
  242. return escapeHtml(label);
  243. }
  244. const badgeMarkup = getTerritoryBadgeMarkupByKey(territoryKey, extraClass);
  245. const escapedLabel = escapeHtml(label);
  246. if (!badgeMarkup) {
  247. return escapedLabel;
  248. }
  249. return `<span class="modal-badge-label">${badgeMarkup}<span>${escapedLabel}</span></span>`;
  250. }
  251. function formatCount(value) {
  252. return new Intl.NumberFormat("fr-FR").format(Number(value || 0));
  253. }
  254. function formatEntityCount(value, singular, plural) {
  255. const numericValue = Number(value || 0);
  256. const label = numericValue === 1 ? singular : plural;
  257. return `${formatCount(numericValue)} ${label}`;
  258. }
  259. function formatPostalCodes(postalCodes) {
  260. if (!Array.isArray(postalCodes) || !postalCodes.length) {
  261. return "";
  262. }
  263. return postalCodes.join(", ");
  264. }
  265. function formatGroupementShortLabel(code) {
  266. if (!code) {
  267. return "";
  268. }
  269. return `GGD${String(code).toUpperCase()}`;
  270. }
  271. function getUnitInstitutionLabel(institution) {
  272. return institution === "PN" ? "Police nationale" : "Gendarmerie nationale";
  273. }
  274. function isGroupementCommuneView(territoryMeta) {
  275. return territoryMeta?.entityType === COMMUNE_ENTITY_TYPE && Boolean(territoryMeta?.groupementView);
  276. }
  277. function isUnitMarkerView(territoryMeta) {
  278. return isGroupementCommuneView(territoryMeta);
  279. }
  280. function buildCompetenceColorByServiceKey(serviceByKey) {
  281. const serviceKeys = Array.from(serviceByKey.entries())
  282. .filter(([, service]) => service?.i === "GN")
  283. .sort((left, right) => left[1].l.localeCompare(right[1].l, "fr", { sensitivity: "base" }))
  284. .map(([serviceKey]) => serviceKey);
  285. const colorByServiceKey = new Map();
  286. const total = serviceKeys.length || 1;
  287. serviceKeys.forEach((serviceKey, index) => {
  288. const hue = Math.round((index * 360) / total);
  289. colorByServiceKey.set(serviceKey, `hsl(${hue} 46% 56%)`);
  290. });
  291. return colorByServiceKey;
  292. }
  293. function getCommuneCompetenceFillColor(featureProperties, usesCompetenceColoring, colorByServiceKey) {
  294. if (!usesCompetenceColoring) {
  295. return themeColors.communeFill;
  296. }
  297. if (featureProperties.competenceInstitution !== "GN" || !featureProperties.competenceServiceKey) {
  298. return themeColors.communeFill;
  299. }
  300. return colorByServiceKey.get(featureProperties.competenceServiceKey) || themeColors.communeFill;
  301. }
  302. function getCommuneCompetenceLabel(featureProperties) {
  303. if (featureProperties.competenceMixedLabel) {
  304. return featureProperties.competenceMixedLabel;
  305. }
  306. if (featureProperties.competenceLabel) {
  307. return featureProperties.competenceLabel;
  308. }
  309. if (featureProperties.competenceInstitution === "PN") {
  310. return "Police nationale";
  311. }
  312. if (featureProperties.competenceInstitution === "UNK") {
  313. return "Compétence territoriale non renseignée";
  314. }
  315. return "";
  316. }
  317. function getCompetenceServiceKeys(featureProperties) {
  318. if (Array.isArray(featureProperties.competenceServiceKeys) && featureProperties.competenceServiceKeys.length) {
  319. return featureProperties.competenceServiceKeys;
  320. }
  321. if (featureProperties.competenceServiceKey) {
  322. return [featureProperties.competenceServiceKey];
  323. }
  324. return [];
  325. }
  326. function encodeHitColor(index) {
  327. return `rgb(${index & 255},${(index >> 8) & 255},${(index >> 16) & 255})`;
  328. }
  329. function getHitColorKey(pixel) {
  330. return `${pixel[0]},${pixel[1]},${pixel[2]}`;
  331. }
  332. function setStatus(message, visible = true) {
  333. mapStatus.textContent = message;
  334. mapStatus.hidden = !visible;
  335. }
  336. function showTooltip(item, clientX, clientY) {
  337. const label = typeof item === "string" ? item : item?.properties?.nom;
  338. if (!label) {
  339. hideTooltip();
  340. return;
  341. }
  342. mapTooltip.innerHTML = `<strong>${escapeHtml(label)}</strong>`;
  343. mapTooltip.style.left = `${clientX}px`;
  344. mapTooltip.style.top = `${clientY}px`;
  345. mapTooltip.classList.add("visible");
  346. }
  347. function hideTooltip() {
  348. mapTooltip.classList.remove("visible");
  349. }
  350. function getTerritoryMeta(key) {
  351. return state.territoryByKey.get(key) || null;
  352. }
  353. function getRegionTerritoryByCode(regionCode) {
  354. if (!regionCode) {
  355. return null;
  356. }
  357. return (
  358. state.territories.find(
  359. (territory) => territory.entityType === DEPARTMENT_ENTITY_TYPE && territory.regionCode === regionCode
  360. ) || null
  361. );
  362. }
  363. function getRegionLabel(regionCode) {
  364. return getRegionTerritoryByCode(regionCode)?.label || regionCode || "";
  365. }
  366. function capitalizeFirst(value) {
  367. const text = String(value || "");
  368. if (!text) {
  369. return "";
  370. }
  371. return text.charAt(0).toUpperCase() + text.slice(1);
  372. }
  373. function isFeminineEntity(entityType) {
  374. return entityType === COMMUNE_ENTITY_TYPE || entityType === REGION_ENTITY_TYPE;
  375. }
  376. function getEntityCopy(territoryKey = state.activeTerritoryKey) {
  377. const territoryMeta = getTerritoryMeta(territoryKey);
  378. const entityType = territoryMeta?.entityType || COMMUNE_ENTITY_TYPE;
  379. const singular = territoryMeta?.entityLabelSingular || "commune";
  380. const plural = territoryMeta?.entityLabelPlural || "communes";
  381. const isFeminine = isFeminineEntity(entityType);
  382. const article = isFeminine ? "une" : "un";
  383. const selectedSuffix = isFeminine ? "e" : "";
  384. const nonePrefix = isFeminine ? "Aucune" : "Aucun";
  385. const foundSuffix = isFeminine ? "e" : "";
  386. return {
  387. entityType,
  388. singular,
  389. plural,
  390. listTitle: capitalizeFirst(plural),
  391. searchTitle: `Rechercher ${article} ${singular}`,
  392. searchPlaceholder: "Nom, code, territoire...",
  393. selectedTitle: capitalizeFirst(singular),
  394. noneFoundLabel: `${nonePrefix} ${singular} trouvé${foundSuffix}.`,
  395. sidebarAria: `Liste des ${plural}`,
  396. };
  397. }
  398. function buildRouteHash(route = {}) {
  399. const territoryKey = route.territoryKey || state.activeTerritoryKey || "france";
  400. const unitKey = route.unitKey || "";
  401. const entityCode = route.entityCode || "";
  402. if (unitKey) {
  403. return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(territoryKey)}/${ROUTE_UNIT_SEGMENT}/${encodeURIComponent(
  404. unitKey
  405. )}`;
  406. }
  407. if (entityCode) {
  408. return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(
  409. territoryKey
  410. )}/${ROUTE_ENTITY_SEGMENT}/${encodeURIComponent(entityCode)}`;
  411. }
  412. return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(territoryKey)}`;
  413. }
  414. function parseRouteHash(hash) {
  415. const sanitizedHash = String(hash || "").replace(/^#/, "").replace(/^\/+/, "");
  416. if (!sanitizedHash) {
  417. return { territoryKey: "france", entityCode: null, unitKey: null, valid: true };
  418. }
  419. const parts = sanitizedHash
  420. .split("/")
  421. .filter(Boolean)
  422. .map((part) => decodeURIComponent(part));
  423. if (parts[0] !== ROUTE_TERRITORY_SEGMENT || !parts[1]) {
  424. return { territoryKey: "france", entityCode: null, unitKey: null, valid: false };
  425. }
  426. if (parts.length === 2) {
  427. return { territoryKey: parts[1], entityCode: null, unitKey: null, valid: true };
  428. }
  429. if (parts.length === 4 && parts[2] === ROUTE_ENTITY_SEGMENT && parts[3]) {
  430. return { territoryKey: parts[1], entityCode: parts[3], unitKey: null, valid: true };
  431. }
  432. if (parts.length === 4 && parts[2] === ROUTE_UNIT_SEGMENT && parts[3]) {
  433. return { territoryKey: parts[1], entityCode: null, unitKey: parts[3], valid: true };
  434. }
  435. return { territoryKey: parts[1], entityCode: null, unitKey: null, valid: false };
  436. }
  437. function buildRouteUrl(hash) {
  438. return `${window.location.pathname}${window.location.search}${hash}`;
  439. }
  440. function getCurrentRouteState() {
  441. return {
  442. territoryKey: state.activeTerritoryKey || "france",
  443. entityCode: state.selectedUnitKey ? null : state.selectedCommuneId || null,
  444. unitKey: state.selectedUnitKey || null,
  445. };
  446. }
  447. function syncRouteFromState(options = {}) {
  448. if (state.routeApplying || !state.activeTerritoryKey) {
  449. return;
  450. }
  451. const nextHash = buildRouteHash(getCurrentRouteState());
  452. if (nextHash === state.routeHash) {
  453. return;
  454. }
  455. const method = options.replace ? "replaceState" : "pushState";
  456. history[method](null, "", buildRouteUrl(nextHash));
  457. state.routeHash = nextHash;
  458. }
  459. function applyEntityUi() {
  460. const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
  461. const entityCopy = getEntityCopy();
  462. entityListTitle.textContent = entityCopy.listTitle;
  463. entitySidebar.setAttribute("aria-label", entityCopy.sidebarAria);
  464. communeSearchInputDesktop.placeholder = entityCopy.searchPlaceholder;
  465. communeSearchInputMobile.placeholder = entityCopy.searchPlaceholder;
  466. communeListModalTitle.textContent = entityCopy.searchTitle;
  467. openMobileCommuneListButton.setAttribute("aria-label", entityCopy.searchTitle);
  468. modalTitle.textContent = entityCopy.selectedTitle;
  469. if (territoryMeta) {
  470. mapShell.setAttribute(
  471. "aria-label",
  472. `Carte interactive des ${entityCopy.plural} de ${territoryMeta.label}`
  473. );
  474. }
  475. }
  476. function getPreparedFeatureById(communeId) {
  477. return state.activeTerritoryData?.featureById?.get(communeId) || null;
  478. }
  479. function getPreparedPartByKey(partKey) {
  480. return state.activeTerritoryData?.partByKey?.get(partKey) || null;
  481. }
  482. function getPreparedFeatureFromPartKey(partKey) {
  483. const preparedPart = getPreparedPartByKey(partKey);
  484. return preparedPart ? getPreparedFeatureById(preparedPart.featureId) : null;
  485. }
  486. function getPreparedUnitByKey(unitKey) {
  487. if (!state.activeTerritoryData?.preparedUnits?.length || !unitKey) {
  488. return null;
  489. }
  490. return state.activeTerritoryData.preparedUnits.find((unit) => unit.key === unitKey) || null;
  491. }
  492. function getPointerPositions(event) {
  493. const rect = mapCanvas.getBoundingClientRect();
  494. const localX = event.clientX - rect.left;
  495. const localY = event.clientY - rect.top;
  496. const [worldX, worldY] = getProjectedPointer(localX, localY);
  497. const scaleX = state.width > 0 ? hitCanvas.width / state.width : 1;
  498. const scaleY = state.height > 0 ? hitCanvas.height / state.height : 1;
  499. return {
  500. rect,
  501. localX,
  502. localY,
  503. worldX,
  504. worldY,
  505. canvasX: Math.floor(worldX * scaleX),
  506. canvasY: Math.floor(worldY * scaleY),
  507. };
  508. }
  509. function getProjectedPointer(localX, localY) {
  510. return [
  511. (localX - state.transform.x) / state.transform.k,
  512. (localY - state.transform.y) / state.transform.k,
  513. ];
  514. }
  515. function isPointInRing(pointX, pointY, ring) {
  516. let inside = false;
  517. for (let index = 0, previousIndex = ring.length - 1; index < ring.length; previousIndex = index, index += 1) {
  518. const [x0, y0] = ring[index];
  519. const [x1, y1] = ring[previousIndex];
  520. const crosses =
  521. (y0 > pointY) !== (y1 > pointY) &&
  522. pointX < ((x1 - x0) * (pointY - y0)) / ((y1 - y0) || Number.EPSILON) + x0;
  523. if (crosses) {
  524. inside = !inside;
  525. }
  526. }
  527. return inside;
  528. }
  529. function isPointInPolygon(pointX, pointY, rings) {
  530. if (!rings.length || !isPointInRing(pointX, pointY, rings[0])) {
  531. return false;
  532. }
  533. for (let index = 1; index < rings.length; index += 1) {
  534. if (isPointInRing(pointX, pointY, rings[index])) {
  535. return false;
  536. }
  537. }
  538. return true;
  539. }
  540. function isPreparedPartUnderPointer(preparedPart, localX, localY) {
  541. if (!preparedPart) {
  542. return false;
  543. }
  544. const [projectedX, projectedY] = getProjectedPointer(localX, localY);
  545. return isPointInPolygon(projectedX, projectedY, preparedPart.projectedRings);
  546. }
  547. function getAssociatedCommunesForUnit(unit) {
  548. if (!unit?.serviceKey || !state.activeTerritoryData?.raw?.features?.length) {
  549. return [];
  550. }
  551. return state.activeTerritoryData.raw.features
  552. .filter((feature) => getCompetenceServiceKeys(feature.properties).includes(unit.serviceKey))
  553. .slice()
  554. .sort((left, right) => compareLabelsAlphabetically(left.properties.nom, right.properties.nom));
  555. }
  556. function getUnitsForFeature(feature) {
  557. const serviceKeys = getCompetenceServiceKeys(feature.properties);
  558. if (!serviceKeys.length || !state.activeTerritoryData?.unitByServiceKey) {
  559. return [];
  560. }
  561. return serviceKeys
  562. .map((serviceKey) => state.activeTerritoryData.unitByServiceKey.get(serviceKey))
  563. .filter(Boolean)
  564. .sort((left, right) => compareLabelsAlphabetically(left.name, right.name));
  565. }
  566. function getPreparedUnitFromPointer(event) {
  567. if (!state.activeTerritoryData?.preparedUnits?.length) {
  568. return null;
  569. }
  570. const { localX, localY } = getPointerPositions(event);
  571. let nearestUnit = null;
  572. let nearestDistanceSquared = Infinity;
  573. state.activeTerritoryData.preparedUnits.forEach((preparedUnit) => {
  574. const screenX = preparedUnit.worldX * state.transform.k + state.transform.x;
  575. const screenY = preparedUnit.worldY * state.transform.k + state.transform.y;
  576. const deltaX = localX - screenX;
  577. const deltaY = localY - screenY;
  578. const distanceSquared = deltaX * deltaX + deltaY * deltaY;
  579. if (distanceSquared <= UNIT_MARKER_HIT_RADIUS * UNIT_MARKER_HIT_RADIUS && distanceSquared < nearestDistanceSquared) {
  580. nearestUnit = preparedUnit;
  581. nearestDistanceSquared = distanceSquared;
  582. }
  583. });
  584. return nearestUnit;
  585. }
  586. function getProjectedAreaFromBounds(bounds) {
  587. const [[x0, y0], [x1, y1]] = bounds;
  588. return Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
  589. }
  590. function isPlanarCoordinateSpace(territoryData) {
  591. return String(territoryData?.raw?.coordinateSpace || "").startsWith("planar");
  592. }
  593. function projectPoint(projection, point, isPlanar) {
  594. if (!isPlanar) {
  595. return projection(point);
  596. }
  597. const scale = projection.scale();
  598. const [translateX, translateY] = projection.translate();
  599. return [point[0] * scale + translateX, point[1] * scale + translateY];
  600. }
  601. function projectPolygonCoordinates(projection, polygonCoordinates, isPlanar = false) {
  602. const projectedRings = [];
  603. const bounds = [
  604. [Infinity, Infinity],
  605. [-Infinity, -Infinity],
  606. ];
  607. polygonCoordinates.forEach((ring) => {
  608. const projectedRing = [];
  609. ring.forEach((point) => {
  610. const projectedPoint = projectPoint(projection, point, isPlanar);
  611. if (!projectedPoint) {
  612. return;
  613. }
  614. projectedRing.push(projectedPoint);
  615. bounds[0][0] = Math.min(bounds[0][0], projectedPoint[0]);
  616. bounds[0][1] = Math.min(bounds[0][1], projectedPoint[1]);
  617. bounds[1][0] = Math.max(bounds[1][0], projectedPoint[0]);
  618. bounds[1][1] = Math.max(bounds[1][1], projectedPoint[1]);
  619. });
  620. if (projectedRing.length >= 3) {
  621. projectedRings.push(projectedRing);
  622. }
  623. });
  624. if (!projectedRings.length) {
  625. return null;
  626. }
  627. return { projectedRings, bounds };
  628. }
  629. function createTerritoryProjection(territoryData) {
  630. const [horizontalPadding, verticalPadding] = getFitPadding();
  631. const projection = isPlanarCoordinateSpace(territoryData) ? d3.geoIdentity() : d3.geoMercator();
  632. projection.fitExtent(
  633. [
  634. [horizontalPadding, verticalPadding],
  635. [state.width - horizontalPadding, state.height - 72],
  636. ],
  637. territoryData.raw
  638. );
  639. const displayScale = TERRITORY_DISPLAY_SCALE[territoryData.key];
  640. if (displayScale && displayScale > 1) {
  641. const currentScale = projection.scale();
  642. const [translateX, translateY] = projection.translate();
  643. const viewportCenterX = (horizontalPadding + (state.width - horizontalPadding)) / 2;
  644. const viewportCenterY = (verticalPadding + (state.height - 72)) / 2;
  645. projection.scale(currentScale * displayScale);
  646. projection.translate([
  647. viewportCenterX + (translateX - viewportCenterX) * displayScale,
  648. viewportCenterY + (translateY - viewportCenterY) * displayScale,
  649. ]);
  650. }
  651. return projection;
  652. }
  653. function buildPath2DFromPreparedParts(parts) {
  654. if (!window.Path2D || !parts?.length) {
  655. return null;
  656. }
  657. const path = new Path2D();
  658. parts.forEach((part) => {
  659. part.projectedRings.forEach((ring) => {
  660. if (!ring.length) {
  661. return;
  662. }
  663. path.moveTo(ring[0][0], ring[0][1]);
  664. for (let index = 1; index < ring.length; index += 1) {
  665. path.lineTo(ring[index][0], ring[index][1]);
  666. }
  667. path.closePath();
  668. });
  669. });
  670. return path;
  671. }
  672. function waitForNextPaint() {
  673. return new Promise((resolve) => {
  674. requestAnimationFrame(() => resolve());
  675. });
  676. }
  677. function traceProjectedPolygon(context, projectedRings) {
  678. context.beginPath();
  679. projectedRings.forEach((ring) => {
  680. if (!ring.length) {
  681. return;
  682. }
  683. context.moveTo(ring[0][0], ring[0][1]);
  684. for (let index = 1; index < ring.length; index += 1) {
  685. context.lineTo(ring[index][0], ring[index][1]);
  686. }
  687. context.closePath();
  688. });
  689. }
  690. function getFeaturePolygonParts(feature) {
  691. if (feature.geometry.type === "Polygon") {
  692. return [feature.geometry.coordinates];
  693. }
  694. return feature.geometry.coordinates;
  695. }
  696. function getUnionBounds(parts) {
  697. if (!parts.length) {
  698. return [
  699. [0, 0],
  700. [0, 0],
  701. ];
  702. }
  703. const initial = [
  704. [Infinity, Infinity],
  705. [-Infinity, -Infinity],
  706. ];
  707. return parts.reduce((accumulator, part) => {
  708. const [[x0, y0], [x1, y1]] = part.bounds;
  709. accumulator[0][0] = Math.min(accumulator[0][0], x0);
  710. accumulator[0][1] = Math.min(accumulator[0][1], y0);
  711. accumulator[1][0] = Math.max(accumulator[1][0], x1);
  712. accumulator[1][1] = Math.max(accumulator[1][1], y1);
  713. return accumulator;
  714. }, initial);
  715. }
  716. function buildDataRows(feature) {
  717. const territoryMeta = getTerritoryMeta(feature.properties.territoryKey);
  718. const entityType = feature.properties.kind || territoryMeta?.entityType || COMMUNE_ENTITY_TYPE;
  719. let rows = [];
  720. if (entityType === REGION_ENTITY_TYPE) {
  721. rows = [{ label: "Territoire", value: territoryMeta?.label || "France", territoryKey: territoryMeta?.key || "france" }];
  722. } else if (entityType === DEPARTMENT_ENTITY_TYPE) {
  723. rows = [
  724. { label: "Code groupement", value: feature.properties.code },
  725. { label: "Appellation courte", value: formatGroupementShortLabel(feature.properties.code) },
  726. { label: "Région de Gendarmerie", value: getRegionLabel(feature.properties.codeRegion) },
  727. { label: "Territoire", value: territoryMeta?.label || "France" },
  728. ];
  729. } else {
  730. const postalCodes = feature.properties.postalCodes || [];
  731. const postalCodeLabel = postalCodes.length > 1 ? "Codes postaux" : "Code postal";
  732. const regionTerritory = getRegionTerritoryByCode(feature.properties.codeRegion);
  733. const linkedUnits = getUnitsForFeature(feature);
  734. const competenceLabel = getCommuneCompetenceLabel(feature.properties);
  735. rows = [
  736. { label: postalCodeLabel, value: formatPostalCodes(postalCodes) || feature.properties.code },
  737. { label: "Groupement", value: feature.properties.codeDepartement },
  738. {
  739. label: "Région de Gendarmerie",
  740. value: getRegionLabel(feature.properties.codeRegion),
  741. territoryKey: regionTerritory?.key || null,
  742. },
  743. {
  744. label: "Territoire",
  745. value: territoryMeta?.label || "France",
  746. territoryKey: territoryMeta?.key || "france",
  747. },
  748. ];
  749. if (competenceLabel) {
  750. const unitLinksMarkup = linkedUnits.length
  751. ? linkedUnits
  752. .map(
  753. (unit) =>
  754. `<button type="button" class="unit-link-button" data-unit-service-key="${escapeHtml(
  755. unit.serviceKey
  756. )}">${escapeHtml(unit.name)}</button>`
  757. )
  758. .join('<span class="unit-link-separator"> / </span>')
  759. : "";
  760. rows.push(
  761. unitLinksMarkup
  762. ? { label: "Compétence territoriale", html: unitLinksMarkup }
  763. : { label: "Compétence territoriale", value: competenceLabel }
  764. );
  765. }
  766. }
  767. return rows
  768. .map(
  769. ({ label, value = "", html = "", territoryKey = null }) =>
  770. `<div class="country-data-row"><strong>${escapeHtml(label)}</strong><span>${
  771. html ||
  772. formatBadgeLabelMarkup(
  773. value,
  774. territoryKey,
  775. "modal-badge-inline"
  776. )
  777. }</span></div>`
  778. )
  779. .join("");
  780. }
  781. function buildUnitDataMarkup(unit) {
  782. const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
  783. const associatedCommunes = getAssociatedCommunesForUnit(unit);
  784. const rows = [
  785. { label: "Institution", value: getUnitInstitutionLabel(unit.institution) },
  786. { label: "Groupement", value: territoryMeta?.label || "" },
  787. { label: "Commune d'implantation", value: unit.hostCommune || "Non renseignée" },
  788. { label: "Téléphone", value: unit.telephone || "Non renseigné" },
  789. { label: "Adresse", value: unit.address || "Non renseignée" },
  790. ];
  791. if (unit.hours) {
  792. rows.push({ label: "Horaires", value: unit.hours });
  793. }
  794. rows.push({
  795. label: "Communes associées",
  796. value: formatEntityCount(associatedCommunes.length, "commune", "communes"),
  797. });
  798. const rowsMarkup = rows
  799. .map(
  800. ({ label, value }) =>
  801. `<div class="country-data-row"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(value)}</span></div>`
  802. )
  803. .join("");
  804. const communesMarkup = associatedCommunes.length
  805. ? associatedCommunes
  806. .map((feature) => `<li>${escapeHtml(feature.properties.nom)}</li>`)
  807. .join("")
  808. : `<li>Aucune commune associée.</li>`;
  809. return `
  810. ${rowsMarkup}
  811. <div class="unit-communes-section">
  812. <strong>Liste des communes associées</strong>
  813. <ul class="unit-communes-list">${communesMarkup}</ul>
  814. </div>
  815. `;
  816. }
  817. function bindUnitLinks(container) {
  818. container.querySelectorAll("[data-unit-service-key]").forEach((element) => {
  819. element.addEventListener("click", () => {
  820. const serviceKey = element.getAttribute("data-unit-service-key");
  821. const unit = state.activeTerritoryData?.unitByServiceKey?.get(serviceKey || "");
  822. if (!unit) {
  823. return;
  824. }
  825. selectUnit(unit, { zoom: false, openModal: true });
  826. });
  827. });
  828. }
  829. function getFeatureOpenLabel(feature) {
  830. const entityType = feature.properties.kind;
  831. if (entityType === REGION_ENTITY_TYPE) {
  832. return "Ouvrir la région de gendarmerie";
  833. }
  834. if (entityType === DEPARTMENT_ENTITY_TYPE) {
  835. return "Ouvrir le groupement";
  836. }
  837. return "";
  838. }
  839. function updateModalAction(feature) {
  840. const childTerritoryKey = feature.properties.childTerritoryKey;
  841. if (!childTerritoryKey) {
  842. modalOpenTerritoryButton.hidden = true;
  843. modalOpenTerritoryButton.textContent = "";
  844. modalOpenTerritoryButton.onclick = null;
  845. return;
  846. }
  847. modalOpenTerritoryButton.hidden = false;
  848. modalOpenTerritoryButton.textContent = getFeatureOpenLabel(feature);
  849. modalOpenTerritoryButton.onclick = async () => {
  850. closeCommuneModal({ syncRoute: false });
  851. await activateTerritory(childTerritoryKey);
  852. };
  853. }
  854. function updateUnitModalAction(unit) {
  855. if (!unit?.url) {
  856. modalOpenTerritoryButton.hidden = true;
  857. modalOpenTerritoryButton.textContent = "";
  858. modalOpenTerritoryButton.onclick = null;
  859. return;
  860. }
  861. modalOpenTerritoryButton.hidden = false;
  862. modalOpenTerritoryButton.textContent = "Ouvrir la fiche officielle";
  863. modalOpenTerritoryButton.onclick = () => {
  864. window.open(unit.url, "_blank", "noopener,noreferrer");
  865. };
  866. }
  867. function openCommuneModal(feature) {
  868. const territoryMeta = getTerritoryMeta(feature.properties.territoryKey);
  869. const entityTerritoryKey =
  870. feature.properties.kind === REGION_ENTITY_TYPE ? feature.properties.childTerritoryKey || null : null;
  871. modalTitle.textContent = getEntityCopy(feature.properties.territoryKey).selectedTitle;
  872. communeName.innerHTML = formatBadgeLabelMarkup(feature.properties.nom, entityTerritoryKey, "modal-badge-heading");
  873. communeTerritory.innerHTML = formatBadgeLabelMarkup(
  874. territoryMeta ? `${territoryMeta.label}` : "France",
  875. territoryMeta?.key || "france",
  876. "modal-badge-inline"
  877. );
  878. communeData.innerHTML = buildDataRows(feature);
  879. bindUnitLinks(communeData);
  880. updateModalAction(feature);
  881. communeModal.classList.add("open");
  882. communeModal.setAttribute("aria-hidden", "false");
  883. }
  884. function openUnitModal(unit) {
  885. const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
  886. modalTitle.textContent = "Unité";
  887. communeName.textContent = unit.name;
  888. communeTerritory.textContent = territoryMeta ? territoryMeta.label : "";
  889. communeData.innerHTML = buildUnitDataMarkup(unit);
  890. updateUnitModalAction(unit);
  891. communeModal.classList.add("open");
  892. communeModal.setAttribute("aria-hidden", "false");
  893. }
  894. function closeCommuneModal(options = {}) {
  895. communeModal.classList.remove("open");
  896. communeModal.setAttribute("aria-hidden", "true");
  897. modalOpenTerritoryButton.hidden = true;
  898. modalOpenTerritoryButton.textContent = "";
  899. modalOpenTerritoryButton.onclick = null;
  900. if (options.clearSelection !== false) {
  901. state.selectedCommuneId = null;
  902. state.selectedPartKey = null;
  903. state.selectedUnitKey = null;
  904. renderCommuneResults(communeSearchInputDesktop.value);
  905. requestRender();
  906. }
  907. if (options.syncRoute !== false) {
  908. syncRouteFromState({ replace: Boolean(options.replaceRoute) });
  909. }
  910. }
  911. function closeCommuneListModal() {
  912. communeListModal.classList.remove("open");
  913. communeListModal.setAttribute("aria-hidden", "true");
  914. }
  915. function openCommuneListModal() {
  916. communeListModal.classList.add("open");
  917. communeListModal.setAttribute("aria-hidden", "false");
  918. communeSearchInputMobile.value = communeSearchInputDesktop.value;
  919. communeSearchInputMobile.focus();
  920. renderCommuneResults(communeSearchInputMobile.value);
  921. }
  922. function clearSearchInputs() {
  923. communeSearchInputDesktop.value = "";
  924. communeSearchInputMobile.value = "";
  925. }
  926. function updateTerritorySummary() {
  927. const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
  928. if (!territoryMeta) {
  929. territorySummary.textContent = "";
  930. return;
  931. }
  932. const entityCopy = getEntityCopy();
  933. territorySummary.textContent = `${territoryMeta.label} • ${formatEntityCount(
  934. territoryMeta.entityCount,
  935. entityCopy.singular,
  936. entityCopy.plural
  937. )} • survolez pour afficher le nom, cliquez pour sélectionner.`;
  938. }
  939. function closeMaskingModal() {
  940. maskingModal.classList.remove("open");
  941. maskingModal.setAttribute("aria-hidden", "true");
  942. }
  943. function buildMaskingModalMarkup(maskedData) {
  944. const communesMarkup = maskedData.communes.length
  945. ? maskedData.communes
  946. .map((commune) => {
  947. const postalCodes = commune.postalCodes.length ? ` • ${commune.postalCodes.join(", ")}` : "";
  948. return `<li><strong>${escapeHtml(commune.name)}</strong>${escapeHtml(postalCodes)}</li>`;
  949. })
  950. .join("")
  951. : "<li>Aucune commune masquée.</li>";
  952. const unitsMarkup = maskedData.units.length
  953. ? maskedData.units
  954. .map((unit) => {
  955. const associatedCommunes = unit.communeNames.join(", ");
  956. const institution = getUnitInstitutionLabel(unit.institution);
  957. return `<li><strong>${escapeHtml(unit.name)}</strong> (${escapeHtml(
  958. institution
  959. )})<br /><span>${escapeHtml(associatedCommunes)}</span></li>`;
  960. })
  961. .join("")
  962. : "<li>Aucune unité associée.</li>";
  963. return `
  964. <section class="masking-modal-block">
  965. <h3>Communes masquées</h3>
  966. <ul class="masking-modal-list">${communesMarkup}</ul>
  967. </section>
  968. <section class="masking-modal-block">
  969. <h3>Unités associées</h3>
  970. <ul class="masking-modal-list">${unitsMarkup}</ul>
  971. </section>
  972. `;
  973. }
  974. function openMaskingModal(options = {}) {
  975. const maskedData = state.activeTerritoryData?.maskedData;
  976. if (!maskedData?.communes?.length) {
  977. return;
  978. }
  979. const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
  980. const focusedCommune = options.focusCommuneCode
  981. ? maskedData.communeByCode.get(options.focusCommuneCode)
  982. : null;
  983. const focusLabel = focusedCommune ? ` (${focusedCommune.name})` : "";
  984. maskingModalSummary.textContent = `${formatEntityCount(
  985. maskedData.communes.length,
  986. "commune masquée",
  987. "communes masquées"
  988. )} dans ${territoryMeta?.label || "ce territoire"}${focusLabel}.`;
  989. maskingModalData.innerHTML = buildMaskingModalMarkup(maskedData);
  990. maskingModal.classList.add("open");
  991. maskingModal.setAttribute("aria-hidden", "false");
  992. }
  993. function updateMaskingDisclaimer() {
  994. const maskedData = state.activeTerritoryData?.maskedData;
  995. if (!maskedData?.communes?.length) {
  996. maskingDisclaimer.hidden = true;
  997. maskingDisclaimerText.textContent = "";
  998. return;
  999. }
  1000. maskingDisclaimer.hidden = false;
  1001. maskingDisclaimerText.textContent = `${formatEntityCount(
  1002. maskedData.communes.length,
  1003. "zone",
  1004. "zones"
  1005. )} masquée(s) sur la carte pour lisibilité.`;
  1006. }
  1007. function renderTerritoryList() {
  1008. territoryList.innerHTML = "";
  1009. state.territories
  1010. .filter((territory) => territory.navVisible !== false)
  1011. .forEach((territory) => {
  1012. const item = document.createElement("li");
  1013. const button = document.createElement("button");
  1014. button.type = "button";
  1015. button.className = "territory-list-item";
  1016. if (territory.key === state.activeTerritoryKey) {
  1017. button.classList.add("active");
  1018. }
  1019. button.innerHTML = `
  1020. ${getTerritoryBadgeMarkup(territory)}
  1021. <span class="territory-list-body">
  1022. <span class="territory-list-title">${escapeHtml(territory.label)}</span>
  1023. <span class="territory-list-meta">${escapeHtml(
  1024. formatEntityCount(
  1025. territory.entityCount,
  1026. territory.entityLabelSingular,
  1027. territory.entityLabelPlural
  1028. )
  1029. )}</span>
  1030. </span>
  1031. `;
  1032. button.addEventListener("click", async () => {
  1033. await activateTerritory(territory.key);
  1034. if (window.innerWidth <= 760) {
  1035. territoryPanel.classList.remove("mobile-open");
  1036. territoryToggleButton.setAttribute("aria-expanded", "false");
  1037. }
  1038. });
  1039. item.appendChild(button);
  1040. territoryList.appendChild(item);
  1041. });
  1042. }
  1043. function sortSearchResults(term, items) {
  1044. const normalizedTerm = normalizeText(term);
  1045. return items
  1046. .map((entry) => {
  1047. const nameIndex = entry.searchName.indexOf(normalizedTerm);
  1048. const startsWith = nameIndex === 0 ? 0 : 1;
  1049. const territoryBoost = entry.territoryKey === state.activeTerritoryKey ? 0 : 1;
  1050. const compactTerm = term.trim().replace(/\s+/g, "");
  1051. const codeBoost = entry.searchCode.replace(/[^a-z0-9]+/g, "").startsWith(compactTerm) ? 0 : 1;
  1052. return { entry, startsWith, territoryBoost, codeBoost, nameIndex };
  1053. })
  1054. .sort((left, right) => {
  1055. if (left.codeBoost !== right.codeBoost) {
  1056. return left.codeBoost - right.codeBoost;
  1057. }
  1058. if (left.startsWith !== right.startsWith) {
  1059. return left.startsWith - right.startsWith;
  1060. }
  1061. if (left.territoryBoost !== right.territoryBoost) {
  1062. return left.territoryBoost - right.territoryBoost;
  1063. }
  1064. if (left.nameIndex !== right.nameIndex) {
  1065. return left.nameIndex - right.nameIndex;
  1066. }
  1067. return compareEntriesAlphabetically(left.entry, right.entry);
  1068. })
  1069. .map((item) => item.entry)
  1070. .slice(0, MAX_SEARCH_RESULTS);
  1071. }
  1072. function getSearchResults(term) {
  1073. const normalizedTerm = normalizeText(term);
  1074. if (!normalizedTerm) {
  1075. return state.searchEntries
  1076. .filter((entry) => entry.territoryKey === state.activeTerritoryKey)
  1077. .sort(compareEntriesAlphabetically)
  1078. .slice(0, MAX_SEARCH_RESULTS);
  1079. }
  1080. const matches = [];
  1081. const activeUnitSearchEntries = state.activeTerritoryData?.unitSearchEntries || [];
  1082. for (const entry of state.searchEntries) {
  1083. if (
  1084. entry.searchName.includes(normalizedTerm) ||
  1085. entry.searchCode.replace(/[^a-z0-9]+/g, "").includes(normalizedTerm.replace(/\s+/g, ""))
  1086. ) {
  1087. matches.push(entry);
  1088. }
  1089. }
  1090. for (const entry of activeUnitSearchEntries) {
  1091. if (
  1092. entry.searchName.includes(normalizedTerm) ||
  1093. entry.searchCode.replace(/[^a-z0-9]+/g, "").includes(normalizedTerm.replace(/\s+/g, ""))
  1094. ) {
  1095. matches.push(entry);
  1096. }
  1097. }
  1098. return sortSearchResults(term, matches);
  1099. }
  1100. function renderCommuneResults(term) {
  1101. const results = getSearchResults(term);
  1102. const normalizedTerm = normalizeText(term);
  1103. const entityCopy = getEntityCopy();
  1104. const resultCountLabel = normalizedTerm
  1105. ? `${formatCount(results.length)} résultat(s) affiché(s)`
  1106. : `${formatEntityCount(results.length, entityCopy.singular, entityCopy.plural)} du territoire affiché`;
  1107. communeListCountDesktop.textContent = resultCountLabel;
  1108. communeListCountMobile.textContent = resultCountLabel;
  1109. const containers = [communeListContainerDesktop, communeListContainerMobile];
  1110. containers.forEach((container) => {
  1111. container.innerHTML = "";
  1112. if (!results.length) {
  1113. const empty = document.createElement("p");
  1114. empty.className = "country-list-empty";
  1115. empty.textContent = entityCopy.noneFoundLabel;
  1116. container.appendChild(empty);
  1117. return;
  1118. }
  1119. results.forEach((entry) => {
  1120. const item = document.createElement("li");
  1121. const button = document.createElement("button");
  1122. const territoryMeta = getTerritoryMeta(entry.territoryKey);
  1123. const territoryLabel = territoryMeta?.shortLabel || territoryMeta?.label || "France";
  1124. const metaLabel = entry.displayCode
  1125. ? `${entry.displayCode} • ${territoryLabel}${entry.isMasked ? " • zone masquée" : ""}`
  1126. : `${territoryLabel}${entry.isMasked ? " • zone masquée" : ""}`;
  1127. button.type = "button";
  1128. button.className = "country-list-item";
  1129. if (entry.isMasked) {
  1130. button.classList.add("masked");
  1131. }
  1132. if (
  1133. (entry.kind === UNIT_SEARCH_KIND && entry.id === state.selectedUnitKey) ||
  1134. (entry.kind !== UNIT_SEARCH_KIND && entry.id === state.selectedCommuneId)
  1135. ) {
  1136. button.classList.add("active");
  1137. }
  1138. button.innerHTML = `
  1139. <span class="country-list-flag">${entry.kind === UNIT_SEARCH_KIND ? "■" : entry.isMasked ? "◌" : "▣"}</span>
  1140. <span class="country-list-body">
  1141. <span class="country-list-title">${escapeHtml(entry.name)}</span>
  1142. <span class="country-list-meta">${escapeHtml(metaLabel)}</span>
  1143. </span>
  1144. `;
  1145. button.addEventListener("click", async () => {
  1146. communeSearchInputDesktop.value = entry.name;
  1147. communeSearchInputMobile.value = entry.name;
  1148. closeCommuneListModal();
  1149. if (entry.isMasked) {
  1150. if (entry.territoryKey !== state.activeTerritoryKey) {
  1151. await activateTerritory(entry.territoryKey, { syncRoute: false });
  1152. }
  1153. openMaskingModal({ focusCommuneCode: entry.id });
  1154. return;
  1155. }
  1156. if (entry.kind === UNIT_SEARCH_KIND) {
  1157. const unit = state.activeTerritoryData?.unitByServiceKey?.get(entry.id);
  1158. if (unit) {
  1159. selectUnit(unit, { zoom: true, openModal: true });
  1160. }
  1161. return;
  1162. }
  1163. if (entry.territoryKey === state.activeTerritoryKey) {
  1164. updateSelection(entry.id, { zoom: true, openModal: true });
  1165. return;
  1166. }
  1167. await activateTerritory(entry.territoryKey, { focusCommuneId: entry.id, openModal: true });
  1168. });
  1169. item.appendChild(button);
  1170. container.appendChild(item);
  1171. });
  1172. });
  1173. }
  1174. async function fetchJson(url) {
  1175. const response = await fetch(url);
  1176. if (!response.ok) {
  1177. throw new Error(`Impossible de charger ${url}`);
  1178. }
  1179. return response.json();
  1180. }
  1181. function prepareSearchEntries(entries) {
  1182. return entries.map((entry) => ({
  1183. id: entry.c,
  1184. code: entry.k === COMMUNE_ENTITY_TYPE ? formatPostalCodes(entry.p) || entry.c : entry.c,
  1185. displayCode:
  1186. entry.k === REGION_ENTITY_TYPE
  1187. ? ""
  1188. : entry.k === COMMUNE_ENTITY_TYPE
  1189. ? formatPostalCodes(entry.p) || entry.c
  1190. : entry.c,
  1191. rawCode: entry.c,
  1192. name: entry.n,
  1193. territoryKey: entry.t,
  1194. kind: entry.k,
  1195. isMasked: Boolean(entry.h),
  1196. searchName: normalizeText(entry.n),
  1197. searchCode:
  1198. entry.k === COMMUNE_ENTITY_TYPE
  1199. ? `${(entry.p || []).join(" ")} ${entry.c}`.trim()
  1200. : entry.c,
  1201. }));
  1202. }
  1203. function prepareUnitSearchEntries(units, territoryKey) {
  1204. return units.map((unit) => ({
  1205. id: unit.serviceKey,
  1206. code: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
  1207. displayCode: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
  1208. rawCode: unit.serviceKey,
  1209. name: unit.name,
  1210. territoryKey,
  1211. kind: UNIT_SEARCH_KIND,
  1212. searchName: normalizeText(unit.name),
  1213. searchCode: `${unit.hostCommune} ${unit.telephone} ${unit.serviceKey}`.trim(),
  1214. }));
  1215. }
  1216. function resizeCanvases() {
  1217. const nextWidth = Math.max(1, Math.round(mapShell.clientWidth));
  1218. const nextHeight = Math.max(1, Math.round(mapShell.clientHeight));
  1219. state.pixelRatio = window.devicePixelRatio || 1;
  1220. state.width = nextWidth;
  1221. state.height = nextHeight;
  1222. [mapCanvas, hitCanvas].forEach((canvas) => {
  1223. canvas.width = Math.round(nextWidth * state.pixelRatio);
  1224. canvas.height = Math.round(nextHeight * state.pixelRatio);
  1225. });
  1226. baseCanvas.width = Math.round(nextWidth * state.pixelRatio);
  1227. baseCanvas.height = Math.round(nextHeight * state.pixelRatio);
  1228. state.baseDirty = true;
  1229. state.hitDirty = true;
  1230. if (state.activeTerritoryData) {
  1231. prepareTerritoryForCurrentViewport(state.activeTerritoryData);
  1232. }
  1233. requestRender();
  1234. }
  1235. function getFitPadding() {
  1236. const horizontalPadding = window.innerWidth <= 760 ? 22 : BASE_PADDING;
  1237. const verticalPadding = window.innerWidth <= 760 ? 120 : BASE_PADDING;
  1238. return [horizontalPadding, verticalPadding];
  1239. }
  1240. function traceRoundedMarker(context, centerX, centerY, size, radius) {
  1241. const half = size / 2;
  1242. const x = centerX - half;
  1243. const y = centerY - half;
  1244. const boundedRadius = Math.min(radius, half);
  1245. context.beginPath();
  1246. context.moveTo(x + boundedRadius, y);
  1247. context.lineTo(x + size - boundedRadius, y);
  1248. context.quadraticCurveTo(x + size, y, x + size, y + boundedRadius);
  1249. context.lineTo(x + size, y + size - boundedRadius);
  1250. context.quadraticCurveTo(x + size, y + size, x + size - boundedRadius, y + size);
  1251. context.lineTo(x + boundedRadius, y + size);
  1252. context.quadraticCurveTo(x, y + size, x, y + size - boundedRadius);
  1253. context.lineTo(x, y + boundedRadius);
  1254. context.quadraticCurveTo(x, y, x + boundedRadius, y);
  1255. context.closePath();
  1256. }
  1257. function prepareTerritoryForCurrentViewport(territoryData) {
  1258. const viewportKey = `${state.width}x${state.height}`;
  1259. if (territoryData.preparedViewportKey === viewportKey && territoryData.preparedFeatures?.length) {
  1260. state.baseDirty = true;
  1261. state.hitDirty = true;
  1262. return;
  1263. }
  1264. const projection = createTerritoryProjection(territoryData);
  1265. const territoryMeta = getTerritoryMeta(territoryData.key);
  1266. const usesPlanarCoordinates = isPlanarCoordinateSpace(territoryData);
  1267. const featureById = new Map();
  1268. const partByKey = new Map();
  1269. const partKeyByHitColor = new Map();
  1270. const preparedParts = [];
  1271. const competenceColorByServiceKey = buildCompetenceColorByServiceKey(territoryData.serviceByKey);
  1272. const usesCompetenceColoring = isGroupementCommuneView(territoryMeta);
  1273. const usesUnitMarkers = isUnitMarkerView(territoryMeta);
  1274. let nextPartKey = 1;
  1275. territoryData.preparedFeatures = territoryData.raw.features.map((feature) => {
  1276. const fillColor = getCommuneCompetenceFillColor(
  1277. feature.properties,
  1278. usesCompetenceColoring,
  1279. competenceColorByServiceKey
  1280. );
  1281. const polygonParts = getFeaturePolygonParts(feature);
  1282. const parts = polygonParts
  1283. .map((polygonCoordinates) => {
  1284. const projected = projectPolygonCoordinates(projection, polygonCoordinates, usesPlanarCoordinates);
  1285. if (!projected) {
  1286. return null;
  1287. }
  1288. const part = {
  1289. key: nextPartKey,
  1290. hitColor: encodeHitColor(nextPartKey),
  1291. featureId: feature.properties.code,
  1292. feature,
  1293. fillColor,
  1294. projectedRings: projected.projectedRings,
  1295. bounds: projected.bounds,
  1296. area: getProjectedAreaFromBounds(projected.bounds),
  1297. };
  1298. nextPartKey += 1;
  1299. partByKey.set(part.key, part);
  1300. partKeyByHitColor.set(part.hitColor.slice(4, -1), part.key);
  1301. preparedParts.push(part);
  1302. return part;
  1303. })
  1304. .filter(Boolean);
  1305. const prepared = {
  1306. feature,
  1307. id: feature.properties.code,
  1308. parts,
  1309. fillColor,
  1310. bounds: getUnionBounds(parts),
  1311. primaryPartKey:
  1312. parts
  1313. .slice()
  1314. .sort((left, right) => right.area - left.area)[0]?.key || null,
  1315. };
  1316. featureById.set(prepared.id, prepared);
  1317. return prepared;
  1318. });
  1319. territoryData.territoryPath = buildPath2DFromPreparedParts(preparedParts);
  1320. territoryData.featureById = featureById;
  1321. territoryData.partByKey = partByKey;
  1322. territoryData.partKeyByHitColor = partKeyByHitColor;
  1323. territoryData.preparedParts = preparedParts;
  1324. territoryData.preparedUnits = usesUnitMarkers
  1325. ? territoryData.units
  1326. .map((unit) => {
  1327. const projectedPoint = projectPoint(projection, [unit.longitude, unit.latitude], usesPlanarCoordinates);
  1328. if (!projectedPoint) {
  1329. return null;
  1330. }
  1331. return {
  1332. ...unit,
  1333. worldX: projectedPoint[0],
  1334. worldY: projectedPoint[1],
  1335. };
  1336. })
  1337. .filter(Boolean)
  1338. : [];
  1339. territoryData.competenceColorByServiceKey = competenceColorByServiceKey;
  1340. territoryData.usesCompetenceColoring = usesCompetenceColoring;
  1341. territoryData.preparedViewportKey = viewportKey;
  1342. state.baseDirty = true;
  1343. state.hitDirty = true;
  1344. }
  1345. function applyCanvasScale(context) {
  1346. context.scale(state.pixelRatio, state.pixelRatio);
  1347. }
  1348. function applyRenderTransform(context) {
  1349. context.scale(state.pixelRatio, state.pixelRatio);
  1350. context.translate(state.transform.x, state.transform.y);
  1351. context.scale(state.transform.k, state.transform.k);
  1352. }
  1353. function getVisibleWorldBounds() {
  1354. return [
  1355. [(-state.transform.x) / state.transform.k, (-state.transform.y) / state.transform.k],
  1356. [
  1357. (state.width - state.transform.x) / state.transform.k,
  1358. (state.height - state.transform.y) / state.transform.k,
  1359. ],
  1360. ];
  1361. }
  1362. function boundsIntersect(bounds, viewportBounds, padding = 0) {
  1363. const [[x0, y0], [x1, y1]] = bounds;
  1364. const [[vx0, vy0], [vx1, vy1]] = viewportBounds;
  1365. return x1 >= vx0 - padding && x0 <= vx1 + padding && y1 >= vy0 - padding && y0 <= vy1 + padding;
  1366. }
  1367. function drawBaseLayer() {
  1368. baseContext.setTransform(1, 0, 0, 1, 0, 0);
  1369. baseContext.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
  1370. baseContext.save();
  1371. applyCanvasScale(baseContext);
  1372. if (state.activeTerritoryData.territoryPath) {
  1373. baseContext.clip(state.activeTerritoryData.territoryPath);
  1374. }
  1375. baseContext.lineJoin = "round";
  1376. baseContext.lineCap = "round";
  1377. baseContext.lineWidth = FILL_SEAL_WIDTH;
  1378. state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
  1379. const fillColor = preparedPart.fillColor || themeColors.communeFill;
  1380. baseContext.fillStyle = fillColor;
  1381. baseContext.strokeStyle = fillColor;
  1382. traceProjectedPolygon(baseContext, preparedPart.projectedRings);
  1383. baseContext.fill();
  1384. baseContext.stroke();
  1385. });
  1386. baseContext.restore();
  1387. }
  1388. function drawHitLayer() {
  1389. hitContext.setTransform(1, 0, 0, 1, 0, 0);
  1390. hitContext.clearRect(0, 0, hitCanvas.width, hitCanvas.height);
  1391. hitContext.save();
  1392. applyCanvasScale(hitContext);
  1393. state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
  1394. hitContext.fillStyle = preparedPart.hitColor;
  1395. traceProjectedPolygon(hitContext, preparedPart.projectedRings);
  1396. hitContext.fill();
  1397. });
  1398. hitContext.restore();
  1399. }
  1400. function drawBaseVectorLayer(context) {
  1401. const viewportBounds = getVisibleWorldBounds();
  1402. const padding = Math.max(2, 8 / state.transform.k);
  1403. context.lineJoin = "round";
  1404. context.lineCap = "round";
  1405. context.lineWidth = Math.max(0.08, FILL_SEAL_WIDTH / state.transform.k);
  1406. state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
  1407. if (!boundsIntersect(preparedPart.bounds, viewportBounds, padding)) {
  1408. return;
  1409. }
  1410. const fillColor = preparedPart.fillColor || themeColors.communeFill;
  1411. context.fillStyle = fillColor;
  1412. context.strokeStyle = fillColor;
  1413. traceProjectedPolygon(context, preparedPart.projectedRings);
  1414. context.fill();
  1415. context.stroke();
  1416. });
  1417. }
  1418. function drawFeatureOverlay(context, preparedFeature, fillColor) {
  1419. if (!preparedFeature?.parts?.length) {
  1420. return;
  1421. }
  1422. context.fillStyle = fillColor;
  1423. preparedFeature.parts.forEach((preparedPart) => {
  1424. traceProjectedPolygon(context, preparedPart.projectedRings);
  1425. context.fill();
  1426. });
  1427. }
  1428. function drawVisibleBoundaries(context) {
  1429. const viewportBounds = getVisibleWorldBounds();
  1430. const padding = Math.max(2, 8 / state.transform.k);
  1431. context.lineJoin = "round";
  1432. context.lineCap = "round";
  1433. context.strokeStyle = themeColors.communeStroke;
  1434. context.lineWidth = Math.max(0.3, BORDER_LINE_WIDTH / state.transform.k);
  1435. state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
  1436. if (boundsIntersect(preparedPart.bounds, viewportBounds, padding)) {
  1437. traceProjectedPolygon(context, preparedPart.projectedRings);
  1438. context.stroke();
  1439. }
  1440. });
  1441. }
  1442. function drawUnitMarkers(context) {
  1443. if (!state.activeTerritoryData?.preparedUnits?.length) {
  1444. return;
  1445. }
  1446. const orderedUnits = state.activeTerritoryData.preparedUnits
  1447. .slice()
  1448. .sort((left, right) => {
  1449. const leftPriority = left.key === state.hoveredUnitKey || left.key === state.selectedUnitKey ? 1 : 0;
  1450. const rightPriority = right.key === state.hoveredUnitKey || right.key === state.selectedUnitKey ? 1 : 0;
  1451. return leftPriority - rightPriority;
  1452. });
  1453. orderedUnits.forEach((preparedUnit) => {
  1454. const isHovered = preparedUnit.key === state.hoveredUnitKey;
  1455. const isSelected = preparedUnit.key === state.selectedUnitKey;
  1456. const markerSize = (isHovered || isSelected ? UNIT_MARKER_HOVER_SIZE : UNIT_MARKER_SIZE) / state.transform.k;
  1457. const cornerRadius = UNIT_MARKER_CORNER_RADIUS / state.transform.k;
  1458. traceRoundedMarker(context, preparedUnit.worldX, preparedUnit.worldY, markerSize, cornerRadius);
  1459. context.fillStyle = isHovered || isSelected ? themeColors.unitMarkerHover : themeColors.unitMarkerFill;
  1460. context.fill();
  1461. context.lineWidth = Math.max(0.9, 1.55 / state.transform.k);
  1462. context.strokeStyle = themeColors.unitMarkerStroke;
  1463. context.stroke();
  1464. });
  1465. }
  1466. function renderMap() {
  1467. state.renderQueued = false;
  1468. mapContext.setTransform(1, 0, 0, 1, 0, 0);
  1469. mapContext.fillStyle = themeColors.background;
  1470. mapContext.fillRect(0, 0, mapCanvas.width, mapCanvas.height);
  1471. if (!state.activeTerritoryData?.preparedFeatures?.length) {
  1472. return;
  1473. }
  1474. if (state.baseDirty) {
  1475. drawBaseLayer();
  1476. state.baseDirty = false;
  1477. }
  1478. if (state.hitDirty) {
  1479. drawHitLayer();
  1480. state.hitDirty = false;
  1481. }
  1482. mapContext.save();
  1483. applyRenderTransform(mapContext);
  1484. const usesVectorBaseLayer = state.transform.k > VECTOR_RENDER_SCALE_THRESHOLD;
  1485. if (usesVectorBaseLayer) {
  1486. drawBaseVectorLayer(mapContext);
  1487. } else {
  1488. mapContext.imageSmoothingEnabled = false;
  1489. mapContext.drawImage(baseCanvas, 0, 0, state.width, state.height);
  1490. mapContext.imageSmoothingEnabled = true;
  1491. }
  1492. const selectedPreparedFeature = getPreparedFeatureById(state.selectedCommuneId);
  1493. const hoveredPreparedFeature = getPreparedFeatureFromPartKey(state.hoveredPartKey);
  1494. drawFeatureOverlay(mapContext, selectedPreparedFeature, themeColors.communeSelected);
  1495. if (!selectedPreparedFeature || hoveredPreparedFeature?.id !== selectedPreparedFeature.id) {
  1496. drawFeatureOverlay(mapContext, hoveredPreparedFeature, themeColors.communeHover);
  1497. }
  1498. drawVisibleBoundaries(mapContext);
  1499. drawUnitMarkers(mapContext);
  1500. mapContext.restore();
  1501. }
  1502. function requestRender() {
  1503. if (state.renderQueued) {
  1504. return;
  1505. }
  1506. state.renderQueued = true;
  1507. requestAnimationFrame(renderMap);
  1508. }
  1509. async function loadTerritoryData(territoryKey) {
  1510. if (state.territoryDataByKey.has(territoryKey)) {
  1511. return state.territoryDataByKey.get(territoryKey);
  1512. }
  1513. const territoryMeta = getTerritoryMeta(territoryKey);
  1514. if (!territoryMeta) {
  1515. throw new Error(`Territoire inconnu: ${territoryKey}`);
  1516. }
  1517. setStatus(`Chargement de ${territoryMeta.label}...`);
  1518. const raw = await fetchJson(territoryMeta.file);
  1519. const serviceByKey = new Map(Object.entries(raw.services || {}));
  1520. const enhanced = {
  1521. key: territoryKey,
  1522. raw,
  1523. units: (raw.units || []).map((unit) => ({
  1524. key: unit.s,
  1525. serviceKey: unit.s,
  1526. institution: unit.i || "GN",
  1527. name: unit.n,
  1528. longitude: Number(unit.x),
  1529. latitude: Number(unit.y),
  1530. communeCode: unit.c || "",
  1531. hostCommune: unit.m || "",
  1532. address: unit.a || "",
  1533. telephone: unit.t || "",
  1534. hours: unit.h || "",
  1535. url: unit.u || "",
  1536. })),
  1537. serviceByKey,
  1538. unitByServiceKey: new Map(),
  1539. unitSearchEntries: [],
  1540. preparedFeatures: [],
  1541. preparedUnits: [],
  1542. territoryPath: null,
  1543. featureById: new Map(),
  1544. partByKey: new Map(),
  1545. partKeyByHitColor: new Map(),
  1546. preparedParts: [],
  1547. competenceColorByServiceKey: new Map(),
  1548. usesCompetenceColoring: isGroupementCommuneView(territoryMeta),
  1549. preparedViewportKey: null,
  1550. maskedData: {
  1551. communes: [],
  1552. units: [],
  1553. communeByCode: new Map(),
  1554. },
  1555. };
  1556. raw.features = raw.features.map((feature) => ({
  1557. type: "Feature",
  1558. properties: {
  1559. code: feature.properties.c,
  1560. nom: feature.properties.n,
  1561. codeDepartement: feature.properties.d,
  1562. codeRegion: feature.properties.r,
  1563. kind: feature.properties.k || territoryMeta.entityType,
  1564. territoryKey,
  1565. childTerritoryKey: feature.properties.x || null,
  1566. postalCodes: state.communePostalCodesByCode.get(feature.properties.c) || [],
  1567. competenceInstitution: feature.properties.i || "",
  1568. competenceServiceKey: feature.properties.u || "",
  1569. competenceServiceKeys: feature.properties.v || (feature.properties.u ? [feature.properties.u] : []),
  1570. competenceMixedLabel: feature.properties.m || "",
  1571. competenceLabel:
  1572. (feature.properties.u && serviceByKey.get(feature.properties.u)?.l) || feature.properties.m || "",
  1573. },
  1574. geometry: feature.geometry,
  1575. }));
  1576. const rawMasked = raw.masked || {};
  1577. const maskedCommunes = (rawMasked.communes || []).map((commune) => ({
  1578. code: commune.c,
  1579. name: commune.n,
  1580. postalCodes: commune.p || [],
  1581. }));
  1582. const maskedUnits = (rawMasked.units || []).map((unit) => ({
  1583. serviceKey: unit.s,
  1584. institution: unit.i || "UNK",
  1585. name: unit.n || unit.s,
  1586. communeCodes: unit.c || [],
  1587. communeNames: unit.m || [],
  1588. }));
  1589. enhanced.maskedData = {
  1590. communes: maskedCommunes
  1591. .slice()
  1592. .sort((left, right) => compareLabelsAlphabetically(left.name, right.name)),
  1593. units: maskedUnits
  1594. .slice()
  1595. .sort((left, right) => compareLabelsAlphabetically(left.name, right.name)),
  1596. communeByCode: new Map(maskedCommunes.map((commune) => [commune.code, commune])),
  1597. };
  1598. enhanced.unitByServiceKey = new Map(enhanced.units.map((unit) => [unit.serviceKey, unit]));
  1599. enhanced.unitSearchEntries = prepareUnitSearchEntries(enhanced.units, territoryKey);
  1600. state.territoryDataByKey.set(territoryKey, enhanced);
  1601. return enhanced;
  1602. }
  1603. function syncZoomTransform(transform) {
  1604. d3.select(mapCanvas).call(zoom.transform, transform);
  1605. }
  1606. function zoomToPreparedFeature(prepared) {
  1607. if (!prepared) {
  1608. return;
  1609. }
  1610. const [[x0, y0], [x1, y1]] = prepared.bounds;
  1611. const dx = Math.max(1, x1 - x0);
  1612. const dy = Math.max(1, y1 - y0);
  1613. const scale = Math.max(
  1614. 1,
  1615. Math.min(48, 0.82 / Math.max(dx / state.width, dy / state.height))
  1616. );
  1617. const translateX = state.width / 2 - scale * (x0 + x1) / 2;
  1618. const translateY = state.height / 2 - scale * (y0 + y1) / 2;
  1619. syncZoomTransform(d3.zoomIdentity.translate(translateX, translateY).scale(scale));
  1620. }
  1621. function zoomToPreparedUnit(unit) {
  1622. if (!unit) {
  1623. return;
  1624. }
  1625. const scale = UNIT_FOCUS_SCALE;
  1626. const translateX = state.width / 2 - scale * unit.worldX;
  1627. const translateY = state.height / 2 - scale * unit.worldY;
  1628. syncZoomTransform(d3.zoomIdentity.translate(translateX, translateY).scale(scale));
  1629. }
  1630. function selectUnit(unit, options = {}) {
  1631. const preparedUnit = getPreparedUnitByKey(unit?.key || unit?.serviceKey) || unit;
  1632. if (!preparedUnit) {
  1633. return;
  1634. }
  1635. state.selectedCommuneId = null;
  1636. state.selectedPartKey = null;
  1637. state.selectedUnitKey = preparedUnit.key;
  1638. if (options.zoom) {
  1639. zoomToPreparedUnit(preparedUnit);
  1640. }
  1641. requestRender();
  1642. if (options.openModal) {
  1643. openUnitModal(preparedUnit);
  1644. }
  1645. if (options.syncRoute !== false) {
  1646. syncRouteFromState({ replace: Boolean(options.replaceRoute) });
  1647. }
  1648. }
  1649. function updateSelection(communeId, options = {}) {
  1650. state.selectedCommuneId = communeId;
  1651. state.selectedPartKey = options.partKey || null;
  1652. state.selectedUnitKey = null;
  1653. if (communeId) {
  1654. const preparedFeature = getPreparedFeatureById(communeId);
  1655. if (preparedFeature && !state.selectedPartKey) {
  1656. state.selectedPartKey = preparedFeature.primaryPartKey;
  1657. }
  1658. const preparedTarget =
  1659. options.zoomTarget === "part"
  1660. ? getPreparedPartByKey(state.selectedPartKey)
  1661. : preparedFeature;
  1662. if (preparedTarget && options.zoom !== false) {
  1663. zoomToPreparedFeature(preparedTarget);
  1664. }
  1665. if (preparedFeature && options.openModal) {
  1666. openCommuneModal(preparedFeature.feature);
  1667. }
  1668. } else {
  1669. state.selectedPartKey = null;
  1670. }
  1671. renderCommuneResults(communeSearchInputDesktop.value);
  1672. requestRender();
  1673. if (options.syncRoute !== false) {
  1674. syncRouteFromState({ replace: Boolean(options.replaceRoute) });
  1675. }
  1676. }
  1677. async function activateTerritory(territoryKey, options = {}) {
  1678. const sameTerritory = territoryKey === state.activeTerritoryKey;
  1679. const territoryMeta = getTerritoryMeta(territoryKey);
  1680. const territoryData = await loadTerritoryData(territoryKey);
  1681. const viewportKey = `${state.width}x${state.height}`;
  1682. const needsPreparation =
  1683. territoryData.preparedViewportKey !== viewportKey || !territoryData.preparedFeatures?.length;
  1684. if (needsPreparation) {
  1685. setStatus(`Préparation de ${territoryMeta?.label || territoryKey}...`);
  1686. await waitForNextPaint();
  1687. await new Promise((resolve) => setTimeout(resolve, 0));
  1688. }
  1689. if (!sameTerritory) {
  1690. closeCommuneModal({ syncRoute: false, clearSelection: false });
  1691. closeMaskingModal();
  1692. }
  1693. state.activeTerritoryKey = territoryKey;
  1694. state.activeTerritoryData = territoryData;
  1695. prepareTerritoryForCurrentViewport(territoryData);
  1696. state.hoveredPartKey = null;
  1697. state.hoveredUnitKey = null;
  1698. hideTooltip();
  1699. applyEntityUi();
  1700. updateTerritorySummary();
  1701. updateMaskingDisclaimer();
  1702. renderTerritoryList();
  1703. if (!sameTerritory) {
  1704. clearSearchInputs();
  1705. state.selectedUnitKey = null;
  1706. syncZoomTransform(d3.zoomIdentity);
  1707. }
  1708. if (needsPreparation) {
  1709. await waitForNextPaint();
  1710. }
  1711. setStatus("", false);
  1712. if (options.focusCommuneId) {
  1713. updateSelection(options.focusCommuneId, {
  1714. zoom: options.zoom !== false,
  1715. openModal: Boolean(options.openModal),
  1716. syncRoute: options.syncRoute,
  1717. replaceRoute: options.replaceRoute,
  1718. });
  1719. } else if (!sameTerritory) {
  1720. updateSelection(null, { zoom: false, syncRoute: false });
  1721. if (options.syncRoute !== false) {
  1722. syncRouteFromState({ replace: Boolean(options.replaceRoute) });
  1723. }
  1724. } else {
  1725. requestRender();
  1726. if (options.syncRoute !== false) {
  1727. syncRouteFromState({ replace: Boolean(options.replaceRoute) });
  1728. }
  1729. }
  1730. }
  1731. async function applyRouteFromHash(hash, options = {}) {
  1732. const requestedHash = String(hash || "");
  1733. const parsedRoute = parseRouteHash(requestedHash);
  1734. let territoryKey = parsedRoute.territoryKey || "france";
  1735. let shouldReplaceRoute = Boolean(options.replace) || !parsedRoute.valid;
  1736. if (!state.territoryByKey.has(territoryKey)) {
  1737. territoryKey = "france";
  1738. shouldReplaceRoute = true;
  1739. }
  1740. state.routeApplying = true;
  1741. try {
  1742. await activateTerritory(territoryKey, { syncRoute: false });
  1743. if (parsedRoute.unitKey) {
  1744. const unit = state.activeTerritoryData?.unitByServiceKey?.get(parsedRoute.unitKey);
  1745. if (unit) {
  1746. selectUnit(unit, { zoom: false, openModal: true, syncRoute: false });
  1747. } else {
  1748. closeCommuneModal({ syncRoute: false });
  1749. shouldReplaceRoute = true;
  1750. }
  1751. } else if (parsedRoute.entityCode) {
  1752. const preparedFeature = getPreparedFeatureById(parsedRoute.entityCode);
  1753. if (preparedFeature) {
  1754. updateSelection(parsedRoute.entityCode, {
  1755. zoom: false,
  1756. openModal: true,
  1757. syncRoute: false,
  1758. });
  1759. } else {
  1760. closeCommuneModal({ syncRoute: false });
  1761. shouldReplaceRoute = true;
  1762. }
  1763. } else {
  1764. closeCommuneModal({ syncRoute: false });
  1765. }
  1766. } finally {
  1767. state.routeApplying = false;
  1768. }
  1769. const finalHash = buildRouteHash(getCurrentRouteState());
  1770. if (shouldReplaceRoute || finalHash !== requestedHash) {
  1771. history.replaceState(null, "", buildRouteUrl(finalHash));
  1772. }
  1773. state.routeHash = finalHash;
  1774. }
  1775. async function handleBrowserRouteChange() {
  1776. const nextHash = window.location.hash || "";
  1777. if (nextHash === state.routeHash) {
  1778. return;
  1779. }
  1780. await applyRouteFromHash(nextHash, { replace: !nextHash });
  1781. }
  1782. function getPreparedFeatureFromPointer(event) {
  1783. if (state.hitDirty) {
  1784. drawHitLayer();
  1785. state.hitDirty = false;
  1786. }
  1787. const { localX, localY, canvasX, canvasY } = getPointerPositions(event);
  1788. if (canvasX < 0 || canvasY < 0 || canvasX >= hitCanvas.width || canvasY >= hitCanvas.height) {
  1789. return null;
  1790. }
  1791. const sampleOffsets = [
  1792. [0, 0],
  1793. [1, 0],
  1794. [-1, 0],
  1795. [0, 1],
  1796. [0, -1],
  1797. [1, 1],
  1798. [1, -1],
  1799. [-1, 1],
  1800. [-1, -1],
  1801. ];
  1802. const testedKeys = new Set();
  1803. for (const [offsetX, offsetY] of sampleOffsets) {
  1804. const sampleX = canvasX + offsetX;
  1805. const sampleY = canvasY + offsetY;
  1806. if (sampleX < 0 || sampleY < 0 || sampleX >= hitCanvas.width || sampleY >= hitCanvas.height) {
  1807. continue;
  1808. }
  1809. const pixel = hitContext.getImageData(sampleX, sampleY, 1, 1).data;
  1810. const hitKey = state.activeTerritoryData?.partKeyByHitColor?.get(getHitColorKey(pixel)) || null;
  1811. if (!hitKey || testedKeys.has(hitKey)) {
  1812. continue;
  1813. }
  1814. testedKeys.add(hitKey);
  1815. const preparedPart = getPreparedPartByKey(hitKey);
  1816. if (isPreparedPartUnderPointer(preparedPart, localX, localY)) {
  1817. return preparedPart;
  1818. }
  1819. }
  1820. return null;
  1821. }
  1822. function handlePointerMove(event) {
  1823. if (!state.activeTerritoryData) {
  1824. return;
  1825. }
  1826. const preparedUnit = getPreparedUnitFromPointer(event);
  1827. if (preparedUnit) {
  1828. const nextHoverUnitKey = preparedUnit.key || null;
  1829. const needsRender =
  1830. nextHoverUnitKey !== state.hoveredUnitKey || state.hoveredPartKey !== null;
  1831. state.hoveredUnitKey = nextHoverUnitKey;
  1832. state.hoveredPartKey = null;
  1833. if (needsRender) {
  1834. requestRender();
  1835. }
  1836. showTooltip(preparedUnit.name, event.clientX, event.clientY);
  1837. return;
  1838. }
  1839. const preparedPart = getPreparedFeatureFromPointer(event);
  1840. const nextHoverPartKey = preparedPart?.key || null;
  1841. const nextHoverUnitKey = null;
  1842. if (nextHoverPartKey !== state.hoveredPartKey || nextHoverUnitKey !== state.hoveredUnitKey) {
  1843. state.hoveredPartKey = nextHoverPartKey;
  1844. state.hoveredUnitKey = nextHoverUnitKey;
  1845. requestRender();
  1846. }
  1847. if (preparedPart) {
  1848. showTooltip(preparedPart.feature, event.clientX, event.clientY);
  1849. } else {
  1850. hideTooltip();
  1851. }
  1852. }
  1853. function handlePointerLeave() {
  1854. state.hoveredPartKey = null;
  1855. state.hoveredUnitKey = null;
  1856. hideTooltip();
  1857. requestRender();
  1858. }
  1859. function handleMapClick(event) {
  1860. const preparedUnit = getPreparedUnitFromPointer(event);
  1861. if (preparedUnit) {
  1862. state.hoveredPartKey = null;
  1863. selectUnit(preparedUnit, { zoom: false, openModal: true });
  1864. return;
  1865. }
  1866. const preparedPart = getPreparedFeatureFromPointer(event);
  1867. if (!preparedPart) {
  1868. return;
  1869. }
  1870. updateSelection(preparedPart.featureId, {
  1871. partKey: preparedPart.key,
  1872. zoom: true,
  1873. zoomTarget: "part",
  1874. openModal: true,
  1875. });
  1876. }
  1877. function handleSearchInput(event) {
  1878. const value = event.target.value;
  1879. communeSearchInputDesktop.value = value;
  1880. communeSearchInputMobile.value = value;
  1881. renderCommuneResults(value);
  1882. }
  1883. async function bootstrap() {
  1884. try {
  1885. setStatus("Chargement des territoires...");
  1886. const [territories, communesIndex] = await Promise.all([
  1887. fetchJson(TERRITORIES_URL),
  1888. fetchJson(COMMUNE_INDEX_URL),
  1889. ]);
  1890. state.territories = territories;
  1891. state.territoryByKey = new Map(territories.map((territory) => [territory.key, territory]));
  1892. state.communesIndex = communesIndex;
  1893. state.communePostalCodesByCode = new Map(
  1894. communesIndex
  1895. .filter((entry) => entry.k === COMMUNE_ENTITY_TYPE)
  1896. .map((entry) => [entry.c, entry.p || []])
  1897. );
  1898. state.searchEntries = prepareSearchEntries(communesIndex);
  1899. resizeCanvases();
  1900. await applyRouteFromHash(window.location.hash, { replace: !window.location.hash });
  1901. renderCommuneResults("");
  1902. setStatus("", false);
  1903. } catch (error) {
  1904. console.error(error);
  1905. setStatus(
  1906. "Impossible de charger la carte. Exécutez d'abord le script de génération des données puis servez le dossier en HTTP."
  1907. );
  1908. }
  1909. }
  1910. window.addEventListener("resize", () => {
  1911. window.clearTimeout(state.resizeTimer);
  1912. state.resizeTimer = window.setTimeout(resizeCanvases, 120);
  1913. });
  1914. window.addEventListener("popstate", () => {
  1915. handleBrowserRouteChange().catch((error) => console.error(error));
  1916. });
  1917. window.addEventListener("hashchange", () => {
  1918. handleBrowserRouteChange().catch((error) => console.error(error));
  1919. });
  1920. mapCanvas.addEventListener("pointermove", handlePointerMove);
  1921. mapCanvas.addEventListener("pointerleave", handlePointerLeave);
  1922. mapCanvas.addEventListener("click", handleMapClick);
  1923. communeSearchInputDesktop.addEventListener("input", handleSearchInput);
  1924. communeSearchInputMobile.addEventListener("input", handleSearchInput);
  1925. openMobileCommuneListButton.addEventListener("click", openCommuneListModal);
  1926. closeCommuneListModalButton.addEventListener("click", closeCommuneListModal);
  1927. closeModalButton.addEventListener("click", () => closeCommuneModal());
  1928. openMaskingModalButton.addEventListener("click", () => openMaskingModal());
  1929. closeMaskingModalButton.addEventListener("click", closeMaskingModal);
  1930. territoryToggleButton.addEventListener("click", () => {
  1931. const nextExpanded = !territoryPanel.classList.contains("mobile-open");
  1932. territoryPanel.classList.toggle("mobile-open", nextExpanded);
  1933. territoryToggleButton.setAttribute("aria-expanded", String(nextExpanded));
  1934. });
  1935. communeListModal.addEventListener("click", (event) => {
  1936. if (event.target === communeListModal) {
  1937. closeCommuneListModal();
  1938. }
  1939. });
  1940. communeModal.addEventListener("click", (event) => {
  1941. if (event.target === communeModal) {
  1942. closeCommuneModal();
  1943. }
  1944. });
  1945. maskingModal.addEventListener("click", (event) => {
  1946. if (event.target === maskingModal) {
  1947. closeMaskingModal();
  1948. }
  1949. });
  1950. document.addEventListener("keydown", (event) => {
  1951. if (event.key === "Escape") {
  1952. closeCommuneModal();
  1953. closeCommuneListModal();
  1954. closeMaskingModal();
  1955. territoryPanel.classList.remove("mobile-open");
  1956. territoryToggleButton.setAttribute("aria-expanded", "false");
  1957. }
  1958. });
  1959. bootstrap();