SXXXXXXX_ControlPanel/map_services.py
2025-04-08 07:53:55 +02:00

285 lines
9.8 KiB
Python

# --- START OF FILE map_services.py ---
# map_services.py
"""
Defines the abstract base class for map tile services and concrete implementations
for specific providers like OpenStreetMap. This allows the application to
interact with different map sources through a common interface.
"""
# Standard library imports
import abc
import logging
import os
from urllib.parse import urlparse # To check URL validity
# Third-party imports
# import requests # Will be used by the manager, not directly here usually
# --- Abstract Base Class ---
class BaseMapService(abc.ABC):
"""
Abstract Base Class for map tile service providers.
Defines the common interface for retrieving tile URLs and providing
essential metadata like attribution and maximum zoom level.
"""
# Class attributes for default values (can be overridden by subclasses)
DEFAULT_TILE_SIZE = 256
DEFAULT_MAX_ZOOM = 19 # A common value, adjust per service
def __init__(
self,
api_key: str | None = None,
tile_size: int = DEFAULT_TILE_SIZE,
max_zoom: int = DEFAULT_MAX_ZOOM,
):
"""
Initializes the BaseMapService.
Args:
api_key (str | None): API key required by the service, if any. Defaults to None.
tile_size (int): The pixel dimension (width/height) of the map tiles. Defaults to 256.
max_zoom (int): The maximum supported zoom level for this service. Defaults to 19.
"""
self._log_prefix = f"[{self.__class__.__name__}]" # Use subclass name in logs
logging.debug(f"{self._log_prefix} Initializing map service.")
self.api_key = api_key
self.tile_size = tile_size
self.max_zoom = max_zoom
if not (isinstance(tile_size, int) and tile_size > 0):
logging.warning(
f"{self._log_prefix} Invalid tile_size {tile_size}. Using default {self.DEFAULT_TILE_SIZE}."
)
self.tile_size = self.DEFAULT_TILE_SIZE
if not (
isinstance(max_zoom, int) and 0 < max_zoom < 30
): # Realistic zoom range
logging.warning(
f"{self._log_prefix} Invalid max_zoom {max_zoom}. Using default {self.DEFAULT_MAX_ZOOM}."
)
self.max_zoom = self.DEFAULT_MAX_ZOOM
@property
@abc.abstractmethod
def name(self) -> str:
"""
Returns the unique, short name of the map service (e.g., 'osm', 'google_roadmap').
Used for identifying the service and potentially for cache directory naming.
"""
pass
@property
@abc.abstractmethod
def attribution(self) -> str:
"""
Returns the required attribution text for the map service.
This should be displayed whenever the map tiles are shown.
"""
pass
@abc.abstractmethod
def get_tile_url(self, z: int, x: int, y: int) -> str | None:
"""
Generates the URL for a specific map tile.
Args:
z (int): The zoom level.
x (int): The tile X coordinate.
y (int): The tile Y coordinate.
Returns:
str | None: The fully formed URL for the tile, or None if the zoom/coords
are invalid for this service or URL construction fails.
"""
pass
def validate_zoom(self, z: int) -> bool:
"""
Checks if the requested zoom level is valid for this service.
Args:
z (int): The zoom level to check.
Returns:
bool: True if the zoom level is valid, False otherwise.
"""
is_valid = 0 <= z <= self.max_zoom
if not is_valid:
logging.warning(
f"{self._log_prefix} Requested zoom level {z} is outside the valid range [0, {self.max_zoom}]."
)
return is_valid
def _validate_generated_url(self, url: str) -> bool:
"""Basic check if the generated URL seems valid."""
try:
result = urlparse(url)
# Check if scheme and netloc (domain) are present
is_valid = all([result.scheme, result.netloc])
if not is_valid:
logging.error(f"{self._log_prefix} Generated URL '{url}' is malformed.")
return is_valid
except Exception as e:
logging.error(
f"{self._log_prefix} Error parsing generated URL '{url}': {e}"
)
return False
def __repr__(self) -> str:
"""Provides a useful representation of the service object."""
return (
f"{self.__class__.__name__}(name='{self.name}', max_zoom={self.max_zoom})"
)
# --- Concrete Implementations ---
class OpenStreetMapService(BaseMapService):
"""
Concrete implementation for fetching tiles from OpenStreetMap (OSM).
Does not require an API key.
"""
SERVICE_NAME = "osm" # Unique identifier
ATTRIBUTION_TEXT = (
"© OpenStreetMap contributors (https://www.openstreetmap.org/copyright)"
)
# OSM URL template (can use subdomains a, b, c for load balancing if desired)
URL_TEMPLATE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# Known max zoom for standard OSM tile layer
MAX_ZOOM_OSM = 19
def __init__(self):
"""Initializes the OpenStreetMap service."""
# Call parent init, explicitly stating no API key needed and providing OSM max zoom
super().__init__(api_key=None, max_zoom=self.MAX_ZOOM_OSM)
logging.info(f"{self._log_prefix} OpenStreetMap service ready.")
@property
def name(self) -> str:
"""Returns the service name."""
return self.SERVICE_NAME
@property
def attribution(self) -> str:
"""Returns the OSM attribution text."""
return self.ATTRIBUTION_TEXT
def get_tile_url(self, z: int, x: int, y: int) -> str | None:
"""
Generates the tile URL for OpenStreetMap.
Args:
z (int): The zoom level.
x (int): The tile X coordinate.
y (int): The tile Y coordinate.
Returns:
str | None: The tile URL or None if zoom is invalid.
"""
# Validate zoom level using parent method
if not self.validate_zoom(z):
logging.warning(
f"{self._log_prefix} Cannot generate URL for invalid zoom {z}."
)
return None
# Format the URL using the class template
try:
url = self.URL_TEMPLATE.format(z=z, x=x, y=y)
# Perform basic validation on the generated URL
if not self._validate_generated_url(url):
return None # Error logged in validation method
logging.debug(f"{self._log_prefix} Generated URL for ({z},{x},{y}): {url}")
return url
except Exception as e:
logging.error(
f"{self._log_prefix} Error formatting URL for ({z},{x},{y}): {e}"
)
return None
# --- Placeholder for future services ---
# class GoogleMapsRoadmapService(BaseMapService):
# SERVICE_NAME = "google_roadmap"
# ATTRIBUTION_TEXT = "© Google Maps"
# # Example URL - Requires API Key and potentially session token, style etc.
# # Consult Google Maps Tile API documentation for the correct format.
# URL_TEMPLATE = "https://tile.googleapis.com/v1/2dtiles/{z}/{x}/{y}?key={api_key}&style=roadmap" # Example only!
# MAX_ZOOM_GOOGLE = 22 # Example value
# def __init__(self, api_key: str):
# if not api_key:
# raise ValueError("API key is required for Google Maps service.")
# super().__init__(api_key=api_key, max_zoom=self.MAX_ZOOM_GOOGLE)
# logging.info(f"{self._log_prefix} Google Maps Roadmap service ready.")
# @property
# def name(self) -> str:
# return self.SERVICE_NAME
# @property
# def attribution(self) -> str:
# return self.ATTRIBUTION_TEXT
# def get_tile_url(self, z: int, x: int, y: int) -> str | None:
# if not self.validate_zoom(z):
# return None
# try:
# # Ensure API key is included
# url = self.URL_TEMPLATE.format(z=z, x=x, y=y, api_key=self.api_key)
# if not self._validate_generated_url(url):
# return None
# logging.debug(f"{self._log_prefix} Generated URL for ({z},{x},{y}): {url}")
# return url
# except Exception as e:
# logging.error(f"{self._log_prefix} Error formatting URL for ({z},{x},{y}): {e}")
# return None
# --- Factory Function (Optional but helpful) ---
def get_map_service(
service_name: str, api_key: str | None = None
) -> BaseMapService | None:
"""
Factory function to get an instance of a specific map service.
Args:
service_name (str): The unique name of the service (e.g., 'osm', 'google_roadmap').
api_key (str | None): API key, if required by the service.
Returns:
BaseMapService | None: An instance of the requested service, or None if not found.
"""
log_prefix = "[MapService Factory]"
service_name_lower = service_name.lower()
logging.debug(f"{log_prefix} Requesting map service '{service_name_lower}'.")
if service_name_lower == OpenStreetMapService.SERVICE_NAME:
return OpenStreetMapService()
# elif service_name_lower == GoogleMapsRoadmapService.SERVICE_NAME:
# if api_key:
# return GoogleMapsRoadmapService(api_key=api_key)
# else:
# logging.error(f"{log_prefix} API key required for service '{service_name}'.")
# return None
# --- Add other services here ---
# elif service_name_lower == "bing_aerial":
# # ... implementation ...
# pass
else:
logging.error(f"{log_prefix} Unknown map service name: '{service_name}'.")
return None
# --- END OF FILE map_services.py ---