diff --git a/requirements.txt b/requirements.txt index 9be6257ef..a1bac4fe9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # generated from manifests external_dependencies geojson +requests shapely diff --git a/web_leaflet_lib/README.rst b/web_leaflet_lib/README.rst index 3f9b1be4b..5ba5f7e4e 100644 --- a/web_leaflet_lib/README.rst +++ b/web_leaflet_lib/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================== Leaflet Javascript Library ========================== @@ -17,7 +13,7 @@ Leaflet Javascript Library .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -109,11 +105,15 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo Other credits ------------- @@ -140,10 +140,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_leaflet_lib/__manifest__.py b/web_leaflet_lib/__manifest__.py index 5405145c8..cfc41baa6 100644 --- a/web_leaflet_lib/__manifest__.py +++ b/web_leaflet_lib/__manifest__.py @@ -1,18 +1,25 @@ # Copyright (C) 2024 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Leaflet Javascript Library", - "summary": "Bring leaflet.js librairy in odoo.", - "version": "18.0.1.1.0", - "author": "GRAP, Odoo Community Association (OCA)", - "maintainers": ["legalsylvain"], + "summary": "Bring leaflet.js library in Odoo with geocoding and routing support.", + "version": "18.0.1.2.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", + "maintainers": ["legalsylvain", "mileo"], "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", "depends": ["base"], - "data": ["data/ir_config_parameter.xml"], + "external_dependencies": { + "python": ["requests"], + }, + "data": [ + "data/ir_config_parameter.xml", + "views/res_config_settings.xml", + ], "assets": { "web.assets_backend": [ "/web_leaflet_lib/static/lib/leaflet/*", diff --git a/web_leaflet_lib/data/ir_config_parameter.xml b/web_leaflet_lib/data/ir_config_parameter.xml index 51d750053..b8af736b0 100644 --- a/web_leaflet_lib/data/ir_config_parameter.xml +++ b/web_leaflet_lib/data/ir_config_parameter.xml @@ -2,27 +2,53 @@ - + + leaflet.copyright OpenStreetMap]]> - + >OpenStreetMap]]> + + + leaflet.tile_url + False + + - leaflet.tile_url - False + leaflet.geocoding_provider + nominatim + + + leaflet.nominatim_url + https://nominatim.openstreetmap.org + + + leaflet.geocoding_throttle_ms + 1000 + + + + + leaflet.routing_provider + osrm + + + leaflet.osrm_url + https://router.project-osrm.org + + + leaflet.max_waypoints + 25 diff --git a/web_leaflet_lib/models/__init__.py b/web_leaflet_lib/models/__init__.py index 9a5eb7187..faeabfb45 100644 --- a/web_leaflet_lib/models/__init__.py +++ b/web_leaflet_lib/models/__init__.py @@ -1 +1,6 @@ +from . import geocoding_mixin from . import ir_http +from . import mapbox_service +from . import osrm_service +from . import res_config_settings +from . import routing_service diff --git a/web_leaflet_lib/models/geocoding_mixin.py b/web_leaflet_lib/models/geocoding_mixin.py new file mode 100644 index 000000000..fb6d37e75 --- /dev/null +++ b/web_leaflet_lib/models/geocoding_mixin.py @@ -0,0 +1,303 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging +import time +from functools import wraps + +import requests + +from odoo import api, models + +from .routing_service import RoutingServiceFactory + +_logger = logging.getLogger(__name__) + +# Throttling for Nominatim API (OSM requirement: max 1 request per second) +NOMINATIM_THROTTLE_MS = 1000 +_last_nominatim_request = 0 + + +def throttle_nominatim(func): + """Decorator to enforce Nominatim rate limiting (1 req/sec).""" + + @wraps(func) + def wrapper(*args, **kwargs): + global _last_nominatim_request + now = time.time() * 1000 # Convert to milliseconds + elapsed = now - _last_nominatim_request + if elapsed < NOMINATIM_THROTTLE_MS: + sleep_time = (NOMINATIM_THROTTLE_MS - elapsed) / 1000 + _logger.debug("Throttling Nominatim request, sleeping %.2fs", sleep_time) + time.sleep(sleep_time) + _last_nominatim_request = time.time() * 1000 + return func(*args, **kwargs) + + return wrapper + + +class GeocodingMixin(models.AbstractModel): + """Mixin providing geocoding capabilities using Nominatim (OSM) or MapBox.""" + + _name = "leaflet.geocoding.mixin" + _description = "Leaflet Geocoding Mixin" + + @api.model + def _get_geocoding_provider(self): + """Get the configured geocoding provider.""" + config = self.env["ir.config_parameter"].sudo() + provider = config.get_param("leaflet.geocoding_provider", "nominatim") + return provider + + @api.model + def _get_mapbox_token(self): + """Get MapBox API token if configured.""" + return RoutingServiceFactory(self.env).get_mapbox_token() + + @api.model + def _get_nominatim_url(self): + """Get Nominatim server URL.""" + config = self.env["ir.config_parameter"].sudo() + return config.get_param( + "leaflet.nominatim_url", "https://nominatim.openstreetmap.org" + ) + + @api.model + def geocode_address(self, address, country_code=None): + """ + Geocode an address to coordinates. + + Args: + address: String address to geocode + country_code: Optional 2-letter ISO country code to limit results + + Returns: + dict with 'lat', 'lng', 'display_name' or None if not found + """ + provider = self._get_geocoding_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if token is configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._geocode_mapbox(address, country_code) + if result: + return result + if provider == "mapbox": + _logger.warning("MapBox geocoding failed, no fallback configured") + return None + + # Fallback to Nominatim (OSM) + return self._geocode_nominatim(address, country_code) + + @api.model + @throttle_nominatim + def _geocode_nominatim(self, address, country_code=None): + """Geocode using Nominatim (OpenStreetMap).""" + base_url = self._get_nominatim_url() + url = f"{base_url}/search" + + params = { + "q": address, + "format": "json", + "limit": 1, + "addressdetails": 1, + } + if country_code: + params["countrycodes"] = country_code.lower() + + headers = { + "User-Agent": "Odoo-Leaflet-Map/1.0 (contact@yourcompany.com)", + } + + try: + response = requests.get(url, params=params, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + if data: + result = data[0] + return { + "lat": float(result["lat"]), + "lng": float(result["lon"]), + "display_name": result.get("display_name", address), + "provider": "nominatim", + } + return None + + except requests.RequestException as e: + _logger.warning("Nominatim geocoding failed: %s", e) + return None + + @api.model + def _geocode_mapbox(self, address, country_code=None): + """Geocode using MapBox API.""" + token = self._get_mapbox_token() + if not token: + return None + + import urllib.parse + + encoded_address = urllib.parse.quote(address) + url = ( + f"https://api.mapbox.com/geocoding/v5/mapbox.places/{encoded_address}.json" + ) + + params = { + "access_token": token, + "limit": 1, + } + if country_code: + params["country"] = country_code.lower() + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + features = data.get("features", []) + if features: + result = features[0] + coords = result["geometry"]["coordinates"] + return { + "lat": coords[1], + "lng": coords[0], + "display_name": result.get("place_name", address), + "provider": "mapbox", + } + return None + + except requests.RequestException as e: + _logger.warning("MapBox geocoding failed: %s", e) + return None + + @api.model + def reverse_geocode(self, lat, lng): + """ + Reverse geocode coordinates to an address. + + Args: + lat: Latitude + lng: Longitude + + Returns: + dict with 'address', 'display_name' or None if not found + """ + provider = self._get_geocoding_provider() + mapbox_token = self._get_mapbox_token() + + # Try MapBox first if token is configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + result = self._reverse_geocode_mapbox(lat, lng) + if result: + return result + + # Fallback to Nominatim + return self._reverse_geocode_nominatim(lat, lng) + + @api.model + @throttle_nominatim + def _reverse_geocode_nominatim(self, lat, lng): + """Reverse geocode using Nominatim (OpenStreetMap).""" + base_url = self._get_nominatim_url() + url = f"{base_url}/reverse" + + params = { + "lat": lat, + "lon": lng, + "format": "json", + "addressdetails": 1, + } + + headers = { + "User-Agent": "Odoo-Leaflet-Map/1.0 (contact@yourcompany.com)", + } + + try: + response = requests.get(url, params=params, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + if data and "address" in data: + return { + "address": data.get("address", {}), + "display_name": data.get("display_name", ""), + "provider": "nominatim", + } + return None + + except requests.RequestException as e: + _logger.warning("Nominatim reverse geocoding failed: %s", e) + return None + + @api.model + def _reverse_geocode_mapbox(self, lat, lng): + """Reverse geocode using MapBox API.""" + token = self._get_mapbox_token() + if not token: + return None + + url = f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json" + params = { + "access_token": token, + "limit": 1, + } + + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + features = data.get("features", []) + if features: + result = features[0] + return { + "address": result.get("context", {}), + "display_name": result.get("place_name", ""), + "provider": "mapbox", + } + return None + + except requests.RequestException as e: + _logger.warning("MapBox reverse geocoding failed: %s", e) + return None + + @api.model + def validate_coordinates(self, lat, lng): + """ + Validate that coordinates are within valid ranges. + + Args: + lat: Latitude (-90 to 90) + lng: Longitude (-180 to 180) + + Returns: + bool: True if valid, False otherwise + """ + try: + lat = float(lat) + lng = float(lng) + return -90 <= lat <= 90 and -180 <= lng <= 180 + except (TypeError, ValueError): + return False + + @api.model + def batch_geocode(self, addresses, country_code=None, delay_ms=None): + """ + Batch geocode multiple addresses with rate limiting. + + Args: + addresses: List of address strings + country_code: Optional country code to limit results + delay_ms: Optional delay between requests (default: provider-specific) + + Returns: + List of geocoding results (or None for failed lookups) + """ + results = [] + for address in addresses: + result = self.geocode_address(address, country_code) + results.append(result) + # Additional delay if specified (on top of provider throttling) + if delay_ms: + time.sleep(delay_ms / 1000) + return results diff --git a/web_leaflet_lib/models/ir_http.py b/web_leaflet_lib/models/ir_http.py index 9fe6aad64..d5b21ead7 100644 --- a/web_leaflet_lib/models/ir_http.py +++ b/web_leaflet_lib/models/ir_http.py @@ -1,5 +1,6 @@ # Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models @@ -13,8 +14,34 @@ def session_info(self): config = self.env["ir.config_parameter"].sudo() result.update( { + # Display configuration "leaflet.tile_url": config.get_param("leaflet.tile_url", default=""), "leaflet.copyright": config.get_param("leaflet.copyright", default=""), + # Geocoding configuration + "leaflet.geocoding_provider": config.get_param( + "leaflet.geocoding_provider", default="nominatim" + ), + "leaflet.nominatim_url": config.get_param( + "leaflet.nominatim_url", + default="https://nominatim.openstreetmap.org", + ), + "leaflet.geocoding_throttle_ms": int( + config.get_param("leaflet.geocoding_throttle_ms", default="1000") + ), + # Routing configuration + "leaflet.routing_provider": config.get_param( + "leaflet.routing_provider", default="osrm" + ), + "leaflet.osrm_url": config.get_param( + "leaflet.osrm_url", default="https://router.project-osrm.org" + ), + "leaflet.max_waypoints": int( + config.get_param("leaflet.max_waypoints", default="25") + ), + # MapBox token (if configured) + "leaflet.mapbox_token": config.get_param( + "leaflet.mapbox_token", default="" + ), } ) return result diff --git a/web_leaflet_lib/models/mapbox_service.py b/web_leaflet_lib/models/mapbox_service.py new file mode 100644 index 000000000..8b4c1aa3e --- /dev/null +++ b/web_leaflet_lib/models/mapbox_service.py @@ -0,0 +1,242 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +""" +MapBox Directions API client. + +Pure Python implementation with no Odoo dependencies. +Can be used standalone or through the RoutingServiceFactory. +""" + +import logging + +import requests + +_logger = logging.getLogger(__name__) + + +class MapBoxService: + """ + MapBox Directions API client. + + Provides methods for: + - Getting routes between waypoints + - Route optimization (Optimization API) + + Requires a valid MapBox access token. + """ + + BASE_URL = "https://api.mapbox.com" + TIMEOUT = 60 + + # Map common profile names to MapBox profiles + PROFILE_MAP = { + "driving": "driving", + "walking": "walking", + "cycling": "cycling", + "car": "driving", + "foot": "walking", + "bike": "cycling", + } + + def __init__(self, access_token, profile="driving"): + """ + Initialize MapBox service. + + Args: + access_token: MapBox API access token + profile: Default routing profile (driving, walking, cycling) + """ + self.access_token = access_token + self.profile = self.PROFILE_MAP.get(profile, "driving") + + def _format_coordinates(self, locations): + """ + Format locations for MapBox API (expects lng,lat order). + + Args: + locations: List of [lat, lng] or (lat, lng) coordinate pairs + + Returns: + String of coordinates in "lng,lat;lng,lat" format + """ + return ";".join([f"{loc[1]},{loc[0]}" for loc in locations]) + + def _convert_geometry_to_latlng(self, geojson_coords): + """ + Convert GeoJSON coordinates from [lng, lat] to [lat, lng]. + + Args: + geojson_coords: List of [lng, lat] coordinates + + Returns: + List of [lat, lng] coordinates + """ + return [[coord[1], coord[0]] for coord in geojson_coords] + + def _get_profile(self, profile=None): + """Get MapBox profile name.""" + profile = profile or self.profile + return self.PROFILE_MAP.get(profile, profile) + + def get_route(self, waypoints, profile=None): + """ + Get route between waypoints using MapBox Directions API. + + Args: + waypoints: List of [lat, lng] or (lat, lng) coordinate pairs + profile: Routing profile (default: instance profile) + + Returns: + dict with: + - geometry: List of [lat, lng] for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - legs: Route segments with distance, duration, and steps + None if request fails + """ + if not self.access_token: + _logger.warning("MapBox access token not configured") + return None + + if len(waypoints) < 2: + return None + + profile = self._get_profile(profile) + coords = self._format_coordinates(waypoints) + url = f"{self.BASE_URL}/directions/v5/mapbox/{profile}/{coords}" + + params = { + "access_token": self.access_token, + "overview": "full", + "geometries": "geojson", + "steps": "true", + } + + try: + response = requests.get(url, params=params, timeout=self.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("MapBox routing failed: %s", data.get("message")) + return None + + route = data["routes"][0] + geometry = self._convert_geometry_to_latlng( + route["geometry"]["coordinates"] + ) + + return { + "geometry": geometry, + "distance": route["distance"], + "duration": route["duration"], + "legs": [ + { + "distance": leg["distance"], + "duration": leg["duration"], + "summary": leg.get("summary", ""), + "steps": [ + { + "distance": step["distance"], + "duration": step["duration"], + "instruction": step.get("maneuver", {}).get( + "instruction", "" + ), + "name": step.get("name", ""), + } + for step in leg.get("steps", []) + ], + } + for leg in route["legs"] + ], + "provider": "mapbox", + } + + except requests.RequestException as e: + _logger.warning("MapBox routing request failed: %s", e) + return None + + def get_optimized_route(self, waypoints, profile=None, roundtrip=False): + """ + Get TSP-optimized route using MapBox Optimization API. + + Args: + waypoints: List of [lat, lng] or (lat, lng) coordinate pairs + profile: Routing profile (default: instance profile) + roundtrip: Whether to return to starting point + + Returns: + dict with: + - geometry: List of [lat, lng] for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - waypoint_order: Optimized order of waypoint indices + - optimized_waypoints: Waypoints reordered according to optimization + None if request fails + """ + if not self.access_token: + _logger.warning("MapBox access token not configured") + return None + + if len(waypoints) < 2: + return None + + profile = self._get_profile(profile) + coords = self._format_coordinates(waypoints) + url = f"{self.BASE_URL}/optimized-trips/v1/mapbox/{profile}/{coords}" + + params = { + "access_token": self.access_token, + "overview": "full", + "geometries": "geojson", + "steps": "true", + "roundtrip": "true" if roundtrip else "false", + "source": "first", + "destination": "last", + } + + try: + response = requests.get(url, params=params, timeout=self.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("MapBox optimization failed: %s", data.get("message")) + return None + + trip = data["trips"][0] + waypoint_order = [wp["waypoint_index"] for wp in data["waypoints"]] + geometry = self._convert_geometry_to_latlng(trip["geometry"]["coordinates"]) + + return { + "geometry": geometry, + "distance": trip["distance"], + "duration": trip["duration"], + "waypoint_order": waypoint_order, + "optimized_waypoints": [waypoints[i] for i in waypoint_order], + "provider": "mapbox", + } + + except requests.RequestException as e: + _logger.warning("MapBox optimization request failed: %s", e) + return None + + def is_available(self): + """ + Check if MapBox API is available and token is valid. + + Returns: + bool: True if API responds with valid token, False otherwise + """ + if not self.access_token: + return False + + try: + # Simple check using a geocoding request (smaller response) + url = f"{self.BASE_URL}/geocoding/v5/mapbox.places/test.json" + params = {"access_token": self.access_token, "limit": 1} + response = requests.get(url, params=params, timeout=5) + return response.status_code == 200 + except requests.RequestException: + return False diff --git a/web_leaflet_lib/models/osrm_service.py b/web_leaflet_lib/models/osrm_service.py new file mode 100644 index 000000000..85bb40e6d --- /dev/null +++ b/web_leaflet_lib/models/osrm_service.py @@ -0,0 +1,268 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +""" +OSRM (Open Source Routing Machine) API client. + +Pure Python implementation with no Odoo dependencies. +Can be used standalone or through the RoutingServiceFactory. +""" + +import logging + +import requests + +_logger = logging.getLogger(__name__) + + +class OSRMService: + """ + OSRM (Open Source Routing Machine) API client. + + Provides methods for: + - Getting distance/duration matrices between multiple locations + - Getting route geometry for visualization (polylines) + - Route optimization (trip API) + + Default server: https://router.project-osrm.org (public demo server) + For production, consider self-hosting OSRM. + """ + + DEFAULT_URL = "https://router.project-osrm.org" + TIMEOUT = 60 + + def __init__(self, base_url=None, profile="driving"): + """ + Initialize OSRM service. + + Args: + base_url: OSRM server URL (default: public server) + profile: Default routing profile (driving, walking, cycling) + """ + self.base_url = (base_url or self.DEFAULT_URL).rstrip("/") + self.profile = profile + + def _format_coordinates(self, locations): + """ + Format locations for OSRM API (expects lng,lat order). + + Args: + locations: List of [lat, lng] or (lat, lng) coordinate pairs + + Returns: + String of coordinates in "lng,lat;lng,lat" format + """ + return ";".join([f"{loc[1]},{loc[0]}" for loc in locations]) + + def _convert_geometry_to_latlng(self, geojson_coords): + """ + Convert GeoJSON coordinates from [lng, lat] to [lat, lng]. + + Args: + geojson_coords: List of [lng, lat] coordinates + + Returns: + List of [lat, lng] coordinates + """ + return [[coord[1], coord[0]] for coord in geojson_coords] + + def get_distance_matrix(self, locations, profile=None): + """ + Get distance and duration matrix between locations using OSRM Table API. + + Args: + locations: List of [lat, lng] or (lat, lng) coordinate pairs + profile: Routing profile (default: instance profile) + + Returns: + dict with: + - distances: 2D list of distances in meters + - durations: 2D list of durations in seconds + - distances_km: 2D list of distances in kilometers (for VRP) + None if request fails + """ + if len(locations) < 2: + return None + + profile = profile or self.profile + coords = self._format_coordinates(locations) + url = f"{self.base_url}/table/v1/{profile}/{coords}" + + params = { + "annotations": "distance,duration", + } + + try: + response = requests.get(url, params=params, timeout=self.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM table failed: %s", data.get("message")) + return None + + # Convert distances from meters to kilometers + distances = data.get("distances", []) + distances_km = [ + [d / 1000 if d is not None else None for d in row] for row in distances + ] + + return { + "distances": distances, + "durations": data.get("durations", []), + "distances_km": distances_km, + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM table request failed: %s", e) + return None + + def get_route(self, waypoints, profile=None): + """ + Get route between waypoints including geometry for polylines. + + Args: + waypoints: List of [lat, lng] or (lat, lng) coordinate pairs + profile: Routing profile (default: instance profile) + + Returns: + dict with: + - geometry: List of [lat, lng] for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - legs: Route segments with distance, duration, and steps + None if request fails + """ + if len(waypoints) < 2: + return None + + profile = profile or self.profile + coords = self._format_coordinates(waypoints) + url = f"{self.base_url}/route/v1/{profile}/{coords}" + + params = { + "overview": "full", + "geometries": "geojson", + "steps": "true", + } + + try: + response = requests.get(url, params=params, timeout=self.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM route failed: %s", data.get("message")) + return None + + route = data["routes"][0] + geometry = self._convert_geometry_to_latlng( + route["geometry"]["coordinates"] + ) + + return { + "geometry": geometry, + "distance": route["distance"], + "duration": route["duration"], + "legs": [ + { + "distance": leg["distance"], + "duration": leg["duration"], + "summary": leg.get("summary", ""), + "steps": [ + { + "distance": step["distance"], + "duration": step["duration"], + "instruction": step.get("maneuver", {}).get( + "instruction", "" + ), + "name": step.get("name", ""), + } + for step in leg.get("steps", []) + ], + } + for leg in route["legs"] + ], + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM route request failed: %s", e) + return None + + def get_optimized_route(self, waypoints, profile=None, roundtrip=False): + """ + Get TSP-optimized route visiting all waypoints using OSRM Trip API. + + Args: + waypoints: List of [lat, lng] or (lat, lng) coordinate pairs + profile: Routing profile (default: instance profile) + roundtrip: Whether to return to starting point + + Returns: + dict with: + - geometry: List of [lat, lng] for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - waypoint_order: Optimized order of waypoint indices + - optimized_waypoints: Waypoints reordered according to optimization + None if request fails + """ + if len(waypoints) < 2: + return None + + profile = profile or self.profile + coords = self._format_coordinates(waypoints) + url = f"{self.base_url}/trip/v1/{profile}/{coords}" + + params = { + "overview": "full", + "geometries": "geojson", + "steps": "true", + "roundtrip": "true" if roundtrip else "false", + "source": "first", + "destination": "last", + } + + try: + response = requests.get(url, params=params, timeout=self.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM trip failed: %s", data.get("message")) + return None + + trip = data["trips"][0] + waypoint_order = [wp["waypoint_index"] for wp in data["waypoints"]] + geometry = self._convert_geometry_to_latlng(trip["geometry"]["coordinates"]) + + return { + "geometry": geometry, + "distance": trip["distance"], + "duration": trip["duration"], + "waypoint_order": waypoint_order, + "optimized_waypoints": [waypoints[i] for i in waypoint_order], + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM trip request failed: %s", e) + return None + + def is_available(self): + """ + Check if OSRM server is available. + + Returns: + bool: True if server responds, False otherwise + """ + try: + # Simple health check - get route between two close points + test_coords = "-43.1729,-22.9068;-43.1739,-22.9078" # Rio de Janeiro + url = f"{self.base_url}/route/v1/driving/{test_coords}" + response = requests.get(url, timeout=5) + return response.status_code == 200 + except requests.RequestException: + return False diff --git a/web_leaflet_lib/models/res_config_settings.py b/web_leaflet_lib/models/res_config_settings.py new file mode 100644 index 000000000..868753eac --- /dev/null +++ b/web_leaflet_lib/models/res_config_settings.py @@ -0,0 +1,92 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Geocoding Configuration + leaflet_geocoding_provider = fields.Selection( + selection=[ + ("nominatim", "Nominatim (OSM) - Free"), + ("mapbox", "MapBox - Premium"), + ("auto", "Auto (MapBox with Nominatim fallback)"), + ], + string="Geocoding Provider", + default="nominatim", + config_parameter="leaflet.geocoding_provider", + help="Provider for address-to-coordinates conversion.\n" + "Nominatim is free but limited to 1 request/second.\n" + "MapBox requires an API token but has higher limits.", + ) + + leaflet_mapbox_token = fields.Char( + string="MapBox Access Token", + config_parameter="leaflet.mapbox_token", + help="Your MapBox public access token for geocoding and routing.\n" + "Get one at https://account.mapbox.com/access-tokens/", + ) + + leaflet_nominatim_url = fields.Char( + string="Nominatim Server URL", + default="https://nominatim.openstreetmap.org", + config_parameter="leaflet.nominatim_url", + help="Nominatim server URL. Use default for OSM public server\n" + "or specify your own self-hosted instance.", + ) + + # Routing Configuration + leaflet_routing_provider = fields.Selection( + selection=[ + ("osrm", "OSRM - Free"), + ("mapbox", "MapBox - Premium"), + ("auto", "Auto (MapBox with OSRM fallback)"), + ], + string="Routing Provider", + default="osrm", + config_parameter="leaflet.routing_provider", + help="Provider for route calculation and directions.\n" + "OSRM is free and can be self-hosted.\n" + "MapBox requires an API token.", + ) + + leaflet_osrm_url = fields.Char( + string="OSRM Server URL", + default="https://router.project-osrm.org", + config_parameter="leaflet.osrm_url", + help="OSRM routing server URL. Use default for public server\n" + "or specify your own self-hosted instance for production use.", + ) + + # Tile Configuration + leaflet_tile_url = fields.Char( + string="Tile Server URL", + config_parameter="leaflet.tile_url", + help="Custom map tile server URL. Leave empty for default OSM tiles.\n" + "Example: https://api.mapbox.com/styles/v1/{username}/{style_id}/tiles/{z}/{x}/{y}", + ) + + leaflet_copyright = fields.Char( + string="Map Copyright", + config_parameter="leaflet.copyright", + help="Copyright attribution for the map tiles.", + ) + + # Performance Settings + leaflet_geocoding_throttle_ms = fields.Integer( + string="Geocoding Throttle (ms)", + default=1000, + config_parameter="leaflet.geocoding_throttle_ms", + help="Minimum delay between geocoding requests in milliseconds.\n" + "Default: 1000ms (required for OSM Nominatim).", + ) + + leaflet_max_waypoints = fields.Integer( + string="Max Routing Waypoints", + default=25, + config_parameter="leaflet.max_waypoints", + help="Maximum number of waypoints for routing requests.\n" + "OSRM and MapBox limit: 25 waypoints.", + ) diff --git a/web_leaflet_lib/models/routing_service.py b/web_leaflet_lib/models/routing_service.py new file mode 100644 index 000000000..85d84fc68 --- /dev/null +++ b/web_leaflet_lib/models/routing_service.py @@ -0,0 +1,256 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +""" +Routing Service Factory. + +Provides Odoo-aware instantiation of routing services (OSRM, MapBox) +based on system configuration parameters. +""" + +import logging + +from .mapbox_service import MapBoxService +from .osrm_service import OSRMService + +_logger = logging.getLogger(__name__) + + +class RoutingServiceFactory: + """ + Factory for creating routing service instances based on Odoo configuration. + + Configuration Parameters: + - leaflet.routing_provider: Provider selection ('osrm', 'mapbox', 'auto') + - leaflet.osrm_url: OSRM server URL + - leaflet.mapbox_token: MapBox API token + - leaflet.max_waypoints: Maximum waypoints for routing + + The factory also supports legacy TMS configuration: + - tms.osrm_server_url: Falls back to this if leaflet.osrm_url not set + """ + + # Default configuration keys + PARAM_ROUTING_PROVIDER = "leaflet.routing_provider" + PARAM_OSRM_URL = "leaflet.osrm_url" + PARAM_MAPBOX_TOKEN = "leaflet.mapbox_token" + PARAM_MAX_WAYPOINTS = "leaflet.max_waypoints" + + # Legacy TMS configuration (for backwards compatibility) + PARAM_TMS_OSRM_URL = "tms.osrm_server_url" + + # Defaults + DEFAULT_PROVIDER = "osrm" + DEFAULT_MAX_WAYPOINTS = 25 + + def __init__(self, env): + """ + Initialize the factory with Odoo environment. + + Args: + env: Odoo Environment (self.env from a model) + """ + self.env = env + self._config = None + + def _get_config(self): + """Get configuration parameter accessor (cached).""" + if self._config is None: + self._config = self.env["ir.config_parameter"].sudo() + return self._config + + def get_routing_provider(self): + """Get the configured routing provider name.""" + return self._get_config().get_param( + self.PARAM_ROUTING_PROVIDER, self.DEFAULT_PROVIDER + ) + + def get_osrm_url(self): + """ + Get OSRM server URL from configuration. + + Priority: + 1. leaflet.osrm_url + 2. tms.osrm_server_url (legacy) + 3. OSRMService.DEFAULT_URL + """ + config = self._get_config() + return ( + config.get_param(self.PARAM_OSRM_URL) + or config.get_param(self.PARAM_TMS_OSRM_URL) + or OSRMService.DEFAULT_URL + ) + + def get_mapbox_token(self): + """Get MapBox API token from configuration.""" + return self._get_config().get_param(self.PARAM_MAPBOX_TOKEN, "") + + def get_max_waypoints(self): + """Get maximum waypoints for routing.""" + return int( + self._get_config().get_param( + self.PARAM_MAX_WAYPOINTS, self.DEFAULT_MAX_WAYPOINTS + ) + ) + + def get_osrm_service(self, profile="driving"): + """ + Get configured OSRM service instance. + + Args: + profile: Routing profile (driving, walking, cycling) + + Returns: + OSRMService instance + """ + return OSRMService(base_url=self.get_osrm_url(), profile=profile) + + def get_mapbox_service(self, profile="driving"): + """ + Get configured MapBox service instance. + + Args: + profile: Routing profile (driving, walking, cycling) + + Returns: + MapBoxService instance or None if token not configured + """ + token = self.get_mapbox_token() + if not token: + return None + return MapBoxService(access_token=token, profile=profile) + + def get_service(self, provider=None, profile="driving"): + """ + Get routing service based on configuration or explicit provider. + + Args: + provider: Explicit provider name ('osrm', 'mapbox', 'auto') + If None, uses configured provider + profile: Routing profile (driving, walking, cycling) + + Returns: + OSRMService or MapBoxService instance + + The 'auto' provider tries MapBox first (if token configured), + then falls back to OSRM. + """ + provider = provider or self.get_routing_provider() + + if provider == "mapbox": + service = self.get_mapbox_service(profile) + if service: + return service + _logger.warning( + "MapBox requested but token not configured, falling back to OSRM" + ) + return self.get_osrm_service(profile) + + if provider == "auto": + # Try MapBox first if token is configured + mapbox_token = self.get_mapbox_token() + if mapbox_token: + return self.get_mapbox_service(profile) + return self.get_osrm_service(profile) + + # Default to OSRM + return self.get_osrm_service(profile) + + def get_route(self, waypoints, profile="driving", provider=None): + """ + Convenience method to get a route using configured service. + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile + provider: Explicit provider (optional) + + Returns: + Route dict or None + """ + max_waypoints = self.get_max_waypoints() + if len(waypoints) > max_waypoints: + _logger.warning( + "Too many waypoints (%d), truncating to %d", + len(waypoints), + max_waypoints, + ) + waypoints = waypoints[:max_waypoints] + + service = self.get_service(provider, profile) + return service.get_route(waypoints) + + def get_optimized_route( + self, waypoints, profile="driving", roundtrip=False, provider=None + ): + """ + Convenience method to get an optimized route using configured service. + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile + roundtrip: Whether to return to starting point + provider: Explicit provider (optional) + + Returns: + Optimized route dict or None + """ + service = self.get_service(provider, profile) + return service.get_optimized_route(waypoints, roundtrip=roundtrip) + + def get_distance_matrix(self, origins, destinations=None): + """ + Get distance matrix using OSRM (MapBox doesn't support matrix API). + + Args: + origins: List of [lat, lng] coordinate pairs + destinations: List of [lat, lng] pairs (defaults to origins) + + Returns: + Distance matrix dict or None + """ + osrm = self.get_osrm_service() + + if destinations is None: + return osrm.get_distance_matrix(origins) + + # OSRM table API with separate sources/destinations + all_coords = origins + destinations + coords = osrm._format_coordinates(all_coords) + + source_indices = list(range(len(origins))) + dest_indices = list(range(len(origins), len(all_coords))) + + import requests + + url = f"{osrm.base_url}/table/v1/driving/{coords}" + params = { + "sources": ";".join(map(str, source_indices)), + "destinations": ";".join(map(str, dest_indices)), + "annotations": "distance,duration", + } + + try: + response = requests.get(url, params=params, timeout=osrm.TIMEOUT) + response.raise_for_status() + data = response.json() + + if data.get("code") != "Ok": + _logger.warning("OSRM table failed: %s", data.get("message")) + return None + + distances = data.get("distances", []) + distances_km = [ + [d / 1000 if d is not None else None for d in row] for row in distances + ] + + return { + "distances": distances, + "durations": data.get("durations", []), + "distances_km": distances_km, + "provider": "osrm", + } + + except requests.RequestException as e: + _logger.warning("OSRM table request failed: %s", e) + return None diff --git a/web_leaflet_lib/readme/CONTRIBUTORS.md b/web_leaflet_lib/readme/CONTRIBUTORS.md index 4a6b63400..f4fbab081 100644 --- a/web_leaflet_lib/readme/CONTRIBUTORS.md +++ b/web_leaflet_lib/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ - Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> diff --git a/web_leaflet_lib/static/description/index.html b/web_leaflet_lib/static/description/index.html index 76651fc02..34afe52fb 100644 --- a/web_leaflet_lib/static/description/index.html +++ b/web_leaflet_lib/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Javascript Library -
+
+

Leaflet Javascript Library

- - -Odoo Community Association - -
-

Leaflet Javascript Library

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

This module extends odoo to include Leaflet Javacript library.

This module is used by web_view_leaflet_map.

Important Note

@@ -426,7 +421,7 @@

Leaflet Javascript Library

-

Configuration

+

Configuration

  • Go to Settings > Technical > Parameters > System Parameters
  • Create or edit the parameter with the key leaflet.tile_url
  • @@ -435,7 +430,7 @@

    Configuration

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -443,21 +438,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Other credits

+

Other credits

The module embed:

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -474,13 +474,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_leaflet_lib/tests/__init__.py b/web_leaflet_lib/tests/__init__.py new file mode 100644 index 000000000..59227a09d --- /dev/null +++ b/web_leaflet_lib/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_geocoding diff --git a/web_leaflet_lib/tests/test_geocoding.py b/web_leaflet_lib/tests/test_geocoding.py new file mode 100644 index 000000000..397a00f1b --- /dev/null +++ b/web_leaflet_lib/tests/test_geocoding.py @@ -0,0 +1,100 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from unittest.mock import MagicMock, patch + +from odoo.tests.common import TransactionCase + + +class TestGeocodingMixin(TransactionCase): + """Tests for the Leaflet Geocoding Mixin.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.GeocodingMixin = cls.env["leaflet.geocoding.mixin"] + + def test_validate_coordinates_valid(self): + """Test coordinate validation with valid coordinates.""" + self.assertTrue(self.GeocodingMixin.validate_coordinates(45.0, 90.0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(-45.0, -90.0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(0, 0)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(90, 180)) + self.assertTrue(self.GeocodingMixin.validate_coordinates(-90, -180)) + + def test_validate_coordinates_invalid(self): + """Test coordinate validation with invalid coordinates.""" + self.assertFalse(self.GeocodingMixin.validate_coordinates(91, 0)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(-91, 0)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(0, 181)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(0, -181)) + self.assertFalse(self.GeocodingMixin.validate_coordinates(None, None)) + self.assertFalse(self.GeocodingMixin.validate_coordinates("invalid", 0)) + + def test_get_geocoding_provider_default(self): + """Test default geocoding provider is nominatim.""" + provider = self.GeocodingMixin._get_geocoding_provider() + self.assertEqual(provider, "nominatim") + + def test_get_nominatim_url_default(self): + """Test default Nominatim URL.""" + url = self.GeocodingMixin._get_nominatim_url() + self.assertEqual(url, "https://nominatim.openstreetmap.org") + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_geocode_nominatim_success(self, mock_get): + """Test successful Nominatim geocoding.""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "lat": "-22.9068", + "lon": "-43.1729", + "display_name": "Rio de Janeiro, Brazil", + } + ] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._geocode_nominatim("Rio de Janeiro, Brazil", "BR") + + self.assertIsNotNone(result) + self.assertAlmostEqual(result["lat"], -22.9068, places=4) + self.assertAlmostEqual(result["lng"], -43.1729, places=4) + self.assertEqual(result["provider"], "nominatim") + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_geocode_nominatim_not_found(self, mock_get): + """Test Nominatim geocoding when address not found.""" + mock_response = MagicMock() + mock_response.json.return_value = [] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._geocode_nominatim("NonexistentPlace12345", None) + + self.assertIsNone(result) + + @patch("odoo.addons.web_leaflet_lib.models.geocoding_mixin.requests.get") + def test_reverse_geocode_nominatim_success(self, mock_get): + """Test successful Nominatim reverse geocoding.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "address": { + "city": "Rio de Janeiro", + "country": "Brazil", + }, + "display_name": "Rio de Janeiro, Brazil", + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result = self.GeocodingMixin._reverse_geocode_nominatim(-22.9068, -43.1729) + + self.assertIsNotNone(result) + self.assertEqual(result["display_name"], "Rio de Janeiro, Brazil") + self.assertEqual(result["provider"], "nominatim") + + def test_batch_geocode_empty_list(self): + """Test batch geocoding with empty list.""" + result = self.GeocodingMixin.batch_geocode([]) + self.assertEqual(result, []) diff --git a/web_leaflet_lib/views/res_config_settings.xml b/web_leaflet_lib/views/res_config_settings.xml new file mode 100644 index 000000000..7fa1719e4 --- /dev/null +++ b/web_leaflet_lib/views/res_config_settings.xml @@ -0,0 +1,99 @@ + + + + + res.config.settings.view.form.leaflet + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ms + + + + + + + + + + diff --git a/web_leaflet_routing/README.rst b/web_leaflet_routing/README.rst new file mode 100644 index 000000000..dbdeac1d6 --- /dev/null +++ b/web_leaflet_routing/README.rst @@ -0,0 +1,358 @@ +=============== +Leaflet Routing +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:83c0e79839a2e4d73c9c51893d41ce2e3eef5f737b9d790d4ab7935d15ff14a6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github + :target: https://github.com/OCA/geospatial/tree/18.0/web_leaflet_routing + :alt: OCA/geospatial +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/geospatial-18-0/geospatial-18-0-web_leaflet_routing + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/geospatial&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides routing services for Leaflet map views. + +Features: + +- OSRM routing for real road-based routes +- MapBox routing support (premium) +- Geocoding services (Nominatim/MapBox) +- Distance matrix calculation +- Route optimization + +The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Configure routing providers through System Parameters (Settings > +Technical > Parameters > System Parameters). + +System Parameters +----------------- + ++------------------------------+-------------------------------------+----------------------+ +| Key | Default | Description | ++==============================+=====================================+======================+ +| ``leaflet.routing_provider`` | ``osrm`` | Routing provider: | +| | | ``osrm``, | +| | | ``mapbox``, or | +| | | ``auto`` | ++------------------------------+-------------------------------------+----------------------+ +| ``leaflet.osrm_url`` | ``https://router.project-osrm.org`` | OSRM server URL | ++------------------------------+-------------------------------------+----------------------+ +| ``leaflet.mapbox_token`` | (empty) | MapBox API access | +| | | token | ++------------------------------+-------------------------------------+----------------------+ +| ``leaflet.max_waypoints`` | ``25`` | Maximum waypoints | +| | | per route request | ++------------------------------+-------------------------------------+----------------------+ + +Provider Selection +------------------ + +- **osrm**: Always use OSRM (free, no API key required) +- **mapbox**: Always use MapBox (requires API token) +- **auto**: Try MapBox first if token configured, fallback to OSRM + +OSRM Configuration (Recommended) +-------------------------------- + +OSRM (Open Source Routing Machine) is free and can be self-hosted. + +**Using public server (default):** + +No configuration needed. The default URL +``https://router.project-osrm.org`` provides free routing with +reasonable rate limits. + +**Self-hosted OSRM:** + +For production use with high volumes, deploy your own OSRM server: + +1. Download OSM data for your region +2. Run OSRM backend with Docker or native installation +3. Set ``leaflet.osrm_url`` to your server address + +:: + + leaflet.osrm_url = http://your-osrm-server:5000 + +See https://github.com/Project-OSRM/osrm-backend for setup instructions. + +MapBox Configuration (Premium) +------------------------------ + +MapBox offers premium routing with additional features. + +1. Create account at https://www.mapbox.com/ +2. Generate an access token with Directions API scope +3. Set the system parameters: + +:: + + leaflet.routing_provider = mapbox + leaflet.mapbox_token = pk.your_mapbox_token_here + +Rate Limiting +------------- + +**OSRM Public Server:** + +- Shared instance with usage limits +- Suitable for development and low-volume production +- Consider self-hosting for high-volume usage + +**MapBox:** + +- Free tier: 100,000 requests/month +- Pay-as-you-go pricing beyond free tier +- See https://www.mapbox.com/pricing for details + +Routing Profiles +---------------- + +Both providers support these profiles: + +=========== ===================== +Profile Description +=========== ===================== +``driving`` Car routing (default) +``walking`` Pedestrian routing +``cycling`` Bicycle routing +=========== ===================== + +Troubleshooting +--------------- + +**Routes not calculating:** + +1. Verify coordinates are valid (lat: -90 to 90, lng: -180 to 180) +2. Check browser console for API errors +3. Verify ``leaflet.osrm_url`` is accessible + +**MapBox authentication errors:** + +1. Verify token is correctly set in System Parameters +2. Check token has Directions API scope enabled +3. Verify account has available quota + +Usage +===== + +This module provides routing services that can be used both in Python +(server-side) and JavaScript (client-side). + +Enabling Routing in Views +------------------------- + +To enable routing visualization in a leaflet_map view, add the +``routing`` attribute: + +.. code:: xml + + + + + + + + + +When routing is enabled, the map will draw polylines connecting markers +in sequence order, grouped by the ``group_by`` field if configured. + +Python Mixin Usage +------------------ + +The ``leaflet.routing.mixin`` provides routing capabilities for your +models: + +.. code:: python + + from odoo import models + + class DeliveryRoute(models.Model): + _name = 'delivery.route' + _inherit = ['leaflet.routing.mixin'] + + def compute_route_distance(self): + """Calculate total route distance and duration.""" + for route in self: + waypoints = [ + [stop.partner_latitude, stop.partner_longitude] + for stop in route.stop_ids.sorted('sequence') + if stop.partner_latitude and stop.partner_longitude + ] + + if len(waypoints) >= 2: + result = self.get_route(waypoints, profile='driving') + if result: + route.distance = result.get('distance', 0) # meters + route.duration = result.get('duration', 0) # seconds + +Available Methods +~~~~~~~~~~~~~~~~~ + +**``get_route(waypoints, profile='driving')``** + +Get a route between waypoints. + +- ``waypoints``: List of ``[lat, lng]`` coordinate pairs +- ``profile``: Routing profile (``driving``, ``walking``, ``cycling``) +- Returns: ``dict`` with ``geometry``, ``distance``, ``duration``, + ``legs`` + +**``get_optimized_route(waypoints, profile='driving', roundtrip=False)``** + +Get an optimized route (TSP - Traveling Salesman Problem). + +- ``waypoints``: List of ``[lat, lng]`` coordinate pairs +- ``profile``: Routing profile +- ``roundtrip``: Whether to return to starting point +- Returns: ``dict`` with route data and ``waypoint_order`` + +**``get_distance_matrix(origins, destinations=None)``** + +Get distances and durations between multiple points. + +- ``origins``: List of ``[lat, lng]`` pairs +- ``destinations``: List of ``[lat, lng]`` pairs (defaults to origins) +- Returns: ``dict`` with ``distances`` and ``durations`` matrices + +JavaScript Service Usage +------------------------ + +For client-side routing, import the ``RoutingService``: + +.. code:: javascript + + import { RoutingService } from "@web_leaflet_routing/routing_service.esm"; + + const routingService = new RoutingService(); + + // Get a simple route + const waypoints = [ + [-23.550520, -46.633308], // Sao Paulo + [-22.906847, -43.172896], // Rio de Janeiro + ]; + + const route = await routingService.getRoute(waypoints, 'driving'); + if (route) { + console.log(`Distance: ${routingService.formatDistance(route.distance)}`); + console.log(`Duration: ${routingService.formatDuration(route.duration)}`); + + // route.geometry contains [lat, lng] pairs for drawing polylines + L.polyline(route.geometry, {color: 'blue'}).addTo(map); + } + + // Get optimized route + const optimized = await routingService.getOptimizedRoute(waypoints, 'driving', false); + if (optimized) { + console.log('Optimal order:', optimized.waypointOrder); + } + +Route Response Structure +------------------------ + +Both Python and JavaScript methods return similar structures: + +.. code:: javascript + + { + geometry: [[lat, lng], ...], // Polyline coordinates + distance: 450000, // Total distance in meters + duration: 18000, // Total duration in seconds + legs: [ // Segments between waypoints + { + distance: 225000, + duration: 9000, + steps: [ + { + distance: 1500, + duration: 120, + instruction: "Turn right", + name: "Main Street" + } + ] + } + ], + provider: "osrm" // Which provider was used + } + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* KMEE + +Contributors +------------ + +- Luis Felipe Mileo mileo@kmee.com.br + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-miloefb| image:: https://github.com/miloefb.png?size=40px + :target: https://github.com/miloefb + :alt: miloefb + +Current `maintainer `__: + +|maintainer-miloefb| + +This module is part of the `OCA/geospatial `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_leaflet_routing/__init__.py b/web_leaflet_routing/__init__.py new file mode 100644 index 000000000..10b23894f --- /dev/null +++ b/web_leaflet_routing/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/web_leaflet_routing/__manifest__.py b/web_leaflet_routing/__manifest__.py new file mode 100644 index 000000000..10b144789 --- /dev/null +++ b/web_leaflet_routing/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Leaflet Routing", + "summary": "Add OSRM/MapBox routing and geocoding services to Leaflet maps.", + "version": "18.0.1.0.0", + "author": "KMEE, Odoo Community Association (OCA)", + "maintainers": ["miloefb"], + "development_status": "Beta", + "website": "https://github.com/OCA/geospatial", + "license": "AGPL-3", + "category": "Extra Tools", + "depends": [ + "web_view_leaflet_map", + ], + "external_dependencies": { + "python": ["requests"], + }, + "data": [ + "security/ir.model.access.csv", + ], + "assets": { + "web.assets_backend": [ + "web_leaflet_routing/static/src/routing_service.esm.js", + "web_leaflet_routing/static/src/geocoding_service.esm.js", + "web_leaflet_routing/static/src/components/routing_renderer.esm.js", + ], + }, + "installable": True, +} diff --git a/web_leaflet_routing/models/__init__.py b/web_leaflet_routing/models/__init__.py new file mode 100644 index 000000000..fe0b05d7f --- /dev/null +++ b/web_leaflet_routing/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import routing_mixin diff --git a/web_leaflet_routing/models/routing_mixin.py b/web_leaflet_routing/models/routing_mixin.py new file mode 100644 index 000000000..dc4b1245e --- /dev/null +++ b/web_leaflet_routing/models/routing_mixin.py @@ -0,0 +1,145 @@ +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, models + +from odoo.addons.web_leaflet_lib.models.routing_service import RoutingServiceFactory + +_logger = logging.getLogger(__name__) + + +class RoutingMixin(models.AbstractModel): + """Mixin providing routing capabilities using OSRM or MapBox.""" + + _name = "leaflet.routing.mixin" + _description = "Leaflet Routing Mixin" + + @api.model + def _get_routing_factory(self): + """Get the routing service factory.""" + return RoutingServiceFactory(self.env) + + @api.model + def _get_routing_provider(self): + """Get the configured routing provider.""" + return self._get_routing_factory().get_routing_provider() + + @api.model + def _get_osrm_url(self): + """Get OSRM server URL.""" + return self._get_routing_factory().get_osrm_url() + + @api.model + def _get_mapbox_token(self): + """Get MapBox API token if configured.""" + return self._get_routing_factory().get_mapbox_token() + + @api.model + def _get_max_waypoints(self): + """Get maximum waypoints for routing.""" + return self._get_routing_factory().get_max_waypoints() + + @api.model + def _get_routing_service(self, provider=None, profile="driving"): + """Get routing service instance.""" + return self._get_routing_factory().get_service(provider, profile) + + @api.model + def get_route(self, waypoints, profile="driving"): + """ + Get a route between waypoints. + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile (driving, walking, cycling) + + Returns: + dict with: + - geometry: List of [lat, lng] coordinates for polyline + - distance: Total distance in meters + - duration: Total duration in seconds + - legs: Route segments between waypoints + """ + if len(waypoints) < 2: + return None + + factory = self._get_routing_factory() + provider = factory.get_routing_provider() + mapbox_token = factory.get_mapbox_token() + max_waypoints = factory.get_max_waypoints() + + if len(waypoints) > max_waypoints: + _logger.warning( + "Too many waypoints (%d), truncating to %d", + len(waypoints), + max_waypoints, + ) + waypoints = waypoints[:max_waypoints] + + # Try MapBox first if configured + if provider == "mapbox" or (provider == "auto" and mapbox_token): + mapbox_service = factory.get_mapbox_service(profile) + if mapbox_service: + result = mapbox_service.get_route(waypoints) + if result: + return result + if provider == "mapbox": + _logger.warning("MapBox routing failed, no fallback configured") + return None + + # Fallback to OSRM + osrm_service = factory.get_osrm_service(profile) + return osrm_service.get_route(waypoints) + + @api.model + def get_optimized_route(self, waypoints, profile="driving", roundtrip=False): + """ + Get an optimized route visiting all waypoints (TSP). + + Args: + waypoints: List of [lat, lng] coordinate pairs + profile: Routing profile + roundtrip: Whether to return to the starting point + + Returns: + dict with optimized route and waypoint ordering + """ + if len(waypoints) < 2: + return None + + factory = self._get_routing_factory() + provider = factory.get_routing_provider() + mapbox_token = factory.get_mapbox_token() + + # Try MapBox first if configured (has optimization API) + if provider == "mapbox" or (provider == "auto" and mapbox_token): + mapbox_service = factory.get_mapbox_service(profile) + if mapbox_service: + result = mapbox_service.get_optimized_route( + waypoints, roundtrip=roundtrip + ) + if result: + return result + + # Fallback to OSRM trip endpoint + osrm_service = factory.get_osrm_service(profile) + return osrm_service.get_optimized_route(waypoints, roundtrip=roundtrip) + + @api.model + def get_distance_matrix(self, origins, destinations=None): + """ + Get distance matrix between origins and destinations. + + Args: + origins: List of [lat, lng] coordinate pairs + destinations: List of [lat, lng] pairs (defaults to origins) + + Returns: + dict with: + - distances: 2D list of distances in meters + - durations: 2D list of durations in seconds + """ + factory = self._get_routing_factory() + return factory.get_distance_matrix(origins, destinations) diff --git a/web_leaflet_routing/pyproject.toml b/web_leaflet_routing/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/web_leaflet_routing/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_leaflet_routing/readme/CONFIGURE.md b/web_leaflet_routing/readme/CONFIGURE.md new file mode 100644 index 000000000..e621d6e83 --- /dev/null +++ b/web_leaflet_routing/readme/CONFIGURE.md @@ -0,0 +1,91 @@ +Configure routing providers through System Parameters +(Settings > Technical > Parameters > System Parameters). + +## System Parameters + +| Key | Default | Description | +|-----|---------|-------------| +| `leaflet.routing_provider` | `osrm` | Routing provider: `osrm`, `mapbox`, or `auto` | +| `leaflet.osrm_url` | `https://router.project-osrm.org` | OSRM server URL | +| `leaflet.mapbox_token` | (empty) | MapBox API access token | +| `leaflet.max_waypoints` | `25` | Maximum waypoints per route request | + +## Provider Selection + +- **osrm**: Always use OSRM (free, no API key required) +- **mapbox**: Always use MapBox (requires API token) +- **auto**: Try MapBox first if token configured, fallback to OSRM + +## OSRM Configuration (Recommended) + +OSRM (Open Source Routing Machine) is free and can be self-hosted. + +**Using public server (default):** + +No configuration needed. The default URL `https://router.project-osrm.org` +provides free routing with reasonable rate limits. + +**Self-hosted OSRM:** + +For production use with high volumes, deploy your own OSRM server: + +1. Download OSM data for your region +2. Run OSRM backend with Docker or native installation +3. Set `leaflet.osrm_url` to your server address + +``` +leaflet.osrm_url = http://your-osrm-server:5000 +``` + +See https://github.com/Project-OSRM/osrm-backend for setup instructions. + +## MapBox Configuration (Premium) + +MapBox offers premium routing with additional features. + +1. Create account at https://www.mapbox.com/ +2. Generate an access token with Directions API scope +3. Set the system parameters: + +``` +leaflet.routing_provider = mapbox +leaflet.mapbox_token = pk.your_mapbox_token_here +``` + +## Rate Limiting + +**OSRM Public Server:** + +- Shared instance with usage limits +- Suitable for development and low-volume production +- Consider self-hosting for high-volume usage + +**MapBox:** + +- Free tier: 100,000 requests/month +- Pay-as-you-go pricing beyond free tier +- See https://www.mapbox.com/pricing for details + +## Routing Profiles + +Both providers support these profiles: + +| Profile | Description | +|---------|-------------| +| `driving` | Car routing (default) | +| `walking` | Pedestrian routing | +| `cycling` | Bicycle routing | + +## Troubleshooting + +**Routes not calculating:** + +1. Verify coordinates are valid (lat: -90 to 90, lng: -180 to 180) +2. Check browser console for API errors +3. Verify `leaflet.osrm_url` is accessible + +**MapBox authentication errors:** + +1. Verify token is correctly set in System Parameters +2. Check token has Directions API scope enabled +3. Verify account has available quota diff --git a/web_leaflet_routing/readme/CONTRIBUTORS.md b/web_leaflet_routing/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..930c23505 --- /dev/null +++ b/web_leaflet_routing/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Luis Felipe Mileo diff --git a/web_leaflet_routing/readme/DESCRIPTION.md b/web_leaflet_routing/readme/DESCRIPTION.md new file mode 100644 index 000000000..58b01c941 --- /dev/null +++ b/web_leaflet_routing/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module provides routing services for Leaflet map views. + +Features: +- OSRM routing for real road-based routes +- MapBox routing support (premium) +- Geocoding services (Nominatim/MapBox) +- Distance matrix calculation +- Route optimization + +The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views. diff --git a/web_leaflet_routing/readme/USAGE.md b/web_leaflet_routing/readme/USAGE.md new file mode 100644 index 000000000..1b9f7df40 --- /dev/null +++ b/web_leaflet_routing/readme/USAGE.md @@ -0,0 +1,136 @@ +This module provides routing services that can be used both in Python (server-side) +and JavaScript (client-side). + +## Enabling Routing in Views + +To enable routing visualization in a leaflet_map view, add the `routing` attribute: + +```xml + + + + + + + +``` + +When routing is enabled, the map will draw polylines connecting markers in +sequence order, grouped by the `group_by` field if configured. + +## Python Mixin Usage + +The `leaflet.routing.mixin` provides routing capabilities for your models: + +```python +from odoo import models + +class DeliveryRoute(models.Model): + _name = 'delivery.route' + _inherit = ['leaflet.routing.mixin'] + + def compute_route_distance(self): + """Calculate total route distance and duration.""" + for route in self: + waypoints = [ + [stop.partner_latitude, stop.partner_longitude] + for stop in route.stop_ids.sorted('sequence') + if stop.partner_latitude and stop.partner_longitude + ] + + if len(waypoints) >= 2: + result = self.get_route(waypoints, profile='driving') + if result: + route.distance = result.get('distance', 0) # meters + route.duration = result.get('duration', 0) # seconds +``` + +### Available Methods + +**`get_route(waypoints, profile='driving')`** + +Get a route between waypoints. + +- `waypoints`: List of `[lat, lng]` coordinate pairs +- `profile`: Routing profile (`driving`, `walking`, `cycling`) +- Returns: `dict` with `geometry`, `distance`, `duration`, `legs` + +**`get_optimized_route(waypoints, profile='driving', roundtrip=False)`** + +Get an optimized route (TSP - Traveling Salesman Problem). + +- `waypoints`: List of `[lat, lng]` coordinate pairs +- `profile`: Routing profile +- `roundtrip`: Whether to return to starting point +- Returns: `dict` with route data and `waypoint_order` + +**`get_distance_matrix(origins, destinations=None)`** + +Get distances and durations between multiple points. + +- `origins`: List of `[lat, lng]` pairs +- `destinations`: List of `[lat, lng]` pairs (defaults to origins) +- Returns: `dict` with `distances` and `durations` matrices + +## JavaScript Service Usage + +For client-side routing, import the `RoutingService`: + +```javascript +import { RoutingService } from "@web_leaflet_routing/routing_service.esm"; + +const routingService = new RoutingService(); + +// Get a simple route +const waypoints = [ + [-23.550520, -46.633308], // Sao Paulo + [-22.906847, -43.172896], // Rio de Janeiro +]; + +const route = await routingService.getRoute(waypoints, 'driving'); +if (route) { + console.log(`Distance: ${routingService.formatDistance(route.distance)}`); + console.log(`Duration: ${routingService.formatDuration(route.duration)}`); + + // route.geometry contains [lat, lng] pairs for drawing polylines + L.polyline(route.geometry, {color: 'blue'}).addTo(map); +} + +// Get optimized route +const optimized = await routingService.getOptimizedRoute(waypoints, 'driving', false); +if (optimized) { + console.log('Optimal order:', optimized.waypointOrder); +} +``` + +## Route Response Structure + +Both Python and JavaScript methods return similar structures: + +```javascript +{ + geometry: [[lat, lng], ...], // Polyline coordinates + distance: 450000, // Total distance in meters + duration: 18000, // Total duration in seconds + legs: [ // Segments between waypoints + { + distance: 225000, + duration: 9000, + steps: [ + { + distance: 1500, + duration: 120, + instruction: "Turn right", + name: "Main Street" + } + ] + } + ], + provider: "osrm" // Which provider was used +} +``` diff --git a/web_leaflet_routing/security/ir.model.access.csv b/web_leaflet_routing/security/ir.model.access.csv new file mode 100644 index 000000000..c431c8349 --- /dev/null +++ b/web_leaflet_routing/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_leaflet_routing_mixin,leaflet.routing.mixin,model_leaflet_routing_mixin,base.group_user,1,0,0,0 diff --git a/web_leaflet_routing/static/description/index.html b/web_leaflet_routing/static/description/index.html new file mode 100644 index 000000000..c5e0110df --- /dev/null +++ b/web_leaflet_routing/static/description/index.html @@ -0,0 +1,730 @@ + + + + + +Leaflet Routing + + + +
+

Leaflet Routing

+ + +

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

This module provides routing services for Leaflet map views.

+

Features:

+
    +
  • OSRM routing for real road-based routes
  • +
  • MapBox routing support (premium)
  • +
  • Geocoding services (Nominatim/MapBox)
  • +
  • Distance matrix calculation
  • +
  • Route optimization
  • +
+

The module adds a mixin that can be used by other modules to integrate +routing functionality into their map views.

+

Table of contents

+ +
+

Configuration

+

Configure routing providers through System Parameters (Settings > +Technical > Parameters > System Parameters).

+
+

System Parameters

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDefaultDescription
leaflet.routing_providerosrmRouting provider: +osrm, +mapbox, or +auto
leaflet.osrm_urlhttps://router.project-osrm.orgOSRM server URL
leaflet.mapbox_token(empty)MapBox API access +token
leaflet.max_waypoints25Maximum waypoints +per route request
+
+
+

Provider Selection

+
    +
  • osrm: Always use OSRM (free, no API key required)
  • +
  • mapbox: Always use MapBox (requires API token)
  • +
  • auto: Try MapBox first if token configured, fallback to OSRM
  • +
+
+ +
+

MapBox Configuration (Premium)

+

MapBox offers premium routing with additional features.

+
    +
  1. Create account at https://www.mapbox.com/
  2. +
  3. Generate an access token with Directions API scope
  4. +
  5. Set the system parameters:
  6. +
+
+leaflet.routing_provider = mapbox
+leaflet.mapbox_token = pk.your_mapbox_token_here
+
+
+
+

Rate Limiting

+

OSRM Public Server:

+
    +
  • Shared instance with usage limits
  • +
  • Suitable for development and low-volume production
  • +
  • Consider self-hosting for high-volume usage
  • +
+

MapBox:

+ +
+
+

Routing Profiles

+

Both providers support these profiles:

+ ++++ + + + + + + + + + + + + + + + + +
ProfileDescription
drivingCar routing (default)
walkingPedestrian routing
cyclingBicycle routing
+
+
+

Troubleshooting

+

Routes not calculating:

+
    +
  1. Verify coordinates are valid (lat: -90 to 90, lng: -180 to 180)
  2. +
  3. Check browser console for API errors
  4. +
  5. Verify leaflet.osrm_url is accessible
  6. +
+

MapBox authentication errors:

+
    +
  1. Verify token is correctly set in System Parameters
  2. +
  3. Check token has Directions API scope enabled
  4. +
  5. Verify account has available quota
  6. +
+
+
+
+

Usage

+

This module provides routing services that can be used both in Python +(server-side) and JavaScript (client-side).

+
+

Enabling Routing in Views

+

To enable routing visualization in a leaflet_map view, add the +routing attribute:

+
+<leaflet_map
+    field_latitude="partner_latitude"
+    field_longitude="partner_longitude"
+    routing="1"
+    group_by="driver_id"
+>
+    <field name="display_name"/>
+    <field name="partner_latitude"/>
+    <field name="partner_longitude"/>
+    <field name="driver_id"/>
+    <field name="sequence"/>
+</leaflet_map>
+
+

When routing is enabled, the map will draw polylines connecting markers +in sequence order, grouped by the group_by field if configured.

+
+
+

Python Mixin Usage

+

The leaflet.routing.mixin provides routing capabilities for your +models:

+
+from odoo import models
+
+class DeliveryRoute(models.Model):
+    _name = 'delivery.route'
+    _inherit = ['leaflet.routing.mixin']
+
+    def compute_route_distance(self):
+        """Calculate total route distance and duration."""
+        for route in self:
+            waypoints = [
+                [stop.partner_latitude, stop.partner_longitude]
+                for stop in route.stop_ids.sorted('sequence')
+                if stop.partner_latitude and stop.partner_longitude
+            ]
+
+            if len(waypoints) >= 2:
+                result = self.get_route(waypoints, profile='driving')
+                if result:
+                    route.distance = result.get('distance', 0)  # meters
+                    route.duration = result.get('duration', 0)  # seconds
+
+
+

Available Methods

+

``get_route(waypoints, profile=’driving’)``

+

Get a route between waypoints.

+
    +
  • waypoints: List of [lat, lng] coordinate pairs
  • +
  • profile: Routing profile (driving, walking, cycling)
  • +
  • Returns: dict with geometry, distance, duration, +legs
  • +
+

``get_optimized_route(waypoints, profile=’driving’, roundtrip=False)``

+

Get an optimized route (TSP - Traveling Salesman Problem).

+
    +
  • waypoints: List of [lat, lng] coordinate pairs
  • +
  • profile: Routing profile
  • +
  • roundtrip: Whether to return to starting point
  • +
  • Returns: dict with route data and waypoint_order
  • +
+

``get_distance_matrix(origins, destinations=None)``

+

Get distances and durations between multiple points.

+
    +
  • origins: List of [lat, lng] pairs
  • +
  • destinations: List of [lat, lng] pairs (defaults to origins)
  • +
  • Returns: dict with distances and durations matrices
  • +
+
+
+
+

JavaScript Service Usage

+

For client-side routing, import the RoutingService:

+
+import { RoutingService } from "@web_leaflet_routing/routing_service.esm";
+
+const routingService = new RoutingService();
+
+// Get a simple route
+const waypoints = [
+    [-23.550520, -46.633308],  // Sao Paulo
+    [-22.906847, -43.172896],  // Rio de Janeiro
+];
+
+const route = await routingService.getRoute(waypoints, 'driving');
+if (route) {
+    console.log(`Distance: ${routingService.formatDistance(route.distance)}`);
+    console.log(`Duration: ${routingService.formatDuration(route.duration)}`);
+
+    // route.geometry contains [lat, lng] pairs for drawing polylines
+    L.polyline(route.geometry, {color: 'blue'}).addTo(map);
+}
+
+// Get optimized route
+const optimized = await routingService.getOptimizedRoute(waypoints, 'driving', false);
+if (optimized) {
+    console.log('Optimal order:', optimized.waypointOrder);
+}
+
+
+
+

Route Response Structure

+

Both Python and JavaScript methods return similar structures:

+
+{
+    geometry: [[lat, lng], ...],  // Polyline coordinates
+    distance: 450000,             // Total distance in meters
+    duration: 18000,              // Total duration in seconds
+    legs: [                       // Segments between waypoints
+        {
+            distance: 225000,
+            duration: 9000,
+            steps: [
+                {
+                    distance: 1500,
+                    duration: 120,
+                    instruction: "Turn right",
+                    name: "Main Street"
+                }
+            ]
+        }
+    ],
+    provider: "osrm"              // Which provider was used
+}
+
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

miloefb

+

This module is part of the OCA/geospatial project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_leaflet_routing/static/src/components/routing_renderer.esm.js b/web_leaflet_routing/static/src/components/routing_renderer.esm.js new file mode 100644 index 000000000..c99ef0af2 --- /dev/null +++ b/web_leaflet_routing/static/src/components/routing_renderer.esm.js @@ -0,0 +1,291 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global L, console */ + +import {RoutingService} from "../routing_service.esm"; + +/** + * RoutingRenderer provides generic route visualization functionality. + * Can be used standalone or integrated with LeafletMapRenderer subclasses. + * + * Features: + * - OSRM/MapBox routing via RoutingService + * - Grouping support for multiple routes + * - Fallback to dashed polylines when routing fails + * - Configurable colors and options + */ +export class RoutingRenderer { + /** + * Create a new RoutingRenderer. + * @param {L.Map} map - The Leaflet map instance + * @param {RoutingService} routingService - Optional routing service instance + */ + constructor(map, routingService = null) { + this.map = map; + this.routingService = routingService || new RoutingService(); + this.routeLayerGroup = L.layerGroup(); + this.routeLayerGroup.addTo(this.map); + } + + /** + * Render routes on the map based on records. + * @param {Array} records - Array of record objects with coordinates + * @param {Object} config - Configuration options + * @param {String} config.groupBy - Field name to group records by + * @param {String} config.sequenceField - Field name for sequencing (default: "sequence") + * @param {String} config.stopTypeField - Field name for stop type (default: null) + * @param {String} config.stopTypeOrderField - Field name for stop type order (default: null) + * @param {String} config.latitudeField - Field name for latitude (default: "latitude") + * @param {String} config.longitudeField - Field name for longitude (default: "longitude") + * @param {Boolean} config.useRealRouting - Use OSRM routing (default: true) + * @param {Array} config.colors - Array of colors for routes + * @param {Boolean} config.skipUnassigned - Skip unassigned groups (default: false) + * @param {String} config.unassignedGroupName - Name for unassigned group (default: "Unassigned") + * @param {Function} config.validateCoordinates - Custom coordinate validator + */ + async renderRoutes(records, config = {}) { + const { + groupBy = null, + sequenceField = "sequence", + stopTypeField = null, + stopTypeOrderField = "stop_type_order", + latitudeField = "latitude", + longitudeField = "longitude", + useRealRouting = true, + colors = ["#007bff", "#28a745", "#dc3545", "#ffc107", "#17a2b8", "#6f42c1"], + skipUnassigned = false, + unassignedGroupName = "Unassigned", + validateCoordinates = this._defaultValidateCoordinates.bind(this), + } = config; + + this.routeLayerGroup.clearLayers(); + + // Group records + const groups = this._groupRecords(records, { + groupBy, + latitudeField, + longitudeField, + sequenceField, + stopTypeField, + stopTypeOrderField, + unassignedGroupName, + validateCoordinates, + }); + + let colorIndex = 0; + for (const groupKey of Object.keys(groups)) { + const group = groups[groupKey]; + + // Skip unassigned groups if configured + if (skipUnassigned && group.isUnassigned) { + continue; + } + + // Sort records by stop_type_order (0=origin, 1=delivery, 2=destination) then by sequence + const sorted = [...group.records].sort((a, b) => { + // Primary sort by stopTypeOrder (already computed as number in _groupRecords) + if (a.stopTypeOrder !== b.stopTypeOrder) { + return a.stopTypeOrder - b.stopTypeOrder; + } + // Secondary sort by sequence + return (a.sequence || 0) - (b.sequence || 0); + }); + + // Debug logging for route order + console.debug( + `[RoutingRenderer] Group ${groupKey} route order (${sorted.length} stops):`, + sorted.map((r) => ({ + stopType: r.stopType, + stopTypeOrder: r.stopTypeOrder, + sequence: r.sequence, + })) + ); + + const waypoints = sorted.map((r) => [r.lat, r.lng]); + + if (waypoints.length < 2) { + continue; + } + + const color = colors[colorIndex % colors.length]; + await this._drawRoute(waypoints, color, useRealRouting); + colorIndex++; + } + } + + /** + * Draw a single route on the map. + * @param {Array} waypoints - Array of [lat, lng] pairs + * @param {String} color - Route color + * @param {Boolean} useRealRouting - Use OSRM routing + */ + async _drawRoute(waypoints, color, useRealRouting) { + if (useRealRouting) { + try { + const route = await this.routingService.getRoute(waypoints); + if (route && route.geometry) { + const polyline = L.polyline(route.geometry, { + color, + weight: 4, + opacity: 0.8, + }); + + // Add tooltip with distance and duration + const distKm = (route.distance / 1000).toFixed(1); + const durMin = Math.round(route.duration / 60); + polyline.bindTooltip(`${distKm} km \u2022 ${durMin} min`, { + sticky: true, + }); + + this.routeLayerGroup.addLayer(polyline); + return; + } + } catch (e) { + console.warn("OSRM routing failed, using fallback", e); + } + } + + // Fallback: dashed polyline + const polyline = L.polyline(waypoints, { + color, + weight: 3, + opacity: 0.7, + dashArray: "10, 10", + }); + this.routeLayerGroup.addLayer(polyline); + } + + /** + * Group records for routing. + * @param {Array} records - Array of record objects + * @param {Object} options - Grouping options + * @returns {Object} Groups object keyed by group name + */ + _groupRecords(records, options) { + const { + groupBy, + latitudeField, + longitudeField, + sequenceField, + stopTypeField, + stopTypeOrderField, + unassignedGroupName, + validateCoordinates, + } = options; + + const groups = {}; + + for (const record of records) { + const lat = record[latitudeField]; + const lng = record[longitudeField]; + + if (!validateCoordinates(lat, lng)) { + continue; + } + + let groupKey = unassignedGroupName; + let isUnassigned = true; + + if (groupBy && record[groupBy]) { + const val = record[groupBy]; + // Handle Many2one fields (array with [id, name]) + groupKey = Array.isArray(val) ? String(val[0]) : String(val); + isUnassigned = false; + } + + if (!groups[groupKey]) { + groups[groupKey] = { + records: [], + isUnassigned, + }; + } + + // Extract stop type order - handle 0 as valid value + // First try the explicit stopTypeOrderField + let stopTypeOrderValue = 1; // Default to delivery + if ( + stopTypeOrderField && + record[stopTypeOrderField] !== undefined && + record[stopTypeOrderField] !== null + ) { + stopTypeOrderValue = Number(record[stopTypeOrderField]); + if (isNaN(stopTypeOrderValue)) { + stopTypeOrderValue = 1; + } + } else if (stopTypeField) { + // Fallback: derive from stop_type field + const stopType = record[stopTypeField]; + if (stopType === "origin") { + stopTypeOrderValue = 0; + } else if (stopType === "destination") { + stopTypeOrderValue = 2; + } + } + + const stopTypeValue = stopTypeField ? record[stopTypeField] : null; + + // Debug: log raw field values + console.debug(`[RoutingRenderer] Record ${record.id}:`, { + stopType: stopTypeValue, + rawStopTypeOrder: record[stopTypeOrderField], + computedStopTypeOrder: stopTypeOrderValue, + sequence: record[sequenceField], + }); + + groups[groupKey].records.push({ + lat, + lng, + sequence: record[sequenceField] || 0, + stopType: stopTypeValue, + stopTypeOrder: stopTypeOrderValue, + record, + }); + } + + return groups; + } + + /** + * Default coordinate validator. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + _defaultValidateCoordinates(lat, lng) { + try { + const latNum = parseFloat(lat); + const lngNum = parseFloat(lng); + return ( + !isNaN(latNum) && + !isNaN(lngNum) && + latNum >= -90 && + latNum <= 90 && + lngNum >= -180 && + lngNum <= 180 + ); + } catch { + return false; + } + } + + /** + * Clear all routes from the map. + */ + clearRoutes() { + this.routeLayerGroup.clearLayers(); + } + + /** + * Remove the route layer group from the map. + */ + destroy() { + if (this.map && this.routeLayerGroup) { + this.map.removeLayer(this.routeLayerGroup); + } + } +} diff --git a/web_leaflet_routing/static/src/geocoding_service.esm.js b/web_leaflet_routing/static/src/geocoding_service.esm.js new file mode 100644 index 000000000..f4cca8764 --- /dev/null +++ b/web_leaflet_routing/static/src/geocoding_service.esm.js @@ -0,0 +1,440 @@ +/** @odoo-module **/ + +/* global console, fetch, URLSearchParams, setTimeout */ + +import {session} from "@web/session"; + +/** + * GeocodingService provides geocoding functionality using Nominatim (OSM) or MapBox. + * Default provider is Nominatim (free, but requires rate limiting of 1 req/sec). + */ +export class GeocodingService { + constructor() { + this.nominatimUrl = + session["leaflet.nominatim_url"] || "https://nominatim.openstreetmap.org"; + this.mapboxToken = session["leaflet.mapbox_token"] || ""; + this.provider = session["leaflet.geocoding_provider"] || "nominatim"; + this.throttleMs = session["leaflet.geocoding_throttle_ms"] || 1000; + + this._lastRequestTime = 0; + this._requestQueue = []; + this._processing = false; + } + + /** + * Throttle requests to respect API rate limits. + * @returns {Promise} + */ + async _throttle() { + const now = Date.now(); + const elapsed = now - this._lastRequestTime; + + if (elapsed < this.throttleMs) { + await new Promise((resolve) => + setTimeout(resolve, this.throttleMs - elapsed) + ); + } + + this._lastRequestTime = Date.now(); + } + + /** + * Geocode an address to coordinates. + * @param {String} address - Address to geocode + * @param {String} countryCode - Optional 2-letter ISO country code + * @returns {Promise} Object with lat, lng, display_name or null + */ + async geocode(address, countryCode = null) { + if (!address || address.trim() === "") { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._geocodeMapBox(address, countryCode); + if (result) { + return result; + } + if (this.provider === "mapbox") { + console.warn("MapBox geocoding failed, no fallback"); + return null; + } + } + + // Fallback to Nominatim + return this._geocodeNominatim(address, countryCode); + } + + /** + * Geocode using Nominatim (OSM). + * @param {String} address + * @param {String} countryCode + * @returns {Promise} + */ + async _geocodeNominatim(address, countryCode = null) { + await this._throttle(); + + const params = new URLSearchParams({ + q: address, + format: "json", + limit: "1", + addressdetails: "1", + }); + + if (countryCode) { + params.append("countrycodes", countryCode.toLowerCase()); + } + + const url = `${this.nominatimUrl}/search?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + if (data && data.length > 0) { + const result = data[0]; + return { + lat: parseFloat(result.lat), + lng: parseFloat(result.lon), + displayName: result.display_name, + address: result.address, + provider: "nominatim", + }; + } + + return null; + } catch (error) { + console.error("Nominatim geocoding failed:", error); + return null; + } + } + + /** + * Geocode using MapBox Geocoding API. + * @param {String} address + * @param {String} countryCode + * @returns {Promise} + */ + async _geocodeMapBox(address, countryCode = null) { + if (!this.mapboxToken) { + return null; + } + + const encodedAddress = encodeURIComponent(address); + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: "1", + }); + + if (countryCode) { + params.append("country", countryCode.toLowerCase()); + } + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + const features = data.features || []; + if (features.length > 0) { + const result = features[0]; + const [lng, lat] = result.geometry.coordinates; + return { + lat, + lng, + displayName: result.place_name, + address: result.context || {}, + provider: "mapbox", + }; + } + + return null; + } catch (error) { + console.error("MapBox geocoding failed:", error); + return null; + } + } + + /** + * Reverse geocode coordinates to an address. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Promise} + */ + async reverseGeocode(lat, lng) { + // Validate coordinates + if (!this._validateCoordinates(lat, lng)) { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._reverseGeocodeMapBox(lat, lng); + if (result) { + return result; + } + } + + // Fallback to Nominatim + return this._reverseGeocodeNominatim(lat, lng); + } + + /** + * Reverse geocode using Nominatim. + * @param {Number} lat + * @param {Number} lng + * @returns {Promise} + */ + async _reverseGeocodeNominatim(lat, lng) { + await this._throttle(); + + const params = new URLSearchParams({ + lat: lat.toString(), + lon: lng.toString(), + format: "json", + addressdetails: "1", + }); + + const url = `${this.nominatimUrl}/reverse?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + if (data && data.address) { + return { + address: data.address, + displayName: data.display_name, + provider: "nominatim", + }; + } + + return null; + } catch (error) { + console.error("Nominatim reverse geocoding failed:", error); + return null; + } + } + + /** + * Reverse geocode using MapBox. + * @param {Number} lat + * @param {Number} lng + * @returns {Promise} + */ + async _reverseGeocodeMapBox(lat, lng) { + if (!this.mapboxToken) { + return null; + } + + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: "1", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + const features = data.features || []; + if (features.length > 0) { + const result = features[0]; + return { + address: result.context || {}, + displayName: result.place_name, + provider: "mapbox", + }; + } + + return null; + } catch (error) { + console.error("MapBox reverse geocoding failed:", error); + return null; + } + } + + /** + * Batch geocode multiple addresses. + * @param {Array} addresses + * @param {String} countryCode + * @returns {Promise>} + */ + async batchGeocode(addresses, countryCode = null) { + const results = []; + + for (const address of addresses) { + const result = await this.geocode(address, countryCode); + results.push(result); + } + + return results; + } + + /** + * Validate coordinates. + * @param {Number} lat + * @param {Number} lng + * @returns {Boolean} + */ + _validateCoordinates(lat, lng) { + return ( + typeof lat === "number" && + typeof lng === "number" && + !isNaN(lat) && + !isNaN(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); + } + + /** + * Search for places/addresses with autocomplete. + * @param {String} query + * @param {Object} options - bbox, countryCode, limit + * @returns {Promise} + */ + async autocomplete(query, options = {}) { + if (!query || query.length < 3) { + return []; + } + + // MapBox has better autocomplete, try it first + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const results = await this._autocompleteMapBox(query, options); + if (results && results.length > 0) { + return results; + } + } + + // Fallback to Nominatim search + return this._autocompleteNominatim(query, options); + } + + /** + * Autocomplete using Nominatim. + * @param {String} query + * @param {Object} options + * @returns {Promise} + */ + async _autocompleteNominatim(query, options = {}) { + await this._throttle(); + + const params = new URLSearchParams({ + q: query, + format: "json", + limit: (options.limit || 5).toString(), + addressdetails: "1", + }); + + if (options.countryCode) { + params.append("countrycodes", options.countryCode.toLowerCase()); + } + + if (options.bbox) { + const [minLng, minLat, maxLng, maxLat] = options.bbox; + params.append("viewbox", `${minLng},${maxLat},${maxLng},${minLat}`); + params.append("bounded", "1"); + } + + const url = `${this.nominatimUrl}/search?${params}`; + + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Odoo-Leaflet-Map/1.0", + }, + }); + + const data = await response.json(); + + return (data || []).map((item) => ({ + lat: parseFloat(item.lat), + lng: parseFloat(item.lon), + displayName: item.display_name, + address: item.address, + type: item.type, + provider: "nominatim", + })); + } catch (error) { + console.error("Nominatim autocomplete failed:", error); + return []; + } + } + + /** + * Autocomplete using MapBox. + * @param {String} query + * @param {Object} options + * @returns {Promise} + */ + async _autocompleteMapBox(query, options = {}) { + if (!this.mapboxToken) { + return []; + } + + const encodedQuery = encodeURIComponent(query); + const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + limit: (options.limit || 5).toString(), + autocomplete: "true", + }); + + if (options.countryCode) { + params.append("country", options.countryCode.toLowerCase()); + } + + if (options.bbox) { + const [minLng, minLat, maxLng, maxLat] = options.bbox; + params.append("bbox", `${minLng},${minLat},${maxLng},${maxLat}`); + } + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + return (data.features || []).map((item) => { + const [lng, lat] = item.geometry.coordinates; + return { + lat, + lng, + displayName: item.place_name, + address: item.context || {}, + type: item.place_type?.[0] || "place", + provider: "mapbox", + }; + }); + } catch (error) { + console.error("MapBox autocomplete failed:", error); + return []; + } + } +} + +// Export singleton instance +export const geocodingService = new GeocodingService(); diff --git a/web_leaflet_routing/static/src/routing_service.esm.js b/web_leaflet_routing/static/src/routing_service.esm.js new file mode 100644 index 000000000..9162dfb69 --- /dev/null +++ b/web_leaflet_routing/static/src/routing_service.esm.js @@ -0,0 +1,364 @@ +/** @odoo-module **/ + +/* global console, fetch, URLSearchParams */ + +import {session} from "@web/session"; + +/** + * RoutingService provides routing functionality using OSRM or MapBox. + * Default provider is OSRM (free, no API key required). + */ +export class RoutingService { + constructor() { + this.osrmUrl = session["leaflet.osrm_url"] || "https://router.project-osrm.org"; + this.mapboxToken = session["leaflet.mapbox_token"] || ""; + this.provider = session["leaflet.routing_provider"] || "osrm"; + this.maxWaypoints = session["leaflet.max_waypoints"] || 25; + } + + /** + * Get a route between waypoints. + * @param {Array} waypoints - Array of [lat, lng] coordinate pairs + * @param {String} profile - Routing profile (driving, walking, cycling) + * @returns {Promise} Route object or null if failed + */ + async getRoute(waypoints, profile = "driving") { + if (waypoints.length < 2) { + return null; + } + + let routeWaypoints = waypoints; + if (waypoints.length > this.maxWaypoints) { + console.warn( + `Too many waypoints (${waypoints.length}), truncating to ${this.maxWaypoints}` + ); + routeWaypoints = waypoints.slice(0, this.maxWaypoints); + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._getRouteMapBox(routeWaypoints, profile); + if (result) { + return result; + } + if (this.provider === "mapbox") { + console.warn("MapBox routing failed, no fallback"); + return null; + } + } + + // Fallback to OSRM + return this._getRouteOSRM(routeWaypoints, profile); + } + + /** + * Get route using OSRM. + * @param {Array} waypoints + * @param {String} profile + * @returns {Promise} + */ + async _getRouteOSRM(waypoints, profile = "driving") { + // OSRM expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `${this.osrmUrl}/route/v1/${profile}/${coords}`; + + const params = new URLSearchParams({ + overview: "full", + geometries: "geojson", + steps: "true", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("OSRM routing failed:", data.message); + return null; + } + + const route = data.routes[0]; + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = route.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: route.distance, + duration: route.duration, + legs: route.legs.map((leg) => ({ + distance: leg.distance, + duration: leg.duration, + steps: (leg.steps || []).map((step) => ({ + distance: step.distance, + duration: step.duration, + instruction: step.maneuver?.instruction || "", + name: step.name || "", + })), + })), + provider: "osrm", + }; + } catch (error) { + console.error("OSRM routing request failed:", error); + return null; + } + } + + /** + * Get route using MapBox Directions API. + * @param {Array} waypoints + * @param {String} profile + * @returns {Promise} + */ + async _getRouteMapBox(waypoints, profile = "driving") { + if (!this.mapboxToken) { + return null; + } + + // Map profile names + const profileMap = { + driving: "driving", + walking: "walking", + cycling: "cycling", + }; + const mapboxProfile = profileMap[profile] || "driving"; + + // MapBox expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `https://api.mapbox.com/directions/v5/mapbox/${mapboxProfile}/${coords}`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + overview: "full", + geometries: "geojson", + steps: "true", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("MapBox routing failed:", data.message); + return null; + } + + const route = data.routes[0]; + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = route.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: route.distance, + duration: route.duration, + legs: route.legs.map((leg) => ({ + distance: leg.distance, + duration: leg.duration, + steps: (leg.steps || []).map((step) => ({ + distance: step.distance, + duration: step.duration, + instruction: step.maneuver?.instruction || "", + name: step.name || "", + })), + })), + provider: "mapbox", + }; + } catch (error) { + console.error("MapBox routing request failed:", error); + return null; + } + } + + /** + * Get optimized route (TSP) visiting all waypoints. + * @param {Array} waypoints - Array of [lat, lng] coordinate pairs + * @param {String} profile - Routing profile + * @param {Boolean} roundtrip - Return to starting point + * @returns {Promise} + */ + async getOptimizedRoute(waypoints, profile = "driving", roundtrip = false) { + if (waypoints.length < 2) { + return null; + } + + // Try MapBox first if configured + if ( + this.provider === "mapbox" || + (this.provider === "auto" && this.mapboxToken) + ) { + const result = await this._getOptimizedRouteMapBox( + waypoints, + profile, + roundtrip + ); + if (result) { + return result; + } + } + + // Fallback to OSRM trip endpoint + return this._getOptimizedRouteOSRM(waypoints, profile, roundtrip); + } + + /** + * Get optimized route using OSRM Trip API. + * @param {Array} waypoints + * @param {String} profile + * @param {Boolean} roundtrip + * @returns {Promise} + */ + async _getOptimizedRouteOSRM(waypoints, profile = "driving", roundtrip = false) { + // OSRM expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `${this.osrmUrl}/trip/v1/${profile}/${coords}`; + + const params = new URLSearchParams({ + overview: "full", + geometries: "geojson", + steps: "true", + roundtrip: roundtrip ? "true" : "false", + source: "first", + destination: "last", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("OSRM trip failed:", data.message); + return null; + } + + const trip = data.trips[0]; + + // Get waypoint ordering + const waypointOrder = data.waypoints.map((wp) => wp.waypoint_index); + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = trip.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: trip.distance, + duration: trip.duration, + waypointOrder, + optimizedWaypoints: waypointOrder.map((i) => waypoints[i]), + provider: "osrm", + }; + } catch (error) { + console.error("OSRM trip request failed:", error); + return null; + } + } + + /** + * Get optimized route using MapBox Optimization API. + * @param {Array} waypoints + * @param {String} profile + * @param {Boolean} roundtrip + * @returns {Promise} + */ + async _getOptimizedRouteMapBox(waypoints, profile = "driving", roundtrip = false) { + if (!this.mapboxToken) { + return null; + } + + const profileMap = { + driving: "driving", + walking: "walking", + cycling: "cycling", + }; + const mapboxProfile = profileMap[profile] || "driving"; + + // MapBox expects lng,lat order + const coords = waypoints.map((wp) => `${wp[1]},${wp[0]}`).join(";"); + const url = `https://api.mapbox.com/optimized-trips/v1/mapbox/${mapboxProfile}/${coords}`; + + const params = new URLSearchParams({ + access_token: this.mapboxToken, + overview: "full", + geometries: "geojson", + steps: "true", + roundtrip: roundtrip ? "true" : "false", + source: "first", + destination: "last", + }); + + try { + const response = await fetch(`${url}?${params}`); + const data = await response.json(); + + if (data.code !== "Ok") { + console.warn("MapBox optimization failed:", data.message); + return null; + } + + const trip = data.trips[0]; + + // Get waypoint ordering + const waypointOrder = data.waypoints.map((wp) => wp.waypoint_index); + + // Convert GeoJSON coordinates from [lng, lat] to [lat, lng] + const geometry = trip.geometry.coordinates.map((coord) => [ + coord[1], + coord[0], + ]); + + return { + geometry, + distance: trip.distance, + duration: trip.duration, + waypointOrder, + optimizedWaypoints: waypointOrder.map((i) => waypoints[i]), + provider: "mapbox", + }; + } catch (error) { + console.error("MapBox optimization request failed:", error); + return null; + } + } + + /** + * Format distance for display. + * @param {Number} meters + * @returns {String} + */ + formatDistance(meters) { + if (meters >= 1000) { + return `${(meters / 1000).toFixed(1)} km`; + } + return `${Math.round(meters)} m`; + } + + /** + * Format duration for display. + * @param {Number} seconds + * @returns {String} + */ + formatDuration(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}min`; + } + return `${minutes} min`; + } +} + +// Export singleton instance +export const routingService = new RoutingService(); diff --git a/web_view_leaflet_map/README.rst b/web_view_leaflet_map/README.rst index e421864bd..0548a901e 100644 --- a/web_view_leaflet_map/README.rst +++ b/web_view_leaflet_map/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================================ Leaflet Map View (OpenStreetMap) ================================ @@ -11,13 +7,13 @@ Leaflet Map View (OpenStreetMap) !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:74bf9361fbf3e37a4355f272c0cfb21bff1711d71639a7164dfe915bfc7773ab + !! source digest: sha256:f9a77df7f01c74ae830ace8e6334efc9780ff34d21ff2f50cb4bd2e63576aa81 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -32,31 +28,53 @@ Leaflet Map View (OpenStreetMap) |badge1| |badge2| |badge3| |badge4| |badge5| -This module extends odoo views, to add a new kind of view, named -``leaflet_map`` that is using the Leaflet javascript library to use -maps. (https://leafletjs.com/) This library is for exemple, used in the -OpenStreetMap project. (https://www.openstreetmap.org/) +This module extends Odoo views to add a new kind of view named +``leaflet_map`` that uses the Leaflet javascript library for interactive +maps. (https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/) + +**Core Map View:** + +- Display records as markers on an interactive map +- Automatic marker clustering for better visibility when zoomed out +- Custom marker icons from image fields +- Click markers to open popups with record details +- Click popup to navigate to record form view + +**Sidebar/Pin List (NEW):** + +- Collapsible sidebar showing all map markers in a list +- Search and filter within the sidebar +- Click items to center map on marker +- Visual grouping by field (e.g., category) +- Show located/unlocated record counts + +**Enhanced Markers (NEW):** -You can see a simple usage in the module -``web_view_leaflet_map_partner`` in the same OCA repository that -displays your contact in a map, if latitude and longitude are defined. -(To define latitude and longitude, refer to the Odoo module -``base_geolocalize``) +- Numbered markers showing sequence +- Color coding by group +- Google Maps navigation buttons in popups +- Coordinate validation (lat: -90..90, lng: -180..180) -A marker will be displayed for each item that has a localization. +**Routing Support (NEW - requires web_leaflet_routing):** -|image1| +- Route polylines between markers +- OSRM integration for real road routes +- Route distance and duration display -If user zooms out, the markers will overlap, which won't be very -visible. +See ``web_view_leaflet_map_partner`` module for a complete example that +displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation. -In that case, nearby markers are grouped together, thanks to +|Precise Map View| + +If user zooms out, nearby markers are grouped together thanks to ``Leaflet.markercluster`` plugin. -|image2| +|Large Map View| -.. |image1| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_precise.png -.. |image2| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_large.png +.. |Precise Map View| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_precise.png +.. |Large Map View| image:: https://raw.githubusercontent.com/OCA/geospatial/18.0/web_view_leaflet_map/static/description/view_res_partner_map_large.png **Table of contents** @@ -66,7 +84,92 @@ In that case, nearby markers are grouped together, thanks to Configuration ============= -- See configuration of the module ``web_leaflet_lib``. +To use this view, define a ``leaflet_map`` view in your XML: + +.. code:: xml + + + + + + + + + +**Required Attributes:** + +=================== ====================================== +Attribute Description +=================== ====================================== +``field_latitude`` Field containing latitude coordinates +``field_longitude`` Field containing longitude coordinates +=================== ====================================== + +**Display Attributes:** + ++-----------------------------+------------------+-------------------------+ +| Attribute | Default | Description | ++=============================+==================+=========================+ +| ``field_title`` | ``display_name`` | Field for marker | +| | | title/label | ++-----------------------------+------------------+-------------------------+ +| ``field_address`` | - | Field for address in | +| | | popup | ++-----------------------------+------------------+-------------------------+ +| ``field_marker_icon_image`` | - | Image field for custom | +| | | marker icon | ++-----------------------------+------------------+-------------------------+ +| ``marker_icon_size_x`` | 64 | Custom icon width in | +| | | pixels | ++-----------------------------+------------------+-------------------------+ +| ``marker_icon_size_y`` | 64 | Custom icon height in | +| | | pixels | ++-----------------------------+------------------+-------------------------+ + +**Map Options:** + +================ ======= ====================== +Attribute Default Description +================ ======= ====================== +``default_zoom`` 7 Initial map zoom level +``max_zoom`` 19 Maximum zoom level +``zoom_snap`` 1 Zoom level increments +================ ======= ====================== + +**New Attributes (v18.0.1.2.0):** + +===================== =========== ====================================== +Attribute Default Description +===================== =========== ====================================== +``show_pin_list`` "1" Show sidebar with marker list +``panel_title`` "Locations" Title for the sidebar +``numbered_markers`` "0" Show numbered markers instead of icons +``routing`` "0" Enable route polylines between markers +``enable_navigation`` "1" Show Google Maps navigation buttons +``group_by`` - Field for grouping markers by color +===================== =========== ====================================== + +**System Configuration:** + +Configure geocoding and routing providers in Settings > Leaflet Maps: + +- Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox + (premium) +- Routing Provider: OSRM free and self-hostable, or MapBox (premium) +- Default Nominatim URL: ``https://nominatim.openstreetmap.org`` +- Default OSRM URL: ``https://router.project-osrm.org`` Development =========== @@ -163,12 +266,17 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers ----------- @@ -185,10 +293,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_view_leaflet_map/__manifest__.py b/web_view_leaflet_map/__manifest__.py index 96549cd84..5cd61e93e 100644 --- a/web_view_leaflet_map/__manifest__.py +++ b/web_view_leaflet_map/__manifest__.py @@ -1,13 +1,14 @@ # Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) # @author: Sylvain LE GAL (https://twitter.com/legalsylvain) +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Leaflet Map View (OpenStreetMap)", - "summary": "Add new 'leaflet_map' view, to display markers.", - "version": "18.0.1.1.2", - "author": "GRAP, Odoo Community Association (OCA)", - "maintainers": ["legalsylvain"], + "summary": "Leaflet map view with sidebar, routing, and numbered markers.", + "version": "18.0.2.0.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", + "maintainers": ["legalsylvain", "mileo"], "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", @@ -17,12 +18,23 @@ ], "assets": { "web.assets_backend": [ - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml", + # Pin List component (base) + "web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js", + "web_view_leaflet_map/static/src/components/pin-list/pin_list.xml", + "web_view_leaflet_map/static/src/components/pin-list/pin_list.css", + # Draggable Pin List component (generic drag-drop) + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js", + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml", + "web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css", + # Leaflet Map View - MVC architecture + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js", + "web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml", + # Shared styles "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml", - "web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js", ], }, "installable": True, diff --git a/web_view_leaflet_map/readme/CONFIGURE.md b/web_view_leaflet_map/readme/CONFIGURE.md index 801711609..ed5898df0 100644 --- a/web_view_leaflet_map/readme/CONFIGURE.md +++ b/web_view_leaflet_map/readme/CONFIGURE.md @@ -1 +1,68 @@ -- See configuration of the module `web_leaflet_lib`. +To use this view, define a `leaflet_map` view in your XML: + +```xml + + + + + + + +``` + +**Required Attributes:** + +| Attribute | Description | +|-----------|-------------| +| `field_latitude` | Field containing latitude coordinates | +| `field_longitude` | Field containing longitude coordinates | + +**Display Attributes:** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `field_title` | `display_name` | Field for marker title/label | +| `field_address` | - | Field for address in popup | +| `field_marker_icon_image` | - | Image field for custom marker icon | +| `marker_icon_size_x` | 64 | Custom icon width in pixels | +| `marker_icon_size_y` | 64 | Custom icon height in pixels | + +**Map Options:** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `default_zoom` | 7 | Initial map zoom level | +| `max_zoom` | 19 | Maximum zoom level | +| `zoom_snap` | 1 | Zoom level increments | + +**New Attributes (v18.0.1.2.0):** + +| Attribute | Default | Description | +|-----------|---------|-------------| +| `show_pin_list` | "1" | Show sidebar with marker list | +| `panel_title` | "Locations" | Title for the sidebar | +| `numbered_markers` | "0" | Show numbered markers instead of icons | +| `routing` | "0" | Enable route polylines between markers | +| `enable_navigation` | "1" | Show Google Maps navigation buttons | +| `group_by` | - | Field for grouping markers by color | + +**System Configuration:** + +Configure geocoding and routing providers in Settings > Leaflet Maps: + +- Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox (premium) +- Routing Provider: OSRM free and self-hostable, or MapBox (premium) +- Default Nominatim URL: `https://nominatim.openstreetmap.org` +- Default OSRM URL: `https://router.project-osrm.org` diff --git a/web_view_leaflet_map/readme/CONTRIBUTORS.md b/web_view_leaflet_map/readme/CONTRIBUTORS.md index 4a6b63400..5a85a028c 100644 --- a/web_view_leaflet_map/readme/CONTRIBUTORS.md +++ b/web_view_leaflet_map/readme/CONTRIBUTORS.md @@ -1 +1,4 @@ -- Sylvain LE GAL () +* Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> + diff --git a/web_view_leaflet_map/readme/DESCRIPTION.md b/web_view_leaflet_map/readme/DESCRIPTION.md index 710c0946a..cbc133635 100644 --- a/web_view_leaflet_map/readme/DESCRIPTION.md +++ b/web_view_leaflet_map/readme/DESCRIPTION.md @@ -1,23 +1,44 @@ -This module extends odoo views, to add a new kind of view, named -`leaflet_map` that is using the Leaflet javascript library to use maps. -() This library is for exemple, used in the -OpenStreetMap project. () +This module extends Odoo views to add a new kind of view named +`leaflet_map` that uses the Leaflet javascript library for interactive maps. +(https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/) -You can see a simple usage in the module `web_view_leaflet_map_partner` -in the same OCA repository that displays your contact in a map, if -latitude and longitude are defined. (To define latitude and longitude, -refer to the Odoo module `base_geolocalize`) +**Core Map View:** -A marker will be displayed for each item that has a localization. +- Display records as markers on an interactive map +- Automatic marker clustering for better visibility when zoomed out +- Custom marker icons from image fields +- Click markers to open popups with record details +- Click popup to navigate to record form view -![](../static/description/view_res_partner_map_precise.png) +**Sidebar/Pin List (NEW):** -If user zooms out, the markers will overlap, which won't be very visible. +- Collapsible sidebar showing all map markers in a list +- Search and filter within the sidebar +- Click items to center map on marker +- Visual grouping by field (e.g., category) +- Show located/unlocated record counts -In that case, nearby markers are grouped together, thanks to -`Leaflet.markercluster` plugin. +**Enhanced Markers (NEW):** + +- Numbered markers showing sequence +- Color coding by group +- Google Maps navigation buttons in popups +- Coordinate validation (lat: -90..90, lng: -180..180) + +**Routing Support (NEW - requires web_leaflet_routing):** -![](../static/description/view_res_partner_map_large.png) +- Route polylines between markers +- OSRM integration for real road routes +- Route distance and duration display +See `web_view_leaflet_map_partner` module for a complete example +that displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation. +![Precise Map View](../static/description/view_res_partner_map_precise.png) + +If user zooms out, nearby markers are grouped together thanks to +`Leaflet.markercluster` plugin. +![Large Map View](../static/description/view_res_partner_map_large.png) diff --git a/web_view_leaflet_map/static/description/index.html b/web_view_leaflet_map/static/description/index.html index 0e9b1721e..26ce8aa00 100644 --- a/web_view_leaflet_map/static/description/index.html +++ b/web_view_leaflet_map/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Map View (OpenStreetMap) -
+
+

Leaflet Map View (OpenStreetMap)

- - -Odoo Community Association - -
-

Leaflet Map View (OpenStreetMap)

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

-

This module extends odoo views, to add a new kind of view, named -leaflet_map that is using the Leaflet javascript library to use -maps. (https://leafletjs.com/) This library is for exemple, used in the -OpenStreetMap project. (https://www.openstreetmap.org/)

-

You can see a simple usage in the module -web_view_leaflet_map_partner in the same OCA repository that -displays your contact in a map, if latitude and longitude are defined. -(To define latitude and longitude, refer to the Odoo module -base_geolocalize)

-

A marker will be displayed for each item that has a localization.

-

image1

-

If user zooms out, the markers will overlap, which won’t be very -visible.

-

In that case, nearby markers are grouped together, thanks to +

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

This module extends Odoo views to add a new kind of view named +leaflet_map that uses the Leaflet javascript library for interactive +maps. (https://leafletjs.com/) This library is used by projects like +OpenStreetMap. (https://www.openstreetmap.org/)

+

Core Map View:

+
    +
  • Display records as markers on an interactive map
  • +
  • Automatic marker clustering for better visibility when zoomed out
  • +
  • Custom marker icons from image fields
  • +
  • Click markers to open popups with record details
  • +
  • Click popup to navigate to record form view
  • +
+

Sidebar/Pin List (NEW):

+
    +
  • Collapsible sidebar showing all map markers in a list
  • +
  • Search and filter within the sidebar
  • +
  • Click items to center map on marker
  • +
  • Visual grouping by field (e.g., category)
  • +
  • Show located/unlocated record counts
  • +
+

Enhanced Markers (NEW):

+
    +
  • Numbered markers showing sequence
  • +
  • Color coding by group
  • +
  • Google Maps navigation buttons in popups
  • +
  • Coordinate validation (lat: -90..90, lng: -180..180)
  • +
+

Routing Support (NEW - requires web_leaflet_routing):

+
    +
  • Route polylines between markers
  • +
  • OSRM integration for real road routes
  • +
  • Route distance and duration display
  • +
+

See web_view_leaflet_map_partner module for a complete example that +displays contacts on a map with avatar markers, grouped sidebar, +auto-geocoding, and Google Maps navigation.

+

Precise Map View

+

If user zooms out, nearby markers are grouped together thanks to Leaflet.markercluster plugin.

-

image2

+

Large Map View

Table of contents

    @@ -407,13 +426,179 @@

    Leaflet Map View (OpenStreetMap)

-

Configuration

+

Configuration

+

To use this view, define a leaflet_map view in your XML:

+
+<leaflet_map
+    field_latitude="partner_latitude"
+    field_longitude="partner_longitude"
+    field_title="display_name"
+    field_address="contact_address"
+    field_marker_icon_image="avatar_128"
+    show_pin_list="1"
+    numbered_markers="1"
+    routing="1"
+    enable_navigation="1"
+    group_by="category_id"
+    panel_title="Locations"
+>
+    <field name="display_name"/>
+    <field name="partner_latitude"/>
+    <field name="partner_longitude"/>
+    <field name="contact_address"/>
+    <field name="category_id"/>
+</leaflet_map>
+
+

Required Attributes:

+ ++++ + + + + + + + + + + + + + +
AttributeDescription
field_latitudeField containing latitude coordinates
field_longitudeField containing longitude coordinates
+

Display Attributes:

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
field_titledisplay_nameField for marker +title/label
field_address
    +
  • +
+
Field for address in +popup
field_marker_icon_image
    +
  • +
+
Image field for custom +marker icon
marker_icon_size_x64Custom icon width in +pixels
marker_icon_size_y64Custom icon height in +pixels
+

Map Options:

+ +++++ + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
default_zoom7Initial map zoom level
max_zoom19Maximum zoom level
zoom_snap1Zoom level increments
+

New Attributes (v18.0.1.2.0):

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeDefaultDescription
show_pin_list“1”Show sidebar with marker list
panel_title“Locations”Title for the sidebar
numbered_markers“0”Show numbered markers instead of icons
routing“0”Enable route polylines between markers
enable_navigation“1”Show Google Maps navigation buttons
group_by
    +
  • +
+
Field for grouping markers by color
+

System Configuration:

+

Configure geocoding and routing providers in Settings > Leaflet Maps:

    -
  • See configuration of the module web_leaflet_lib.
  • +
  • Geocoding Provider: Nominatim (OSM) free with 1 req/s limit, or MapBox +(premium)
  • +
  • Routing Provider: OSRM free and self-hostable, or MapBox (premium)
  • +
  • Default Nominatim URL: https://nominatim.openstreetmap.org
  • +
  • Default OSRM URL: https://router.project-osrm.org
-

Development

+

Development

Create a new view :

 <record id="view_my_model_map" model="ir.ui.view">
@@ -467,7 +652,7 @@ 

Development

globally, or per model.

-

Known issues / Roadmap

+

Known issues / Roadmap

  • For the time being, at the start of the map loading, the call of invalidateSize() is required. We should investigate why and try to @@ -489,7 +674,7 @@

    Known issues / Roadmap

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -497,21 +682,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -519,13 +709,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css new file mode 100644 index 000000000..9dc0b1529 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.css @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + * + * Styles for DraggablePinList component - drag-and-drop functionality + */ + +/* ====================================== + Drag Handle + ====================================== */ + +.o_drag_handle { + cursor: grab; + opacity: 0.4; + transition: opacity 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + flex-shrink: 0; +} + +.o_pin_item_draggable:hover .o_drag_handle { + opacity: 1; +} + +.o_drag_handle:active { + cursor: grabbing; +} + +/* ====================================== + Drag States + ====================================== */ + +/* During drag - element being dragged */ +.o_pin_item_draggable.o_dragging { + opacity: 0.5; + background: var(--o-gray-200, #e9ecef); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 4px; +} + +/* Dragged element class (from sortable system) */ +.o_pin_item_draggable.o_dragged { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background: white; + z-index: 1000; +} + +/* ====================================== + Drop Target + ====================================== */ + +/* Drop target (group receiving the dragged element) */ +.o_pin_group.o_drop_target { + background: rgba(0, 123, 255, 0.08); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 6px; + transition: + background-color 0.15s ease, + border-color 0.15s ease; +} + +.o_pin_group.o_drop_target .o_pin_group_header { + background: rgba(0, 123, 255, 0.1); +} + +/* ====================================== + Draggable Item Layout + ====================================== */ + +.o_pin_item_draggable { + display: flex; + align-items: center; + transition: + background-color 0.15s ease, + opacity 0.15s ease; +} + +/* Placeholder for drop position */ +.o_pin_item_draggable.o_sortable_placeholder { + background: rgba(0, 123, 255, 0.15); + border: 2px dashed var(--o-primary, #007bff); + border-radius: 4px; + min-height: 40px; +} + +/* ====================================== + Navigate All Button + ====================================== */ + +.o_group_navigate_all { + padding: 2px 8px; + font-size: 0.75rem; + line-height: 1.2; + border-radius: 4px; + white-space: nowrap; +} + +.o_group_navigate_all:hover { + background: var(--o-primary, #007bff); + color: white; + border-color: var(--o-primary, #007bff); +} + +/* ====================================== + Mobile/Touch Enhancements + ====================================== */ + +@media (max-width: 768px) { + .o_drag_handle { + width: 30px; + opacity: 0.6; + padding: 8px 4px; + } + + .o_group_navigate_all { + padding: 4px 10px; + font-size: 0.85rem; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js new file mode 100644 index 000000000..0a3510b2c --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.esm.js @@ -0,0 +1,215 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global document, window */ + +import {useRef} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {useSortable} from "@web/core/utils/sortable_owl"; + +import {PinList} from "./pin_list.esm"; + +/** + * DraggablePinList extends PinList with drag-and-drop reordering capabilities. + * + * This is a generic component that can be used by any model that supports + * resequencing. It enables users to: + * - Reorder items within the same group (drag up/down) + * - Move items between groups (drag to different group) + * - Navigate to all items in a group via Google Maps + * + * Configuration is done via props: + * - groupField: The field name for the group (e.g., "order_id") + * - onResequence: Callback function for resequencing + */ +export class DraggablePinList extends PinList { + static template = "web_view_leaflet_map.DraggablePinList"; + // Inherits props from PinList which includes "*": true for extensibility + // Additional props used: onResequence (Function), groupField (String) + + setup() { + super.setup(); + this.notification = useService("notification"); + this.rootRef = useRef("root"); + + // Track drag state + this._draggedRecordId = null; + this._sourceGroupId = null; + + // Initialize sortable only if onResequence is provided + if (this.props.onResequence) { + this._setupSortable(); + } + } + + /** + * Setup the sortable functionality for drag-and-drop. + */ + _setupSortable() { + useSortable({ + ref: this.rootRef, + elements: ".o_pin_item_draggable", + handle: ".o_drag_handle", + groups: ".o_pin_group", + connectGroups: true, + cursor: "grabbing", + + onDragStart: ({element, group}) => { + element.classList.add("o_dragging"); + this._draggedRecordId = parseInt(element.dataset.id, 10); + this._sourceGroupId = group ? this._getGroupId(group) : null; + }, + + onGroupEnter: ({group}) => { + if (group) { + group.classList.add("o_drop_target"); + } + }, + + onGroupLeave: ({group}) => { + if (group) { + group.classList.remove("o_drop_target"); + } + }, + + onDrop: async ({element, parent, previous}) => { + await this._handleDrop(element, parent, previous); + }, + + onDragEnd: ({element}) => { + element.classList.remove("o_dragging"); + document + .querySelectorAll(".o_drop_target") + .forEach((el) => el.classList.remove("o_drop_target")); + }, + }); + } + + /** + * Handle drop event after drag-and-drop operation. + * + * @param {HTMLElement} element - The dragged element + * @param {HTMLElement|null} parent - The new parent group element + * @param {HTMLElement|null} previous - The preceding sibling element + */ + async _handleDrop(element, parent, previous) { + const recordId = parseInt(element.dataset.id, 10); + + // Determine target group ID + // If parent is provided, use its group ID (null for unassigned group is valid) + // If parent is null/undefined, keep the source group + const targetGroupId = parent ? this._getGroupId(parent) : this._sourceGroupId; + + // Find reference record (previous element after drop) + let previousRecordId = null; + if (previous && previous.dataset.id) { + previousRecordId = parseInt(previous.dataset.id, 10); + } + + try { + if (this.props.onResequence) { + await this.props.onResequence( + recordId, + targetGroupId, + previousRecordId + ); + } + } catch (error) { + this.notification.add(error.message || "Failed to reorder item", { + type: "danger", + }); + } + } + + /** + * Extract group ID from a group element. + * + * @param {HTMLElement} groupElement - The group DOM element + * @returns {Number|null} The group ID or null + */ + _getGroupId(groupElement) { + const groupId = groupElement?.dataset?.groupId; + if ( + !groupId || + groupId === "" || + groupId === "null" || + groupId === "undefined" + ) { + return null; + } + const parsed = parseInt(groupId, 10); + return isNaN(parsed) ? null : parsed; + } + + /** + * Get the group ID from a record based on the groupField prop. + * + * @param {Object} record - The record object + * @returns {Number|null} The group ID or null + */ + getRecordGroupId(record) { + // Use groupField from props if available, otherwise fall back to groupBy + const fieldName = this.props.groupField || this.props.groupBy; + if (!fieldName) return null; + + const value = record[fieldName]; + if (!value) return null; + + // Handle Many2one fields (array with [id, name]) + return Array.isArray(value) ? value[0] : value; + } + + /** + * Generate Google Maps URL for all records in a group (route). + * Uses the same format as the backend generate_google_maps_url method. + * + * @param {Object} group - The group object with records + * @returns {String|null} The Google Maps URL or null + */ + getGroupGoogleMapsUrl(group) { + const records = group.records + .filter((r) => this.hasValidCoordinates(r)) + .sort((a, b) => (a.sequence || 0) - (b.sequence || 0)); + + if (records.length < 2) { + return null; + } + + const coords = records.map((r) => [ + r[this.props.fieldLatitude], + r[this.props.fieldLongitude], + ]); + + const origin = `${coords[0][0]},${coords[0][1]}`; + const destination = `${coords[coords.length - 1][0]},${coords[coords.length - 1][1]}`; + const waypoints = coords + .slice(1, -1) + .map((c) => `${c[0]},${c[1]}`) + .join("|"); + + let url = `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${destination}`; + if (waypoints) { + url += `&waypoints=${waypoints}`; + } + return url; + } + + /** + * Handler for "Navigate All" button click on group header. + * Opens Google Maps with full route for the group. + * + * @param {Event} ev - The click event + * @param {Object} group - The group object + */ + onGroupNavigateClick(ev, group) { + ev.stopPropagation(); + const url = this.getGroupGoogleMapsUrl(group); + if (url) { + window.open(url, "_blank"); + } + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml new file mode 100644 index 000000000..d9c1ee499 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/draggable_pin_list.xml @@ -0,0 +1,178 @@ + + + + + +
+ +
+ + + + + + +
+ + + + +
+ + + located + + + + not located + + + +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ + + + + + + + + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.css b/web_view_leaflet_map/static/src/components/pin-list/pin_list.css new file mode 100644 index 000000000..789561757 --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.css @@ -0,0 +1,303 @@ +/* + Pin List Component Styles + Sidebar component for displaying map markers in a list format +*/ + +/* Main container */ +.o_leaflet_pin_list { + display: flex; + flex-direction: column; + width: 320px; + min-width: 320px; + background-color: #fff; + border-right: 1px solid #dee2e6; + overflow: hidden; + transition: + width 0.2s ease-in-out, + min-width 0.2s ease-in-out; +} + +.o_leaflet_pin_list.o_collapsed { + width: 48px; + min-width: 48px; +} + +/* Header */ +.o_pin_list_header { + display: flex; + align-items: center; + padding: 12px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + min-height: 48px; +} + +.o_pin_list_toggle { + padding: 4px 8px; + margin-right: 8px; + color: #495057; +} + +.o_collapsed .o_pin_list_toggle { + margin-right: 0; +} + +.o_pin_list_title { + font-weight: 600; + font-size: 14px; + color: #212529; +} + +.o_pin_list_count { + font-size: 12px; +} + +/* Search */ +.o_pin_list_search .input-group-text { + background-color: transparent; + border-right: none; +} + +.o_pin_list_search .form-control { + border-left: none; +} + +.o_pin_list_search .form-control:focus { + border-color: #ced4da; + box-shadow: none; +} + +/* Stats */ +.o_pin_list_stats { + border-bottom: 1px solid #dee2e6; +} + +/* Groups container */ +.o_pin_list_groups { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Group */ +.o_pin_group { + border-bottom: 1px solid #f0f0f0; +} + +.o_pin_group:last-child { + border-bottom: none; +} + +/* Group header */ +.o_pin_group_header { + display: flex; + align-items: center; + padding: 8px 12px; + background-color: #f8f9fa; + cursor: pointer; + user-select: none; + gap: 8px; +} + +.o_pin_group_header:hover { + background-color: #e9ecef; +} + +.o_pin_group_header i { + width: 12px; + color: #6c757d; +} + +.o_pin_group_color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.o_pin_group_name { + font-weight: 500; + font-size: 13px; + color: #495057; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_pin_group_count { + flex-shrink: 0; +} + +/* Unassigned group styling */ +.o_pin_group.o_pin_group_unassigned { + border-left: 3px solid #fd7e14; +} + +.o_pin_group_header.o_unassigned_header { + background-color: #fff8f0; +} + +.o_pin_group_header.o_unassigned_header:hover { + background-color: #ffe8d6; +} + +/* Group items */ +.o_pin_group_items { + /* No padding - items handle their own */ +} + +/* Pin item */ +.o_pin_item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; + transition: background-color 0.15s ease; + gap: 10px; +} + +.o_pin_item:hover { + background-color: #f0f7ff; +} + +.o_pin_item:last-child { + border-bottom: none; +} + +.o_pin_item.o_pin_item_unlocated { + opacity: 0.6; + cursor: default; +} + +.o_pin_item.o_pin_item_unlocated:hover { + background-color: #fff3cd; +} + +/* Pin number */ +.o_pin_number { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #007bff; + color: white; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.o_pin_item_unlocated .o_pin_number { + background-color: #ffc107; + color: #212529; +} + +/* Pin content */ +.o_pin_content { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.o_pin_title { + font-size: 13px; + font-weight: 500; + color: #212529; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_pin_address { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + +/* Navigate button */ +.o_pin_navigate { + flex-shrink: 0; + padding: 4px 8px; + font-size: 12px; + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.o_pin_item:hover .o_pin_navigate { + opacity: 1; +} + +/* Status icon for unlocated items */ +.o_pin_status { + flex-shrink: 0; + font-size: 14px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .o_leaflet_pin_list { + width: 100%; + min-width: 100%; + max-height: 40vh; + border-right: none; + border-bottom: 1px solid #dee2e6; + } + + .o_leaflet_pin_list.o_collapsed { + width: 100%; + min-width: 100%; + max-height: 48px; + } + + .o_pin_navigate { + opacity: 1; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .o_leaflet_pin_list { + background-color: #212529; + border-color: #495057; + } + + .o_pin_list_header { + background-color: #343a40; + border-color: #495057; + } + + .o_pin_list_title { + color: #f8f9fa; + } + + .o_pin_list_toggle { + color: #adb5bd; + } + + .o_pin_group_header { + background-color: #343a40; + } + + .o_pin_group_header:hover { + background-color: #495057; + } + + .o_pin_group_name { + color: #e9ecef; + } + + .o_pin_item { + border-color: #343a40; + } + + .o_pin_item:hover { + background-color: #495057; + } + + .o_pin_title { + color: #f8f9fa; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js new file mode 100644 index 000000000..f493aecec --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.esm.js @@ -0,0 +1,235 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {Component, useState} from "@odoo/owl"; + +/** + * PinList component displays a sidebar with a list of map markers. + * Supports grouping, collapsing, and click-to-center functionality. + */ +export class PinList extends Component { + static template = "web_view_leaflet_map.PinList"; + static props = { + records: {type: Array}, + groupBy: {type: [String, {value: null}, {value: undefined}], optional: true}, + groupColors: {type: Object, optional: true}, + panelTitle: {type: String, optional: true}, + onPinClick: {type: Function}, + onNavigateClick: {type: Function, optional: true}, + fieldTitle: {type: String, optional: true}, + fieldAddress: {type: String, optional: true}, + fieldLatitude: {type: String}, + fieldLongitude: {type: String}, + unassignedGroupName: {type: String, optional: true}, + // Accept additional props from extending modules (groupField, onResequence, etc.) + // without strict type validation - enables extensibility + "*": true, + }; + static defaultProps = { + panelTitle: "Locations", + groupColors: {}, + unassignedGroupName: "Unassigned", + }; + + setup() { + this.state = useState({ + collapsed: false, + collapsedGroups: {}, + }); + } + + /** + * Get records organized by groups. + * Records without a groupBy value are placed in the unassigned group. + */ + get groupedRecords() { + const records = this.props.records; + const UNASSIGNED_GROUP_NAME = this.props.unassignedGroupName; + // Orange color for unassigned group + const UNASSIGNED_COLOR = "#fd7e14"; + + if (!this.props.groupBy) { + return [{name: null, records, color: null, isUnassigned: false}]; + } + + const groups = {}; + for (const record of records) { + const groupValue = record[this.props.groupBy]; + let isUnassigned = false; + let groupKey = UNASSIGNED_GROUP_NAME; + + // Handle Many2one fields (array with [id, name]) and empty values + if (!groupValue || (Array.isArray(groupValue) && !groupValue[0])) { + // No group value - unassigned + isUnassigned = true; + } else { + groupKey = Array.isArray(groupValue) ? groupValue[1] : groupValue; + } + + if (!groups[groupKey]) { + groups[groupKey] = { + name: groupKey, + records: [], + color: isUnassigned + ? UNASSIGNED_COLOR + : this.getGroupColor(groupKey), + isUnassigned: isUnassigned, + }; + } + groups[groupKey].records.push(record); + } + + // Sort: alphabetically, then unassigned group last + return Object.values(groups).sort((a, b) => { + if (a.isUnassigned && !b.isUnassigned) return 1; + if (!a.isUnassigned && b.isUnassigned) return -1; + return String(a.name).localeCompare(String(b.name)); + }); + } + + /** + * Get total count of located records + */ + get locatedCount() { + return this.props.records.filter( + (r) => + r[this.props.fieldLatitude] && + r[this.props.fieldLongitude] && + this.validateCoordinates( + r[this.props.fieldLatitude], + r[this.props.fieldLongitude] + ) + ).length; + } + + /** + * Get count of records without valid coordinates + */ + get unlocatedCount() { + return this.props.records.length - this.locatedCount; + } + + /** + * Validate coordinates are within valid ranges + */ + validateCoordinates(lat, lng) { + return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; + } + + /** + * Get display title for a record + */ + getRecordTitle(record) { + if (this.props.fieldTitle) { + return record[this.props.fieldTitle] || record.display_name || ""; + } + return record.display_name || ""; + } + + /** + * Get address for a record + */ + getRecordAddress(record) { + if (this.props.fieldAddress) { + return record[this.props.fieldAddress] || ""; + } + return ""; + } + + /** + * Check if a record has valid coordinates + */ + hasValidCoordinates(record) { + const lat = record[this.props.fieldLatitude]; + const lng = record[this.props.fieldLongitude]; + return lat && lng && this.validateCoordinates(lat, lng); + } + + /** + * Get a color for a group (generates consistent colors based on group name) + */ + getGroupColor(groupName) { + if (this.props.groupColors[groupName]) { + return this.props.groupColors[groupName]; + } + + // Generate a color based on the hash of the group name + const colors = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85C1E9", + ]; + + let hash = 0; + for (let i = 0; i < String(groupName).length; i++) { + hash = String(groupName).charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; + } + + /** + * Toggle sidebar collapse state + */ + toggleSidebar() { + this.state.collapsed = !this.state.collapsed; + } + + /** + * Toggle group collapse state + */ + toggleGroup(groupName) { + this.state.collapsedGroups[groupName] = !this.isGroupCollapsed(groupName); + } + + /** + * Check if a group is collapsed. + * The unassigned group is collapsed by default. + */ + isGroupCollapsed(groupName) { + // If explicitly set, use that value; otherwise default to collapsed for unassigned + if (groupName in this.state.collapsedGroups) { + return this.state.collapsedGroups[groupName]; + } + // Default: unassigned group is collapsed, others are expanded + return groupName === this.props.unassignedGroupName; + } + + /** + * Handle click on a pin item + */ + onPinItemClick(record) { + if (this.hasValidCoordinates(record)) { + this.props.onPinClick(record); + } + } + + /** + * Handle click on navigate button + */ + onNavigateButtonClick(ev, record) { + ev.stopPropagation(); + if (this.props.onNavigateClick && this.hasValidCoordinates(record)) { + this.props.onNavigateClick(record); + } + } + + /** + * Generate Google Maps navigation URL + */ + getGoogleMapsUrl(record) { + const lat = record[this.props.fieldLatitude]; + const lng = record[this.props.fieldLongitude]; + return `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + } +} diff --git a/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml new file mode 100644 index 000000000..efd536d7a --- /dev/null +++ b/web_view_leaflet_map/static/src/components/pin-list/pin_list.xml @@ -0,0 +1,145 @@ + + + + +
+ +
+ + + + + + +
+ + + + +
+ + + located + + + + not located + + + +
+ + +
+ +
+ + +
+ + + + + + + + + + + +
+
+ + +
+ +
+ + + + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+ +
diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js new file mode 100644 index 000000000..af34465a0 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_arch_parser.esm.js @@ -0,0 +1,163 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {visitXML} from "@web/core/utils/xml"; + +/** + * LeafletMapArchParser parses the XML architecture of leaflet_map views. + * Extracts field definitions and view options from the arch XML. + */ +export class LeafletMapArchParser { + /** + * Parse the arch XML and extract view configuration. + * Follows the standard Odoo pattern with (arch, fields) signature. + * + * @param {Element} arch - The XML arch element + * @param {Object} fields - Field definitions from the model (optional) + * @returns {Object} Parsed arch information + */ + parse(arch, fields = {}) { + const archInfo = { + // Required fields that are always loaded + fieldNames: ["id", "display_name"], + // Fields displayed in marker popup + fieldNamesMarkerPopup: [], + // Field metadata from arch + fieldNodes: {}, + // Store fields reference for validation + fields, + }; + + visitXML(arch, (node) => { + if (node.tagName === "leaflet_map") { + this._parseMapAttributes(node, archInfo); + } + + if (node.tagName === "field") { + this._parseFieldNode(node, archInfo, fields); + } + }); + + return archInfo; + } + + /** + * Parse attributes from the leaflet_map element. + * + * @param {Element} node - The leaflet_map XML element + * @param {Object} archInfo - The arch info object to populate + */ + _parseMapAttributes(node, archInfo) { + const getAttr = (name, defaultVal = null) => + node.getAttribute(name) || defaultVal; + + // Coordinate fields (required) + archInfo.fieldLatitude = getAttr("field_latitude"); + archInfo.fieldLongitude = getAttr("field_longitude"); + if (archInfo.fieldLatitude) { + archInfo.fieldNames.push(archInfo.fieldLatitude); + } + if (archInfo.fieldLongitude) { + archInfo.fieldNames.push(archInfo.fieldLongitude); + } + + // Display fields + archInfo.fieldTitle = getAttr("field_title"); + archInfo.fieldAddress = getAttr("field_address"); + archInfo.fieldMarkerIconImage = getAttr("field_marker_icon_image"); + if (archInfo.fieldTitle) { + archInfo.fieldNames.push(archInfo.fieldTitle); + } + if (archInfo.fieldAddress) { + archInfo.fieldNames.push(archInfo.fieldAddress); + } + if (archInfo.fieldMarkerIconImage) { + archInfo.fieldNames.push(archInfo.fieldMarkerIconImage); + } + + // Marker icon configuration + archInfo.markerIconSizeX = parseInt(getAttr("marker_icon_size_x", "64"), 10); + archInfo.markerIconSizeY = parseInt(getAttr("marker_icon_size_y", "64"), 10); + archInfo.markerPopupAnchorX = parseInt( + getAttr("marker_popup_anchor_x", "0"), + 10 + ); + archInfo.markerPopupAnchorY = parseInt( + getAttr("marker_popup_anchor_y", "-32"), + 10 + ); + + // Map configuration + archInfo.defaultZoom = parseInt(getAttr("default_zoom", "7"), 10); + archInfo.maxZoom = parseInt(getAttr("max_zoom", "19"), 10); + archInfo.zoomSnap = parseInt(getAttr("zoom_snap", "1"), 10); + + // View options + archInfo.showPinList = getAttr("show_pin_list") !== "0"; + archInfo.panelTitle = getAttr("panel_title") || "Locations"; + archInfo.numberedMarkers = getAttr("numbered_markers") === "1"; + archInfo.routing = getAttr("routing") === "1"; + archInfo.enableNavigation = getAttr("enable_navigation") !== "0"; + + // Grouping configuration + archInfo.groupBy = getAttr("group_by"); + if (archInfo.groupBy) { + archInfo.fieldNames.push(archInfo.groupBy); + } + + // Drag-and-drop configuration + archInfo.draggable = getAttr("draggable") === "1"; + archInfo.groupField = getAttr("group_field"); + archInfo.defaultOrder = getAttr("default_order"); + if (archInfo.groupField) { + archInfo.fieldNames.push(archInfo.groupField); + } + if (archInfo.defaultOrder) { + archInfo.fieldNames.push(archInfo.defaultOrder); + } + + // Data limits + archInfo.limit = parseInt(getAttr("limit", "500"), 10); + + // Custom js_class for extended views + archInfo.jsClass = getAttr("js_class"); + + // Configurable unassigned group name (default: "Unassigned") + archInfo.unassignedGroupName = getAttr("unassigned_group_name"); + } + + /** + * Parse a field node from the arch. + * + * @param {Element} node - The field XML element + * @param {Object} archInfo - The arch info object to populate + * @param {Object} fields - Field definitions from the model + */ + _parseFieldNode(node, archInfo, fields) { + const fieldName = node.getAttribute("name"); + if (!fieldName) { + return; + } + + archInfo.fieldNames.push(fieldName); + + // Get field info from model definition if available + const fieldDef = fields[fieldName] || {}; + + archInfo.fieldNodes[fieldName] = { + name: fieldName, + string: node.getAttribute("string") || fieldDef.string || fieldName, + invisible: node.getAttribute("invisible") === "1", + type: fieldDef.type, + }; + + archInfo.fieldNamesMarkerPopup.push({ + fieldName, + string: node.getAttribute("string") || fieldDef.string || fieldName, + }); + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js new file mode 100644 index 000000000..264254caf --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_controller.esm.js @@ -0,0 +1,109 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {Component, useRef} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {useModelWithSampleData} from "@web/model/model"; +import {standardViewProps} from "@web/views/standard_view_props"; +import {useSetupAction} from "@web/search/action_hook"; +import {Layout} from "@web/search/layout"; +import {SearchBar} from "@web/search/search_bar/search_bar"; +import {useSearchBarToggler} from "@web/search/search_bar/search_bar_toggler"; +import {CogMenu} from "@web/search/cog_menu/cog_menu"; +import {executeButtonCallback} from "@web/views/view_button/view_button_hook"; + +/** + * LeafletMapController is the main controller for the leaflet map view. + * It manages the model lifecycle and coordinates between the search panel + * and the renderer. Follows the standard Odoo view controller pattern. + */ +export class LeafletMapController extends Component { + static template = "web_view_leaflet_map.LeafletMapController"; + static components = {Layout, SearchBar, CogMenu}; + + static props = { + ...standardViewProps, + Model: Function, + modelParams: Object, + Renderer: Function, + buttonTemplate: {type: String, optional: true}, + }; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + + // Root ref for button callbacks and action state management + this.rootRef = useRef("root"); + + // Use the standard model hook that integrates with WithSearch + this.model = useModelWithSampleData(this.props.Model, this.props.modelParams); + + // Setup action hook for state management + useSetupAction({ + rootRef: this.rootRef, + getLocalState: () => ({metaData: this.model.metaData}), + }); + + // Setup search bar toggler for mobile responsiveness + this.searchBarToggler = useSearchBarToggler(); + } + + /** + * Handle click on Create button. + * Uses executeButtonCallback for proper button state management. + */ + async onClickCreate() { + return executeButtonCallback(this.rootRef.el, () => this.createRecord()); + } + + /** + * Create a new record using the view's createRecord prop. + */ + async createRecord() { + await this.props.createRecord(); + } + + /** + * Handle resequence event from the renderer. + * + * @param {Number} recordId - ID of the record being moved + * @param {Number} targetGroupId - ID of the target group + * @param {Number|null} previousRecordId - ID of the preceding record + */ + async onResequence(recordId, targetGroupId, previousRecordId) { + try { + const result = await this.model.resequence( + recordId, + targetGroupId, + previousRecordId + ); + + if (!result.success) { + this.notification.add(result.error || "Failed to reorder item", { + type: "danger", + }); + } + return result; + } catch (error) { + this.notification.add(error.message || "Failed to reorder item", { + type: "danger", + }); + return {success: false, error: error.message}; + } + } + + /** + * Get props to pass to the Renderer component. + */ + get rendererProps() { + return { + model: this.model, + onResequence: this.onResequence.bind(this), + }; + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js new file mode 100644 index 000000000..1d3e893cb --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_model.esm.js @@ -0,0 +1,352 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {Model} from "@web/model/model"; +import {KeepLast} from "@web/core/utils/concurrency"; + +/** + * LeafletMapModel handles data loading and manipulation for the leaflet map view. + * Extends the core Model class to integrate with Odoo's search infrastructure. + */ +export class LeafletMapModel extends Model { + /** + * Setup the model with initial parameters. + * Called by the Model base class during construction. + * + * @param {Object} params - Model parameters from view props + */ + setup(params) { + this.keepLast = new KeepLast(); + + // Store metadata for state restoration + this.metaData = { + ...params, + }; + + // Data state + this.data = { + records: [], + recordGroups: [], + count: 0, + numberOfLocatedRecords: 0, + isGrouped: false, + groupByKey: false, + }; + } + + /** + * Load records based on search parameters. + * Called by WithSearch when domain/groupBy/context changes. + * + * @param {Object} searchParams - Search parameters from WithSearch + * @param {Array} searchParams.domain - Domain filter + * @param {Array} searchParams.groupBy - GroupBy fields + * @param {Object} searchParams.context - Additional context + * @returns {Promise} + */ + async load(searchParams) { + const {domain = [], groupBy = [], context = {}} = searchParams; + + // Merge search params into metadata + const metaData = { + ...this.metaData, + domain, + groupBy: groupBy.length > 0 ? groupBy[0] : this.metaData.archInfo?.groupBy, + context: {...this.metaData.context, ...context}, + }; + + // Fetch data with the merged parameters + this.data = await this.keepLast.add(this._fetchData(metaData)); + + // Update metadata after successful load + this.metaData = metaData; + + this.notify(); + } + + /** + * Check if the model has data to display. + * + * @returns {Boolean} + */ + hasData() { + return this.data.records.length > 0; + } + + /** + * Fetch data from the server. + * + * @param {Object} metaData - Metadata with fetch parameters + * @returns {Promise} The fetched data + */ + async _fetchData(metaData) { + const { + resModel, + domain = [], + context = {}, + archInfo = {}, + limit, + offset = 0, + } = metaData; + + const fields = this._getFieldsToLoad(metaData); + const recordLimit = limit || archInfo.limit || 500; + + const records = await this.orm.searchRead(resModel, domain, fields, { + limit: recordLimit, + offset, + context, + }); + + // Determine groupBy - from search params or archInfo + const groupByField = metaData.groupBy || archInfo.groupBy; + + // Calculate located records + const locatedRecords = this._filterLocatedRecords(records, archInfo); + + // Group records if groupBy is configured + let recordGroups = []; + let isGrouped = false; + let groupByKey = false; + + if (groupByField) { + recordGroups = this._groupRecords(records, groupByField); + isGrouped = true; + groupByKey = groupByField; + } else { + recordGroups = [{name: null, id: null, records}]; + } + + return { + records, + recordGroups, + count: records.length, + numberOfLocatedRecords: locatedRecords.length, + isGrouped, + groupByKey, + }; + } + + /** + * Get the list of fields to load based on archInfo. + * + * @param {Object} metaData - Metadata containing archInfo + * @returns {Array} Field names to load + */ + _getFieldsToLoad(metaData) { + const archInfo = metaData.archInfo || {}; + const fields = new Set(archInfo.fieldNames || []); + + // Ensure essential fields are always loaded + fields.add("id"); + fields.add("display_name"); + + // Add sequence field if available (for ordering) + if (metaData.fields?.sequence) { + fields.add("sequence"); + } + + return Array.from(fields); + } + + /** + * Filter records that have valid coordinates. + * + * @param {Array} records - All records + * @param {Object} archInfo - Arch info with coordinate field names + * @returns {Array} Records with valid coordinates + */ + _filterLocatedRecords(records, archInfo) { + const {fieldLatitude, fieldLongitude} = archInfo; + if (!fieldLatitude || !fieldLongitude) { + return records; + } + + return records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return this._validateCoordinates(lat, lng); + }); + } + + /** + * Validate that coordinates are within valid ranges. + * + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + _validateCoordinates(lat, lng) { + try { + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + return ( + !isNaN(parsedLat) && + !isNaN(parsedLng) && + parsedLat >= -90 && + parsedLat <= 90 && + parsedLng >= -180 && + parsedLng <= 180 + ); + } catch { + return false; + } + } + + /** + * Group records by the groupBy field. + * + * @param {Array} records - Records to group + * @param {String} groupBy - Field name to group by + * @returns {Array} Grouped records + */ + _groupRecords(records, groupBy) { + const groups = {}; + + for (const record of records) { + const groupValue = record[groupBy]; + // Handle Many2one fields (array with [id, name]) + const groupName = Array.isArray(groupValue) + ? groupValue[1] + : groupValue || "Undefined"; + const groupId = Array.isArray(groupValue) ? groupValue[0] : groupValue; + + if (!groups[groupName]) { + groups[groupName] = { + name: groupName, + id: groupId, + records: [], + }; + } + groups[groupName].records.push(record); + } + + // Sort groups alphabetically by name + return Object.values(groups).sort((a, b) => + String(a.name).localeCompare(String(b.name)) + ); + } + + /** + * Generic resequence method for drag-and-drop operations. + * This base implementation updates the sequence field directly. + * Override in subclasses for model-specific behavior. + * + * @param {Number} recordId - ID of the record being moved + * @param {Number} targetGroupId - ID of the target group + * @param {Number|null} previousRecordId - ID of the preceding record (null = first) + * @returns {Promise} Result of the operation + */ + async resequence(recordId, targetGroupId, previousRecordId) { + const archInfo = this.metaData.archInfo || {}; + + if (!archInfo.defaultOrder) { + return {success: false, error: "Resequencing not configured"}; + } + + const record = this.data.records.find((r) => r.id === recordId); + if (!record) { + return {success: false, error: "Record not found"}; + } + + const updates = {}; + const sequenceField = archInfo.defaultOrder; + + // Calculate new sequence value + if (previousRecordId) { + const prevRecord = this.data.records.find((r) => r.id === previousRecordId); + if (prevRecord && prevRecord[sequenceField] !== undefined) { + updates[sequenceField] = prevRecord[sequenceField] + 1; + } else { + updates[sequenceField] = 10; + } + } else { + // Insert at beginning - find minimum sequence in target group + const targetRecords = this.data.records.filter((r) => { + if (!archInfo.groupField) return true; + const groupValue = r[archInfo.groupField]; + const groupId = Array.isArray(groupValue) ? groupValue[0] : groupValue; + return groupId === targetGroupId; + }); + + if (targetRecords.length > 0) { + const minSeq = Math.min( + ...targetRecords.map((r) => r[sequenceField] || 0) + ); + updates[sequenceField] = minSeq - 10; + } else { + updates[sequenceField] = 10; + } + } + + // Update group field if moving between groups + if (archInfo.groupField) { + const currentGroupValue = record[archInfo.groupField]; + const currentGroupId = Array.isArray(currentGroupValue) + ? currentGroupValue[0] + : currentGroupValue; + + if (currentGroupId !== targetGroupId) { + // Use false to clear Many2one field when moving to unassigned group + updates[archInfo.groupField] = + targetGroupId === null ? false : targetGroupId; + } + } + + try { + await this.orm.write(this.metaData.resModel, [recordId], updates); + // Reload data to reflect server-side changes + this.data = await this._fetchData(this.metaData); + this.notify(); + return {success: true}; + } catch (error) { + return {success: false, error: this._extractErrorMessage(error)}; + } + } + + /** + * Extract a user-friendly error message from an RPC error. + * Odoo RPCError stores the actual message in error.data.message, + * while error.message is the generic "Odoo Server Error". + * + * @param {Error} error - The caught error + * @returns {String} User-friendly error message + */ + _extractErrorMessage(error) { + return error.data?.message || error.message || String(error); + } + + /** + * Get records with valid coordinates. + * + * @returns {Array} Records with valid lat/lng + */ + getLocatedRecords() { + const archInfo = this.metaData.archInfo || {}; + const {fieldLatitude, fieldLongitude} = archInfo; + return this.data.records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return this._validateCoordinates(lat, lng); + }); + } + + /** + * Get records without valid coordinates. + * + * @returns {Array} Records without valid lat/lng + */ + getUnlocatedRecords() { + const archInfo = this.metaData.archInfo || {}; + const {fieldLatitude, fieldLongitude} = archInfo; + return this.data.records.filter((r) => { + const lat = r[fieldLatitude]; + const lng = r[fieldLongitude]; + return !this._validateCoordinates(lat, lng); + }); + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js new file mode 100644 index 000000000..aee53ca28 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_renderer.esm.js @@ -0,0 +1,575 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +/* global L, document, window */ + +import { + Component, + onMounted, + onPatched, + onWillStart, + useRef, + useState, +} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {session} from "@web/session"; + +import {PinList} from "../components/pin-list/pin_list.esm"; + +// Default colors for group markers +const GROUP_COLORS = [ + "#FF6B6B", + "#4ECDC4", + "#45B7D1", + "#96CEB4", + "#FFEAA7", + "#DDA0DD", + "#98D8C8", + "#F7DC6F", + "#BB8FCE", + "#85C1E9", +]; + +/** + * LeafletMapRenderer component for displaying records on a Leaflet map. + * Supports markers, clustering, popups, routing, and a sidebar pin list. + */ +export class LeafletMapRenderer extends Component { + static template = "web_view_leaflet_map.LeafletMapRenderer"; + static components = {PinList}; + + static props = { + model: {type: Object}, + onResequence: {type: Function, optional: true}, + // Accept additional props from extending modules for extensibility + "*": true, + }; + + /** + * Initializes the LeafletMapRenderer component. + */ + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.mapRef = useRef("mapContainer"); + + // Session configuration + this.leafletTileUrl = session["leaflet.tile_url"]; + this.leafletCopyright = session["leaflet.copyright"]; + + // Extract configuration from model.metaData.archInfo + const metaData = this.props.model.metaData || {}; + const archInfo = metaData.archInfo || {}; + + this.resModel = metaData.resModel; + this.fieldLatitude = archInfo.fieldLatitude; + this.fieldLongitude = archInfo.fieldLongitude; + this.fieldTitle = archInfo.fieldTitle; + this.fieldAddress = archInfo.fieldAddress; + this.fieldMarkerIconImage = archInfo.fieldMarkerIconImage; + + // Marker icon configuration + this.markerIconSizeX = archInfo.markerIconSizeX || 64; + this.markerIconSizeY = archInfo.markerIconSizeY || 64; + this.markerPopupAnchorX = archInfo.markerPopupAnchorX || 0; + this.markerPopupAnchorY = archInfo.markerPopupAnchorY || -32; + + // Map configuration + this.defaultZoom = archInfo.defaultZoom || 7; + this.maxZoom = archInfo.maxZoom || 19; + this.zoomSnap = archInfo.zoomSnap || 1; + + // View options + this.showPinList = archInfo.showPinList !== false; + this.groupBy = archInfo.groupBy; + this.panelTitle = archInfo.panelTitle || "Locations"; + this.showNumberedMarkers = archInfo.numberedMarkers === true; + this.enableRouting = archInfo.routing === true; + this.enableNavigation = archInfo.enableNavigation !== false; + + // Drag-and-drop configuration + this.draggable = archInfo.draggable === true; + this.groupField = archInfo.groupField; + this.defaultOrder = archInfo.defaultOrder; + + // Configurable unassigned group name + this.unassignedGroupName = archInfo.unassignedGroupName || "Unassigned"; + + // Internal state (for backward compatibility with direct rendering) + this.state = useState({ + selectedRecord: null, + }); + + // Map references + this.leafletMap = null; + this.mainLayer = null; + this.routeLayer = null; + this.markersById = {}; + this.groupColors = {}; + this.defaultLatLng = null; + + onWillStart(async () => { + await this.initDefaultPosition(); + }); + + onMounted(() => { + this.initMap(); + this.renderMarkers(); + }); + + onPatched(() => { + if (this.leafletMap) { + this.renderMarkers(); + } + }); + } + + /** + * Get records from the model. + */ + get records() { + return this.props.model.data?.records || []; + } + + /** + * Get loading state from the model. + */ + get loading() { + return this.props.model.data?.loading || false; + } + + /** + * Validates that coordinates are within valid ranges. + * Accepts 0.0 as valid (equator/prime meridian), rejects null/undefined. + * @param {Number} lat - Latitude + * @param {Number} lng - Longitude + * @returns {Boolean} + */ + validateCoordinates(lat, lng) { + try { + // Reject null, undefined, empty string + if (lat === null || lat === undefined || lat === "") return false; + if (lng === null || lng === undefined || lng === "") return false; + + const parsedLat = parseFloat(lat); + const parsedLng = parseFloat(lng); + return ( + !isNaN(parsedLat) && + !isNaN(parsedLng) && + parsedLat >= -90 && + parsedLat <= 90 && + parsedLng >= -180 && + parsedLng <= 180 + ); + } catch { + return false; + } + } + + /** + * Initializes the default position of the map by calling the server method. + * @returns {Promise} + */ + async initDefaultPosition() { + const result = await this.orm.call( + "res.users", + "get_default_leaflet_position", + [this.resModel] + ); + this.defaultLatLng = L.latLng(result.lat, result.lng); + } + + /** + * Initializes the Leaflet map in the container. + */ + initMap() { + const mapDiv = this.mapRef.el; + if (!mapDiv) { + return; + } + + this.leafletMap = L.map(mapDiv, { + zoomSnap: this.zoomSnap, + }).setView(this.defaultLatLng, this.defaultZoom); + + L.tileLayer(this.leafletTileUrl, { + maxZoom: this.maxZoom, + attribution: this.leafletCopyright, + }).addTo(this.leafletMap); + + // Initialize route layer for polylines + if (this.enableRouting) { + this.routeLayer = L.layerGroup().addTo(this.leafletMap); + } + } + + /** + * Gets a color for a group based on its name. + * @param {String} groupName + * @returns {String} + */ + getGroupColor(groupName) { + if (this.groupColors[groupName]) { + return this.groupColors[groupName]; + } + + let hash = 0; + for (let i = 0; i < String(groupName).length; i++) { + hash = String(groupName).charCodeAt(i) + ((hash << 5) - hash); + } + const color = GROUP_COLORS[Math.abs(hash) % GROUP_COLORS.length]; + this.groupColors[groupName] = color; + return color; + } + + /** + * Gets the group name for a record. + * @param {Object} record + * @returns {string|null} + */ + getGroupName(record) { + if (!this.groupBy) return null; + const value = record[this.groupBy]; + // Handle Many2one fields (array with [id, name]) + return Array.isArray(value) ? value[1] : value || "Undefined"; + } + + /** + * Renders the markers on the map based on the loaded records. + */ + renderMarkers() { + if (!this.leafletMap) { + return; + } + + if (this.mainLayer) { + this.leafletMap.removeLayer(this.mainLayer); + } + + this.mainLayer = L.markerClusterGroup(); + this.markersById = {}; + + let markerIndex = 0; + for (const record of this.records) { + const marker = this.prepareMarker(record, markerIndex); + if (marker) { + this.mainLayer.addLayer(marker); + this.markersById[record.id] = marker; + markerIndex++; + } + } + + const bounds = this.mainLayer.getBounds(); + if (bounds.isValid()) { + this.leafletMap.fitBounds(bounds.pad(0.1)); + } + + this.leafletMap.addLayer(this.mainLayer); + + // Draw route lines if routing is enabled + if (this.enableRouting && this.records.length > 1) { + this.renderRouteLines(); + } + } + + /** + * Renders route lines connecting records in order. + * Groups records by groupBy field if configured. + */ + renderRouteLines() { + if (!this.routeLayer) { + this.routeLayer = L.layerGroup().addTo(this.leafletMap); + } + this.routeLayer.clearLayers(); + + // Group records by groupBy field if configured + const groups = {}; + for (const record of this.records) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (!this.validateCoordinates(lat, lng)) { + continue; + } + + let groupKey = "default"; + if (this.groupBy && record[this.groupBy]) { + const groupValue = record[this.groupBy]; + groupKey = Array.isArray(groupValue) ? groupValue[0] : groupValue; + } + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push({ + lat, + lng, + sequence: record.sequence || 0, + }); + } + + // Draw lines for each group using GROUP_COLORS for consistency with markers + let colorIndex = 0; + + for (const groupKey in groups) { + const stops = groups[groupKey].sort((a, b) => a.sequence - b.sequence); + + if (stops.length < 2) { + continue; + } + + const waypoints = stops.map((s) => [s.lat, s.lng]); + const color = GROUP_COLORS[colorIndex % GROUP_COLORS.length]; + + const polyline = L.polyline(waypoints, { + color: color, + weight: 3, + opacity: 0.7, + dashArray: "10, 10", + }); + + polyline.addTo(this.routeLayer); + colorIndex++; + } + } + + /** + * Prepares a Leaflet marker for the given record. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index for numbering + * @returns {L.Marker|null} + */ + prepareMarker(record, index) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + // Use validateCoordinates for proper check (0.0 is valid) + if (!this.validateCoordinates(lat, lng)) { + return null; + } + + const latlng = L.latLng(lat, lng); + const markerOptions = this.prepareMarkerOptions(record, index); + + const marker = L.marker(latlng, markerOptions); + const popup = L.popup().setContent(this.preparePopUpData(record, index)); + + marker.bindPopup(popup).on("popupopen", () => { + const selector = document.querySelector(".o_map_selector"); + if (selector) { + selector.addEventListener("click", (ev) => { + ev.preventDefault(); + this.onClickLeafletPopup(record); + }); + } + }); + + return marker; + } + + /** + * Creates a numbered marker icon using SVG. + * @param {Number} number - The number to display + * @param {String} color - The marker color + * @returns {L.DivIcon} + */ + createNumberedMarker(number, color = "#007bff") { + const svg = ` + + + + ${number} + + `; + + return L.divIcon({ + className: "o_leaflet_numbered_marker", + html: svg, + iconSize: [30, 40], + iconAnchor: [15, 40], + popupAnchor: [0, -40], + }); + } + + /** + * Prepares the Leaflet icon for the marker using the image field. + * @param {Object} record - The record object containing marker data + * @returns {L.Icon} + */ + prepareMarkerIcon(record) { + // Use any date field as cache buster, or fall back to current time + const lastUpdate = + record.date_localization || + record.write_date || + record.id || + new Date().toISOString(); + const unique = String(lastUpdate).replace(/[^0-9]/g, ""); + const iconUrl = `/web/image?model=${this.resModel}&id=${record.id}&field=${this.fieldMarkerIconImage}&unique=${unique}`; + + return L.icon({ + iconUrl: iconUrl, + className: "leaflet_marker_icon", + iconSize: [this.markerIconSizeX, this.markerIconSizeY], + popupAnchor: [this.markerPopupAnchorX, this.markerPopupAnchorY], + }); + } + + /** + * Prepares the options for the leaflet marker. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index + * @returns {Object} + */ + prepareMarkerOptions(record, index) { + const title = record[this.fieldTitle] || ""; + const result = { + title: title, + alt: title, + riseOnHover: true, + }; + + // Use numbered markers if enabled + if (this.showNumberedMarkers) { + const groupName = this.getGroupName(record); + const color = groupName ? this.getGroupColor(groupName) : "#007bff"; + result.icon = this.createNumberedMarker(index + 1, color); + } else if (this.fieldMarkerIconImage) { + result.icon = this.prepareMarkerIcon(record); + } + + return result; + } + + /** + * Prepares the HTML content for the leaflet popup. + * @param {Object} record - The record object containing marker data + * @param {Number} index - The marker index + * @returns {String} + */ + preparePopUpData(record, index) { + const title = record[this.fieldTitle] || record.display_name || ""; + const address = record[this.fieldAddress] || ""; + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + // Build navigation URL + const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + + let html = ` +
+
+ ${this.showNumberedMarkers ? `${index + 1}. ` : ""} + ${this.escapeHtml(title)} +
+ ${address ? `
${this.escapeHtml(address)}
` : ""} + `; + + // Add navigation button if enabled + if (this.enableNavigation) { + html += ` + + `; + } + + html += `
`; + return html; + } + + /** + * Escapes HTML to prevent XSS. + * @param {String} text + * @returns {String} + */ + escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + /** + * Handles click on the leaflet popup to open the record form view. + * @param {Object} record - The record object containing marker data + */ + onClickLeafletPopup(record) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: this.resModel, + res_id: record.id, + views: [[false, "form"]], + target: "current", + }); + } + + /** + * Centers the map on a specific record and opens its popup. + * Called from PinList component. + * @param {Object} record + */ + onPinClick(record) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (!this.validateCoordinates(lat, lng)) { + return; + } + + const marker = this.markersById[record.id]; + if (marker && this.leafletMap) { + // Center map on the marker + this.leafletMap.setView( + [lat, lng], + Math.max(this.leafletMap.getZoom(), 14) + ); + + // Open the marker popup (may need to unspider if in cluster) + if (this.mainLayer.hasLayer(marker)) { + this.mainLayer.zoomToShowLayer(marker, () => { + marker.openPopup(); + }); + } else { + marker.openPopup(); + } + } + } + + /** + * Handles navigation button click from PinList. + * Opens Google Maps directions in a new tab. + * @param {Object} record + */ + onNavigateClick(record) { + const lat = record[this.fieldLatitude]; + const lng = record[this.fieldLongitude]; + + if (this.validateCoordinates(lat, lng)) { + const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + window.open(url, "_blank"); + } + } + + /** + * Callback for resequence events from DraggablePinList. + * Delegates to the controller's onResequence handler. + */ + async onResequence(recordId, targetGroupId, previousRecordId) { + if (this.props.onResequence) { + return this.props.onResequence(recordId, targetGroupId, previousRecordId); + } + } + + /** + * Get the PinList component class to use. + * Override in subclasses to use DraggablePinList. + */ + get PinListComponent() { + return PinList; + } +} diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js new file mode 100644 index 000000000..a97bb2d62 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.esm.js @@ -0,0 +1,118 @@ +/** @odoo-module **/ +/* + * Copyright (C) 2025 KMEE (https://kmee.com.br) + * @author Luis Felipe Mileo + * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + */ + +import {registry} from "@web/core/registry"; + +import {LeafletMapArchParser} from "./leaflet_map_arch_parser.esm"; +import {LeafletMapController} from "./leaflet_map_controller.esm"; +import {LeafletMapModel} from "./leaflet_map_model.esm"; +import {LeafletMapRenderer} from "./leaflet_map_renderer.esm"; + +/** + * Definition of the leaflet_map view for Odoo. + * - Separate ArchParser, Model, Controller, Renderer classes + * - Extensible via spread syntax for custom views + * - Uses js_class attribute to select custom view implementations + * - Integrates with Odoo's search infrastructure (SearchBar, filters, groupBy, favorites) + */ +export const leafletMapView = { + type: "leaflet_map", + display_name: "Map", + icon: "fa fa-map-o", + multiRecord: true, + + // Core components + Controller: LeafletMapController, + Renderer: LeafletMapRenderer, + Model: LeafletMapModel, + ArchParser: LeafletMapArchParser, + + // Search menu configuration - enables filter, groupBy, and favorites + searchMenuTypes: ["filter", "groupBy", "favorite"], + + // Button template for control panel (empty by default) + buttonTemplate: "web_view_leaflet_map.LeafletMapView.Buttons", + + /** + * Transform generic props into view-specific props. + * Follows the pattern from graphView in Odoo core. + * + * @param {Object} genericProps - Props from the view registry + * @param {Object} view - The view definition + * @returns {Object} Transformed props for the controller + */ + props(genericProps, view) { + let modelParams = null; + + // Check if we have state from a previous session (for state restoration) + if (genericProps.state?.metaData) { + modelParams = genericProps.state.metaData; + } else { + const {arch, resModel, fields} = genericProps; + + // Parse the arch using the ArchParser + const parser = new (view.ArchParser || LeafletMapArchParser)(); + const archInfo = parser.parse(arch, fields); + + // Check if a custom js_class is specified + if (archInfo.jsClass) { + const customView = registry + .category("views") + .get(archInfo.jsClass, null); + if (customView) { + // Re-parse with custom parser if available + const customParser = new (customView.ArchParser || + LeafletMapArchParser)(); + const customArchInfo = customParser.parse(arch, fields); + Object.assign(archInfo, customArchInfo); + } + } + + modelParams = { + archInfo, + fields, + fieldNames: archInfo.fieldNames || [], + limit: archInfo.limit || 500, + offset: 0, + resModel, + context: genericProps.context || {}, + }; + } + + // Get the view definition (may be overridden by js_class) + let viewDefinition = view; + if (modelParams.archInfo?.jsClass) { + const customView = registry + .category("views") + .get(modelParams.archInfo.jsClass, null); + if (customView) { + viewDefinition = customView; + } + } + + return { + ...genericProps, + modelParams, + Model: viewDefinition.Model || LeafletMapModel, + Renderer: viewDefinition.Renderer || LeafletMapRenderer, + buttonTemplate: + viewDefinition.buttonTemplate || + "web_view_leaflet_map.LeafletMapView.Buttons", + }; + }, +}; + +// Register the view +registry.category("views").add("leaflet_map", leafletMapView); + +// Export components for extension +export { + LeafletMapArchParser, + LeafletMapController, + LeafletMapModel, + LeafletMapRenderer, +}; diff --git a/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml new file mode 100644 index 000000000..a6ab1fb55 --- /dev/null +++ b/web_view_leaflet_map/static/src/leaflet_map_view/leaflet_map_view.xml @@ -0,0 +1,111 @@ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + +
+
+ + +
+
+ Loading... +
+
+
+
+
+
+ + diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js deleted file mode 100644 index 28035db33..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.esm.js +++ /dev/null @@ -1,24 +0,0 @@ -import {Component, useRef} from "@odoo/owl"; -import {Layout} from "@web/search/layout"; -import {MapRenderer} from "./leaflet_map_renderer.esm"; -import {executeButtonCallback} from "@web/views/view_button/view_button_hook"; - -/** - * Controller class for the Map view, setting up the environment configuration. - */ -export class MapController extends Component { - static template = "web_view_leaflet_map.MapView"; - static components = {Layout, MapRenderer}; - - setup() { - this.rootRef = useRef("root"); - } - - async onClickCreate() { - return executeButtonCallback(this.rootRef.el, () => this.createRecord()); - } - - async createRecord() { - await this.props.createRecord(); - } -} diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml deleted file mode 100644 index ad032dd7b..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_controller.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - -
- - - - - - - -
-
- -
diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css index cc88d6356..a8d36ffb4 100644 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css +++ b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.css @@ -2,19 +2,44 @@ Custom CSS for the elements introduced by web_view_leaflet_map module */ +/* Main container with flex layout for sidebar + map */ .o_leaflet_main_container { display: flex; + flex-direction: row; align-content: stretch; - overflow-x: visible; + overflow: hidden; height: 100%; width: 100%; } +/* Map wrapper for positioning */ +.o_leaflet_map_wrapper { + flex: 1; + position: relative; + min-width: 0; +} + +/* Map container */ .o_leaflet_map_container { height: 100%; width: 100%; } +/* Loading overlay */ +.o_leaflet_loading_overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +/* Popup selector styling */ .o_map_selector { cursor: pointer; } @@ -23,12 +48,52 @@ background-color: #eee; } +/* Popup styling */ +.o_map_popup { + min-width: 150px; +} + +.o_map_popup .o_popup_number { + font-weight: bold; + color: #007bff; +} + +.o_map_popup .o_popup_address { + color: #6c757d; + font-size: 0.9em; + margin-top: 4px; +} + +.o_map_popup .o_popup_actions { + border-top: 1px solid #dee2e6; + padding-top: 8px; +} + +.o_map_popup .o_popup_actions .btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; +} + +.o_map_popup .o_popup_actions .btn-primary:hover { + color: #fff; + background-color: #0056b3; + border-color: #004085; +} + +/* Marker icon styling */ .leaflet_marker_icon { background-color: white; border: 1px black solid; border-radius: 50% !important; } +/* Numbered marker styling */ +.o_leaflet_numbered_marker { + background: transparent !important; + border: none !important; +} + /* Overload leaflet CSS to work with custom specific things of Odoo CSS */ @@ -58,3 +123,10 @@ [aria-hidden="1"] { display: inline !important; } + +/* Responsive: Stack sidebar above map on mobile */ +@media (max-width: 768px) { + .o_leaflet_main_container { + flex-direction: column; + } +} diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js deleted file mode 100644 index 35127a885..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.esm.js +++ /dev/null @@ -1,275 +0,0 @@ -import {session} from "@web/session"; -import {useService} from "@web/core/utils/hooks"; - -/* global L, console, document */ - -const {Component, onWillStart, onMounted, onPatched, useRef} = owl; - -export class MapRenderer extends Component { - static template = "web_view_leaflet_map.MapRenderer"; - static components = {}; - - /** - * Initializes the MapRenderer component, setting up services, references, and configuration. - */ - // eslint-disable-next-line complexity - setup() { - this.orm = useService("orm"); - this.action = useService("action"); - this.mapRef = useRef("mapContainer"); - this.leafletTileUrl = session["leaflet.tile_url"]; - this.leafletCopyright = session["leaflet.copyright"]; - - const archAttrs = this.props.archInfo.arch.attributes; - - this.resModel = this.props.resModel; - this.defaultZoom = parseInt(archAttrs.default_zoom, 10) || 7; - this.maxZoom = parseInt(archAttrs.max_zoom, 10) || 19; - this.zoomSnap = parseInt(archAttrs.zoom_snap, 10) || 1; - - this.fieldLatitude = archAttrs.field_latitude?.value; - this.fieldLongitude = archAttrs.field_longitude?.value; - this.fieldTitle = archAttrs.field_title?.value; - this.fieldAddress = archAttrs.field_address?.value; - this.fieldMarkerIconImage = archAttrs.field_marker_icon_image?.value; - - this.markerIconSizeX = parseInt(archAttrs.marker_icon_size_x?.value, 10) || 64; - this.markerIconSizeY = parseInt(archAttrs.marker_icon_size_y?.value, 10) || 64; - this.markerPopupAnchorX = - parseInt(archAttrs.marker_popup_anchor_x?.value, 10) || 0; - this.markerPopupAnchorY = - parseInt(archAttrs.marker_popup_anchor_y?.value, 10) || -32; - - this.leafletMap = null; - this.mainLayer = null; - - onWillStart(async () => { - await this.initDefaultPosition(); - await this.loadRecords(); - }); - - onMounted(() => { - this.initMap(); - this.renderMarkers(); - }); - - onPatched(() => { - if (this.leafletMap) { - this.renderMarkers(); - } - }); - } - - /** - * Loads records from the server based on the provided domain and fields. - * @returns {Promise} - */ - async loadRecords() { - const fields = this.getFields(); - - try { - // Cargar registros usando searchRead - const records = await this.orm.searchRead( - this.resModel, - this.props.domain || [], - fields, - { - limit: this.props.limit || 80, - context: this.props.context || {}, - } - ); - this.records = records; - } catch (error) { - console.error("Error loading records:", error); - this.records = []; - } - } - - /** - * Gathers the required fields for the map view. - * @returns {any[]} - */ - getFields() { - const fields = new Set(); - - // Required fields - fields.add("id"); - fields.add("display_name"); - fields.add("date_localization"); - - // Optional fields based on arch attributes - if (this.fieldLatitude) fields.add(this.fieldLatitude); - if (this.fieldLongitude) fields.add(this.fieldLongitude); - if (this.fieldTitle) fields.add(this.fieldTitle); - if (this.fieldAddress) fields.add(this.fieldAddress); - if (this.fieldMarkerIconImage) fields.add(this.fieldMarkerIconImage); - - return Array.from(fields); - } - - /** - * Initializes the default position of the map by calling the server method. - * @returns {Promise} - */ - async initDefaultPosition() { - const result = await this.orm.call( - "res.users", - "get_default_leaflet_position", - [this.props.resModel] - ); - this.defaultLatLng = L.latLng(result.lat, result.lng); - } - - /** - * Initializes the Leaflet map in the container. - */ - initMap() { - const mapDiv = this.mapRef.el; - if (!mapDiv) { - console.error("Map container not found"); - return; - } - - this.leafletMap = L.map(mapDiv, { - zoomSnap: this.zoomSnap, - }).setView(this.defaultLatLng, this.defaultZoom); - - L.tileLayer(this.leafletTileUrl, { - maxZoom: this.maxZoom, - attribution: this.leafletCopyright, - }).addTo(this.leafletMap); - } - - /** - * Renders the markers on the map based on the loaded records. - */ - renderMarkers() { - if (!this.leafletMap) { - console.warn("Map not initialized yet"); - return; - } - - if (this.mainLayer) { - this.leafletMap.removeLayer(this.mainLayer); - } - - this.mainLayer = L.markerClusterGroup(); - for (const record of this.records) { - const marker = this.prepareMarker(record); - if (marker) { - this.mainLayer.addLayer(marker); - } - } - const bounds = this.mainLayer.getBounds(); - if (bounds.isValid()) { - // Adapt the map's position based on the map's points - this.leafletMap.fitBounds(bounds.pad(0.1)); - } - - this.leafletMap.addLayer(this.mainLayer); - } - - /** - * Prepares a Leaflet marker for the given record. - * @param {Object} record - The record object containing marker data - * @returns {*} - */ - prepareMarker(record) { - const lat = record[this.fieldLatitude]; - const lng = record[this.fieldLongitude]; - let marker = null; - if (!lat || !lng) { - console.debug(`Record ${record.id} has no coordinates`); - return; - } - - const latlng = L.latLng(lat, lng); - if (latlng.lat !== 0 && latlng.lng !== 0) { - const markerOptions = this.prepareMarkerOptions(record); - - marker = L.marker(latlng, markerOptions); - const popup = L.popup().setContent(this.preparePopUpData(record)); - - marker.bindPopup(popup).on("popupopen", () => { - const selector = document.querySelector(".o_map_selector"); - if (selector) { - selector.addEventListener("click", (ev) => { - ev.preventDefault(); - this.onClickLeafletPopup(record); - }); - } - }); - - return marker; - } - } - - /** - * Prepares the Leaflet icon for the marker using the image field. - * @param {Object} record - The record object containing marker data - * @returns {*} - */ - prepareMarkerIcon(record) { - const lastUpdate = record.date_localization || new Date().toISOString(); - const unique = lastUpdate.replace(/[^0-9]/g, ""); - const iconUrl = `/web/image?model=${this.resModel}&id=${record.id}&field=${this.fieldMarkerIconImage}&unique=${unique}`; - - return L.icon({ - iconUrl: iconUrl, - className: "leaflet_marker_icon", - iconSize: [this.markerIconSizeX, this.markerIconSizeY], - popupAnchor: [this.markerPopupAnchorX, this.markerPopupAnchorY], - }); - } - - /** - * Prepares the options for the leaflet marker. - * @param {Object} record - The record object containing marker data - * @returns {{riseOnHover: Boolean, alt: (*|string), title: (*|string)}} - */ - prepareMarkerOptions(record) { - const title = record[this.fieldTitle] || ""; - const result = { - title: title, - alt: title, - riseOnHover: true, - }; - - if (this.fieldMarkerIconImage) { - result.icon = this.prepareMarkerIcon(record); - } - - return result; - } - - /** - * Prepares the HTML content for the leaflet popup. - * @param {Object} record - The record object containing marker data - * @returns {String} - */ - preparePopUpData(record) { - const title = record[this.fieldTitle] || ""; - const address = record[this.fieldAddress] || ""; - - return ` -
- ${title}
- ${address ? ` - ${address}` : ""} -
- `; - } - - /** - * Handles click on the leaflet popup to open the record form view. - * @param {Object} record - The record object containing marker data - */ - onClickLeafletPopup(record) { - this.action.doAction({ - type: "ir.actions.act_window", - res_model: this.resModel, - res_id: record.id, - views: [[false, "form"]], - target: "current", - }); - } -} diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml deleted file mode 100644 index d6ed3ecf9..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_renderer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - -
-
-
- - - diff --git a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js b/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js deleted file mode 100644 index 801e69b11..000000000 --- a/web_view_leaflet_map/static/src/views/leaflet_map/leaflet_map_view.esm.js +++ /dev/null @@ -1,53 +0,0 @@ -import {MapController} from "./leaflet_map_controller.esm"; -import {MapRenderer} from "./leaflet_map_renderer.esm"; -import {registry} from "@web/core/registry"; - -/* global DOMParser */ - -/** - * Helper function that normalize the architecture input to ensure it is an HTMLElement. - * @param arch - * @returns {HTMLElement|*} - */ -function normalizeArch(arch) { - if (arch && typeof arch !== "string") return arch; - const xml = String(arch || ""); - const doc = new DOMParser().parseFromString(xml, "text/xml"); - return doc.documentElement; -} - -/** - * Definition of the map view for Odoo, including its properties and components. - * @type {{ - * type: string, - * display_name: string, - * icon: string, - * multiRecord: boolean, - * Controller: MapController, - * Renderer: MapRenderer, - * searchMenuTypes: string[], - * props: (function(*, *): *&{archInfo: {arch: *}, Renderer: MapRenderer}) - * }} - */ -export const leafletMapView = { - type: "leaflet_map", - display_name: "Map", - icon: "fa fa-map-o", - multiRecord: true, - Controller: MapController, - Renderer: MapRenderer, - searchMenuTypes: ["filter", "favorite"], - - props: (genericProps) => { - const archEl = normalizeArch(genericProps.arch); - return { - ...genericProps, - Renderer: MapRenderer, - archInfo: { - arch: archEl, - }, - }; - }, -}; - -registry.category("views").add("leaflet_map", leafletMapView); diff --git a/web_view_leaflet_map_partner/README.rst b/web_view_leaflet_map_partner/README.rst index 5d8b42adc..a3486756c 100644 --- a/web_view_leaflet_map_partner/README.rst +++ b/web_view_leaflet_map_partner/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================================= Leaflet Map View for Partners (OpenStreetMap) ============================================= @@ -17,7 +13,7 @@ Leaflet Map View for Partners (OpenStreetMap) .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fgeospatial-lightgray.png?logo=github @@ -79,12 +75,17 @@ Authors ------- * GRAP +* KMEE Contributors ------------ - Sylvain LE GAL (https://www.twitter.com/legalsylvain) +- `KMEE `__: + + - Luis Felipe Mileo + Maintainers ----------- @@ -101,10 +102,13 @@ promote its widespread use. .. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px :target: https://github.com/legalsylvain :alt: legalsylvain +.. |maintainer-mileo| image:: https://github.com/mileo.png?size=40px + :target: https://github.com/mileo + :alt: mileo -Current `maintainer `__: +Current `maintainers `__: -|maintainer-legalsylvain| +|maintainer-legalsylvain| |maintainer-mileo| This module is part of the `OCA/geospatial `_ project on GitHub. diff --git a/web_view_leaflet_map_partner/__manifest__.py b/web_view_leaflet_map_partner/__manifest__.py index 3845fb3cc..ef073f0d3 100644 --- a/web_view_leaflet_map_partner/__manifest__.py +++ b/web_view_leaflet_map_partner/__manifest__.py @@ -1,8 +1,12 @@ +# Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) +# Copyright (C) 2025 KMEE (https://kmee.com.br) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + { "name": "Leaflet Map View for Partners (OpenStreetMap)", - "summary": "Add a leaflet map view for partners model", - "version": "18.0.1.0.1", - "author": "GRAP, Odoo Community Association (OCA)", + "summary": "Add a leaflet map view for partners with auto-geocoding support.", + "version": "18.0.1.1.0", + "author": "GRAP, KMEE, Odoo Community Association (OCA)", "website": "https://github.com/OCA/geospatial", "license": "AGPL-3", "category": "Extra Tools", @@ -19,5 +23,6 @@ "installable": True, "maintainers": [ "legalsylvain", + "mileo", ], } diff --git a/web_view_leaflet_map_partner/models/res_partner.py b/web_view_leaflet_map_partner/models/res_partner.py index c296e6015..53f8def33 100644 --- a/web_view_leaflet_map_partner/models/res_partner.py +++ b/web_view_leaflet_map_partner/models/res_partner.py @@ -1,6 +1,12 @@ # Copyright (C) 2019, Open Source Integrators +# Copyright (C) 2025 KMEE (https://kmee.com.br) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import fields, models + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) class ResPartner(models.Model): @@ -8,6 +14,186 @@ class ResPartner(models.Model): display_address = fields.Char(compute="_compute_display_address") + # Flag to enable/disable auto-geocoding per partner + auto_geocode = fields.Boolean( + string="Auto-geocode Address", + default=True, + help="Automatically update coordinates when address changes.", + ) + + # Geocoding metadata + geocoding_date = fields.Datetime( + string="Last Geocoding", + readonly=True, + help="Date when coordinates were last updated via geocoding.", + ) + geocoding_provider = fields.Char( + readonly=True, + help="Provider used for the last geocoding (nominatim, mapbox).", + ) + def _compute_display_address(self): for partner in self: partner.display_address = partner._display_address(without_company=True) + + @api.model + def _get_geocoding_fields(self): + """ + Return the list of address fields that trigger auto-geocoding. + """ + return ["street", "street2", "city", "zip", "state_id", "country_id"] + + def write(self, vals): + """ + Override write to trigger auto-geocoding when address changes. + """ + result = super().write(vals) + + # Check if any geocoding-triggering field was modified + geocoding_fields = self._get_geocoding_fields() + if any(field in vals for field in geocoding_fields): + # Auto-geocode partners that have the flag enabled + partners_to_geocode = self.filtered( + lambda p: p.auto_geocode and not p.partner_latitude + ) + if partners_to_geocode: + partners_to_geocode._auto_geocode_address() + + return result + + @api.model_create_multi + def create(self, vals_list): + """ + Override create to trigger auto-geocoding for new partners with address. + """ + partners = super().create(vals_list) + + # Auto-geocode partners with address but no coordinates + geocoding_fields = self._get_geocoding_fields() + for partner, vals in zip(partners, vals_list, strict=False): + if ( + partner.auto_geocode + and any(vals.get(field) for field in geocoding_fields) + and not partner.partner_latitude + ): + partner._auto_geocode_address() + + return partners + + def _auto_geocode_address(self): + """ + Automatically geocode the partner's address. + + Uses the leaflet.geocoding.mixin if available. + Rate-limited to respect Nominatim's 1 req/sec requirement. + """ + GeocodingMixin = self.env.get("leaflet.geocoding.mixin") + if not GeocodingMixin: + _logger.warning( + "Geocoding mixin not available. " + "Install web_leaflet_lib for auto-geocoding." + ) + return + + for partner in self: + # Build address string + address = partner._build_geocoding_address() + if not address: + continue + + # Get country code for better geocoding results + country_code = None + if partner.country_id: + country_code = partner.country_id.code + + try: + result = GeocodingMixin.geocode_address(address, country_code) + + if result: + partner.write( + { + "partner_latitude": result["lat"], + "partner_longitude": result["lng"], + "geocoding_date": fields.Datetime.now(), + "geocoding_provider": result.get("provider", "unknown"), + } + ) + _logger.info( + "Auto-geocoded partner %s: %s -> (%s, %s)", + partner.id, + address, + result["lat"], + result["lng"], + ) + else: + _logger.warning( + "Failed to geocode partner %s: %s", + partner.id, + address, + ) + except Exception as e: + _logger.error( + "Error geocoding partner %s: %s", + partner.id, + e, + ) + + def _build_geocoding_address(self): + """ + Build address string for geocoding from partner fields. + + Returns: + str: Formatted address string + """ + self.ensure_one() + + parts = [] + + if self.street: + parts.append(self.street) + if self.street2: + parts.append(self.street2) + if self.city: + parts.append(self.city) + if self.state_id: + parts.append(self.state_id.name) + if self.zip: + parts.append(self.zip) + if self.country_id: + parts.append(self.country_id.name) + + return ", ".join(parts) + + def action_geocode_address(self): + """ + Manual action to geocode the partner's address. + """ + self._auto_geocode_address() + return True + + def action_clear_coordinates(self): + """ + Clear the partner's coordinates. + """ + self.write( + { + "partner_latitude": 0.0, + "partner_longitude": 0.0, + "geocoding_date": False, + "geocoding_provider": False, + } + ) + return True + + def action_open_in_map(self): + """ + Open the partner's location on a map. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Location: {self.display_name}", + "res_model": "res.partner", + "view_mode": "leaflet_map", + "domain": [("id", "=", self.id)], + } diff --git a/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md b/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md index 4a6b63400..ad11e40c5 100644 --- a/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md +++ b/web_view_leaflet_map_partner/readme/CONTRIBUTORS.md @@ -1 +1,3 @@ -- Sylvain LE GAL () +* Sylvain LE GAL () +- [KMEE](https://kmee.com.br/): + - Luis Felipe Mileo \<\> diff --git a/web_view_leaflet_map_partner/static/description/index.html b/web_view_leaflet_map_partner/static/description/index.html index 1b9d4efe6..41a964b5f 100644 --- a/web_view_leaflet_map_partner/static/description/index.html +++ b/web_view_leaflet_map_partner/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Leaflet Map View for Partners (OpenStreetMap) -
+
+

Leaflet Map View for Partners (OpenStreetMap)

- - -Odoo Community Association - -
-

Leaflet Map View for Partners (OpenStreetMap)

-

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/geospatial Translate me on Weblate Try me on Runboat

This module extends the web_view_leaflet_map odoo module, to add a map view on the res.partner model.

Table of contents

@@ -392,12 +387,12 @@

Leaflet Map View for Partners (OpenStreetMap)

-

Configuration

+

Configuration

You should configure first the module web_view_leaflet_map to enable the feature.

-

Usage

+

Usage

  • go to ‘Contact’.
  • a new map icon is available.
  • @@ -410,7 +405,7 @@

    Usage

    image2

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -418,21 +413,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • GRAP
  • +
  • KMEE
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -440,13 +440,12 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

Current maintainer:

-

legalsylvain

+

Current maintainers:

+

legalsylvain mileo

This module is part of the OCA/geospatial project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/web_view_leaflet_map_partner/views/res_partner.xml b/web_view_leaflet_map_partner/views/res_partner.xml index b5f871303..cb6e6cfd8 100644 --- a/web_view_leaflet_map_partner/views/res_partner.xml +++ b/web_view_leaflet_map_partner/views/res_partner.xml @@ -1,5 +1,11 @@ + + res.partner @@ -9,18 +15,89 @@ field_title="display_name" field_address="display_address" field_marker_icon_image="avatar_128" - /> + show_pin_list="1" + enable_navigation="1" + panel_title="Contacts" + > + + + + + + + + + kanban,list,form,activity,leaflet_map + leaflet_map + + + + res.partner.form.geocoding + res.partner + + + + + + + + + + + + + + + + + + + + + + + + Geocode Selected Partners + + + list + code + +if records: + records.filtered(lambda p: not p.partner_latitude)._auto_geocode_address() + +