| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633 |
- const mapCanvas = document.getElementById("map-canvas");
- const mapShell = document.getElementById("map-shell");
- const mapTooltip = document.getElementById("map-tooltip");
- const mapStatus = document.getElementById("map-status");
- const mapHeader = document.querySelector(".map-header");
- const entitySidebar = document.getElementById("entity-sidebar");
- const entityListTitle = document.getElementById("entity-list-title");
- const communeName = document.getElementById("commune-name");
- const communeTerritory = document.getElementById("commune-territory");
- const communeData = document.getElementById("commune-data");
- const communeModal = document.getElementById("commune-modal");
- const closeModalButton = document.getElementById("close-modal");
- const modalOpenTerritoryButton = document.getElementById("modal-open-territory");
- const modalTitle = document.getElementById("modal-title");
- const communeSearchInputDesktop = document.getElementById("commune-search");
- const communeSearchInputMobile = document.getElementById("commune-search-mobile");
- const communeListContainerDesktop = document.getElementById("commune-list");
- const communeListContainerMobile = document.getElementById("commune-list-mobile");
- const communeListCountDesktop = document.getElementById("commune-list-count");
- const communeListCountMobile = document.getElementById("commune-list-count-mobile");
- const openMobileCommuneListButton = document.getElementById("open-mobile-commune-list");
- const closeCommuneListModalButton = document.getElementById("close-commune-list-modal");
- const communeListModal = document.getElementById("commune-list-modal");
- const communeListModalTitle = document.getElementById("commune-list-modal-title");
- const territoryPanel = document.getElementById("territory-panel");
- const territorySummary = document.getElementById("territory-summary");
- const territoryList = document.getElementById("territory-list");
- const territoryToggleButton = document.getElementById("toggle-mobile-territories");
- const maskingDisclaimer = document.getElementById("masking-disclaimer");
- const maskingDisclaimerText = document.getElementById("masking-disclaimer-text");
- const openMaskingModalButton = document.getElementById("open-masking-modal");
- const maskingModal = document.getElementById("masking-modal");
- const closeMaskingModalButton = document.getElementById("close-masking-modal");
- const maskingModalSummary = document.getElementById("masking-modal-summary");
- const maskingModalData = document.getElementById("masking-modal-data");
- const COMMUNE_INDEX_URL = "./data/communes-index.json";
- const TERRITORIES_URL = "./data/territories.json";
- const MAX_SEARCH_RESULTS = 160;
- const BASE_PADDING = 28;
- const FILL_SEAL_WIDTH = 1.2;
- const BORDER_LINE_WIDTH = 0.85;
- const VECTOR_RENDER_SCALE_THRESHOLD = 2.2;
- const UNIT_MARKER_SIZE = 10.5;
- const UNIT_MARKER_CORNER_RADIUS = 2.8;
- const UNIT_MARKER_HOVER_SIZE = 12;
- const UNIT_MARKER_HIT_RADIUS = 10;
- const UNIT_FOCUS_SCALE = 14;
- const FRANCE_REGION_BADGE_SIZE = 28.8;
- const MARITIME_GLOW_COLOR = "rgba(23, 74, 142, 1)";
- const MARITIME_GLOW_BLUR = 88;
- const TERRITORY_DISPLAY_SCALE = {};
- const REGION_ENTITY_TYPE = "region";
- const DEPARTMENT_ENTITY_TYPE = "departement";
- const COMMUNE_ENTITY_TYPE = "commune";
- const UNIT_SEARCH_KIND = "unit";
- const ROUTE_TERRITORY_SEGMENT = "territoire";
- const ROUTE_ENTITY_SEGMENT = "entite";
- const ROUTE_UNIT_SEGMENT = "unite";
- const TERRITORY_BADGE_ASSETS = {
- "region-ile-de-france": {
- src: "./assets/territory-badges/region-ile-de-france.svg",
- },
- "region-centre-val-de-loire": {
- src: "./assets/territory-badges/region-centre-val-de-loire.svg",
- },
- "region-bourgogne-franche-comte": {
- src: "./assets/territory-badges/region-bourgogne-franche-comte.png",
- },
- "region-normandie": {
- src: "./assets/territory-badges/region-normandie.svg",
- },
- "region-hauts-de-france": {
- src: "./assets/territory-badges/region-hauts-de-france.svg",
- },
- "region-grand-est": {
- src: "./assets/territory-badges/region-grand-est.svg",
- },
- "region-pays-de-la-loire": {
- src: "./assets/territory-badges/region-pays-de-la-loire.png",
- },
- "region-bretagne": {
- src: "./assets/territory-badges/region-bretagne.svg",
- },
- "region-nouvelle-aquitaine": {
- src: "./assets/territory-badges/region-nouvelle-aquitaine.svg",
- },
- "region-occitanie": {
- src: "./assets/territory-badges/region-occitanie.svg",
- },
- "region-auvergne-rhone-alpes": {
- src: "./assets/territory-badges/region-auvergne-rhone-alpes.svg",
- },
- "region-provence-alpes-cote-d-azur": {
- src: "./assets/territory-badges/region-provence-alpes-cote-d-azur.svg",
- },
- "region-corse": {
- src: "./assets/territory-badges/region-corse.svg",
- },
- guadeloupe: {
- src: "./assets/territory-badges/guadeloupe.png",
- },
- martinique: {
- src: "./assets/territory-badges/martinique.png",
- },
- guyane: {
- src: "./assets/territory-badges/guyane.png",
- },
- "la-reunion": {
- src: "./assets/territory-badges/la-reunion.png",
- },
- mayotte: {
- src: "./assets/territory-badges/mayotte.png",
- },
- "saint-pierre-et-miquelon": {
- src: "./assets/territory-badges/saint-pierre-et-miquelon.png",
- },
- "saint-barthelemy": {
- src: "./assets/territory-badges/saint-barthelemy.svg",
- className: "territory-badge-flag",
- },
- "saint-martin": {
- src: "./assets/territory-badges/saint-martin.svg",
- className: "territory-badge-flag",
- },
- "polynesie-francaise": {
- src: "./assets/territory-badges/polynesie-francaise.png",
- },
- "nouvelle-caledonie": {
- src: "./assets/territory-badges/nouvelle-caledonie.png",
- },
- "wallis-et-futuna": {
- src: "./assets/territory-badges/wallis-et-futuna.png",
- },
- };
- const mapContext = mapCanvas.getContext("2d", { alpha: false });
- const hitCanvas = document.createElement("canvas");
- const hitContext = hitCanvas.getContext("2d", { alpha: false, willReadFrequently: true });
- const baseCanvas = document.createElement("canvas");
- const baseContext = baseCanvas.getContext("2d", { alpha: false });
- const themeStyles = getComputedStyle(document.documentElement);
- const themeColors = {
- background: themeStyles.getPropertyValue("--bg").trim(),
- communeFill: themeStyles.getPropertyValue("--commune-fill").trim(),
- communeHover: themeStyles.getPropertyValue("--commune-hover").trim(),
- communeSelected: themeStyles.getPropertyValue("--commune-selected").trim(),
- communeStroke: themeStyles.getPropertyValue("--commune-stroke").trim(),
- unitMarkerFill: themeStyles.getPropertyValue("--unit-marker-fill").trim() || "#394149",
- unitMarkerStroke: themeStyles.getPropertyValue("--unit-marker-stroke").trim() || "#07090b",
- unitMarkerHover: themeStyles.getPropertyValue("--unit-marker-hover").trim() || "#5a6672",
- };
- const state = {
- pixelRatio: window.devicePixelRatio || 1,
- width: 0,
- height: 0,
- communesIndex: [],
- communePostalCodesByCode: new Map(),
- searchEntries: [],
- territories: [],
- territoryByKey: new Map(),
- territoryDataByKey: new Map(),
- activeTerritoryKey: null,
- activeTerritoryData: null,
- hoveredPartKey: null,
- hoveredUnitKey: null,
- listHoveredFeatureIds: new Set(),
- listHoveredUnitKey: null,
- selectedCommuneId: null,
- selectedPartKey: null,
- selectedUnitKey: null,
- transform: d3.zoomIdentity,
- renderQueued: false,
- baseDirty: true,
- hitDirty: true,
- resizeTimer: null,
- routeHash: "",
- routeApplying: false,
- };
- const territoryBadgeImagesByKey = new Map();
- const territoryBadgeLoadingKeys = new Set();
- const zoom = d3
- .zoom()
- .scaleExtent([1, 80])
- .on("start", () => hideTooltip())
- .on("zoom", (event) => {
- state.transform = event.transform;
- requestRender();
- });
- d3.select(mapCanvas).call(zoom).on("dblclick.zoom", null);
- function normalizeText(value) {
- return String(value || "")
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, " ")
- .trim();
- }
- function compareEntriesAlphabetically(left, right) {
- if (left.searchName !== right.searchName) {
- return left.searchName.localeCompare(right.searchName, "fr", { sensitivity: "base" });
- }
- if (left.name !== right.name) {
- return left.name.localeCompare(right.name, "fr", { sensitivity: "base" });
- }
- if (left.code !== right.code) {
- return left.code.localeCompare(right.code, "fr", { sensitivity: "base" });
- }
- return left.rawCode.localeCompare(right.rawCode, "fr", { sensitivity: "base" });
- }
- function compareLabelsAlphabetically(left, right) {
- return String(left || "").localeCompare(String(right || ""), "fr", { sensitivity: "base" });
- }
- function escapeHtml(value) {
- return String(value)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
- }
- function hasTerritoryBadge(territoryKey) {
- return territoryKey === "france" || Boolean(TERRITORY_BADGE_ASSETS[territoryKey]);
- }
- function getTerritoryBadgeMarkup(territory, extraClass = "") {
- const classNames = ["territory-badge", extraClass];
- if (territory.key === "france") {
- classNames.push("territory-badge-flag");
- return `<span class="${classNames.filter(Boolean).join(" ")}" aria-hidden="true">🇫🇷</span>`;
- }
- const badge = TERRITORY_BADGE_ASSETS[territory.key];
- if (!badge) {
- return `<span class="territory-list-marker" aria-hidden="true">▦</span>`;
- }
- if (badge.className) {
- classNames.push(badge.className);
- }
- return `
- <span class="${classNames.filter(Boolean).join(" ")}" aria-hidden="true">
- <img class="territory-badge-image" src="${badge.src}" alt="" loading="lazy" decoding="async" />
- </span>
- `;
- }
- function getTerritoryBadgeMarkupByKey(territoryKey, extraClass = "") {
- if (!territoryKey || !hasTerritoryBadge(territoryKey)) {
- return "";
- }
- return getTerritoryBadgeMarkup(getTerritoryMeta(territoryKey) || { key: territoryKey }, extraClass);
- }
- function formatBadgeLabelMarkup(label, territoryKey, extraClass = "") {
- if (territoryKey === "france") {
- return escapeHtml(label);
- }
- const badgeMarkup = getTerritoryBadgeMarkupByKey(territoryKey, extraClass);
- const escapedLabel = escapeHtml(label);
- if (!badgeMarkup) {
- return escapedLabel;
- }
- return `<span class="modal-badge-label">${badgeMarkup}<span>${escapedLabel}</span></span>`;
- }
- function formatCount(value) {
- return new Intl.NumberFormat("fr-FR").format(Number(value || 0));
- }
- function formatEntityCount(value, singular, plural) {
- const numericValue = Number(value || 0);
- const label = numericValue === 1 ? singular : plural;
- return `${formatCount(numericValue)} ${label}`;
- }
- function formatPostalCodes(postalCodes) {
- if (!Array.isArray(postalCodes) || !postalCodes.length) {
- return "";
- }
- return postalCodes.join(", ");
- }
- function formatGroupementShortLabel(code) {
- if (!code) {
- return "";
- }
- return `GGD${String(code).toUpperCase()}`;
- }
- function getUnitInstitutionLabel(institution) {
- return institution === "PN" ? "Police nationale" : "Gendarmerie nationale";
- }
- function formatUnitFilterLabel(unit) {
- const hostCommune = String(unit?.hostCommune || "").trim();
- if (unit?.institution === "PN" && hostCommune) {
- return `Commissariat de ${hostCommune}`;
- }
- if (unit?.institution === "GN" && hostCommune) {
- return `Brigade de ${hostCommune}`;
- }
- return String(unit?.name || hostCommune || "Unité");
- }
- function isGroupementCommuneView(territoryMeta) {
- return territoryMeta?.entityType === COMMUNE_ENTITY_TYPE && Boolean(territoryMeta?.groupementView);
- }
- function isUnitMarkerView(territoryMeta) {
- return isGroupementCommuneView(territoryMeta);
- }
- function usesUnitDefaultList(territoryKey = state.activeTerritoryKey) {
- return isGroupementCommuneView(getTerritoryMeta(territoryKey));
- }
- function buildCompetenceColorByServiceKey(serviceByKey) {
- const serviceKeys = Array.from(serviceByKey.entries())
- .filter(([, service]) => service?.i === "GN")
- .sort((left, right) => left[1].l.localeCompare(right[1].l, "fr", { sensitivity: "base" }))
- .map(([serviceKey]) => serviceKey);
- const colorByServiceKey = new Map();
- const total = serviceKeys.length || 1;
- serviceKeys.forEach((serviceKey, index) => {
- const hue = Math.round((index * 360) / total);
- colorByServiceKey.set(serviceKey, `hsl(${hue} 46% 56%)`);
- });
- return colorByServiceKey;
- }
- function getCommuneCompetenceFillColor(featureProperties, usesCompetenceColoring, colorByServiceKey) {
- if (!usesCompetenceColoring) {
- return themeColors.communeFill;
- }
- if (featureProperties.competenceInstitution !== "GN" || !featureProperties.competenceServiceKey) {
- return themeColors.communeFill;
- }
- return colorByServiceKey.get(featureProperties.competenceServiceKey) || themeColors.communeFill;
- }
- function getCommuneCompetenceLabel(featureProperties) {
- if (featureProperties.competenceMixedLabel) {
- return featureProperties.competenceMixedLabel;
- }
- if (featureProperties.competenceLabel) {
- return featureProperties.competenceLabel;
- }
- if (featureProperties.competenceInstitution === "PN") {
- return "Police nationale";
- }
- if (featureProperties.competenceInstitution === "UNK") {
- return "Compétence territoriale non renseignée";
- }
- return "";
- }
- function getCompetenceServiceKeys(featureProperties) {
- if (Array.isArray(featureProperties.competenceServiceKeys) && featureProperties.competenceServiceKeys.length) {
- return featureProperties.competenceServiceKeys;
- }
- if (featureProperties.competenceServiceKey) {
- return [featureProperties.competenceServiceKey];
- }
- return [];
- }
- function encodeHitColor(index) {
- return `rgb(${index & 255},${(index >> 8) & 255},${(index >> 16) & 255})`;
- }
- function getHitColorKey(pixel) {
- return `${pixel[0]},${pixel[1]},${pixel[2]}`;
- }
- function setStatus(message, visible = true) {
- mapStatus.textContent = message;
- mapStatus.hidden = !visible;
- }
- function showTooltip(item, clientX, clientY) {
- const label = typeof item === "string" ? item : item?.properties?.nom;
- if (!label) {
- hideTooltip();
- return;
- }
- mapTooltip.innerHTML = `<strong>${escapeHtml(label)}</strong>`;
- mapTooltip.style.left = `${clientX}px`;
- mapTooltip.style.top = `${clientY}px`;
- mapTooltip.classList.add("visible");
- }
- function hideTooltip() {
- mapTooltip.classList.remove("visible");
- }
- function getTerritoryMeta(key) {
- return state.territoryByKey.get(key) || null;
- }
- function isOverseasDepartmentCode(code) {
- return /^(97|98)/.test(String(code || "").toUpperCase());
- }
- function isOverseasTerritory(territoryMeta) {
- const codes = territoryMeta?.departmentCodes || [];
- return codes.length > 0 && codes.every(isOverseasDepartmentCode);
- }
- function getRegionTerritoryByCode(regionCode) {
- if (!regionCode) {
- return null;
- }
- return (
- state.territories.find(
- (territory) => territory.entityType === DEPARTMENT_ENTITY_TYPE && territory.regionCode === regionCode
- ) || null
- );
- }
- function getRegionLabel(regionCode) {
- return getRegionTerritoryByCode(regionCode)?.label || regionCode || "";
- }
- function capitalizeFirst(value) {
- const text = String(value || "");
- if (!text) {
- return "";
- }
- return text.charAt(0).toUpperCase() + text.slice(1);
- }
- function isFeminineEntity(entityType) {
- return entityType === COMMUNE_ENTITY_TYPE || entityType === REGION_ENTITY_TYPE;
- }
- function getEntityCopy(territoryKey = state.activeTerritoryKey) {
- const territoryMeta = getTerritoryMeta(territoryKey);
- const entityType = territoryMeta?.entityType || COMMUNE_ENTITY_TYPE;
- const singular = territoryMeta?.entityLabelSingular || "commune";
- const plural = territoryMeta?.entityLabelPlural || "communes";
- const isFeminine = isFeminineEntity(entityType);
- const article = isFeminine ? "une" : "un";
- const selectedSuffix = isFeminine ? "e" : "";
- const nonePrefix = isFeminine ? "Aucune" : "Aucun";
- const foundSuffix = isFeminine ? "e" : "";
- return {
- entityType,
- singular,
- plural,
- listTitle: capitalizeFirst(plural),
- searchTitle: `Rechercher ${article} ${singular}`,
- searchPlaceholder: "Nom, code, territoire...",
- selectedTitle: capitalizeFirst(singular),
- noneFoundLabel: `${nonePrefix} ${singular} trouvé${foundSuffix}.`,
- sidebarAria: `Liste des ${plural}`,
- };
- }
- function buildRouteHash(route = {}) {
- const territoryKey = route.territoryKey || state.activeTerritoryKey || "france";
- const unitKey = route.unitKey || "";
- const entityCode = route.entityCode || "";
- if (unitKey) {
- return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(territoryKey)}/${ROUTE_UNIT_SEGMENT}/${encodeURIComponent(
- unitKey
- )}`;
- }
- if (entityCode) {
- return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(
- territoryKey
- )}/${ROUTE_ENTITY_SEGMENT}/${encodeURIComponent(entityCode)}`;
- }
- return `#/${ROUTE_TERRITORY_SEGMENT}/${encodeURIComponent(territoryKey)}`;
- }
- function parseRouteHash(hash) {
- const sanitizedHash = String(hash || "").replace(/^#/, "").replace(/^\/+/, "");
- if (!sanitizedHash) {
- return { territoryKey: "france", entityCode: null, unitKey: null, valid: true };
- }
- const parts = sanitizedHash
- .split("/")
- .filter(Boolean)
- .map((part) => decodeURIComponent(part));
- if (parts[0] !== ROUTE_TERRITORY_SEGMENT || !parts[1]) {
- return { territoryKey: "france", entityCode: null, unitKey: null, valid: false };
- }
- if (parts.length === 2) {
- return { territoryKey: parts[1], entityCode: null, unitKey: null, valid: true };
- }
- if (parts.length === 4 && parts[2] === ROUTE_ENTITY_SEGMENT && parts[3]) {
- return { territoryKey: parts[1], entityCode: parts[3], unitKey: null, valid: true };
- }
- if (parts.length === 4 && parts[2] === ROUTE_UNIT_SEGMENT && parts[3]) {
- return { territoryKey: parts[1], entityCode: null, unitKey: parts[3], valid: true };
- }
- return { territoryKey: parts[1], entityCode: null, unitKey: null, valid: false };
- }
- function buildRouteUrl(hash) {
- return `${window.location.pathname}${window.location.search}${hash}`;
- }
- function getCurrentRouteState() {
- return {
- territoryKey: state.activeTerritoryKey || "france",
- entityCode: state.selectedUnitKey ? null : state.selectedCommuneId || null,
- unitKey: state.selectedUnitKey || null,
- };
- }
- function syncRouteFromState(options = {}) {
- if (state.routeApplying || !state.activeTerritoryKey) {
- return;
- }
- const nextHash = buildRouteHash(getCurrentRouteState());
- if (nextHash === state.routeHash) {
- return;
- }
- const method = options.replace ? "replaceState" : "pushState";
- history[method](null, "", buildRouteUrl(nextHash));
- state.routeHash = nextHash;
- }
- function applyEntityUi() {
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- const entityCopy = getEntityCopy();
- const useUnitList = usesUnitDefaultList(state.activeTerritoryKey);
- entityListTitle.textContent = useUnitList ? "Unités" : entityCopy.listTitle;
- entitySidebar.setAttribute("aria-label", useUnitList ? "Liste des unités" : entityCopy.sidebarAria);
- communeSearchInputDesktop.placeholder = entityCopy.searchPlaceholder;
- communeSearchInputMobile.placeholder = entityCopy.searchPlaceholder;
- communeListModalTitle.textContent = entityCopy.searchTitle;
- openMobileCommuneListButton.setAttribute("aria-label", entityCopy.searchTitle);
- modalTitle.textContent = entityCopy.selectedTitle;
- if (territoryMeta) {
- mapShell.setAttribute(
- "aria-label",
- `Carte interactive des ${entityCopy.plural} de ${territoryMeta.label}`
- );
- }
- }
- function getPreparedFeatureById(communeId) {
- return state.activeTerritoryData?.featureById?.get(communeId) || null;
- }
- function getPreparedPartByKey(partKey) {
- return state.activeTerritoryData?.partByKey?.get(partKey) || null;
- }
- function getPreparedFeatureFromPartKey(partKey) {
- const preparedPart = getPreparedPartByKey(partKey);
- return preparedPart ? getPreparedFeatureById(preparedPart.featureId) : null;
- }
- function getPreparedFeaturesFromIds(featureIds) {
- if (!featureIds?.size) {
- return [];
- }
- const preparedFeatures = [];
- featureIds.forEach((featureId) => {
- const preparedFeature = getPreparedFeatureById(featureId);
- if (preparedFeature) {
- preparedFeatures.push(preparedFeature);
- }
- });
- return preparedFeatures;
- }
- function clearListHover(options = {}) {
- const hadHover = state.listHoveredFeatureIds.size > 0 || Boolean(state.listHoveredUnitKey);
- state.listHoveredFeatureIds = new Set();
- state.listHoveredUnitKey = null;
- if (hadHover && options.render !== false) {
- requestRender();
- }
- }
- function setListHoverFromEntry(entry) {
- if (!entry || entry.territoryKey !== state.activeTerritoryKey) {
- clearListHover();
- return;
- }
- const nextFeatureIds = new Set();
- let nextHoveredUnitKey = null;
- if (entry.kind === UNIT_SEARCH_KIND) {
- const unit = state.activeTerritoryData?.unitByServiceKey?.get(entry.id);
- if (unit) {
- nextHoveredUnitKey = unit.key || unit.serviceKey || entry.id;
- getAssociatedCommunesForUnit(unit).forEach((feature) => {
- nextFeatureIds.add(feature.properties.code);
- });
- }
- } else if (!entry.isMasked) {
- nextFeatureIds.add(entry.id);
- }
- let hasChanges =
- nextHoveredUnitKey !== state.listHoveredUnitKey ||
- nextFeatureIds.size !== state.listHoveredFeatureIds.size;
- if (!hasChanges) {
- nextFeatureIds.forEach((featureId) => {
- if (!state.listHoveredFeatureIds.has(featureId)) {
- hasChanges = true;
- }
- });
- }
- if (!hasChanges) {
- return;
- }
- state.listHoveredFeatureIds = nextFeatureIds;
- state.listHoveredUnitKey = nextHoveredUnitKey;
- requestRender();
- }
- function getBoundsCenter(bounds) {
- const [[x0, y0], [x1, y1]] = bounds;
- return [(x0 + x1) / 2, (y0 + y1) / 2];
- }
- function getTerritoryBadgeImage(territoryKey) {
- if (!territoryKey) {
- return null;
- }
- const cachedImage = territoryBadgeImagesByKey.get(territoryKey);
- if (cachedImage) {
- return cachedImage;
- }
- const badgeAsset = TERRITORY_BADGE_ASSETS[territoryKey];
- if (!badgeAsset || territoryBadgeLoadingKeys.has(territoryKey)) {
- return null;
- }
- const image = new Image();
- territoryBadgeLoadingKeys.add(territoryKey);
- image.decoding = "async";
- image.onload = () => {
- territoryBadgeLoadingKeys.delete(territoryKey);
- territoryBadgeImagesByKey.set(territoryKey, image);
- requestRender();
- };
- image.onerror = () => {
- territoryBadgeLoadingKeys.delete(territoryKey);
- };
- image.src = badgeAsset.src;
- return null;
- }
- function updateMobileLayoutOffsets() {
- if (!mapHeader || window.innerWidth > 760) {
- return;
- }
- const headerBounds = mapHeader.getBoundingClientRect();
- const controlsTop = Math.ceil(headerBounds.bottom + 8);
- const legendTop = controlsTop + 54;
- const rootStyle = document.documentElement.style;
- rootStyle.setProperty("--mobile-controls-top", `${controlsTop}px`);
- rootStyle.setProperty("--mobile-legend-top", `${legendTop}px`);
- }
- function getPreparedUnitByKey(unitKey) {
- if (!state.activeTerritoryData?.preparedUnits?.length || !unitKey) {
- return null;
- }
- return state.activeTerritoryData.preparedUnits.find((unit) => unit.key === unitKey) || null;
- }
- function getPointerPositions(event) {
- const rect = mapCanvas.getBoundingClientRect();
- const localX = event.clientX - rect.left;
- const localY = event.clientY - rect.top;
- const [worldX, worldY] = getProjectedPointer(localX, localY);
- const scaleX = state.width > 0 ? hitCanvas.width / state.width : 1;
- const scaleY = state.height > 0 ? hitCanvas.height / state.height : 1;
- return {
- rect,
- localX,
- localY,
- worldX,
- worldY,
- canvasX: Math.floor(worldX * scaleX),
- canvasY: Math.floor(worldY * scaleY),
- };
- }
- function getProjectedPointer(localX, localY) {
- return [
- (localX - state.transform.x) / state.transform.k,
- (localY - state.transform.y) / state.transform.k,
- ];
- }
- function isPointInRing(pointX, pointY, ring) {
- let inside = false;
- for (let index = 0, previousIndex = ring.length - 1; index < ring.length; previousIndex = index, index += 1) {
- const [x0, y0] = ring[index];
- const [x1, y1] = ring[previousIndex];
- const crosses =
- (y0 > pointY) !== (y1 > pointY) &&
- pointX < ((x1 - x0) * (pointY - y0)) / ((y1 - y0) || Number.EPSILON) + x0;
- if (crosses) {
- inside = !inside;
- }
- }
- return inside;
- }
- function isPointInPolygon(pointX, pointY, rings) {
- if (!rings.length || !isPointInRing(pointX, pointY, rings[0])) {
- return false;
- }
- for (let index = 1; index < rings.length; index += 1) {
- if (isPointInRing(pointX, pointY, rings[index])) {
- return false;
- }
- }
- return true;
- }
- function isPreparedPartUnderPointer(preparedPart, localX, localY) {
- if (!preparedPart) {
- return false;
- }
- const [projectedX, projectedY] = getProjectedPointer(localX, localY);
- return isPointInPolygon(projectedX, projectedY, preparedPart.projectedRings);
- }
- function getAssociatedCommunesForUnit(unit) {
- if (!unit?.serviceKey || !state.activeTerritoryData?.raw?.features?.length) {
- return [];
- }
- return state.activeTerritoryData.raw.features
- .filter((feature) => getCompetenceServiceKeys(feature.properties).includes(unit.serviceKey))
- .slice()
- .sort((left, right) => compareLabelsAlphabetically(left.properties.nom, right.properties.nom));
- }
- function getUnitsForFeature(feature) {
- const serviceKeys = getCompetenceServiceKeys(feature.properties);
- if (!serviceKeys.length || !state.activeTerritoryData?.unitByServiceKey) {
- return [];
- }
- return serviceKeys
- .map((serviceKey) => state.activeTerritoryData.unitByServiceKey.get(serviceKey))
- .filter(Boolean)
- .sort((left, right) => compareLabelsAlphabetically(left.name, right.name));
- }
- function getPreparedUnitFromPointer(event) {
- if (!state.activeTerritoryData?.preparedUnits?.length) {
- return null;
- }
- const { localX, localY } = getPointerPositions(event);
- let nearestUnit = null;
- let nearestDistanceSquared = Infinity;
- state.activeTerritoryData.preparedUnits.forEach((preparedUnit) => {
- const screenX = preparedUnit.worldX * state.transform.k + state.transform.x;
- const screenY = preparedUnit.worldY * state.transform.k + state.transform.y;
- const deltaX = localX - screenX;
- const deltaY = localY - screenY;
- const distanceSquared = deltaX * deltaX + deltaY * deltaY;
- if (distanceSquared <= UNIT_MARKER_HIT_RADIUS * UNIT_MARKER_HIT_RADIUS && distanceSquared < nearestDistanceSquared) {
- nearestUnit = preparedUnit;
- nearestDistanceSquared = distanceSquared;
- }
- });
- return nearestUnit;
- }
- function getProjectedAreaFromBounds(bounds) {
- const [[x0, y0], [x1, y1]] = bounds;
- return Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
- }
- function isPlanarCoordinateSpace(territoryData) {
- return String(territoryData?.raw?.coordinateSpace || "").startsWith("planar");
- }
- function projectPoint(projection, point, isPlanar) {
- if (!isPlanar) {
- return projection(point);
- }
- const scale = projection.scale();
- const [translateX, translateY] = projection.translate();
- return [point[0] * scale + translateX, point[1] * scale + translateY];
- }
- function projectPolygonCoordinates(projection, polygonCoordinates, isPlanar = false) {
- const projectedRings = [];
- const bounds = [
- [Infinity, Infinity],
- [-Infinity, -Infinity],
- ];
- polygonCoordinates.forEach((ring) => {
- const projectedRing = [];
- ring.forEach((point) => {
- const projectedPoint = projectPoint(projection, point, isPlanar);
- if (!projectedPoint) {
- return;
- }
- projectedRing.push(projectedPoint);
- bounds[0][0] = Math.min(bounds[0][0], projectedPoint[0]);
- bounds[0][1] = Math.min(bounds[0][1], projectedPoint[1]);
- bounds[1][0] = Math.max(bounds[1][0], projectedPoint[0]);
- bounds[1][1] = Math.max(bounds[1][1], projectedPoint[1]);
- });
- if (projectedRing.length >= 3) {
- projectedRings.push(projectedRing);
- }
- });
- if (!projectedRings.length) {
- return null;
- }
- return { projectedRings, bounds };
- }
- function createTerritoryProjection(territoryData) {
- const [horizontalPadding, verticalPadding] = getFitPadding();
- const projection = isPlanarCoordinateSpace(territoryData) ? d3.geoIdentity() : d3.geoMercator();
- projection.fitExtent(
- [
- [horizontalPadding, verticalPadding],
- [state.width - horizontalPadding, state.height - 72],
- ],
- territoryData.raw
- );
- const displayScale = TERRITORY_DISPLAY_SCALE[territoryData.key];
- if (displayScale && displayScale > 1) {
- const currentScale = projection.scale();
- const [translateX, translateY] = projection.translate();
- const viewportCenterX = (horizontalPadding + (state.width - horizontalPadding)) / 2;
- const viewportCenterY = (verticalPadding + (state.height - 72)) / 2;
- projection.scale(currentScale * displayScale);
- projection.translate([
- viewportCenterX + (translateX - viewportCenterX) * displayScale,
- viewportCenterY + (translateY - viewportCenterY) * displayScale,
- ]);
- }
- return projection;
- }
- function buildSupplementalIslandMaritimeLines(preparedFeatures) {
- const lines = [];
- preparedFeatures.forEach((preparedFeature) => {
- if (!preparedFeature?.parts || preparedFeature.parts.length < 2 || !preparedFeature.primaryPartKey) {
- return;
- }
- const primaryPart = preparedFeature.parts.find((part) => part.key === preparedFeature.primaryPartKey);
- const primaryArea = primaryPart?.area || 0;
- preparedFeature.parts.forEach((part) => {
- if (!part || part.key === preparedFeature.primaryPartKey) {
- return;
- }
- if (primaryArea > 0 && part.area > primaryArea * 0.6) {
- return;
- }
- const outerRing = part.projectedRings?.[0];
- if (!outerRing || outerRing.length < 3) {
- return;
- }
- const points = outerRing.slice();
- const firstPoint = points[0];
- const lastPoint = points[points.length - 1];
- if (
- firstPoint &&
- lastPoint &&
- (firstPoint[0] !== lastPoint[0] || firstPoint[1] !== lastPoint[1])
- ) {
- points.push([firstPoint[0], firstPoint[1]]);
- }
- if (points.length >= 2) {
- lines.push({
- points,
- bounds: getLineBounds(points),
- });
- }
- });
- });
- return lines;
- }
- function buildPath2DFromPreparedParts(parts) {
- if (!window.Path2D || !parts?.length) {
- return null;
- }
- const path = new Path2D();
- parts.forEach((part) => {
- part.projectedRings.forEach((ring) => {
- if (!ring.length) {
- return;
- }
- path.moveTo(ring[0][0], ring[0][1]);
- for (let index = 1; index < ring.length; index += 1) {
- path.lineTo(ring[index][0], ring[index][1]);
- }
- path.closePath();
- });
- });
- return path;
- }
- function waitForNextPaint() {
- return new Promise((resolve) => {
- requestAnimationFrame(() => resolve());
- });
- }
- function traceProjectedPolygon(context, projectedRings) {
- context.beginPath();
- projectedRings.forEach((ring) => {
- if (!ring.length) {
- return;
- }
- context.moveTo(ring[0][0], ring[0][1]);
- for (let index = 1; index < ring.length; index += 1) {
- context.lineTo(ring[index][0], ring[index][1]);
- }
- context.closePath();
- });
- }
- function getFeaturePolygonParts(feature) {
- if (feature.geometry.type === "Polygon") {
- return [feature.geometry.coordinates];
- }
- return feature.geometry.coordinates;
- }
- function getUnionBounds(parts) {
- if (!parts.length) {
- return [
- [0, 0],
- [0, 0],
- ];
- }
- const initial = [
- [Infinity, Infinity],
- [-Infinity, -Infinity],
- ];
- return parts.reduce((accumulator, part) => {
- const [[x0, y0], [x1, y1]] = part.bounds;
- accumulator[0][0] = Math.min(accumulator[0][0], x0);
- accumulator[0][1] = Math.min(accumulator[0][1], y0);
- accumulator[1][0] = Math.max(accumulator[1][0], x1);
- accumulator[1][1] = Math.max(accumulator[1][1], y1);
- return accumulator;
- }, initial);
- }
- function getLineBounds(points) {
- if (!points.length) {
- return [
- [0, 0],
- [0, 0],
- ];
- }
- const initial = [
- [Infinity, Infinity],
- [-Infinity, -Infinity],
- ];
- return points.reduce((accumulator, [x, y]) => {
- accumulator[0][0] = Math.min(accumulator[0][0], x);
- accumulator[0][1] = Math.min(accumulator[0][1], y);
- accumulator[1][0] = Math.max(accumulator[1][0], x);
- accumulator[1][1] = Math.max(accumulator[1][1], y);
- return accumulator;
- }, initial);
- }
- function buildDataRows(feature) {
- const territoryMeta = getTerritoryMeta(feature.properties.territoryKey);
- const entityType = feature.properties.kind || territoryMeta?.entityType || COMMUNE_ENTITY_TYPE;
- let rows = [];
- if (entityType === REGION_ENTITY_TYPE) {
- rows = [{ label: "Territoire", value: territoryMeta?.label || "France", territoryKey: territoryMeta?.key || "france" }];
- } else if (entityType === DEPARTMENT_ENTITY_TYPE) {
- rows = [
- { label: "Code groupement", value: feature.properties.code },
- { label: "Appellation courte", value: formatGroupementShortLabel(feature.properties.code) },
- { label: "Région de Gendarmerie", value: getRegionLabel(feature.properties.codeRegion) },
- { label: "Territoire", value: territoryMeta?.label || "France" },
- ];
- } else {
- const postalCodes = feature.properties.postalCodes || [];
- const postalCodeLabel = postalCodes.length > 1 ? "Codes postaux" : "Code postal";
- const regionTerritory = getRegionTerritoryByCode(feature.properties.codeRegion);
- const linkedUnits = getUnitsForFeature(feature);
- const competenceLabel = getCommuneCompetenceLabel(feature.properties);
- rows = [
- { label: postalCodeLabel, value: formatPostalCodes(postalCodes) || feature.properties.code },
- { label: "Groupement", value: feature.properties.codeDepartement },
- {
- label: "Région de Gendarmerie",
- value: getRegionLabel(feature.properties.codeRegion),
- territoryKey: regionTerritory?.key || null,
- },
- {
- label: "Territoire",
- value: territoryMeta?.label || "France",
- territoryKey: territoryMeta?.key || "france",
- },
- ];
- if (competenceLabel) {
- const unitLinksMarkup = linkedUnits.length
- ? linkedUnits
- .map(
- (unit) =>
- `<button type="button" class="unit-link-button" data-unit-service-key="${escapeHtml(
- unit.serviceKey
- )}">${escapeHtml(unit.name)}</button>`
- )
- .join('<span class="unit-link-separator"> / </span>')
- : "";
- rows.push(
- unitLinksMarkup
- ? { label: "Compétence territoriale", html: unitLinksMarkup }
- : { label: "Compétence territoriale", value: competenceLabel }
- );
- }
- }
- return rows
- .map(
- ({ label, value = "", html = "", territoryKey = null }) =>
- `<div class="country-data-row"><strong>${escapeHtml(label)}</strong><span>${
- html ||
- formatBadgeLabelMarkup(
- value,
- territoryKey,
- "modal-badge-inline"
- )
- }</span></div>`
- )
- .join("");
- }
- function buildUnitDataMarkup(unit) {
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- const associatedCommunes = getAssociatedCommunesForUnit(unit);
- const rows = [
- { label: "Institution", value: getUnitInstitutionLabel(unit.institution) },
- { label: "Groupement", value: territoryMeta?.label || "" },
- { label: "Commune d'implantation", value: unit.hostCommune || "Non renseignée" },
- { label: "Téléphone", value: unit.telephone || "Non renseigné" },
- { label: "Adresse", value: unit.address || "Non renseignée" },
- ];
- if (unit.hours) {
- rows.push({ label: "Horaires", value: unit.hours });
- }
- rows.push({
- label: "Communes associées",
- value: formatEntityCount(associatedCommunes.length, "commune", "communes"),
- });
- const rowsMarkup = rows
- .map(
- ({ label, value }) =>
- `<div class="country-data-row"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(value)}</span></div>`
- )
- .join("");
- const communesMarkup = associatedCommunes.length
- ? associatedCommunes
- .map((feature) => `<li>${escapeHtml(feature.properties.nom)}</li>`)
- .join("")
- : `<li>Aucune commune associée.</li>`;
- return `
- ${rowsMarkup}
- <div class="unit-communes-section">
- <strong>Liste des communes associées</strong>
- <ul class="unit-communes-list">${communesMarkup}</ul>
- </div>
- `;
- }
- function bindUnitLinks(container) {
- container.querySelectorAll("[data-unit-service-key]").forEach((element) => {
- element.addEventListener("click", () => {
- const serviceKey = element.getAttribute("data-unit-service-key");
- const unit = state.activeTerritoryData?.unitByServiceKey?.get(serviceKey || "");
- if (!unit) {
- return;
- }
- selectUnit(unit, { zoom: false, openModal: true });
- });
- });
- }
- function getFeatureOpenLabel(feature) {
- const entityType = feature.properties.kind;
- if (entityType === REGION_ENTITY_TYPE) {
- return "Ouvrir la région de gendarmerie";
- }
- if (entityType === DEPARTMENT_ENTITY_TYPE) {
- return "Ouvrir le groupement";
- }
- return "";
- }
- function updateModalAction(feature) {
- const childTerritoryKey = feature.properties.childTerritoryKey;
- if (!childTerritoryKey) {
- modalOpenTerritoryButton.hidden = true;
- modalOpenTerritoryButton.textContent = "";
- modalOpenTerritoryButton.onclick = null;
- return;
- }
- modalOpenTerritoryButton.hidden = false;
- modalOpenTerritoryButton.textContent = getFeatureOpenLabel(feature);
- modalOpenTerritoryButton.onclick = async () => {
- closeCommuneModal({ syncRoute: false });
- await activateTerritory(childTerritoryKey);
- };
- }
- function updateUnitModalAction(unit) {
- if (!unit?.url) {
- modalOpenTerritoryButton.hidden = true;
- modalOpenTerritoryButton.textContent = "";
- modalOpenTerritoryButton.onclick = null;
- return;
- }
- modalOpenTerritoryButton.hidden = false;
- modalOpenTerritoryButton.textContent = "Ouvrir la fiche officielle";
- modalOpenTerritoryButton.onclick = () => {
- window.open(unit.url, "_blank", "noopener,noreferrer");
- };
- }
- function openCommuneModal(feature) {
- const territoryMeta = getTerritoryMeta(feature.properties.territoryKey);
- const entityTerritoryKey =
- feature.properties.kind === REGION_ENTITY_TYPE ? feature.properties.childTerritoryKey || null : null;
- modalTitle.textContent = getEntityCopy(feature.properties.territoryKey).selectedTitle;
- communeName.innerHTML = formatBadgeLabelMarkup(feature.properties.nom, entityTerritoryKey, "modal-badge-heading");
- communeTerritory.innerHTML = formatBadgeLabelMarkup(
- territoryMeta ? `${territoryMeta.label}` : "France",
- territoryMeta?.key || "france",
- "modal-badge-inline"
- );
- communeData.innerHTML = buildDataRows(feature);
- bindUnitLinks(communeData);
- updateModalAction(feature);
- communeModal.classList.add("open");
- communeModal.setAttribute("aria-hidden", "false");
- }
- function openUnitModal(unit) {
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- modalTitle.textContent = "Unité";
- communeName.textContent = unit.name;
- communeTerritory.textContent = territoryMeta ? territoryMeta.label : "";
- communeData.innerHTML = buildUnitDataMarkup(unit);
- updateUnitModalAction(unit);
- communeModal.classList.add("open");
- communeModal.setAttribute("aria-hidden", "false");
- }
- function closeCommuneModal(options = {}) {
- communeModal.classList.remove("open");
- communeModal.setAttribute("aria-hidden", "true");
- modalOpenTerritoryButton.hidden = true;
- modalOpenTerritoryButton.textContent = "";
- modalOpenTerritoryButton.onclick = null;
- if (options.clearSelection !== false) {
- state.selectedCommuneId = null;
- state.selectedPartKey = null;
- state.selectedUnitKey = null;
- renderCommuneResults(communeSearchInputDesktop.value);
- requestRender();
- }
- if (options.syncRoute !== false) {
- syncRouteFromState({ replace: Boolean(options.replaceRoute) });
- }
- }
- function closeCommuneListModal() {
- communeListModal.classList.remove("open");
- communeListModal.setAttribute("aria-hidden", "true");
- }
- function openCommuneListModal() {
- communeListModal.classList.add("open");
- communeListModal.setAttribute("aria-hidden", "false");
- communeSearchInputMobile.value = communeSearchInputDesktop.value;
- communeSearchInputMobile.focus();
- renderCommuneResults(communeSearchInputMobile.value);
- }
- function clearSearchInputs() {
- communeSearchInputDesktop.value = "";
- communeSearchInputMobile.value = "";
- }
- function updateTerritorySummary() {
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- if (!territoryMeta) {
- territorySummary.textContent = "";
- return;
- }
- const entityCopy = getEntityCopy();
- territorySummary.textContent = `${territoryMeta.label} • ${formatEntityCount(
- territoryMeta.entityCount,
- entityCopy.singular,
- entityCopy.plural
- )} • survolez pour afficher le nom, cliquez pour sélectionner.`;
- }
- function closeMaskingModal() {
- maskingModal.classList.remove("open");
- maskingModal.setAttribute("aria-hidden", "true");
- }
- function buildMaskingModalMarkup(maskedData) {
- const communesMarkup = maskedData.communes.length
- ? maskedData.communes
- .map((commune) => {
- const postalCodes = commune.postalCodes.length ? ` • ${commune.postalCodes.join(", ")}` : "";
- return `<li><strong>${escapeHtml(commune.name)}</strong>${escapeHtml(postalCodes)}</li>`;
- })
- .join("")
- : "<li>Aucune commune masquée.</li>";
- const unitsMarkup = maskedData.units.length
- ? maskedData.units
- .map((unit) => {
- const associatedCommunes = unit.communeNames.join(", ");
- const institution = getUnitInstitutionLabel(unit.institution);
- return `<li><strong>${escapeHtml(unit.name)}</strong> (${escapeHtml(
- institution
- )})<br /><span>${escapeHtml(associatedCommunes)}</span></li>`;
- })
- .join("")
- : "<li>Aucune unité associée.</li>";
- return `
- <section class="masking-modal-block">
- <h3>Communes masquées</h3>
- <ul class="masking-modal-list">${communesMarkup}</ul>
- </section>
- <section class="masking-modal-block">
- <h3>Unités associées</h3>
- <ul class="masking-modal-list">${unitsMarkup}</ul>
- </section>
- `;
- }
- function openMaskingModal(options = {}) {
- const maskedData = state.activeTerritoryData?.maskedData;
- if (!maskedData?.communes?.length) {
- return;
- }
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- const focusedCommune = options.focusCommuneCode
- ? maskedData.communeByCode.get(options.focusCommuneCode)
- : null;
- const focusLabel = focusedCommune ? ` (${focusedCommune.name})` : "";
- maskingModalSummary.textContent = `${formatEntityCount(
- maskedData.communes.length,
- "commune masquée",
- "communes masquées"
- )} dans ${territoryMeta?.label || "ce territoire"}${focusLabel}.`;
- maskingModalData.innerHTML = buildMaskingModalMarkup(maskedData);
- maskingModal.classList.add("open");
- maskingModal.setAttribute("aria-hidden", "false");
- }
- function updateMaskingDisclaimer() {
- const shouldShowDisclaimerForTerritory = state.activeTerritoryKey === "polynesie-francaise";
- const maskedData = state.activeTerritoryData?.maskedData;
- if (!shouldShowDisclaimerForTerritory || !maskedData?.communes?.length) {
- maskingDisclaimer.hidden = true;
- maskingDisclaimerText.textContent = "";
- return;
- }
- maskingDisclaimer.hidden = false;
- maskingDisclaimerText.textContent = `${formatEntityCount(
- maskedData.communes.length,
- "zone",
- "zones"
- )} masquée(s) sur la carte pour lisibilité.`;
- }
- function renderTerritoryList() {
- territoryList.innerHTML = "";
- state.territories
- .filter((territory) => territory.navVisible !== false)
- .forEach((territory) => {
- const item = document.createElement("li");
- const button = document.createElement("button");
- button.type = "button";
- button.className = "territory-list-item";
- if (territory.key === state.activeTerritoryKey) {
- button.classList.add("active");
- }
- button.innerHTML = `
- ${getTerritoryBadgeMarkup(territory)}
- <span class="territory-list-body">
- <span class="territory-list-title">${escapeHtml(territory.label)}</span>
- <span class="territory-list-meta">${escapeHtml(
- formatEntityCount(
- territory.entityCount,
- territory.entityLabelSingular,
- territory.entityLabelPlural
- )
- )}</span>
- </span>
- `;
- button.addEventListener("click", async () => {
- await activateTerritory(territory.key);
- if (window.innerWidth <= 760) {
- territoryPanel.classList.remove("mobile-open");
- territoryToggleButton.setAttribute("aria-expanded", "false");
- }
- });
- item.appendChild(button);
- territoryList.appendChild(item);
- });
- }
- function sortSearchResults(term, items) {
- const normalizedTerm = normalizeText(term);
- return items
- .map((entry) => {
- const nameIndex = entry.searchName.indexOf(normalizedTerm);
- const startsWith = nameIndex === 0 ? 0 : 1;
- const territoryBoost = entry.territoryKey === state.activeTerritoryKey ? 0 : 1;
- const compactTerm = term.trim().replace(/\s+/g, "");
- const codeBoost = entry.searchCode.replace(/[^a-z0-9]+/g, "").startsWith(compactTerm) ? 0 : 1;
- return { entry, startsWith, territoryBoost, codeBoost, nameIndex };
- })
- .sort((left, right) => {
- if (left.codeBoost !== right.codeBoost) {
- return left.codeBoost - right.codeBoost;
- }
- if (left.startsWith !== right.startsWith) {
- return left.startsWith - right.startsWith;
- }
- if (left.territoryBoost !== right.territoryBoost) {
- return left.territoryBoost - right.territoryBoost;
- }
- if (left.nameIndex !== right.nameIndex) {
- return left.nameIndex - right.nameIndex;
- }
- return compareEntriesAlphabetically(left.entry, right.entry);
- })
- .map((item) => item.entry)
- .slice(0, MAX_SEARCH_RESULTS);
- }
- function getSearchResults(term) {
- const normalizedTerm = normalizeText(term);
- const activeUnitSearchEntries = state.activeTerritoryData?.unitSearchEntries || [];
- const useUnitList = usesUnitDefaultList(state.activeTerritoryKey);
- if (!normalizedTerm) {
- if (useUnitList) {
- return activeUnitSearchEntries.slice().sort(compareEntriesAlphabetically).slice(0, MAX_SEARCH_RESULTS);
- }
- return state.searchEntries
- .filter((entry) => entry.territoryKey === state.activeTerritoryKey)
- .sort(compareEntriesAlphabetically)
- .slice(0, MAX_SEARCH_RESULTS);
- }
- const matches = [];
- for (const entry of state.searchEntries) {
- if (
- entry.searchName.includes(normalizedTerm) ||
- entry.searchCode.replace(/[^a-z0-9]+/g, "").includes(normalizedTerm.replace(/\s+/g, ""))
- ) {
- matches.push(entry);
- }
- }
- for (const entry of activeUnitSearchEntries) {
- if (
- entry.searchName.includes(normalizedTerm) ||
- entry.searchCode.replace(/[^a-z0-9]+/g, "").includes(normalizedTerm.replace(/\s+/g, ""))
- ) {
- matches.push(entry);
- }
- }
- return sortSearchResults(term, matches);
- }
- function renderCommuneResults(term) {
- const results = getSearchResults(term);
- const normalizedTerm = normalizeText(term);
- const entityCopy = getEntityCopy();
- const useUnitList = !normalizedTerm && usesUnitDefaultList(state.activeTerritoryKey);
- const listSingular = useUnitList ? "unité" : entityCopy.singular;
- const listPlural = useUnitList ? "unités" : entityCopy.plural;
- const emptyListLabel = useUnitList ? "Aucune unité trouvée." : entityCopy.noneFoundLabel;
- const resultCountLabel = normalizedTerm
- ? `${formatCount(results.length)} résultat(s) affiché(s)`
- : `${formatEntityCount(results.length, listSingular, listPlural)} du territoire affiché`;
- communeListCountDesktop.textContent = resultCountLabel;
- communeListCountMobile.textContent = resultCountLabel;
- clearListHover({ render: false });
- const containers = [communeListContainerDesktop, communeListContainerMobile];
- containers.forEach((container) => {
- container.innerHTML = "";
- if (!results.length) {
- const empty = document.createElement("p");
- empty.className = "country-list-empty";
- empty.textContent = emptyListLabel;
- container.appendChild(empty);
- return;
- }
- results.forEach((entry) => {
- const item = document.createElement("li");
- const button = document.createElement("button");
- const territoryMeta = getTerritoryMeta(entry.territoryKey);
- const territoryLabel = territoryMeta?.shortLabel || territoryMeta?.label || "France";
- const metaLabel = entry.displayCode
- ? `${entry.displayCode} • ${territoryLabel}${entry.isMasked ? " • zone masquée" : ""}`
- : `${territoryLabel}${entry.isMasked ? " • zone masquée" : ""}`;
- button.type = "button";
- button.className = "country-list-item";
- if (entry.isMasked) {
- button.classList.add("masked");
- }
- if (
- (entry.kind === UNIT_SEARCH_KIND && entry.id === state.selectedUnitKey) ||
- (entry.kind !== UNIT_SEARCH_KIND && entry.id === state.selectedCommuneId)
- ) {
- button.classList.add("active");
- }
- button.innerHTML = `
- <span class="country-list-flag">${entry.kind === UNIT_SEARCH_KIND ? "■" : entry.isMasked ? "◌" : "▣"}</span>
- <span class="country-list-body">
- <span class="country-list-title">${escapeHtml(entry.name)}</span>
- <span class="country-list-meta">${escapeHtml(metaLabel)}</span>
- </span>
- `;
- button.addEventListener("mouseenter", () => setListHoverFromEntry(entry));
- button.addEventListener("mouseleave", () => clearListHover());
- button.addEventListener("focus", () => setListHoverFromEntry(entry));
- button.addEventListener("blur", () => clearListHover());
- button.addEventListener("click", async () => {
- clearListHover({ render: false });
- communeSearchInputDesktop.value = entry.name;
- communeSearchInputMobile.value = entry.name;
- closeCommuneListModal();
- if (entry.isMasked) {
- if (entry.territoryKey !== state.activeTerritoryKey) {
- await activateTerritory(entry.territoryKey, { syncRoute: false });
- }
- openMaskingModal({ focusCommuneCode: entry.id });
- return;
- }
- if (entry.kind === UNIT_SEARCH_KIND) {
- const unit = state.activeTerritoryData?.unitByServiceKey?.get(entry.id);
- if (unit) {
- selectUnit(unit, { zoom: true, openModal: true });
- }
- return;
- }
- if (entry.territoryKey === state.activeTerritoryKey) {
- updateSelection(entry.id, { zoom: true, openModal: true });
- return;
- }
- await activateTerritory(entry.territoryKey, { focusCommuneId: entry.id, openModal: true });
- });
- item.appendChild(button);
- container.appendChild(item);
- });
- });
- }
- async function fetchJson(url) {
- const response = await fetch(url);
- if (!response.ok) {
- throw new Error(`Impossible de charger ${url}`);
- }
- return response.json();
- }
- function prepareSearchEntries(entries) {
- return entries.map((entry) => ({
- id: entry.c,
- code: entry.k === COMMUNE_ENTITY_TYPE ? formatPostalCodes(entry.p) || entry.c : entry.c,
- displayCode:
- entry.k === REGION_ENTITY_TYPE
- ? ""
- : entry.k === COMMUNE_ENTITY_TYPE
- ? formatPostalCodes(entry.p) || entry.c
- : entry.c,
- rawCode: entry.c,
- name: entry.n,
- territoryKey: entry.t,
- kind: entry.k,
- isMasked: Boolean(entry.h),
- searchName: normalizeText(entry.n),
- searchCode:
- entry.k === COMMUNE_ENTITY_TYPE
- ? `${(entry.p || []).join(" ")} ${entry.c}`.trim()
- : entry.c,
- }));
- }
- function prepareUnitSearchEntries(units, territoryKey) {
- return units.map((unit) => {
- const filterLabel = formatUnitFilterLabel(unit);
- return {
- id: unit.serviceKey,
- code: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
- displayCode: `${unit.institution}${unit.hostCommune ? ` • ${unit.hostCommune}` : ""}`,
- rawCode: unit.serviceKey,
- name: filterLabel,
- territoryKey,
- kind: UNIT_SEARCH_KIND,
- searchName: normalizeText(`${filterLabel} ${unit.name}`),
- searchCode: `${unit.hostCommune} ${unit.telephone} ${unit.serviceKey}`.trim(),
- };
- });
- }
- function resizeCanvases() {
- const nextWidth = Math.max(1, Math.round(mapShell.clientWidth));
- const nextHeight = Math.max(1, Math.round(mapShell.clientHeight));
- state.pixelRatio = window.devicePixelRatio || 1;
- state.width = nextWidth;
- state.height = nextHeight;
- [mapCanvas, hitCanvas].forEach((canvas) => {
- canvas.width = Math.round(nextWidth * state.pixelRatio);
- canvas.height = Math.round(nextHeight * state.pixelRatio);
- });
- baseCanvas.width = Math.round(nextWidth * state.pixelRatio);
- baseCanvas.height = Math.round(nextHeight * state.pixelRatio);
- state.baseDirty = true;
- state.hitDirty = true;
- if (state.activeTerritoryData) {
- prepareTerritoryForCurrentViewport(state.activeTerritoryData);
- }
- updateMobileLayoutOffsets();
- requestRender();
- }
- function getFitPadding() {
- const horizontalPadding = window.innerWidth <= 760 ? 22 : BASE_PADDING;
- const verticalPadding = window.innerWidth <= 760 ? 120 : BASE_PADDING;
- return [horizontalPadding, verticalPadding];
- }
- function traceRoundedMarker(context, centerX, centerY, size, radius) {
- const half = size / 2;
- const x = centerX - half;
- const y = centerY - half;
- const boundedRadius = Math.min(radius, half);
- context.beginPath();
- context.moveTo(x + boundedRadius, y);
- context.lineTo(x + size - boundedRadius, y);
- context.quadraticCurveTo(x + size, y, x + size, y + boundedRadius);
- context.lineTo(x + size, y + size - boundedRadius);
- context.quadraticCurveTo(x + size, y + size, x + size - boundedRadius, y + size);
- context.lineTo(x + boundedRadius, y + size);
- context.quadraticCurveTo(x, y + size, x, y + size - boundedRadius);
- context.lineTo(x, y + boundedRadius);
- context.quadraticCurveTo(x, y, x + boundedRadius, y);
- context.closePath();
- }
- function prepareTerritoryForCurrentViewport(territoryData) {
- const viewportKey = `${state.width}x${state.height}`;
- if (territoryData.preparedViewportKey === viewportKey && territoryData.preparedFeatures?.length) {
- state.baseDirty = true;
- state.hitDirty = true;
- return;
- }
- const projection = createTerritoryProjection(territoryData);
- const territoryMeta = getTerritoryMeta(territoryData.key);
- const usesPlanarCoordinates = isPlanarCoordinateSpace(territoryData);
- const featureById = new Map();
- const partByKey = new Map();
- const partKeyByHitColor = new Map();
- const preparedParts = [];
- const competenceColorByServiceKey = buildCompetenceColorByServiceKey(territoryData.serviceByKey);
- const usesCompetenceColoring = isGroupementCommuneView(territoryMeta);
- const usesUnitMarkers = isUnitMarkerView(territoryMeta);
- let nextPartKey = 1;
- territoryData.preparedFeatures = territoryData.raw.features.map((feature) => {
- const fillColor = getCommuneCompetenceFillColor(
- feature.properties,
- usesCompetenceColoring,
- competenceColorByServiceKey
- );
- const polygonParts = getFeaturePolygonParts(feature);
- const parts = polygonParts
- .map((polygonCoordinates) => {
- const projected = projectPolygonCoordinates(projection, polygonCoordinates, usesPlanarCoordinates);
- if (!projected) {
- return null;
- }
- const part = {
- key: nextPartKey,
- hitColor: encodeHitColor(nextPartKey),
- featureId: feature.properties.code,
- feature,
- fillColor,
- projectedRings: projected.projectedRings,
- bounds: projected.bounds,
- area: getProjectedAreaFromBounds(projected.bounds),
- };
- nextPartKey += 1;
- partByKey.set(part.key, part);
- partKeyByHitColor.set(part.hitColor.slice(4, -1), part.key);
- preparedParts.push(part);
- return part;
- })
- .filter(Boolean);
- const prepared = {
- feature,
- id: feature.properties.code,
- parts,
- fillColor,
- bounds: getUnionBounds(parts),
- primaryPartKey:
- parts
- .slice()
- .sort((left, right) => right.area - left.area)[0]?.key || null,
- };
- featureById.set(prepared.id, prepared);
- return prepared;
- });
- territoryData.territoryPath = buildPath2DFromPreparedParts(preparedParts);
- territoryData.featureById = featureById;
- territoryData.partByKey = partByKey;
- territoryData.partKeyByHitColor = partKeyByHitColor;
- territoryData.preparedParts = preparedParts;
- territoryData.preparedUnits = usesUnitMarkers
- ? territoryData.units
- .map((unit) => {
- const projectedPoint = projectPoint(projection, [unit.longitude, unit.latitude], usesPlanarCoordinates);
- if (!projectedPoint) {
- return null;
- }
- return {
- ...unit,
- worldX: projectedPoint[0],
- worldY: projectedPoint[1],
- };
- })
- .filter(Boolean)
- : [];
- territoryData.preparedMaritimeLines = (territoryData.maritimeLines || [])
- .map((line) => {
- const projectedPoints = line
- .map((point) => projectPoint(projection, point, usesPlanarCoordinates))
- .filter(Boolean);
- if (projectedPoints.length < 2) {
- return null;
- }
- return {
- points: projectedPoints,
- bounds: getLineBounds(projectedPoints),
- };
- })
- .filter(Boolean);
- territoryData.preparedMaritimeLines.push(...buildSupplementalIslandMaritimeLines(territoryData.preparedFeatures));
- territoryData.competenceColorByServiceKey = competenceColorByServiceKey;
- territoryData.usesCompetenceColoring = usesCompetenceColoring;
- territoryData.preparedViewportKey = viewportKey;
- state.baseDirty = true;
- state.hitDirty = true;
- }
- function applyCanvasScale(context) {
- context.scale(state.pixelRatio, state.pixelRatio);
- }
- function applyRenderTransform(context) {
- context.scale(state.pixelRatio, state.pixelRatio);
- context.translate(state.transform.x, state.transform.y);
- context.scale(state.transform.k, state.transform.k);
- }
- function getVisibleWorldBounds() {
- return [
- [(-state.transform.x) / state.transform.k, (-state.transform.y) / state.transform.k],
- [
- (state.width - state.transform.x) / state.transform.k,
- (state.height - state.transform.y) / state.transform.k,
- ],
- ];
- }
- function boundsIntersect(bounds, viewportBounds, padding = 0) {
- const [[x0, y0], [x1, y1]] = bounds;
- const [[vx0, vy0], [vx1, vy1]] = viewportBounds;
- return x1 >= vx0 - padding && x0 <= vx1 + padding && y1 >= vy0 - padding && y0 <= vy1 + padding;
- }
- function drawBaseLayer() {
- baseContext.setTransform(1, 0, 0, 1, 0, 0);
- baseContext.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
- baseContext.save();
- applyCanvasScale(baseContext);
- if (state.activeTerritoryData.territoryPath) {
- baseContext.clip(state.activeTerritoryData.territoryPath);
- }
- baseContext.lineJoin = "round";
- baseContext.lineCap = "round";
- baseContext.lineWidth = FILL_SEAL_WIDTH;
- state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
- const fillColor = preparedPart.fillColor || themeColors.communeFill;
- baseContext.fillStyle = fillColor;
- baseContext.strokeStyle = fillColor;
- traceProjectedPolygon(baseContext, preparedPart.projectedRings);
- baseContext.fill();
- baseContext.stroke();
- });
- baseContext.restore();
- }
- function drawHitLayer() {
- hitContext.setTransform(1, 0, 0, 1, 0, 0);
- hitContext.clearRect(0, 0, hitCanvas.width, hitCanvas.height);
- hitContext.save();
- applyCanvasScale(hitContext);
- state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
- hitContext.fillStyle = preparedPart.hitColor;
- traceProjectedPolygon(hitContext, preparedPart.projectedRings);
- hitContext.fill();
- });
- hitContext.restore();
- }
- function drawBaseVectorLayer(context) {
- const viewportBounds = getVisibleWorldBounds();
- const padding = Math.max(2, 8 / state.transform.k);
- context.lineJoin = "round";
- context.lineCap = "round";
- context.lineWidth = Math.max(0.08, FILL_SEAL_WIDTH / state.transform.k);
- state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
- if (!boundsIntersect(preparedPart.bounds, viewportBounds, padding)) {
- return;
- }
- const fillColor = preparedPart.fillColor || themeColors.communeFill;
- context.fillStyle = fillColor;
- context.strokeStyle = fillColor;
- traceProjectedPolygon(context, preparedPart.projectedRings);
- context.fill();
- context.stroke();
- });
- }
- function drawFeatureOverlay(context, preparedFeature, fillColor) {
- if (!preparedFeature?.parts?.length) {
- return;
- }
- context.fillStyle = fillColor;
- preparedFeature.parts.forEach((preparedPart) => {
- traceProjectedPolygon(context, preparedPart.projectedRings);
- context.fill();
- });
- }
- function drawFeatureOverlays(context, preparedFeatures, fillColor, excludedFeatureId = null) {
- preparedFeatures.forEach((preparedFeature) => {
- if (!preparedFeature || preparedFeature.id === excludedFeatureId) {
- return;
- }
- drawFeatureOverlay(context, preparedFeature, fillColor);
- });
- }
- function drawMaritimeEdgeGlow(context) {
- const territoryMeta = getTerritoryMeta(state.activeTerritoryKey);
- if (isOverseasTerritory(territoryMeta)) {
- return;
- }
- const maritimeLines = state.activeTerritoryData?.preparedMaritimeLines || [];
- if (!maritimeLines.length) {
- return;
- }
- const viewportBounds = getVisibleWorldBounds();
- const padding = Math.max(8, 24 / state.transform.k);
- const visibleLines = maritimeLines.filter((line) => boundsIntersect(line.bounds, viewportBounds, padding));
- if (!visibleLines.length) {
- return;
- }
- context.save();
- context.lineJoin = "round";
- context.lineCap = "butt";
- context.globalCompositeOperation = "source-over";
- const traceVisibleMaritimePath = () => {
- context.beginPath();
- visibleLines.forEach((line) => {
- context.moveTo(line.points[0][0], line.points[0][1]);
- for (let index = 1; index < line.points.length; index += 1) {
- context.lineTo(line.points[index][0], line.points[index][1]);
- }
- });
- };
- const renderGlowPass = (lineWidth, strokeStyle, blur) => {
- context.lineWidth = lineWidth;
- context.strokeStyle = strokeStyle;
- context.shadowColor = MARITIME_GLOW_COLOR;
- context.shadowBlur = blur;
- context.shadowOffsetX = 0;
- context.shadowOffsetY = 0;
- traceVisibleMaritimePath();
- context.stroke();
- };
- const screenScale = 1 / Math.max(0.001, state.transform.k);
- const targetRadiusPx = Math.max(152, MARITIME_GLOW_BLUR * 1.72);
- const haloPasses = [
- { widthPx: 2.2, alpha: 0.18, blur: Math.round(targetRadiusPx * 0.22) },
- { widthPx: 7.4, alpha: 0.145, blur: Math.round(targetRadiusPx * 0.44) },
- { widthPx: 14.9, alpha: 0.115, blur: Math.round(targetRadiusPx * 0.72) },
- { widthPx: 28.4, alpha: 0.09, blur: targetRadiusPx },
- ];
- haloPasses.forEach((pass) => {
- renderGlowPass(
- Math.max(0.7, pass.widthPx * screenScale),
- `rgba(23, 74, 142, ${pass.alpha})`,
- Math.round(Math.max(1, pass.blur))
- );
- });
- context.restore();
- }
- function drawVisibleBoundaries(context) {
- const viewportBounds = getVisibleWorldBounds();
- const padding = Math.max(2, 8 / state.transform.k);
- context.lineJoin = "round";
- context.lineCap = "round";
- context.strokeStyle = themeColors.communeStroke;
- context.lineWidth = Math.max(0.3, BORDER_LINE_WIDTH / state.transform.k);
- state.activeTerritoryData.preparedParts.forEach((preparedPart) => {
- if (boundsIntersect(preparedPart.bounds, viewportBounds, padding)) {
- traceProjectedPolygon(context, preparedPart.projectedRings);
- context.stroke();
- }
- });
- }
- function drawUnitMarkers(context) {
- if (!state.activeTerritoryData?.preparedUnits?.length) {
- return;
- }
- const orderedUnits = state.activeTerritoryData.preparedUnits
- .slice()
- .sort((left, right) => {
- const leftPriority =
- left.key === state.hoveredUnitKey || left.key === state.listHoveredUnitKey || left.key === state.selectedUnitKey
- ? 1
- : 0;
- const rightPriority =
- right.key === state.hoveredUnitKey || right.key === state.listHoveredUnitKey || right.key === state.selectedUnitKey
- ? 1
- : 0;
- return leftPriority - rightPriority;
- });
- orderedUnits.forEach((preparedUnit) => {
- const isHovered = preparedUnit.key === state.hoveredUnitKey || preparedUnit.key === state.listHoveredUnitKey;
- const isSelected = preparedUnit.key === state.selectedUnitKey;
- const markerSize = (isHovered || isSelected ? UNIT_MARKER_HOVER_SIZE : UNIT_MARKER_SIZE) / state.transform.k;
- const cornerRadius = UNIT_MARKER_CORNER_RADIUS / state.transform.k;
- traceRoundedMarker(context, preparedUnit.worldX, preparedUnit.worldY, markerSize, cornerRadius);
- context.fillStyle = isHovered || isSelected ? themeColors.unitMarkerHover : themeColors.unitMarkerFill;
- context.fill();
- context.lineWidth = Math.max(0.9, 1.55 / state.transform.k);
- context.strokeStyle = themeColors.unitMarkerStroke;
- context.stroke();
- });
- }
- function drawFranceRegionBadges(context) {
- if (state.activeTerritoryKey !== "france" || !state.activeTerritoryData?.preparedFeatures?.length) {
- return;
- }
- const viewportBounds = getVisibleWorldBounds();
- const maxBadgeSize = FRANCE_REGION_BADGE_SIZE / state.transform.k;
- const padding = Math.max(4, 20 / state.transform.k);
- state.activeTerritoryData.preparedFeatures.forEach((preparedFeature) => {
- const territoryKey = preparedFeature.feature?.properties?.childTerritoryKey;
- if (!territoryKey) {
- return;
- }
- const badgeImage = getTerritoryBadgeImage(territoryKey);
- if (!badgeImage) {
- return;
- }
- const primaryPart =
- preparedFeature.parts.find((part) => part.key === preparedFeature.primaryPartKey) || preparedFeature.parts[0];
- if (!primaryPart || !boundsIntersect(primaryPart.bounds, viewportBounds, padding)) {
- return;
- }
- const [centerX, centerY] = getBoundsCenter(primaryPart.bounds);
- const naturalWidth = Math.max(1, badgeImage.naturalWidth || badgeImage.width || 1);
- const naturalHeight = Math.max(1, badgeImage.naturalHeight || badgeImage.height || 1);
- const aspectRatio = naturalWidth / naturalHeight;
- let drawWidth = maxBadgeSize;
- let drawHeight = maxBadgeSize;
- if (aspectRatio >= 1) {
- drawHeight = maxBadgeSize / aspectRatio;
- } else {
- drawWidth = maxBadgeSize * aspectRatio;
- }
- context.drawImage(
- badgeImage,
- centerX - drawWidth / 2,
- centerY - drawHeight / 2,
- drawWidth,
- drawHeight
- );
- });
- }
- function renderMap() {
- state.renderQueued = false;
- mapContext.setTransform(1, 0, 0, 1, 0, 0);
- mapContext.fillStyle = themeColors.background;
- mapContext.fillRect(0, 0, mapCanvas.width, mapCanvas.height);
- if (!state.activeTerritoryData?.preparedFeatures?.length) {
- return;
- }
- if (state.baseDirty) {
- drawBaseLayer();
- state.baseDirty = false;
- }
- if (state.hitDirty) {
- drawHitLayer();
- state.hitDirty = false;
- }
- mapContext.save();
- applyRenderTransform(mapContext);
- drawMaritimeEdgeGlow(mapContext);
- const usesVectorBaseLayer = state.transform.k > VECTOR_RENDER_SCALE_THRESHOLD;
- if (usesVectorBaseLayer) {
- drawBaseVectorLayer(mapContext);
- } else {
- mapContext.imageSmoothingEnabled = false;
- mapContext.drawImage(baseCanvas, 0, 0, state.width, state.height);
- mapContext.imageSmoothingEnabled = true;
- }
- const selectedPreparedFeature = getPreparedFeatureById(state.selectedCommuneId);
- const hoveredPreparedFeature = getPreparedFeatureFromPartKey(state.hoveredPartKey);
- const listHoveredPreparedFeatures = getPreparedFeaturesFromIds(state.listHoveredFeatureIds);
- drawFeatureOverlay(mapContext, selectedPreparedFeature, themeColors.communeSelected);
- drawFeatureOverlays(mapContext, listHoveredPreparedFeatures, themeColors.communeHover, selectedPreparedFeature?.id || null);
- if (!selectedPreparedFeature || hoveredPreparedFeature?.id !== selectedPreparedFeature.id) {
- drawFeatureOverlay(mapContext, hoveredPreparedFeature, themeColors.communeHover);
- }
- drawVisibleBoundaries(mapContext);
- drawFranceRegionBadges(mapContext);
- drawUnitMarkers(mapContext);
- mapContext.restore();
- }
- function requestRender() {
- if (state.renderQueued) {
- return;
- }
- state.renderQueued = true;
- requestAnimationFrame(renderMap);
- }
- async function loadTerritoryData(territoryKey) {
- if (state.territoryDataByKey.has(territoryKey)) {
- return state.territoryDataByKey.get(territoryKey);
- }
- const territoryMeta = getTerritoryMeta(territoryKey);
- if (!territoryMeta) {
- throw new Error(`Territoire inconnu: ${territoryKey}`);
- }
- setStatus(`Chargement de ${territoryMeta.label}...`);
- const raw = await fetchJson(territoryMeta.file);
- const serviceByKey = new Map(Object.entries(raw.services || {}));
- const enhanced = {
- key: territoryKey,
- raw,
- units: (raw.units || []).map((unit) => ({
- key: unit.s,
- serviceKey: unit.s,
- institution: unit.i || "GN",
- name: unit.n,
- longitude: Number(unit.x),
- latitude: Number(unit.y),
- communeCode: unit.c || "",
- hostCommune: unit.m || "",
- address: unit.a || "",
- telephone: unit.t || "",
- hours: unit.h || "",
- url: unit.u || "",
- })),
- maritimeLines: (raw.maritime || []).map((line) =>
- (line || [])
- .map((point) => [Number(point[0]), Number(point[1])])
- .filter((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))
- ),
- serviceByKey,
- unitByServiceKey: new Map(),
- unitSearchEntries: [],
- preparedFeatures: [],
- preparedUnits: [],
- preparedMaritimeLines: [],
- territoryPath: null,
- featureById: new Map(),
- partByKey: new Map(),
- partKeyByHitColor: new Map(),
- preparedParts: [],
- competenceColorByServiceKey: new Map(),
- usesCompetenceColoring: isGroupementCommuneView(territoryMeta),
- preparedViewportKey: null,
- maskedData: {
- communes: [],
- units: [],
- communeByCode: new Map(),
- },
- };
- raw.features = raw.features.map((feature) => ({
- type: "Feature",
- properties: {
- code: feature.properties.c,
- nom: feature.properties.n,
- codeDepartement: feature.properties.d,
- codeRegion: feature.properties.r,
- kind: feature.properties.k || territoryMeta.entityType,
- territoryKey,
- childTerritoryKey: feature.properties.x || null,
- postalCodes: state.communePostalCodesByCode.get(feature.properties.c) || [],
- competenceInstitution: feature.properties.i || "",
- competenceServiceKey: feature.properties.u || "",
- competenceServiceKeys: feature.properties.v || (feature.properties.u ? [feature.properties.u] : []),
- competenceMixedLabel: feature.properties.m || "",
- competenceLabel:
- (feature.properties.u && serviceByKey.get(feature.properties.u)?.l) || feature.properties.m || "",
- },
- geometry: feature.geometry,
- }));
- const rawMasked = raw.masked || {};
- const maskedCommunes = (rawMasked.communes || []).map((commune) => ({
- code: commune.c,
- name: commune.n,
- postalCodes: commune.p || [],
- }));
- const maskedUnits = (rawMasked.units || []).map((unit) => ({
- serviceKey: unit.s,
- institution: unit.i || "UNK",
- name: unit.n || unit.s,
- communeCodes: unit.c || [],
- communeNames: unit.m || [],
- }));
- enhanced.maskedData = {
- communes: maskedCommunes
- .slice()
- .sort((left, right) => compareLabelsAlphabetically(left.name, right.name)),
- units: maskedUnits
- .slice()
- .sort((left, right) => compareLabelsAlphabetically(left.name, right.name)),
- communeByCode: new Map(maskedCommunes.map((commune) => [commune.code, commune])),
- };
- enhanced.unitByServiceKey = new Map(enhanced.units.map((unit) => [unit.serviceKey, unit]));
- enhanced.unitSearchEntries = prepareUnitSearchEntries(enhanced.units, territoryKey);
- state.territoryDataByKey.set(territoryKey, enhanced);
- return enhanced;
- }
- function syncZoomTransform(transform) {
- d3.select(mapCanvas).call(zoom.transform, transform);
- }
- function zoomToPreparedFeature(prepared) {
- if (!prepared) {
- return;
- }
- const [[x0, y0], [x1, y1]] = prepared.bounds;
- const dx = Math.max(1, x1 - x0);
- const dy = Math.max(1, y1 - y0);
- const scale = Math.max(
- 1,
- Math.min(48, 0.82 / Math.max(dx / state.width, dy / state.height))
- );
- const translateX = state.width / 2 - scale * (x0 + x1) / 2;
- const translateY = state.height / 2 - scale * (y0 + y1) / 2;
- syncZoomTransform(d3.zoomIdentity.translate(translateX, translateY).scale(scale));
- }
- function zoomToPreparedUnit(unit) {
- if (!unit) {
- return;
- }
- const scale = UNIT_FOCUS_SCALE;
- const translateX = state.width / 2 - scale * unit.worldX;
- const translateY = state.height / 2 - scale * unit.worldY;
- syncZoomTransform(d3.zoomIdentity.translate(translateX, translateY).scale(scale));
- }
- function selectUnit(unit, options = {}) {
- const preparedUnit = getPreparedUnitByKey(unit?.key || unit?.serviceKey) || unit;
- if (!preparedUnit) {
- return;
- }
- state.selectedCommuneId = null;
- state.selectedPartKey = null;
- state.selectedUnitKey = preparedUnit.key;
- if (options.zoom) {
- zoomToPreparedUnit(preparedUnit);
- }
- requestRender();
- if (options.openModal) {
- openUnitModal(preparedUnit);
- }
- if (options.syncRoute !== false) {
- syncRouteFromState({ replace: Boolean(options.replaceRoute) });
- }
- }
- function updateSelection(communeId, options = {}) {
- state.selectedCommuneId = communeId;
- state.selectedPartKey = options.partKey || null;
- state.selectedUnitKey = null;
- if (communeId) {
- const preparedFeature = getPreparedFeatureById(communeId);
- if (preparedFeature && !state.selectedPartKey) {
- state.selectedPartKey = preparedFeature.primaryPartKey;
- }
- const preparedTarget =
- options.zoomTarget === "part"
- ? getPreparedPartByKey(state.selectedPartKey)
- : preparedFeature;
- if (preparedTarget && options.zoom !== false) {
- zoomToPreparedFeature(preparedTarget);
- }
- if (preparedFeature && options.openModal) {
- openCommuneModal(preparedFeature.feature);
- }
- } else {
- state.selectedPartKey = null;
- }
- renderCommuneResults(communeSearchInputDesktop.value);
- requestRender();
- if (options.syncRoute !== false) {
- syncRouteFromState({ replace: Boolean(options.replaceRoute) });
- }
- }
- async function activateTerritory(territoryKey, options = {}) {
- const sameTerritory = territoryKey === state.activeTerritoryKey;
- const territoryMeta = getTerritoryMeta(territoryKey);
- const territoryData = await loadTerritoryData(territoryKey);
- const viewportKey = `${state.width}x${state.height}`;
- const needsPreparation =
- territoryData.preparedViewportKey !== viewportKey || !territoryData.preparedFeatures?.length;
- if (needsPreparation) {
- setStatus(`Préparation de ${territoryMeta?.label || territoryKey}...`);
- await waitForNextPaint();
- await new Promise((resolve) => setTimeout(resolve, 0));
- }
- if (!sameTerritory) {
- closeCommuneModal({ syncRoute: false, clearSelection: false });
- closeMaskingModal();
- clearListHover({ render: false });
- }
- state.activeTerritoryKey = territoryKey;
- state.activeTerritoryData = territoryData;
- prepareTerritoryForCurrentViewport(territoryData);
- state.hoveredPartKey = null;
- state.hoveredUnitKey = null;
- hideTooltip();
- applyEntityUi();
- updateTerritorySummary();
- updateMaskingDisclaimer();
- renderTerritoryList();
- if (!sameTerritory) {
- clearSearchInputs();
- state.selectedUnitKey = null;
- syncZoomTransform(d3.zoomIdentity);
- }
- if (needsPreparation) {
- await waitForNextPaint();
- }
- setStatus("", false);
- if (options.focusCommuneId) {
- updateSelection(options.focusCommuneId, {
- zoom: options.zoom !== false,
- openModal: Boolean(options.openModal),
- syncRoute: options.syncRoute,
- replaceRoute: options.replaceRoute,
- });
- } else if (!sameTerritory) {
- updateSelection(null, { zoom: false, syncRoute: false });
- if (options.syncRoute !== false) {
- syncRouteFromState({ replace: Boolean(options.replaceRoute) });
- }
- } else {
- requestRender();
- if (options.syncRoute !== false) {
- syncRouteFromState({ replace: Boolean(options.replaceRoute) });
- }
- }
- }
- async function applyRouteFromHash(hash, options = {}) {
- const requestedHash = String(hash || "");
- const parsedRoute = parseRouteHash(requestedHash);
- let territoryKey = parsedRoute.territoryKey || "france";
- let shouldReplaceRoute = Boolean(options.replace) || !parsedRoute.valid;
- if (!state.territoryByKey.has(territoryKey)) {
- territoryKey = "france";
- shouldReplaceRoute = true;
- }
- state.routeApplying = true;
- try {
- await activateTerritory(territoryKey, { syncRoute: false });
- if (parsedRoute.unitKey) {
- const unit = state.activeTerritoryData?.unitByServiceKey?.get(parsedRoute.unitKey);
- if (unit) {
- selectUnit(unit, { zoom: false, openModal: true, syncRoute: false });
- } else {
- closeCommuneModal({ syncRoute: false });
- shouldReplaceRoute = true;
- }
- } else if (parsedRoute.entityCode) {
- const preparedFeature = getPreparedFeatureById(parsedRoute.entityCode);
- if (preparedFeature) {
- updateSelection(parsedRoute.entityCode, {
- zoom: false,
- openModal: true,
- syncRoute: false,
- });
- } else {
- closeCommuneModal({ syncRoute: false });
- shouldReplaceRoute = true;
- }
- } else {
- closeCommuneModal({ syncRoute: false });
- }
- } finally {
- state.routeApplying = false;
- }
- const finalHash = buildRouteHash(getCurrentRouteState());
- if (shouldReplaceRoute || finalHash !== requestedHash) {
- history.replaceState(null, "", buildRouteUrl(finalHash));
- }
- state.routeHash = finalHash;
- }
- async function handleBrowserRouteChange() {
- const nextHash = window.location.hash || "";
- if (nextHash === state.routeHash) {
- return;
- }
- await applyRouteFromHash(nextHash, { replace: !nextHash });
- }
- function getPreparedFeatureFromPointer(event) {
- if (state.hitDirty) {
- drawHitLayer();
- state.hitDirty = false;
- }
- const { localX, localY, canvasX, canvasY } = getPointerPositions(event);
- if (canvasX < 0 || canvasY < 0 || canvasX >= hitCanvas.width || canvasY >= hitCanvas.height) {
- return null;
- }
- const sampleOffsets = [
- [0, 0],
- [1, 0],
- [-1, 0],
- [0, 1],
- [0, -1],
- [1, 1],
- [1, -1],
- [-1, 1],
- [-1, -1],
- ];
- const testedKeys = new Set();
- for (const [offsetX, offsetY] of sampleOffsets) {
- const sampleX = canvasX + offsetX;
- const sampleY = canvasY + offsetY;
- if (sampleX < 0 || sampleY < 0 || sampleX >= hitCanvas.width || sampleY >= hitCanvas.height) {
- continue;
- }
- const pixel = hitContext.getImageData(sampleX, sampleY, 1, 1).data;
- const hitKey = state.activeTerritoryData?.partKeyByHitColor?.get(getHitColorKey(pixel)) || null;
- if (!hitKey || testedKeys.has(hitKey)) {
- continue;
- }
- testedKeys.add(hitKey);
- const preparedPart = getPreparedPartByKey(hitKey);
- if (isPreparedPartUnderPointer(preparedPart, localX, localY)) {
- return preparedPart;
- }
- }
- return null;
- }
- function handlePointerMove(event) {
- if (!state.activeTerritoryData) {
- return;
- }
- const preparedUnit = getPreparedUnitFromPointer(event);
- if (preparedUnit) {
- const nextHoverUnitKey = preparedUnit.key || null;
- const needsRender =
- nextHoverUnitKey !== state.hoveredUnitKey || state.hoveredPartKey !== null;
- state.hoveredUnitKey = nextHoverUnitKey;
- state.hoveredPartKey = null;
- if (needsRender) {
- requestRender();
- }
- showTooltip(preparedUnit.name, event.clientX, event.clientY);
- return;
- }
- const preparedPart = getPreparedFeatureFromPointer(event);
- const nextHoverPartKey = preparedPart?.key || null;
- const nextHoverUnitKey = null;
- if (nextHoverPartKey !== state.hoveredPartKey || nextHoverUnitKey !== state.hoveredUnitKey) {
- state.hoveredPartKey = nextHoverPartKey;
- state.hoveredUnitKey = nextHoverUnitKey;
- requestRender();
- }
- if (preparedPart) {
- showTooltip(preparedPart.feature, event.clientX, event.clientY);
- } else {
- hideTooltip();
- }
- }
- function handlePointerLeave() {
- state.hoveredPartKey = null;
- state.hoveredUnitKey = null;
- hideTooltip();
- requestRender();
- }
- function handleMapClick(event) {
- const preparedUnit = getPreparedUnitFromPointer(event);
- if (preparedUnit) {
- state.hoveredPartKey = null;
- selectUnit(preparedUnit, { zoom: false, openModal: true });
- return;
- }
- const preparedPart = getPreparedFeatureFromPointer(event);
- if (!preparedPart) {
- return;
- }
- updateSelection(preparedPart.featureId, {
- partKey: preparedPart.key,
- zoom: true,
- zoomTarget: "part",
- openModal: true,
- });
- }
- function handleSearchInput(event) {
- const value = event.target.value;
- communeSearchInputDesktop.value = value;
- communeSearchInputMobile.value = value;
- renderCommuneResults(value);
- }
- async function bootstrap() {
- try {
- setStatus("Chargement des territoires...");
- const [territories, communesIndex] = await Promise.all([
- fetchJson(TERRITORIES_URL),
- fetchJson(COMMUNE_INDEX_URL),
- ]);
- state.territories = territories;
- state.territoryByKey = new Map(territories.map((territory) => [territory.key, territory]));
- state.communesIndex = communesIndex;
- state.communePostalCodesByCode = new Map(
- communesIndex
- .filter((entry) => entry.k === COMMUNE_ENTITY_TYPE)
- .map((entry) => [entry.c, entry.p || []])
- );
- state.searchEntries = prepareSearchEntries(communesIndex);
- resizeCanvases();
- await applyRouteFromHash(window.location.hash, { replace: !window.location.hash });
- renderCommuneResults("");
- setStatus("", false);
- } catch (error) {
- console.error(error);
- setStatus(
- "Impossible de charger la carte. Exécutez d'abord le script de génération des données puis servez le dossier en HTTP."
- );
- }
- }
- window.addEventListener("resize", () => {
- window.clearTimeout(state.resizeTimer);
- state.resizeTimer = window.setTimeout(resizeCanvases, 120);
- });
- window.addEventListener("popstate", () => {
- handleBrowserRouteChange().catch((error) => console.error(error));
- });
- window.addEventListener("hashchange", () => {
- handleBrowserRouteChange().catch((error) => console.error(error));
- });
- mapCanvas.addEventListener("pointermove", handlePointerMove);
- mapCanvas.addEventListener("pointerleave", handlePointerLeave);
- mapCanvas.addEventListener("click", handleMapClick);
- communeSearchInputDesktop.addEventListener("input", handleSearchInput);
- communeSearchInputMobile.addEventListener("input", handleSearchInput);
- openMobileCommuneListButton.addEventListener("click", openCommuneListModal);
- closeCommuneListModalButton.addEventListener("click", closeCommuneListModal);
- closeModalButton.addEventListener("click", () => closeCommuneModal());
- openMaskingModalButton.addEventListener("click", () => openMaskingModal());
- closeMaskingModalButton.addEventListener("click", closeMaskingModal);
- territoryToggleButton.addEventListener("click", () => {
- const nextExpanded = !territoryPanel.classList.contains("mobile-open");
- territoryPanel.classList.toggle("mobile-open", nextExpanded);
- territoryToggleButton.setAttribute("aria-expanded", String(nextExpanded));
- });
- communeListModal.addEventListener("click", (event) => {
- if (event.target === communeListModal) {
- closeCommuneListModal();
- }
- });
- communeModal.addEventListener("click", (event) => {
- if (event.target === communeModal) {
- closeCommuneModal();
- }
- });
- maskingModal.addEventListener("click", (event) => {
- if (event.target === maskingModal) {
- closeMaskingModal();
- }
- });
- document.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- closeCommuneModal();
- closeCommuneListModal();
- closeMaskingModal();
- territoryPanel.classList.remove("mobile-open");
- territoryToggleButton.setAttribute("aria-expanded", "false");
- }
- });
- bootstrap();
|