"""A line map of a neighborhood for a pen plotter, from OpenStreetMap data.

The sketch downloads the map data once and keeps it in osm_data.json next to
this file. Change LAT, LON or SCALE and it downloads again.

Map data © OpenStreetMap contributors, available under the Open Database
License: https://www.openstreetmap.org/copyright
"""
import json
import math
import urllib.parse
import urllib.request
from pathlib import Path

import vsketch
from shapely.geometry import LineString, box
from shapely.ops import unary_union

LAT, LON = 52.0800, 4.3068  # center of the map: the Hofkwartier in The Hague
SCALE = 3500  # 1 mm on paper is 3.5 m on the ground
MAP_WIDTH, MAP_HEIGHT = 267, 180  # mm: A4 landscape with 15 mm margins

OVERPASS_URL = "https://overpass-api.de/api/interpreter"
CACHE = Path(__file__).with_name("osm_data.json")
CREDIT = "Map data from OpenStreetMap, openstreetmap.org/copyright"
METERS_PER_DEGREE = 111_320  # one degree of latitude, near enough
NO_STREET = {"footway", "path", "steps", "cycleway", "service", "pedestrian",
             "track", "corridor", "platform", "construction", "proposed",
             "elevator", "bridleway"}
WANTED = ('way["building"]', 'relation["building"]', 'way["highway"]',
          'way["natural"="water"]', 'relation["natural"="water"]',
          'way["waterway"]', 'way["railway"="tram"]',
          'way["leisure"="park"]', 'relation["leisure"="park"]')


def download(query):
    """Ask the Overpass API once; after that, read the saved answer."""
    if CACHE.exists():
        saved = json.loads(CACHE.read_text(encoding="utf-8"))
        if saved["query"] == query:
            return saved["elements"]
    print("Downloading from OpenStreetMap ...")
    request = urllib.request.Request(
        OVERPASS_URL,
        data=urllib.parse.urlencode({"data": query}).encode(),
        headers={"User-Agent": "osm-map-sketch/1.0 (pen plotter sketch)"},
    )
    try:
        with urllib.request.urlopen(request, timeout=120) as response:
            elements = json.load(response)["elements"]
    except OSError as error:  # no connection, a busy server, a refused certificate
        raise RuntimeError(f"Could not download the map data: {error}") from error
    CACHE.write_text(
        json.dumps({"query": query, "elements": elements}), encoding="utf-8"
    )
    return elements


def layer_of(tags):
    """Which layer does an element belong to? None means: leave it out."""
    if "building" in tags:
        return "buildings"
    if tags.get("natural") == "water" or "waterway" in tags:
        return "water"
    if tags.get("leisure") == "park":
        return "parks"
    if tags.get("railway") == "tram":
        return "tram"
    if "highway" in tags:
        if tags["highway"] in NO_STREET or tags.get("area") == "yes":
            return None
        return "streets"
    return None


def ways_of(element):
    """The lists of points of a way, or of every way in a relation."""
    if element["type"] == "way":
        return [element.get("geometry", [])]
    return [member["geometry"] for member in element.get("members", [])
            if member["type"] == "way" and member.get("geometry")]


def lines_in(shape):
    """The line parts of a shape; cutting can leave loose points behind."""
    return [part for part in getattr(shape, "geoms", [shape])
            if part.geom_type in ("LineString", "MultiLineString")]


class OsmMapSketch(vsketch.SketchClass):
    buildings = vsketch.Param(True)
    water = vsketch.Param(True)
    parks = vsketch.Param(True)
    tram = vsketch.Param(True)
    streets = vsketch.Param(False)  # center lines: a busier picture
    frame = vsketch.Param(True)
    credit = vsketch.Param(True)  # the line of text under the map

    def draw(self, vsk: vsketch.Vsketch) -> None:
        vsk.size("a4", landscape=True)  # the map is centered on the page
        vsk.scale("mm")

        # The piece of the world that fits on the paper at this scale.
        meters_per_mm = SCALE / 1000
        meters_per_degree_lon = METERS_PER_DEGREE * math.cos(math.radians(LAT))
        half_height = MAP_HEIGHT * meters_per_mm / 2 / METERS_PER_DEGREE
        half_width = MAP_WIDTH * meters_per_mm / 2 / meters_per_degree_lon
        south, north = LAT - half_height, LAT + half_height
        west, east = LON - half_width, LON + half_width

        def on_paper(point):
            """Latitude and longitude to millimeters; y runs down the page."""
            x = (point["lon"] - west) * meters_per_degree_lon / meters_per_mm
            y = (north - point["lat"]) * METERS_PER_DEGREE / meters_per_mm
            return x, y

        area = f"({south:.5f},{west:.5f},{north:.5f},{east:.5f})"
        query = ("[out:json][timeout:90];("
                 + "".join(f"{wanted}{area};" for wanted in WANTED)
                 + ");out geom;")

        layers = {"buildings": [], "water": [], "parks": [], "tram": [],
                  "streets": []}
        for element in download(query):
            name = layer_of(element.get("tags", {}))
            if name is None:
                continue
            for way in ways_of(element):
                if len(way) > 1:
                    layers[name].append(LineString([on_paper(p) for p in way]))

        paper = box(0, 0, MAP_WIDTH, MAP_HEIGHT)
        for number, name in enumerate(layers, start=1):
            if not getattr(self, name) or not layers[name]:
                continue
            # The union draws a wall shared by two buildings only once;
            # the intersection cuts everything off at the edge of the map.
            shape = unary_union(layers[name]).intersection(paper)
            vsk.stroke(number)
            for part in lines_in(shape):
                vsk.geometry(part)
        vsk.stroke(6)
        if self.frame:
            vsk.rect(0, 0, MAP_WIDTH, MAP_HEIGHT)
        if self.credit:
            vsk.text(CREDIT, MAP_WIDTH, MAP_HEIGHT + 4.5, size=2.5, align="right")

    def finalize(self, vsk: vsketch.Vsketch) -> None:
        vsk.vpype("linemerge linesimplify reloop linesort")


if __name__ == "__main__":
    OsmMapSketch.display()
