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 `
`;
}
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();