fix(figures): calculer center/zoom côté serveur au lieu d'utiliser bounds

La prop `bounds` de dash-leaflet lève une exception JS (TypeError sur
.equals() avec une valeur précédente indéfinie) au tout premier montage
du composant Map — confirmé via la stack trace navigateur, reproductible
même en dernière version (1.1.3). Cette exception laissait le clustering
dans un état incohérent (declustering impossible avant un zoom complet
suivi d'un dézoom).

bounds_to_center_zoom calcule un center/zoom approximatifs (projection
Web Mercator, taille de conteneur supposée) pour cadrer l'organisme et
ses contreparties, en évitant complètement la prop `bounds` bugguée.
Cadrage moins précis qu'un vrai fitBounds() navigateur, mais robuste.
This commit is contained in:
Colin Maudry
2026-07-03 22:54:21 +02:00
parent 6739e4926b
commit eb8f54dbf2
2 changed files with 88 additions and 11 deletions
+57 -9
View File
@@ -1,3 +1,4 @@
import math
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
@@ -564,6 +565,53 @@ def make_clusters_map(region: dict) -> dl.Map:
return leaflet_map return leaflet_map
_MERCATOR_TILE_SIZE = 256
def _mercator_y(lat: float) -> float:
lat_rad = math.radians(lat)
return math.log(math.tan(math.pi / 4 + lat_rad / 2)) / (2 * math.pi)
def bounds_to_center_zoom(
min_lat: float,
min_lon: float,
max_lat: float,
max_lon: float,
container_width_px: float = 350,
container_height_px: float = 300,
padding_px: float = 30,
max_zoom: int = 12,
) -> tuple[list[float], int]:
"""Calcule un centre et un niveau de zoom (projection Web Mercator) pour
cadrer une bounding box dans un conteneur de taille donnée.
Approximation côté serveur d'un fitBounds Leaflet : la taille réelle du
conteneur navigateur n'est pas connue côté Python, `container_width_px`
est une estimation raisonnable (la carte occupe une colonne étroite de
la page). Le résultat n'est donc pas pixel-perfect, contrairement à un
fitBounds() exécuté côté client, mais évite d'utiliser la prop `bounds`
de dash-leaflet (bug connu : exception au premier montage du composant).
"""
avail_w = max(container_width_px - 2 * padding_px, 1)
avail_h = max(container_height_px - 2 * padding_px, 1)
dx_norm = (max_lon - min_lon) / 360
dy_norm = abs(_mercator_y(max_lat) - _mercator_y(min_lat))
zoom_candidates = []
if dx_norm > 0:
zoom_candidates.append(math.log2(avail_w / (_MERCATOR_TILE_SIZE * dx_norm)))
if dy_norm > 0:
zoom_candidates.append(math.log2(avail_h / (_MERCATOR_TILE_SIZE * dy_norm)))
zoom = math.floor(min(zoom_candidates)) if zoom_candidates else max_zoom
zoom = max(0, min(zoom, max_zoom))
center = [(min_lat + max_lat) / 2, (min_lon + max_lon) / 2]
return center, zoom
def get_org_location_map( def get_org_location_map(
dff: pl.DataFrame, dff: pl.DataFrame,
home_type: Literal["acheteur", "titulaire"], home_type: Literal["acheteur", "titulaire"],
@@ -573,9 +621,11 @@ def get_org_location_map(
Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
consultée) ainsi que ceux de son type complémentaire, clusterisés et consultée) ainsi que ceux de son type complémentaire, clusterisés et
colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur colorés comme sur /observatoire. Cadrage calculé côté serveur
l'ensemble des points ; repli sur une vue France fixe si aucun point (`bounds_to_center_zoom`) sur l'ensemble des points ; repli sur une vue
n'est disponible. France fixe si aucun point n'est disponible. Un center/zoom statiques
sont utilisés plutôt que la prop `bounds` de dash-leaflet, qui lève une
exception au premier montage du composant (bug connu de la librairie).
""" """
lff = dff.lazy() lff = dff.lazy()
markers_by_type = { markers_by_type = {
@@ -611,21 +661,19 @@ def get_org_location_map(
) )
) )
map_kwargs: dict = {}
if all_points: if all_points:
lats = [lat for lat, _ in all_points] lats = [lat for lat, _ in all_points]
lons = [lon for _, lon in all_points] lons = [lon for _, lon in all_points]
map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]] center, zoom = bounds_to_center_zoom(min(lats), min(lons), max(lats), max(lons))
map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
else: else:
map_kwargs["center"] = [46.6, 2.2] center, zoom = [46.6, 2.2], 5
map_kwargs["zoom"] = 5
return dl.Map( return dl.Map(
layers, layers,
center=center,
zoom=zoom,
style={"width": "100%", "height": "300px"}, style={"width": "100%", "height": "300px"},
id=map_id, id=map_id,
**map_kwargs,
) )
+31 -2
View File
@@ -1,4 +1,5 @@
import polars as pl import polars as pl
import pytest
def _make_lff(rows): def _make_lff(rows):
@@ -254,7 +255,7 @@ def _org_map_dff():
) )
def test_get_org_location_map_bounds_cover_home_and_counterpart(): def test_get_org_location_map_centers_on_home_and_counterpart():
import dash_leaflet as dl import dash_leaflet as dl
from src.figures import get_org_location_map from src.figures import get_org_location_map
@@ -262,7 +263,9 @@ def test_get_org_location_map_bounds_cover_home_and_counterpart():
leaflet_map = get_org_location_map(_org_map_dff(), "acheteur", "test-map") leaflet_map = get_org_location_map(_org_map_dff(), "acheteur", "test-map")
assert isinstance(leaflet_map, dl.Map) assert isinstance(leaflet_map, dl.Map)
assert leaflet_map.bounds == [[48.11, -1.68], [48.85, 2.35]] # Centre = milieu du point acheteur (2.35, 48.85) et titulaire (-1.68, 48.11).
assert leaflet_map.center == pytest.approx([48.48, 0.335])
assert leaflet_map.zoom == 6
geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)] geojson_layers = [c for c in leaflet_map.children if isinstance(c, dl.GeoJSON)]
assert {layer.id for layer in geojson_layers} == { assert {layer.id for layer in geojson_layers} == {
@@ -320,3 +323,29 @@ def test_get_org_location_map_defaults_to_france_view_without_coordinates():
assert leaflet_map.center == [46.6, 2.2] assert leaflet_map.center == [46.6, 2.2]
assert leaflet_map.zoom == 5 assert leaflet_map.zoom == 5
def test_bounds_to_center_zoom_single_point_uses_max_zoom():
from src.figures import bounds_to_center_zoom
center, zoom = bounds_to_center_zoom(48.85, 2.35, 48.85, 2.35, max_zoom=12)
assert center == [48.85, 2.35]
assert zoom == 12
def test_bounds_to_center_zoom_wider_bounds_give_smaller_zoom():
from src.figures import bounds_to_center_zoom
_, zoom_narrow = bounds_to_center_zoom(48.11, -1.68, 48.85, 2.35)
_, zoom_wide = bounds_to_center_zoom(16.23, -61.55, 51.0, 8.0)
assert zoom_wide < zoom_narrow
def test_bounds_to_center_zoom_center_is_bbox_midpoint():
from src.figures import bounds_to_center_zoom
center, _ = bounds_to_center_zoom(40.0, 0.0, 50.0, 4.0)
assert center == [45.0, 2.0]