From 302d253e6e9211aa5d5c3e8716a0f51d21ce2150 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 14 Mar 2026 00:28:02 +0100 Subject: [PATCH] =?UTF-8?q?Utilisation=20de=20cluster=20de=20marqueurs=20g?= =?UTF-8?q?r=C3=A2ce=20=C3=A0=20dash-leaflet=20#65?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 +- src/assets/dash_clientside.js | 23 ++++++ src/figures.py | 145 +++++++++++++++++++++------------- src/pages/statistiques.py | 4 +- 4 files changed, 116 insertions(+), 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cdca66b..2befbcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,9 @@ dependencies = [ "plotly[express]", "httpx", "pandas", # utilisé pour la création de certains graphiques - "unidecode" + "unidecode", + "dash-leaflet", + "dash-extensions" ] [project.optional-dependencies] diff --git a/src/assets/dash_clientside.js b/src/assets/dash_clientside.js index efef901..babba1c 100644 --- a/src/assets/dash_clientside.js +++ b/src/assets/dash_clientside.js @@ -1,4 +1,27 @@ window.dash_clientside = Object.assign({}, window.dash_clientside, { + leaflet: { + pointToLayer: function (feature, latlng, context) { + return L.circleMarker(latlng, { + radius: 5, + fillColor: feature.properties.marker_color, + color: "white", + weight: 1, + opacity: 1, + fillOpacity: 0.8, + }).bindTooltip(feature.properties.tooltip); + }, + clusterToLayer: function (feature, latlng, index, context) { + const count = feature.properties.point_count; + const size = count < 100 ? 30 : count < 1000 ? 40 : 50; + const color = "#333"; // Default cluster color + const icon = L.divIcon({ + html: `
${count}
`, + className: "marker-cluster", + iconSize: L.point(size, size), + }); + return L.marker(latlng, { icon: icon }); + }, + }, clientside: { clean_filters: function (trigger) { if (!trigger) { diff --git a/src/figures.py b/src/figures.py index c95be13..15319b8 100644 --- a/src/figures.py +++ b/src/figures.py @@ -3,10 +3,13 @@ from typing import Literal from urllib.error import HTTPError, URLError import dash_bootstrap_components as dbc +import dash_leaflet as dl +import dash_leaflet.express as dlx import plotly.express as px import plotly.graph_objects as go import polars as pl from dash import dash_table, dcc, html +from dash_extensions.javascript import Namespace from src.utils import data_schema, df, format_number @@ -452,8 +455,8 @@ def get_geographic_maps(dff: pl.DataFrame) -> list | None: # Ajout des DOM s'ils ont des données for code in dom_codes: - dom_data = dff.filter(pl.col("acheteur_departement_code") == code) - if dom_data.height > 0: + dff_dom = dff.filter(pl.col("acheteur_departement_code") == code) + if dff_dom.height > 0: name = f"Département {code}" if code == "971": name = "Guadeloupe" @@ -465,76 +468,106 @@ def get_geographic_maps(dff: pl.DataFrame) -> list | None: name = "La Réunion" elif code == "976": name = "Mayotte" - regions[name] = dom_data + regions[name] = dff_dom + + # Region centers for dash-leaflet + region_centers = { + "Métropole": ([46.6, 2.2], 6), + "Guadeloupe": ([16.23, -61.55], 9), + "Martinique": ([14.64, -61.02], 10), + "Guyane": ([3.93, -53.12], 7), + "La Réunion": ([-21.11, 55.53], 10), + "Mayotte": ([-12.82, 45.16], 11), + } + + # JavaScript functions for styling + ns = Namespace("dash_clientside", "leaflet") + point_to_layer = ns("pointToLayer") + cluster_to_layer = ns("clusterToLayer") cols = [] for name, region_df in regions.items(): - fig = go.Figure() + # Prepare data for GeoJSON + marker_dicts = [] # Trace Acheteurs - mask_acheteur = region_df.filter( - pl.col("acheteur_latitude").is_not_null() - & pl.col("acheteur_longitude").is_not_null() - ) - if mask_acheteur.height > 0: - fig.add_trace( - go.Scattergeo( - lat=mask_acheteur["acheteur_latitude"], - lon=mask_acheteur["acheteur_longitude"], - mode="markers", - marker=dict(size=6, color=color_acheteur, opacity=0.5), - name="Acheteurs", - text=mask_acheteur["acheteur_nom"], - ) + mask_acheteur = ( + region_df.select( + "uid", "acheteur_longitude", "acheteur_latitude", "acheteur_nom" ) + .group_by("acheteur_longitude", "acheteur_latitude", "acheteur_nom") + .len("nb_marches") + .filter( + pl.col("acheteur_latitude").is_not_null() + & pl.col("acheteur_longitude").is_not_null() + ) + ) + + if mask_acheteur.height > 0: + for row in mask_acheteur.to_dicts(): + marker_dicts.append( + { + "lat": row["acheteur_latitude"], + "lon": row["acheteur_longitude"], + "tooltip": f"{row['acheteur_nom']} ({row['nb_marches']} marchés)", + "marker_color": color_acheteur, + } + ) # Trace Titulaires - mask_titulaire = region_df.filter( - pl.col("titulaire_latitude").is_not_null() - & pl.col("titulaire_longitude").is_not_null() - ) - if mask_titulaire.height > 0: - fig.add_trace( - go.Scattergeo( - lat=mask_titulaire["titulaire_latitude"], - lon=mask_titulaire["titulaire_longitude"], - mode="markers", - marker=dict(size=6, color=color_titulaire, opacity=0.5), - name="Titulaires", - text=mask_titulaire["titulaire_nom"], - ) + mask_titulaire = ( + region_df.select( + "uid", "titulaire_longitude", "titulaire_latitude", "titulaire_nom" + ) + .group_by("titulaire_longitude", "titulaire_latitude", "titulaire_nom") + .len("nb_marches") + .filter( + pl.col("titulaire_latitude").is_not_null() + & pl.col("titulaire_longitude").is_not_null() ) - - # Configuration spécifique de la vue - geo_config = dict( - projection_type="mercator", - showland=True, - landcolor="lightgray", - showcountries=True, - countrycolor="white", - fitbounds="locations" if name != "Métropole" else False, - resolution=50, ) - if name == "Métropole": - geo_config["lataxis_range"] = [41, 52] - geo_config["lonaxis_range"] = [-5, 10] + if mask_titulaire.height > 0: + for row in mask_titulaire.to_dicts(): + marker_dicts.append( + { + "lat": row["titulaire_latitude"], + "lon": row["titulaire_longitude"], + "tooltip": f"{row['titulaire_nom']} ({row['nb_marches']} marchés)", + "marker_color": color_titulaire, + } + ) - fig.update_layout( - title=name, - geo=geo_config, - margin=dict(l=0, r=0, t=30, b=0), - height=400 if name == "Métropole" else 300, - showlegend=(name == "Métropole"), - legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), - ) + geojson_data = dlx.dicts_to_geojson(marker_dicts) - # Taille de la colonne : 4 slots (md=12) pour Métropole, 1 slot (md=3) pour DOM - # On suppose une grille de 12 colonnes où 1 card = md=3 + center, zoom = region_centers.get(name, ([46.6, 2.2], 6)) col_width = 12 if name == "Métropole" else 3 + region_id = name.lower().replace(" ", "-") cols.append( dbc.Col( - dcc.Graph(figure=fig, config={"displayModeBar": False}), + [ + html.H5(name), + dl.Map( + [ + dl.TileLayer(), + dl.GeoJSON( + data=geojson_data, + cluster=True, + zoomToBoundsOnClick=True, + pointToLayer=point_to_layer, + clusterToLayer=cluster_to_layer, + id=f"geojson-{region_id}", + ), + ], + center=center, + zoom=zoom, + style={ + "width": "100%", + "height": "400px" if name == "Métropole" else "300px", + }, + id=f"map-{region_id}", + ), + ], width=12, md=col_width, className="mb-4", diff --git a/src/pages/statistiques.py b/src/pages/statistiques.py index 0237abb..1a74231 100644 --- a/src/pages/statistiques.py +++ b/src/pages/statistiques.py @@ -142,9 +142,7 @@ def udpate_dashboard_cards( ) if dashboard_acheteur_categorie: - lff = lff.filter( - pl.col("acheteur_categorie").is_in(dashboard_acheteur_categorie) - ) + lff = lff.filter(pl.col("acheteur_categorie") == dashboard_acheteur_categorie) if dashboard_acheteur_departement_code: lff = lff.filter(