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 ``; } const badge = TERRITORY_BADGE_ASSETS[territory.key]; if (!badge) { return ``; } if (badge.className) { classNames.push(badge.className); } return ` `; } 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 `${badgeMarkup}${escapedLabel}`; } 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 = `${escapeHtml(label)}`; 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) => `` ) .join(' / ') : ""; rows.push( unitLinksMarkup ? { label: "Compétence territoriale", html: unitLinksMarkup } : { label: "Compétence territoriale", value: competenceLabel } ); } } return rows .map( ({ label, value = "", html = "", territoryKey = null }) => `
${escapeHtml(label)}${ html || formatBadgeLabelMarkup( value, territoryKey, "modal-badge-inline" ) }
` ) .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 }) => `
${escapeHtml(label)}${escapeHtml(value)}
` ) .join(""); const communesMarkup = associatedCommunes.length ? associatedCommunes .map((feature) => `
  • ${escapeHtml(feature.properties.nom)}
  • `) .join("") : `
  • Aucune commune associĂ©e.
  • `; return ` ${rowsMarkup}
    Liste des communes associées
    `; } 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 `
  • ${escapeHtml(commune.name)}${escapeHtml(postalCodes)}
  • `; }) .join("") : "
  • Aucune commune masquĂ©e.
  • "; const unitsMarkup = maskedData.units.length ? maskedData.units .map((unit) => { const associatedCommunes = unit.communeNames.join(", "); const institution = getUnitInstitutionLabel(unit.institution); return `
  • ${escapeHtml(unit.name)} (${escapeHtml( institution )})
    ${escapeHtml(associatedCommunes)}
  • `; }) .join("") : "
  • Aucune unitĂ© associĂ©e.
  • "; return `

    Communes masquées

    Unités associées

    `; } 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)} ${escapeHtml(territory.label)} ${escapeHtml( formatEntityCount( territory.entityCount, territory.entityLabelSingular, territory.entityLabelPlural ) )} `; 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 = ` ${entry.kind === UNIT_SEARCH_KIND ? "■" : entry.isMasked ? "◌" : "▣"} ${escapeHtml(entry.name)} ${escapeHtml(metaLabel)} `; 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();