step 1: lock public api and pure core
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -2,15 +2,15 @@
|
||||
|
||||
## Current status
|
||||
|
||||
Rebuild initialized.
|
||||
Step 1 complete.
|
||||
|
||||
## Completed steps
|
||||
|
||||
None yet.
|
||||
Step 1 - Public API contract and pure core.
|
||||
|
||||
## Current step
|
||||
|
||||
Step 1 - Public API contract and pure core.
|
||||
Step 2 - Thread-safe state, commands, overlays, and cache model.
|
||||
|
||||
## Design decisions
|
||||
|
||||
@@ -31,7 +31,13 @@ None yet.
|
||||
|
||||
- Read `STEPS.md`, `FEATURES.md`, and `ARCHITECTURE.md`.
|
||||
- Created initial package, examples, tests, and agent-log structure.
|
||||
- Implemented public exports, exceptions, common types, tile provider registry, projection helpers, cache dataclasses, and GUI-dependent API stubs.
|
||||
- Added Step 1 tests for imports, providers, projection, and cache dataclasses.
|
||||
- Ran `uv run pytest`.
|
||||
- Ran `uv run ruff check .`.
|
||||
- Ran `uv run ruff format --check .`.
|
||||
- Ran `uv run pyright`.
|
||||
|
||||
## Next action
|
||||
|
||||
Implement Step 1.
|
||||
Implement Step 2.
|
||||
|
||||
26
README.md
26
README.md
@@ -0,0 +1,26 @@
|
||||
# dpg-map
|
||||
|
||||
`dpg-map` is a Dear PyGui map widget package under rebuild.
|
||||
|
||||
The Step 1 public API contract is in place:
|
||||
|
||||
```python
|
||||
import dpg_map as dpgm
|
||||
|
||||
provider = dpgm.TileProvider(
|
||||
name="custom",
|
||||
url_template="https://example.com/{z}/{x}/{y}.png",
|
||||
attribution="Tiles (c) Example",
|
||||
)
|
||||
dpgm.register_provider(provider)
|
||||
```
|
||||
|
||||
Implemented in Step 1:
|
||||
|
||||
- public package exports
|
||||
- tile provider definitions and registry
|
||||
- Web Mercator projection helpers
|
||||
- initial cache dataclasses
|
||||
- explicit stubs for GUI-dependent public functions
|
||||
|
||||
GUI widget rendering and runtime state updates are planned for later rebuild steps.
|
||||
|
||||
@@ -1,2 +1,87 @@
|
||||
"""Public API for dpg-map."""
|
||||
|
||||
from .api import (
|
||||
add_layer,
|
||||
add_marker,
|
||||
add_polyline,
|
||||
add_trajectory,
|
||||
clear_disk_cache,
|
||||
clear_layer,
|
||||
clear_map,
|
||||
clear_memory_cache,
|
||||
configure,
|
||||
delete_overlay,
|
||||
fit_bounds,
|
||||
get_cache_stats,
|
||||
get_center,
|
||||
get_map_debug_state,
|
||||
get_zoom,
|
||||
hide_layer,
|
||||
latlon_to_screen,
|
||||
screen_to_latlon,
|
||||
set_center,
|
||||
set_marker_label,
|
||||
set_marker_position,
|
||||
set_overlay_show,
|
||||
set_polyline_points,
|
||||
set_provider,
|
||||
set_view,
|
||||
set_zoom,
|
||||
show_layer,
|
||||
update_marker,
|
||||
update_polyline,
|
||||
update_trajectory,
|
||||
)
|
||||
from .providers import (
|
||||
TileProvider,
|
||||
get_provider,
|
||||
list_providers,
|
||||
register_provider,
|
||||
unregister_provider,
|
||||
)
|
||||
from .widget import map_widget
|
||||
|
||||
__all__ = [
|
||||
"TileProvider",
|
||||
"add_layer",
|
||||
"add_marker",
|
||||
"add_polyline",
|
||||
"add_trajectory",
|
||||
"clear_disk_cache",
|
||||
"clear_layer",
|
||||
"clear_map",
|
||||
"clear_memory_cache",
|
||||
"configure",
|
||||
"delete_overlay",
|
||||
"fit_bounds",
|
||||
"get_cache_stats",
|
||||
"get_center",
|
||||
"get_map_debug_state",
|
||||
"get_provider",
|
||||
"get_zoom",
|
||||
"hide_layer",
|
||||
"latlon_to_screen",
|
||||
"list_providers",
|
||||
"map_widget",
|
||||
"register_provider",
|
||||
"screen_to_latlon",
|
||||
"set_center",
|
||||
"set_marker_label",
|
||||
"set_marker_position",
|
||||
"set_overlay_show",
|
||||
"set_polyline_points",
|
||||
"set_provider",
|
||||
"set_view",
|
||||
"set_zoom",
|
||||
"show_layer",
|
||||
"unregister_provider",
|
||||
"update_marker",
|
||||
"update_polyline",
|
||||
"update_trajectory",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Hello from dpg-map!")
|
||||
"""Console entry point placeholder."""
|
||||
|
||||
print("dpg-map")
|
||||
|
||||
@@ -1 +1,211 @@
|
||||
"""Public API wrappers for dpg-map."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from .cache import CacheStats
|
||||
from .exceptions import DpgMapNotImplementedError
|
||||
from .providers import TileProvider
|
||||
from .types import Bounds, LatLon, Point, Tag
|
||||
|
||||
|
||||
def _stub(name: str) -> NoReturn:
|
||||
raise DpgMapNotImplementedError(f"dpg_map.{name} is not implemented until a later rebuild step")
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
user_agent: str | None = None,
|
||||
cache_dir: str | Path | None = None,
|
||||
default_provider: str | TileProvider = "osm",
|
||||
memory_cache_max_tiles: int = 512,
|
||||
disk_cache_max_bytes: int | None = 2_000_000_000,
|
||||
prefetch_margin_tiles: int = 1,
|
||||
tile_worker_count: int = 4,
|
||||
overlay_update_policy: str = "coalesce",
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
_stub("configure")
|
||||
|
||||
|
||||
def set_center(lat: float, lon: float, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_center")
|
||||
|
||||
|
||||
def get_center(*, map_tag: Tag | None = None) -> LatLon:
|
||||
_stub("get_center")
|
||||
|
||||
|
||||
def set_zoom(zoom: int, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_zoom")
|
||||
|
||||
|
||||
def get_zoom(*, map_tag: Tag | None = None) -> int:
|
||||
_stub("get_zoom")
|
||||
|
||||
|
||||
def set_view(
|
||||
*,
|
||||
center: LatLon | None = None,
|
||||
zoom: int | None = None,
|
||||
map_tag: Tag | None = None,
|
||||
) -> None:
|
||||
_stub("set_view")
|
||||
|
||||
|
||||
def fit_bounds(bounds: Bounds, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("fit_bounds")
|
||||
|
||||
|
||||
def screen_to_latlon(x: float, y: float, *, map_tag: Tag | None = None) -> LatLon:
|
||||
_stub("screen_to_latlon")
|
||||
|
||||
|
||||
def latlon_to_screen(lat: float, lon: float, *, map_tag: Tag | None = None) -> Point:
|
||||
_stub("latlon_to_screen")
|
||||
|
||||
|
||||
def add_marker(
|
||||
tag: Tag,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
label: str | None = None,
|
||||
layer: str = "default",
|
||||
show: bool = True,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Tag:
|
||||
_stub("add_marker")
|
||||
|
||||
|
||||
def add_polyline(
|
||||
tag: Tag,
|
||||
*,
|
||||
points: Sequence[LatLon] | None = None,
|
||||
lats: Sequence[float] | None = None,
|
||||
lons: Sequence[float] | None = None,
|
||||
layer: str = "default",
|
||||
show: bool = True,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Tag:
|
||||
_stub("add_polyline")
|
||||
|
||||
|
||||
def add_trajectory(
|
||||
tag: Tag,
|
||||
*,
|
||||
points: Sequence[LatLon] | None = None,
|
||||
lats: Sequence[float] | None = None,
|
||||
lons: Sequence[float] | None = None,
|
||||
layer: str = "default",
|
||||
show: bool = True,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Tag:
|
||||
_stub("add_trajectory")
|
||||
|
||||
|
||||
def update_marker(
|
||||
tag: Tag,
|
||||
*,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
label: str | None = None,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_stub("update_marker")
|
||||
|
||||
|
||||
def update_polyline(
|
||||
tag: Tag,
|
||||
*,
|
||||
points: Sequence[LatLon] | None = None,
|
||||
lats: Sequence[float] | None = None,
|
||||
lons: Sequence[float] | None = None,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_stub("update_polyline")
|
||||
|
||||
|
||||
def update_trajectory(
|
||||
tag: Tag,
|
||||
*,
|
||||
points: Sequence[LatLon] | None = None,
|
||||
lats: Sequence[float] | None = None,
|
||||
lons: Sequence[float] | None = None,
|
||||
map_tag: Tag | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_stub("update_trajectory")
|
||||
|
||||
|
||||
def set_marker_position(tag: Tag, lat: float, lon: float, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_marker_position")
|
||||
|
||||
|
||||
def set_marker_label(tag: Tag, label: str, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_marker_label")
|
||||
|
||||
|
||||
def set_polyline_points(
|
||||
tag: Tag,
|
||||
points: Sequence[LatLon],
|
||||
*,
|
||||
map_tag: Tag | None = None,
|
||||
) -> None:
|
||||
_stub("set_polyline_points")
|
||||
|
||||
|
||||
def set_overlay_show(tag: Tag, show: bool, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_overlay_show")
|
||||
|
||||
|
||||
def delete_overlay(tag: Tag, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("delete_overlay")
|
||||
|
||||
|
||||
def add_layer(name: str, *, show: bool = True, map_tag: Tag | None = None) -> None:
|
||||
_stub("add_layer")
|
||||
|
||||
|
||||
def show_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("show_layer")
|
||||
|
||||
|
||||
def hide_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("hide_layer")
|
||||
|
||||
|
||||
def clear_layer(name: str, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("clear_layer")
|
||||
|
||||
|
||||
def clear_map(*, map_tag: Tag | None = None) -> None:
|
||||
_stub("clear_map")
|
||||
|
||||
|
||||
def set_provider(provider: str | TileProvider, *, map_tag: Tag | None = None) -> None:
|
||||
_stub("set_provider")
|
||||
|
||||
|
||||
def clear_memory_cache(*, map_tag: Tag | None = None) -> None:
|
||||
_stub("clear_memory_cache")
|
||||
|
||||
|
||||
def clear_disk_cache(*, map_tag: Tag | None = None) -> None:
|
||||
_stub("clear_disk_cache")
|
||||
|
||||
|
||||
def get_cache_stats(*, map_tag: Tag | None = None) -> CacheStats:
|
||||
_stub("get_cache_stats")
|
||||
|
||||
|
||||
def get_map_debug_state(*, map_tag: Tag | None = None) -> dict[str, Any]:
|
||||
_stub("get_map_debug_state")
|
||||
|
||||
@@ -1 +1,36 @@
|
||||
"""Memory and disk cache helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CacheStats:
|
||||
"""Public cache statistics snapshot."""
|
||||
|
||||
memory_tiles: int = 0
|
||||
memory_max_tiles: int = 0
|
||||
memory_hits: int = 0
|
||||
memory_misses: int = 0
|
||||
disk_bytes: int = 0
|
||||
disk_max_bytes: int | None = None
|
||||
disk_hits: int = 0
|
||||
disk_misses: int = 0
|
||||
disk_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemoryCacheConfig:
|
||||
"""Initial memory cache configuration."""
|
||||
|
||||
max_tiles: int = 512
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiskCacheConfig:
|
||||
"""Initial persistent disk cache configuration."""
|
||||
|
||||
path: Path | None = None
|
||||
max_bytes: int | None = 2_000_000_000
|
||||
|
||||
@@ -1 +1,29 @@
|
||||
"""Public exception types."""
|
||||
"""Public exception types for dpg-map."""
|
||||
|
||||
|
||||
class DpgMapError(Exception):
|
||||
"""Base exception for all public dpg-map errors."""
|
||||
|
||||
|
||||
class DpgMapNotImplementedError(DpgMapError, NotImplementedError):
|
||||
"""Raised by public APIs that are intentionally stubbed during the rebuild."""
|
||||
|
||||
|
||||
class ProviderError(DpgMapError):
|
||||
"""Base exception for tile provider errors."""
|
||||
|
||||
|
||||
class ProviderExistsError(ProviderError):
|
||||
"""Raised when registering a provider name that already exists."""
|
||||
|
||||
|
||||
class ProviderNotFoundError(ProviderError):
|
||||
"""Raised when a requested tile provider is not registered."""
|
||||
|
||||
|
||||
class InvalidProviderError(ProviderError, ValueError):
|
||||
"""Raised when a tile provider definition is invalid."""
|
||||
|
||||
|
||||
class ProjectionError(DpgMapError, ValueError):
|
||||
"""Raised when geographic projection input is invalid."""
|
||||
|
||||
@@ -1 +1,92 @@
|
||||
"""Web Mercator projection helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .exceptions import ProjectionError
|
||||
from .types import LatLon, Point
|
||||
|
||||
WEB_MERCATOR_MAX_LAT = 85.05112878
|
||||
DEFAULT_TILE_SIZE = 256
|
||||
|
||||
|
||||
def clamp_latitude(lat: float) -> float:
|
||||
"""Clamp latitude to the Web Mercator representable range."""
|
||||
|
||||
return min(max(lat, -WEB_MERCATOR_MAX_LAT), WEB_MERCATOR_MAX_LAT)
|
||||
|
||||
|
||||
def map_size(zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> int:
|
||||
"""Return square pixel size of the full world at a zoom level."""
|
||||
|
||||
if zoom < 0:
|
||||
raise ProjectionError("zoom must be >= 0")
|
||||
if tile_size <= 0:
|
||||
raise ProjectionError("tile_size must be > 0")
|
||||
return tile_size * (2**zoom)
|
||||
|
||||
|
||||
def latlon_to_world(lat: float, lon: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> Point:
|
||||
"""Project latitude/longitude to world pixel coordinates."""
|
||||
|
||||
size = map_size(zoom, tile_size)
|
||||
lat = clamp_latitude(lat)
|
||||
lon = ((lon + 180.0) % 360.0) - 180.0
|
||||
|
||||
sin_lat = math.sin(math.radians(lat))
|
||||
x = (lon + 180.0) / 360.0 * size
|
||||
y = (0.5 - math.log((1.0 + sin_lat) / (1.0 - sin_lat)) / (4.0 * math.pi)) * size
|
||||
return (x, y)
|
||||
|
||||
|
||||
def world_to_latlon(x: float, y: float, zoom: int, tile_size: int = DEFAULT_TILE_SIZE) -> LatLon:
|
||||
"""Unproject world pixel coordinates to latitude/longitude."""
|
||||
|
||||
size = map_size(zoom, tile_size)
|
||||
lon = x / size * 360.0 - 180.0
|
||||
n = math.pi - 2.0 * math.pi * y / size
|
||||
lat = math.degrees(math.atan(math.sinh(n)))
|
||||
return (lat, lon)
|
||||
|
||||
|
||||
def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int, int]:
|
||||
"""Return the XYZ tile coordinate containing a latitude/longitude point."""
|
||||
|
||||
x, y = latlon_to_world(lat, lon, zoom)
|
||||
scale = 2**zoom
|
||||
tile_x = min(max(int(x // DEFAULT_TILE_SIZE), 0), scale - 1)
|
||||
tile_y = min(max(int(y // DEFAULT_TILE_SIZE), 0), scale - 1)
|
||||
return (tile_x, tile_y, zoom)
|
||||
|
||||
|
||||
def world_to_screen(
|
||||
world_x: float,
|
||||
world_y: float,
|
||||
*,
|
||||
center: LatLon,
|
||||
zoom: int,
|
||||
width: int,
|
||||
height: int,
|
||||
tile_size: int = DEFAULT_TILE_SIZE,
|
||||
) -> Point:
|
||||
"""Convert world pixels to screen pixels for a centered viewport."""
|
||||
|
||||
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
|
||||
return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0)
|
||||
|
||||
|
||||
def screen_to_world(
|
||||
screen_x: float,
|
||||
screen_y: float,
|
||||
*,
|
||||
center: LatLon,
|
||||
zoom: int,
|
||||
width: int,
|
||||
height: int,
|
||||
tile_size: int = DEFAULT_TILE_SIZE,
|
||||
) -> Point:
|
||||
"""Convert screen pixels to world pixels for a centered viewport."""
|
||||
|
||||
center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size)
|
||||
return (screen_x + center_x - width / 2.0, screen_y + center_y - height / 2.0)
|
||||
|
||||
@@ -1 +1,145 @@
|
||||
"""Tile provider definitions and registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from string import Formatter
|
||||
from threading import RLock
|
||||
|
||||
from .exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError
|
||||
|
||||
_REQUIRED_TEMPLATE_FIELDS = frozenset({"x", "y", "z"})
|
||||
_OPTIONAL_TEMPLATE_FIELDS = frozenset({"s", "r", "ext"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TileProvider:
|
||||
"""XYZ raster tile provider definition."""
|
||||
|
||||
name: str
|
||||
url_template: str
|
||||
min_zoom: int = 0
|
||||
max_zoom: int = 19
|
||||
tile_size: int = 256
|
||||
attribution: str = ""
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
subdomains: tuple[str, ...] = ()
|
||||
retina: bool = False
|
||||
file_extension: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
name = self.name.strip()
|
||||
if not name:
|
||||
raise InvalidProviderError("provider name must not be empty")
|
||||
if name != self.name:
|
||||
object.__setattr__(self, "name", name)
|
||||
|
||||
if not self.url_template.strip():
|
||||
raise InvalidProviderError("provider url_template must not be empty")
|
||||
|
||||
fields = {
|
||||
field_name
|
||||
for _, field_name, _, _ in Formatter().parse(self.url_template)
|
||||
if field_name is not None and field_name != ""
|
||||
}
|
||||
missing = _REQUIRED_TEMPLATE_FIELDS - fields
|
||||
if missing:
|
||||
missing_text = ", ".join(sorted(missing))
|
||||
raise InvalidProviderError(f"provider url_template missing field(s): {missing_text}")
|
||||
|
||||
unknown = fields - _REQUIRED_TEMPLATE_FIELDS - _OPTIONAL_TEMPLATE_FIELDS
|
||||
if unknown:
|
||||
unknown_text = ", ".join(sorted(unknown))
|
||||
raise InvalidProviderError(
|
||||
f"provider url_template contains unknown field(s): {unknown_text}"
|
||||
)
|
||||
|
||||
if self.min_zoom < 0:
|
||||
raise InvalidProviderError("provider min_zoom must be >= 0")
|
||||
if self.max_zoom < self.min_zoom:
|
||||
raise InvalidProviderError("provider max_zoom must be >= min_zoom")
|
||||
if self.tile_size <= 0:
|
||||
raise InvalidProviderError("provider tile_size must be > 0")
|
||||
|
||||
object.__setattr__(self, "headers", dict(self.headers))
|
||||
object.__setattr__(self, "subdomains", tuple(self.subdomains))
|
||||
|
||||
def build_url(self, *, x: int, y: int, z: int) -> str:
|
||||
"""Build a concrete tile URL for an XYZ tile coordinate."""
|
||||
|
||||
if z < self.min_zoom or z > self.max_zoom:
|
||||
raise InvalidProviderError(
|
||||
f"zoom {z} is outside provider range {self.min_zoom}-{self.max_zoom}"
|
||||
)
|
||||
|
||||
subdomain = ""
|
||||
if self.subdomains:
|
||||
subdomain = self.subdomains[(x + y + z) % len(self.subdomains)]
|
||||
|
||||
retina_suffix = "@2x" if self.retina else ""
|
||||
extension = self.file_extension or ""
|
||||
return self.url_template.format(
|
||||
x=x,
|
||||
y=y,
|
||||
z=z,
|
||||
s=subdomain,
|
||||
r=retina_suffix,
|
||||
ext=extension,
|
||||
)
|
||||
|
||||
|
||||
OSM = TileProvider(
|
||||
name="osm",
|
||||
url_template="https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
min_zoom=0,
|
||||
max_zoom=19,
|
||||
tile_size=256,
|
||||
attribution="\u00a9 OpenStreetMap contributors",
|
||||
)
|
||||
|
||||
_providers: dict[str, TileProvider] = {OSM.name: OSM}
|
||||
_providers_lock = RLock()
|
||||
|
||||
|
||||
def register_provider(provider: TileProvider, *, replace: bool = False) -> None:
|
||||
"""Register a tile provider by name."""
|
||||
|
||||
if not isinstance(provider, TileProvider):
|
||||
raise InvalidProviderError("provider must be a TileProvider")
|
||||
|
||||
with _providers_lock:
|
||||
if provider.name in _providers and not replace:
|
||||
raise ProviderExistsError(f"provider already registered: {provider.name}")
|
||||
_providers[provider.name] = provider
|
||||
|
||||
|
||||
def unregister_provider(name: str) -> None:
|
||||
"""Remove a registered tile provider."""
|
||||
|
||||
with _providers_lock:
|
||||
if name not in _providers:
|
||||
raise ProviderNotFoundError(f"provider not registered: {name}")
|
||||
del _providers[name]
|
||||
|
||||
|
||||
def get_provider(name: str) -> TileProvider:
|
||||
"""Return a registered tile provider."""
|
||||
|
||||
with _providers_lock:
|
||||
try:
|
||||
return _providers[name]
|
||||
except KeyError as exc:
|
||||
raise ProviderNotFoundError(f"provider not registered: {name}") from exc
|
||||
|
||||
|
||||
def get_default_provider() -> TileProvider:
|
||||
"""Return the default OpenStreetMap provider."""
|
||||
|
||||
return get_provider(OSM.name)
|
||||
|
||||
|
||||
def list_providers() -> list[str]:
|
||||
"""List registered provider names in stable sorted order."""
|
||||
|
||||
with _providers_lock:
|
||||
return sorted(_providers)
|
||||
|
||||
@@ -1 +1,31 @@
|
||||
"""Shared type aliases and small value objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
Tag: TypeAlias = str | int
|
||||
LatLon: TypeAlias = tuple[float, float]
|
||||
Point: TypeAlias = tuple[float, float]
|
||||
Bounds: TypeAlias = tuple[LatLon, LatLon]
|
||||
TileCoord: TypeAlias = tuple[int, int, int]
|
||||
Color: TypeAlias = tuple[int, int, int] | tuple[int, int, int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScreenPoint:
|
||||
"""A pixel position in map widget coordinates."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeoBounds:
|
||||
"""Latitude/longitude bounds with south-west and north-east corners."""
|
||||
|
||||
south: float
|
||||
west: float
|
||||
north: float
|
||||
east: float
|
||||
|
||||
@@ -1 +1,31 @@
|
||||
"""Dear PyGui map widget construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .exceptions import DpgMapNotImplementedError
|
||||
from .providers import TileProvider
|
||||
from .types import LatLon, Tag
|
||||
|
||||
|
||||
@contextmanager
|
||||
def map_widget(
|
||||
*,
|
||||
tag: Tag | None = None,
|
||||
center: LatLon = (0.0, 0.0),
|
||||
zoom: int = 2,
|
||||
provider: str | TileProvider | None = None,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
autosize_x: bool = False,
|
||||
autosize_y: bool = False,
|
||||
**kwargs: object,
|
||||
) -> Iterator[Tag | None]:
|
||||
"""Stub context manager for the future Dear PyGui widget."""
|
||||
|
||||
raise DpgMapNotImplementedError(
|
||||
"dpg_map.map_widget is not implemented until the GUI rebuild steps"
|
||||
)
|
||||
yield tag
|
||||
|
||||
31
tests/test_cache.py
Normal file
31
tests/test_cache.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dpg_map.cache import CacheStats, DiskCacheConfig, MemoryCacheConfig
|
||||
|
||||
|
||||
def test_cache_stats_dataclass_construction() -> None:
|
||||
stats = CacheStats(
|
||||
memory_tiles=3,
|
||||
memory_max_tiles=512,
|
||||
memory_hits=10,
|
||||
memory_misses=2,
|
||||
disk_bytes=1024,
|
||||
disk_max_bytes=None,
|
||||
disk_hits=7,
|
||||
disk_misses=1,
|
||||
disk_path=Path("/tmp/dpg-map-cache"),
|
||||
)
|
||||
|
||||
assert stats.memory_tiles == 3
|
||||
assert stats.disk_max_bytes is None
|
||||
assert stats.disk_path == Path("/tmp/dpg-map-cache")
|
||||
|
||||
|
||||
def test_initial_cache_config_dataclasses() -> None:
|
||||
memory_config = MemoryCacheConfig()
|
||||
disk_config = DiskCacheConfig()
|
||||
|
||||
assert memory_config.max_tiles == 512
|
||||
assert disk_config.max_bytes == 2_000_000_000
|
||||
44
tests/test_projection.py
Normal file
44
tests/test_projection.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from dpg_map.projection import (
|
||||
WEB_MERCATOR_MAX_LAT,
|
||||
clamp_latitude,
|
||||
latlon_to_tile,
|
||||
latlon_to_world,
|
||||
screen_to_world,
|
||||
world_to_latlon,
|
||||
world_to_screen,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lat", "lon", "zoom"),
|
||||
[
|
||||
(0.0, 0.0, 0),
|
||||
(47.9029, 1.9093, 15),
|
||||
(-33.8688, 151.2093, 10),
|
||||
(WEB_MERCATOR_MAX_LAT, 179.999, 4),
|
||||
],
|
||||
)
|
||||
def test_projection_roundtrip(lat: float, lon: float, zoom: int) -> None:
|
||||
x, y = latlon_to_world(lat, lon, zoom)
|
||||
roundtrip_lat, roundtrip_lon = world_to_latlon(x, y, zoom)
|
||||
|
||||
assert roundtrip_lat == pytest.approx(clamp_latitude(lat), abs=1e-7)
|
||||
assert roundtrip_lon == pytest.approx(lon, abs=1e-7)
|
||||
|
||||
|
||||
def test_latlon_to_tile_returns_xyz_coordinate() -> None:
|
||||
assert latlon_to_tile(0.0, 0.0, 1) == (1, 1, 1)
|
||||
|
||||
|
||||
def test_world_screen_roundtrip() -> None:
|
||||
center = (47.9029, 1.9093)
|
||||
world = latlon_to_world(47.91, 1.92, 14)
|
||||
screen = world_to_screen(*world, center=center, zoom=14, width=800, height=600)
|
||||
|
||||
assert screen_to_world(*screen, center=center, zoom=14, width=800, height=600) == pytest.approx(
|
||||
world
|
||||
)
|
||||
71
tests/test_providers.py
Normal file
71
tests/test_providers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import dpg_map as dpgm
|
||||
from dpg_map.exceptions import InvalidProviderError, ProviderExistsError, ProviderNotFoundError
|
||||
|
||||
|
||||
def test_default_provider_is_registered() -> None:
|
||||
provider = dpgm.get_provider("osm")
|
||||
|
||||
assert provider.name == "osm"
|
||||
assert "osm" in dpgm.list_providers()
|
||||
assert provider.build_url(x=1, y=2, z=3) == "https://tile.openstreetmap.org/3/1/2.png"
|
||||
|
||||
|
||||
def test_provider_registration_roundtrip() -> None:
|
||||
provider = dpgm.TileProvider(
|
||||
name="unit-test-provider",
|
||||
url_template="https://tiles.example.test/{z}/{x}/{y}.png",
|
||||
attribution="Example",
|
||||
)
|
||||
|
||||
dpgm.register_provider(provider)
|
||||
try:
|
||||
assert dpgm.get_provider("unit-test-provider") == provider
|
||||
assert "unit-test-provider" in dpgm.list_providers()
|
||||
finally:
|
||||
dpgm.unregister_provider("unit-test-provider")
|
||||
|
||||
with pytest.raises(ProviderNotFoundError):
|
||||
dpgm.get_provider("unit-test-provider")
|
||||
|
||||
|
||||
def test_duplicate_provider_registration_fails() -> None:
|
||||
provider = dpgm.TileProvider(
|
||||
name="unit-test-duplicate",
|
||||
url_template="https://tiles.example.test/{z}/{x}/{y}.png",
|
||||
)
|
||||
|
||||
dpgm.register_provider(provider)
|
||||
try:
|
||||
with pytest.raises(ProviderExistsError):
|
||||
dpgm.register_provider(provider)
|
||||
finally:
|
||||
dpgm.unregister_provider("unit-test-duplicate")
|
||||
|
||||
|
||||
def test_provider_url_building_with_subdomains_retina_and_extension() -> None:
|
||||
provider = dpgm.TileProvider(
|
||||
name="unit-test-template",
|
||||
url_template="https://{s}.tiles.example.test/{z}/{x}/{y}{r}.{ext}",
|
||||
subdomains=("a", "b", "c"),
|
||||
retina=True,
|
||||
file_extension="webp",
|
||||
)
|
||||
|
||||
assert provider.build_url(x=1, y=2, z=3) == "https://a.tiles.example.test/3/1/2@2x.webp"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template",
|
||||
[
|
||||
"https://tiles.example.test/{z}/{x}.png",
|
||||
"https://tiles.example.test/{z}/{x}/{y}/{quadkey}.png",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_invalid_provider_templates_fail(template: str) -> None:
|
||||
with pytest.raises(InvalidProviderError):
|
||||
dpgm.TileProvider(name="bad-template", url_template=template)
|
||||
48
tests/test_public_api.py
Normal file
48
tests/test_public_api.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_package_exports_required_public_api() -> None:
|
||||
import dpg_map as dpgm
|
||||
|
||||
expected = {
|
||||
"configure",
|
||||
"TileProvider",
|
||||
"register_provider",
|
||||
"unregister_provider",
|
||||
"get_provider",
|
||||
"list_providers",
|
||||
"map_widget",
|
||||
"set_center",
|
||||
"get_center",
|
||||
"set_zoom",
|
||||
"get_zoom",
|
||||
"set_view",
|
||||
"fit_bounds",
|
||||
"screen_to_latlon",
|
||||
"latlon_to_screen",
|
||||
"add_marker",
|
||||
"add_polyline",
|
||||
"add_trajectory",
|
||||
"update_marker",
|
||||
"update_polyline",
|
||||
"update_trajectory",
|
||||
"set_marker_position",
|
||||
"set_marker_label",
|
||||
"set_polyline_points",
|
||||
"set_overlay_show",
|
||||
"delete_overlay",
|
||||
"add_layer",
|
||||
"show_layer",
|
||||
"hide_layer",
|
||||
"clear_layer",
|
||||
"clear_map",
|
||||
"set_provider",
|
||||
"clear_memory_cache",
|
||||
"clear_disk_cache",
|
||||
"get_cache_stats",
|
||||
"get_map_debug_state",
|
||||
}
|
||||
|
||||
assert set(dpgm.__all__) == expected
|
||||
for name in expected:
|
||||
assert hasattr(dpgm, name)
|
||||
Reference in New Issue
Block a user