""" Figures displayed on the map. """ from typing import Any, Iterator, Optional import numpy as np from colour import Color from shapely.geometry import LineString from svgwrite import Drawing from svgwrite.container import Group from svgwrite.path import Path from map_machine.direction import DirectionSet, Sector from map_machine.drawing import PathCommands from map_machine.flinger import Flinger from map_machine.osm_reader import OSMNode, Tagged from map_machine.road import Lane from map_machine.scheme import LineStyle, RoadMatcher, Scheme __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" BUILDING_HEIGHT_SCALE: float = 2.5 BUILDING_MINIMAL_HEIGHT: float = 8.0 class Figure(Tagged): """ Some figure on the map: way or area. """ def __init__( self, tags: dict[str, str], inners: list[list[OSMNode]], outers: list[list[OSMNode]], ) -> None: super().__init__(tags) self.inners: list[list[OSMNode]] = list(map(make_clockwise, inners)) self.outers: list[list[OSMNode]] = list( map(make_counter_clockwise, outers) ) def get_path( self, flinger: Flinger, offset: np.ndarray = np.array((0, 0)) ) -> str: """ Get SVG path commands. :param flinger: converter for geo coordinates :param offset: offset vector """ path: str = "" for outer_nodes in self.outers: path += f"{get_path(outer_nodes, offset, flinger)} " for inner_nodes in self.inners: path += f"{get_path(inner_nodes, offset, flinger)} " return path def get_outer_path( self, flinger: Flinger, parallel_offset: float = 0 ) -> str: """Get path of the first outer node list.""" points: list[tuple[float, float]] = [ tuple(flinger.fling(x.coordinates)) for x in self.outers[0] ] offset = LineString(points).parallel_offset(parallel_offset) path: str = "" for index, point in enumerate(offset.coords): path += ("L" if index else "M") + f" {point[0]},{point[1]} " return path[:-1] class Building(Figure): """ Building on the map. """ def __init__( self, tags: dict[str, str], inners: list[list[OSMNode]], outers: list[list[OSMNode]], flinger: Flinger, scheme: Scheme, ) -> None: super().__init__(tags, inners, outers) style: dict[str, Any] = { "fill": scheme.get_color("building_color").hex, "stroke": scheme.get_color("building_border_color").hex, } self.line_style: LineStyle = LineStyle(style) self.parts: list[Segment] = [] for nodes in self.inners + self.outers: for i in range(len(nodes) - 1): flung_1: np.ndarray = flinger.fling(nodes[i].coordinates) flung_2: np.ndarray = flinger.fling(nodes[i + 1].coordinates) self.parts.append(Segment(flung_1, flung_2)) self.parts = sorted(self.parts) self.height: float = BUILDING_MINIMAL_HEIGHT self.min_height: float = 0.0 levels: Optional[str] = self.get_float("building:levels") if levels: self.height = float(levels) * BUILDING_HEIGHT_SCALE levels: Optional[str] = self.get_float("building:min_level") if levels: self.min_height = float(levels) * BUILDING_HEIGHT_SCALE height: Optional[float] = self.get_length("height") if height: self.height = height height: Optional[float] = self.get_length("min_height") if height: self.min_height = height def draw(self, svg: Drawing, flinger: Flinger) -> None: """Draw simple building shape.""" path: Path = Path(d=self.get_path(flinger)) path.update(self.line_style.style) path.update({"stroke-linejoin": "round"}) svg.add(path) def draw_shade(self, building_shade: Group, flinger: Flinger) -> None: """Draw shade casted by the building.""" scale: float = flinger.get_scale() / 3.0 shift_1: np.ndarray = np.array((scale * self.min_height, 0)) shift_2: np.ndarray = np.array((scale * self.height, 0)) commands: str = self.get_path(flinger, shift_1) path: Path = Path( d=commands, fill="#000000", stroke="#000000", stroke_width=1 ) building_shade.add(path) for nodes in self.inners + self.outers: for i in range(len(nodes) - 1): flung_1 = flinger.fling(nodes[i].coordinates) flung_2 = flinger.fling(nodes[i + 1].coordinates) command: PathCommands = [ "M", np.add(flung_1, shift_1), "L", np.add(flung_2, shift_1), np.add(flung_2, shift_2), np.add(flung_1, shift_2), "Z", ] path: Path = Path( command, fill="#000000", stroke="#000000", stroke_width=1 ) building_shade.add(path) def draw_walls( self, svg: Drawing, height: float, previous_height: float, scale: float ) -> None: """Draw building walls.""" shift_1: np.ndarray = np.array((0, -previous_height * scale)) shift_2: np.ndarray = np.array((0, -height * scale)) for segment in self.parts: fill: Color if height == 2: fill = Color("#AAAAAA") elif height == 4: fill = Color("#C3C3C3") else: color_part: float = 0.8 + segment.angle * 0.2 fill = Color(rgb=(color_part, color_part, color_part)) command = ( "M", segment.point_1 + shift_1, "L", segment.point_2 + shift_1, segment.point_2 + shift_2, segment.point_1 + shift_2, segment.point_1 + shift_1, "Z", ) path: Path = svg.path( d=command, fill=fill.hex, stroke=fill.hex, stroke_width=1, stroke_linejoin="round", ) svg.add(path) def draw_roof(self, svg: Drawing, flinger: Flinger, scale: float) -> None: """Draw building roof.""" path: Path = Path( d=self.get_path(flinger, np.array([0, -self.height * scale])) ) path.update(self.line_style.style) path.update({"stroke-linejoin": "round"}) svg.add(path) class StyledFigure(Figure): """ Figure with stroke and fill style. """ def __init__( self, tags: dict[str, str], inners: list[list[OSMNode]], outers: list[list[OSMNode]], line_style: LineStyle, ) -> None: super().__init__(tags, inners, outers) self.line_style: LineStyle = line_style class Road(Figure): """ Road or track on the map. """ def __init__( self, tags: dict[str, str], inners: list[list[OSMNode]], outers: list[list[OSMNode]], matcher: RoadMatcher, ) -> None: super().__init__(tags, inners, outers) self.matcher: RoadMatcher = matcher self.width: Optional[float] = None self.lanes: list[Lane] = [] if "lanes" in tags: try: self.width = int(tags["lanes"]) * 3.7 self.lanes = [Lane()] * int(tags["lanes"]) except ValueError: pass number: int if "lanes:forward" in tags: number = int(tags["lanes:forward"]) [x.set_forward(True) for x in self.lanes[-number:]] if "lanes:backward" in tags: number = int(tags["lanes:backward"]) [x.set_forward(False) for x in self.lanes[:number]] if "width" in tags: try: self.width = float(tags["width"]) except ValueError: pass self.layer: float = 0 if "layer" in tags: self.layer = float(tags["layer"]) def draw( self, svg: Drawing, flinger: Flinger, color: Color, extra_width: float = 0, ) -> None: """Draw road as simple SVG path.""" flinger.get_scale() width: float if self.width is not None: width = self.width else: width = self.matcher.default_width cap: str = "round" if extra_width: cap = "butt" if self.tags.get("bridge") == "yes": color = Color("#666666") scale: float = flinger.get_scale(self.outers[0][0].coordinates) path_commands: str = self.get_path(flinger) path: Path = Path(d=path_commands) style: dict[str, Any] = { "fill": "none", "stroke": color.hex, "stroke-linecap": cap, "stroke-linejoin": "round", "stroke-width": scale * width + extra_width, } path.update(style) svg.add(path) def draw_lanes(self, svg: Drawing, flinger: Flinger, color: Color) -> None: scale: float = flinger.get_scale(self.outers[0][0].coordinates) if len(self.lanes) < 2: return for index in range(1, len(self.lanes)): shift = scale * ( -self.width / 2 + index * self.width / len(self.lanes) ) path: Path = Path(d=self.get_outer_path(flinger, shift)) style: dict[str, Any] = { "fill": "none", "stroke": color.hex, "stroke-linejoin": "round", "stroke-width": 1, "opacity": 0.5, } path.update(style) svg.add(path) class Crater(Tagged): """ Volcano or impact crater on the map. """ def __init__( self, tags: dict[str, str], coordinates: np.ndarray, point: np.ndarray ) -> None: super().__init__(tags) self.coordinates: np.ndarray = coordinates self.point: np.ndarray = point def draw(self, svg: Drawing, flinger: Flinger) -> None: """Draw crater ridge.""" scale: float = flinger.get_scale(self.coordinates) assert "diameter" in self.tags radius: float = float(self.tags["diameter"]) / 2.0 radial_gradient = svg.radialGradient( center=self.point + np.array((0, radius * scale / 7)), r=radius * scale, gradientUnits="userSpaceOnUse", ) color: Color = Color("#000000") gradient = svg.defs.add(radial_gradient) ( gradient .add_stop_color(0, color.hex, opacity=0.2) .add_stop_color(0.7, color.hex, opacity=0.2) .add_stop_color(1, color.hex, opacity=1) ) # fmt: skip circle = svg.circle( self.point, radius * scale, fill=gradient.get_paint_server(), opacity=0.2, ) svg.add(circle) class Tree(Tagged): """ Tree on the map. """ def __init__( self, tags: dict[str, str], coordinates: np.ndarray, point: np.ndarray ) -> None: super().__init__(tags) self.coordinates: np.ndarray = coordinates self.point: np.ndarray = point def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme) -> None: """Draw crown and trunk.""" scale: float = flinger.get_scale(self.coordinates) radius: float if "diameter_crown" in self.tags: radius = float(self.tags["diameter_crown"]) / 2.0 else: radius = 2.0 color: Color = scheme.get_color("evergreen_color") svg.add(svg.circle(self.point, radius * scale, fill=color, opacity=0.3)) if "circumference" in self.tags: radius: float = float(self.tags["circumference"]) / 2.0 / np.pi svg.add(svg.circle(self.point, radius * scale, fill="#B89A74")) class DirectionSector(Tagged): """ Sector that represents direction. """ def __init__(self, tags: dict[str, str], point: np.ndarray) -> None: super().__init__(tags) self.point: np.ndarray = point def draw(self, svg: Drawing, scheme: Scheme) -> None: """Draw gradient sector.""" angle: Optional[float] = None is_revert_gradient: bool = False direction: str direction_radius: float direction_color: Color if self.get_tag("man_made") == "surveillance": direction = self.get_tag("camera:direction") if "camera:angle" in self.tags: angle = float(self.get_tag("camera:angle")) if "angle" in self.tags: angle = float(self.get_tag("angle")) direction_radius = 50 direction_color = scheme.get_color("direction_camera_color") elif self.get_tag("traffic_sign") == "stop": direction = self.get_tag("direction") direction_radius = 25 direction_color = Color("red") else: direction = self.get_tag("direction") direction_radius = 50 direction_color = scheme.get_color("direction_view_color") is_revert_gradient = True if not direction: return point: np.ndarray = (self.point.astype(int)).astype(float) paths: Iterator[PathCommands] if angle is not None: paths = [Sector(direction, angle).draw(point, direction_radius)] else: paths = DirectionSet(direction).draw(point, direction_radius) for path in paths: radial_gradient = svg.radialGradient( center=point, r=direction_radius, gradientUnits="userSpaceOnUse", ) gradient = svg.defs.add(radial_gradient) if is_revert_gradient: ( gradient .add_stop_color(0, direction_color.hex, opacity=0) .add_stop_color(1, direction_color.hex, opacity=0.7) ) # fmt: skip else: ( gradient .add_stop_color(0, direction_color.hex, opacity=0.4) .add_stop_color(1, direction_color.hex, opacity=0) ) # fmt: skip path_element: Path = svg.path( d=["M", point] + path + ["L", point, "Z"], fill=gradient.get_paint_server(), ) svg.add(path_element) class Segment: """ Line segment. """ def __init__(self, point_1: np.ndarray, point_2: np.ndarray) -> None: self.point_1: np.ndarray = point_1 self.point_2: np.ndarray = point_2 difference: np.ndarray = point_2 - point_1 vector: np.ndarray = difference / np.linalg.norm(difference) self.angle: float = np.arccos(np.dot(vector, np.array((0, 1)))) / np.pi def __lt__(self, other: "Segment") -> bool: return ( ((self.point_1 + self.point_2) / 2)[1] < ((other.point_1 + other.point_2) / 2)[1] ) # fmt: skip def is_clockwise(polygon: list[OSMNode]) -> bool: """ Return true if polygon nodes are in clockwise order. :param polygon: list of OpenStreetMap nodes """ count: float = 0 for index, node in enumerate(polygon): next_index: int = 0 if index == len(polygon) - 1 else index + 1 count += (polygon[next_index].coordinates[0] - node.coordinates[0]) * ( polygon[next_index].coordinates[1] + node.coordinates[1] ) return count >= 0 def make_clockwise(polygon: list[OSMNode]) -> list[OSMNode]: """ Make polygon nodes clockwise. :param polygon: list of OpenStreetMap nodes """ return polygon if is_clockwise(polygon) else list(reversed(polygon)) def make_counter_clockwise(polygon: list[OSMNode]) -> list[OSMNode]: """ Make polygon nodes counter-clockwise. :param polygon: list of OpenStreetMap nodes """ return polygon if not is_clockwise(polygon) else list(reversed(polygon)) def get_path(nodes: list[OSMNode], shift: np.ndarray, flinger: Flinger) -> str: """Construct SVG path commands from nodes.""" path: str = "" prev_node: Optional[OSMNode] = None for node in nodes: flung: np.ndarray = flinger.fling(node.coordinates) + shift path += ("L" if prev_node else "M") + f" {flung[0]},{flung[1]} " prev_node = node if nodes[0] == nodes[-1]: path += "Z" else: path = path[:-1] return path