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:
+57
-9
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
@@ -564,6 +565,53 @@ def make_clusters_map(region: dict) -> dl.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(
|
||||
dff: pl.DataFrame,
|
||||
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
|
||||
consultée) ainsi que ceux de son type complémentaire, clusterisés et
|
||||
colorés comme sur /observatoire. Cadrage automatique (fitBounds) sur
|
||||
l'ensemble des points ; repli sur une vue France fixe si aucun point
|
||||
n'est disponible.
|
||||
colorés comme sur /observatoire. Cadrage calculé côté serveur
|
||||
(`bounds_to_center_zoom`) sur l'ensemble des points ; repli sur une vue
|
||||
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()
|
||||
markers_by_type = {
|
||||
@@ -611,21 +661,19 @@ def get_org_location_map(
|
||||
)
|
||||
)
|
||||
|
||||
map_kwargs: dict = {}
|
||||
if all_points:
|
||||
lats = [lat for lat, _ in all_points]
|
||||
lons = [lon for _, lon in all_points]
|
||||
map_kwargs["bounds"] = [[min(lats), min(lons)], [max(lats), max(lons)]]
|
||||
map_kwargs["boundsOptions"] = {"padding": [30, 30], "maxZoom": 12}
|
||||
center, zoom = bounds_to_center_zoom(min(lats), min(lons), max(lats), max(lons))
|
||||
else:
|
||||
map_kwargs["center"] = [46.6, 2.2]
|
||||
map_kwargs["zoom"] = 5
|
||||
center, zoom = [46.6, 2.2], 5
|
||||
|
||||
return dl.Map(
|
||||
layers,
|
||||
center=center,
|
||||
zoom=zoom,
|
||||
style={"width": "100%", "height": "300px"},
|
||||
id=map_id,
|
||||
**map_kwargs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+31
-2
@@ -1,4 +1,5 @@
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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)]
|
||||
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.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]
|
||||
|
||||
Reference in New Issue
Block a user