250 lines
9.7 KiB
Python
250 lines
9.7 KiB
Python
# geoelevation/map_viewer/map_services.py
|
|
"""
|
|
Defines an abstract base class for map tile services and provides concrete
|
|
implementations for specific map providers (e.g., OpenStreetMap).
|
|
|
|
This allows the application to interact with different map sources through a
|
|
common, well-defined interface, facilitating extensibility to other services.
|
|
"""
|
|
|
|
# Standard library imports
|
|
import abc # Abstract Base Classes
|
|
import logging
|
|
from urllib.parse import urlparse # For basic URL validation
|
|
from typing import Optional, Tuple, Dict, Any
|
|
|
|
# Module-level logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BaseMapService(abc.ABC):
|
|
"""
|
|
Abstract Base Class for map tile service providers.
|
|
|
|
Subclasses must implement the 'name', 'attribution', and 'get_tile_url'
|
|
properties and methods.
|
|
"""
|
|
|
|
DEFAULT_TILE_PIXEL_SIZE: int = 256
|
|
DEFAULT_MAX_ZOOM_LEVEL: int = 19 # Common for many services like OSM
|
|
|
|
def __init__(
|
|
self,
|
|
service_api_key: Optional[str] = None,
|
|
tile_pixel_dim: int = DEFAULT_TILE_PIXEL_SIZE,
|
|
max_supported_zoom: int = DEFAULT_MAX_ZOOM_LEVEL
|
|
) -> None:
|
|
"""
|
|
Initializes the BaseMapService.
|
|
|
|
Args:
|
|
service_api_key: API key required by the service, if any.
|
|
tile_pixel_dim: The pixel dimension (width/height) of map tiles.
|
|
max_supported_zoom: The maximum zoom level supported by this service.
|
|
"""
|
|
# Use class name of the concrete subclass for logging
|
|
self._service_log_prefix = f"[{self.__class__.__name__}]"
|
|
logger.debug(f"{self._service_log_prefix} Initializing base map service.")
|
|
|
|
self.api_key: Optional[str] = service_api_key
|
|
self.tile_size: int = tile_pixel_dim
|
|
self.max_zoom: int = max_supported_zoom
|
|
|
|
# Validate provided tile_size and max_zoom
|
|
if not (isinstance(self.tile_size, int) and self.tile_size > 0):
|
|
logger.warning(
|
|
f"{self._service_log_prefix} Invalid tile_size '{self.tile_size}'. "
|
|
f"Using default: {self.DEFAULT_TILE_PIXEL_SIZE}px."
|
|
)
|
|
self.tile_size = self.DEFAULT_TILE_PIXEL_SIZE
|
|
|
|
# Practical limits for Web Mercator zoom levels
|
|
if not (isinstance(self.max_zoom, int) and 0 <= self.max_zoom <= 25):
|
|
logger.warning(
|
|
f"{self._service_log_prefix} Invalid max_zoom '{self.max_zoom}'. "
|
|
f"Using default: {self.DEFAULT_MAX_ZOOM_LEVEL}."
|
|
)
|
|
self.max_zoom = self.DEFAULT_MAX_ZOOM_LEVEL
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def name(self) -> str:
|
|
"""
|
|
Returns the unique, short name of the map service (e.g., 'osm').
|
|
This is used for identification and potentially for cache directory naming.
|
|
"""
|
|
pass
|
|
|
|
@property
|
|
@abc.abstractmethod
|
|
def attribution(self) -> str:
|
|
"""
|
|
Returns the required attribution text for the map service.
|
|
This text should be displayed whenever map tiles from this service are shown.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]:
|
|
"""
|
|
Generates the full URL for a specific map tile based on its ZXY coordinates.
|
|
|
|
Args:
|
|
z: The zoom level of the tile.
|
|
x: The X coordinate of the tile.
|
|
y: The Y coordinate of the tile.
|
|
|
|
Returns:
|
|
The fully formed URL string for the tile, or None if the zoom level
|
|
is invalid for this service or if URL construction fails.
|
|
"""
|
|
pass
|
|
|
|
def is_zoom_level_valid(self, zoom_level: int) -> bool:
|
|
"""
|
|
Checks if the requested zoom level is within the valid range for this service.
|
|
|
|
Args:
|
|
zoom_level: The zoom level to validate.
|
|
|
|
Returns:
|
|
True if the zoom level is valid, False otherwise.
|
|
"""
|
|
is_valid = 0 <= zoom_level <= self.max_zoom
|
|
if not is_valid:
|
|
logger.warning(
|
|
f"{self._service_log_prefix} Requested zoom level {zoom_level} is outside "
|
|
f"the valid range [0, {self.max_zoom}] for this service."
|
|
)
|
|
return is_valid
|
|
|
|
def _is_generated_url_structurally_valid(self, url_string: str) -> bool:
|
|
"""Performs a basic structural validation of a generated URL string."""
|
|
if not url_string: # Check for empty string
|
|
logger.error(f"{self._service_log_prefix} Generated URL is empty.")
|
|
return False
|
|
try:
|
|
parsed_url = urlparse(url_string)
|
|
# A valid URL typically has a scheme (http/https) and a netloc (domain name).
|
|
has_scheme_and_netloc = bool(parsed_url.scheme and parsed_url.netloc)
|
|
if not has_scheme_and_netloc:
|
|
logger.error(f"{self._service_log_prefix} Generated URL '{url_string}' appears malformed (missing scheme or netloc).")
|
|
return has_scheme_and_netloc
|
|
except Exception as e_url_parse: # Catch potential errors from urlparse itself
|
|
logger.error(
|
|
f"{self._service_log_prefix} Error parsing generated URL '{url_string}': {e_url_parse}"
|
|
)
|
|
return False
|
|
|
|
def __repr__(self) -> str:
|
|
"""Provides a concise and informative string representation of the service object."""
|
|
return (
|
|
f"<{self.__class__.__name__}(Name: '{self.name}', MaxZoom: {self.max_zoom}, TileSize: {self.tile_size})>"
|
|
)
|
|
|
|
|
|
class OpenStreetMapService(BaseMapService):
|
|
"""
|
|
Concrete implementation for fetching map tiles from OpenStreetMap (OSM).
|
|
This service does not require an API key.
|
|
"""
|
|
|
|
SERVICE_IDENTIFIER_NAME: str = "osm"
|
|
SERVICE_ATTRIBUTION_TEXT: str = \
|
|
"© OpenStreetMap contributors (openstreetmap.org/copyright)"
|
|
# Standard URL template for OSM tiles. Subdomains (a,b,c) can be used for load balancing.
|
|
TILE_URL_TEMPLATE: str = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
# OSM standard tile servers typically support up to zoom level 19.
|
|
OSM_MAX_ZOOM_LEVEL: int = 19
|
|
# Optional: cycle through subdomains for better load distribution
|
|
SUBDOMAINS: Tuple[str, ...] = ('a', 'b', 'c')
|
|
_subdomain_index: int = 0 # Class variable for simple round-robin
|
|
|
|
def __init__(self) -> None:
|
|
"""Initializes the OpenStreetMap tile service."""
|
|
super().__init__(
|
|
service_api_key=None, # OSM does not require an API key
|
|
max_supported_zoom=self.OSM_MAX_ZOOM_LEVEL
|
|
)
|
|
logger.info(f"{self._service_log_prefix} OpenStreetMap service instance ready.")
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Returns the unique name for the OpenStreetMap service."""
|
|
return self.SERVICE_IDENTIFIER_NAME
|
|
|
|
@property
|
|
def attribution(self) -> str:
|
|
"""Returns the required attribution text for OpenStreetMap."""
|
|
return self.SERVICE_ATTRIBUTION_TEXT
|
|
|
|
def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]:
|
|
"""
|
|
Generates the tile URL for an OpenStreetMap tile.
|
|
|
|
Args:
|
|
z: The zoom level.
|
|
x: The tile X coordinate.
|
|
y: The tile Y coordinate.
|
|
|
|
Returns:
|
|
The tile URL string, or None if the zoom level is invalid.
|
|
"""
|
|
if not self.is_zoom_level_valid(z):
|
|
# Warning logged by is_zoom_level_valid
|
|
return None
|
|
|
|
# Simple round-robin for subdomains
|
|
subdomain = self.SUBDOMAINS[OpenStreetMapService._subdomain_index % len(self.SUBDOMAINS)]
|
|
OpenStreetMapService._subdomain_index += 1
|
|
|
|
try:
|
|
# Format the URL using the class template and selected subdomain
|
|
tile_url = self.TILE_URL_TEMPLATE.format(s=subdomain, z=z, x=x, y=y)
|
|
|
|
if not self._is_generated_url_structurally_valid(tile_url):
|
|
# Error logged by _is_generated_url_structurally_valid
|
|
return None
|
|
|
|
logger.debug(f"{self._service_log_prefix} Generated URL for ({z},{x},{y}): {tile_url}")
|
|
return tile_url
|
|
except Exception as e_url_format: # Catch potential errors during .format()
|
|
logger.error(
|
|
f"{self._service_log_prefix} Error formatting tile URL for ({z},{x},{y}): {e_url_format}"
|
|
)
|
|
return None
|
|
|
|
|
|
# --- Factory Function to Get Map Service Instances ---
|
|
|
|
def get_map_service_instance(
|
|
service_name_key: str,
|
|
api_key_value: Optional[str] = None
|
|
) -> Optional[BaseMapService]:
|
|
"""
|
|
Factory function to create and return an instance of a specific map service.
|
|
|
|
Args:
|
|
service_name_key: The unique string identifier for the desired service (e.g., 'osm').
|
|
api_key_value: The API key, if required by the selected service.
|
|
|
|
Returns:
|
|
An instance of a BaseMapService subclass, or None if the service_name_key
|
|
is unknown or if a required API key is missing.
|
|
"""
|
|
log_prefix_factory = "[MapServiceFactory]"
|
|
normalized_service_name = service_name_key.lower().strip()
|
|
logger.debug(f"{log_prefix_factory} Requesting map service instance for '{normalized_service_name}'.")
|
|
|
|
if normalized_service_name == OpenStreetMapService.SERVICE_IDENTIFIER_NAME:
|
|
return OpenStreetMapService()
|
|
# Example for a future service requiring an API key:
|
|
# elif normalized_service_name == "some_other_service_key":
|
|
# if api_key_value:
|
|
# return SomeOtherMapService(api_key=api_key_value)
|
|
# else:
|
|
# logger.error(f"{log_prefix_factory} API key is required for '{normalized_service_name}' but was not provided.")
|
|
# return None
|
|
else:
|
|
logger.error(f"{log_prefix_factory} Unknown map service name specified: '{service_name_key}'.")
|
|
return None |