MapToPoster / src /maptoposter /models.py
isaachwf's picture
Sync from GitHub via hub-sync
dbea2d0 verified
Raw History Blame Contribute Delete
7.36 kB
"""Typed configuration and data models for MapToPoster."""
from __future__ import annotations
import math
from dataclasses import dataclass, field, fields, replace
from enum import StrEnum
from pathlib import Path
class LayoutPreset(StrEnum):
CLASSIC = "classic"
EDITORIAL = "editorial"
MINIMAL = "minimal"
BOTTOM_LEFT = "bottom_left"
CENTERED = "centered"
class SizePreset(StrEnum):
THREE_FOUR = "3:4"
FOUR_FIVE = "4:5"
TWO_THREE = "2:3"
FOUR_THREE = "4:3"
FIVE_FOUR = "5:4"
THREE_TWO = "3:2"
SQUARE = "1:1"
NINE_SIXTEEN = "9:16"
SIXTEEN_NINE = "16:9"
A4 = "A4"
A3 = "A3"
A4_LANDSCAPE = "A4-landscape"
A3_LANDSCAPE = "A3-landscape"
CUSTOM = "custom"
class OutputFormat(StrEnum):
PNG = "png"
SVG = "svg"
PDF = "pdf"
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
def __post_init__(self) -> None:
if not math.isfinite(self.latitude) or not -90 <= self.latitude <= 90:
raise ValueError("Latitude must be a finite value between -90 and 90.")
if not math.isfinite(self.longitude) or not -180 <= self.longitude <= 180:
raise ValueError("Longitude must be a finite value between -180 and 180.")
@dataclass(frozen=True)
class BBox:
west: float
south: float
east: float
north: float
def __post_init__(self) -> None:
values = (self.west, self.south, self.east, self.north)
if not all(math.isfinite(value) for value in values):
raise ValueError("Bounding box values must be finite.")
if not -90 <= self.south < self.north <= 90:
raise ValueError("Bounding box latitude values are invalid.")
if not -180 <= self.west < self.east <= 180:
raise ValueError("Bounding boxes crossing the antimeridian are not supported.")
def as_tuple(self) -> tuple[float, float, float, float]:
"""Return the OSMnx 2.x bbox order: west, south, east, north."""
return (self.west, self.south, self.east, self.north)
@dataclass(frozen=True)
class Location:
display_name: str
coordinate: Coordinate
country: str = ""
region: str = ""
country_code: str = ""
provider: str = "manual"
@dataclass(frozen=True)
class MapViewport:
center: Coordinate
bbox: BBox
zoom: float | None = None
distance_m: float | None = None
@dataclass(frozen=True)
class MapConfig:
viewport: MapViewport
network_type: str = "all"
data_layers: tuple[str, ...] = ("roads", "water", "parks")
def __post_init__(self) -> None:
if self.network_type not in {"all", "drive", "walk", "bike"}:
raise ValueError(f"Unsupported network type: {self.network_type}")
@dataclass(frozen=True)
class LayerConfig:
motorway: bool = True
primary: bool = True
secondary: bool = True
residential: bool = True
water: bool = True
parks: bool = True
@dataclass(frozen=True)
class StyleConfig:
id: str
name: str
description: str = ""
preview: str = ""
background: str = "#FFFFFF"
text: str = "#000000"
water: str = "#C0C0C0"
parks: str = "#F0F0F0"
road_motorway: str = "#0A0A0A"
road_primary: str = "#1A1A1A"
road_secondary: str = "#2A2A2A"
road_tertiary: str = "#3A3A3A"
road_residential: str = "#4A4A4A"
road_default: str = "#3A3A3A"
gradient: str = "#FFFFFF"
def with_colors(self, **colors: str) -> StyleConfig:
"""Return a customized style without mutating its preset."""
allowed = {item.name for item in fields(self)} - {"id", "name", "description", "preview"}
unknown = set(colors) - allowed
if unknown:
raise ValueError(f"Unknown style colors: {', '.join(sorted(unknown))}")
return replace(self, **colors)
@dataclass(frozen=True)
class TypographyConfig:
title: str = ""
subtitle: str = ""
caption: str = ""
coordinates: str = ""
font_family: str = "auto"
title_size: float = 54
subtitle_size: float = 22
caption_size: float = 14
coordinate_size: float = 12
letter_spacing: float = 0.08
line_height: float = 1.15
alignment: str = "center"
show_coordinates: bool = True
show_divider: bool = True
def __post_init__(self) -> None:
if self.alignment not in {"left", "center", "right"}:
raise ValueError("Typography alignment must be left, center, or right.")
sizes = (self.title_size, self.subtitle_size, self.caption_size, self.coordinate_size)
if not all(6 <= size <= 240 for size in sizes):
raise ValueError("Typography sizes must be between 6 and 240 points.")
SIZE_PRESETS: dict[SizePreset, tuple[float, float]] = {
SizePreset.THREE_FOUR: (12.0, 16.0),
SizePreset.FOUR_FIVE: (12.0, 15.0),
SizePreset.TWO_THREE: (12.0, 18.0),
SizePreset.FOUR_THREE: (16.0, 12.0),
SizePreset.FIVE_FOUR: (15.0, 12.0),
SizePreset.THREE_TWO: (18.0, 12.0),
SizePreset.SQUARE: (12.0, 12.0),
SizePreset.NINE_SIXTEEN: (9.0, 16.0),
SizePreset.SIXTEEN_NINE: (16.0, 9.0),
SizePreset.A4: (8.2677, 11.6929),
SizePreset.A3: (11.6929, 16.5354),
SizePreset.A4_LANDSCAPE: (11.6929, 8.2677),
SizePreset.A3_LANDSCAPE: (16.5354, 11.6929),
}
@dataclass(frozen=True)
class PosterSize:
preset: SizePreset = SizePreset.THREE_FOUR
width_in: float | None = None
height_in: float | None = None
def __post_init__(self) -> None:
if self.preset is SizePreset.CUSTOM:
if self.width_in is None or self.height_in is None:
raise ValueError("Custom poster size requires width and height.")
if not 4 <= self.width_in <= 40 or not 4 <= self.height_in <= 40:
raise ValueError("Custom poster dimensions must be between 4 and 40 inches.")
@property
def dimensions(self) -> tuple[float, float]:
if self.preset is SizePreset.CUSTOM:
assert self.width_in is not None and self.height_in is not None
return self.width_in, self.height_in
return SIZE_PRESETS[self.preset]
@dataclass(frozen=True)
class LayoutConfig:
preset: LayoutPreset = LayoutPreset.CLASSIC
@dataclass(frozen=True)
class PosterConfig:
location: Location
map: MapConfig
style: StyleConfig
typography: TypographyConfig = field(default_factory=TypographyConfig)
layout: LayoutConfig = field(default_factory=LayoutConfig)
layers: LayerConfig = field(default_factory=LayerConfig)
size: PosterSize = field(default_factory=PosterSize)
@dataclass(frozen=True)
class PreviewConfig:
dpi: int = 120
max_dimension_px: int = 1200
@dataclass(frozen=True)
class ExportConfig:
output_format: OutputFormat = OutputFormat.PNG
dpi: int = 300
output_path: Path | None = None
def __post_init__(self) -> None:
if not 72 <= self.dpi <= 600:
raise ValueError("Export DPI must be between 72 and 600.")
@dataclass(frozen=True)
class MapDataRef:
cache_key: str
cache_path: Path
cache_hit: bool
@dataclass
class MapData:
bbox: BBox
roads: object
water_polygons: object | None = None
water_lines: object | None = None
parks: object | None = None
crs: str = "EPSG:4326"
metadata: dict[str, object] = field(default_factory=dict)