Spaces:
Running
Running
File size: 7,362 Bytes
008947a dbea2d0 008947a dbea2d0 008947a dbea2d0 008947a dbea2d0 008947a dbea2d0 008947a dbea2d0 008947a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | """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)
|