diff --git a/roentgen/boundary_box.py b/roentgen/boundary_box.py index aa3793e..06fa5f3 100644 --- a/roentgen/boundary_box.py +++ b/roentgen/boundary_box.py @@ -4,6 +4,7 @@ Rectangle that limit space on the map. import logging import re from dataclasses import dataclass +from typing import Optional import numpy as np @@ -26,7 +27,7 @@ class BoundaryBox: top: float # Maximum latitude. @classmethod - def from_text(cls, boundary_box: str): + def from_text(cls, boundary_box: str) -> "BoundaryBox": """ Parse boundary box string representation. @@ -41,7 +42,7 @@ class BoundaryBox: """ boundary_box = boundary_box.replace(" ", "") - matcher = re.match( + matcher: Optional[re.Match] = re.match( "(?P[0-9.-]*),(?P[0-9.-]*)," + "(?P[0-9.-]*),(?P[0-9.-]*)", boundary_box, diff --git a/roentgen/constructor.py b/roentgen/constructor.py index a26f34e..48b54e4 100644 --- a/roentgen/constructor.py +++ b/roentgen/constructor.py @@ -17,11 +17,13 @@ from roentgen.map_configuration import DrawingMode, MapConfiguration # fmt: off from roentgen.icon import ( - DEFAULT_SMALL_SHAPE_ID, Icon, IconSet, ShapeExtractor, ShapeSpecification + DEFAULT_SMALL_SHAPE_ID, Icon, IconSet, Shape, ShapeExtractor, + ShapeSpecification ) from roentgen.osm_reader import OSMData, OSMNode, OSMRelation, OSMWay from roentgen.point import Point -from roentgen.scheme import DEFAULT_COLOR, LineStyle, Scheme +from roentgen.scheme import DEFAULT_COLOR, LineStyle, RoadMatcher, Scheme +from roentgen.text import Label from roentgen.ui import BuildingMode from roentgen.util import MinMax # fmt: on @@ -40,7 +42,9 @@ TIME_COLOR_SCALE: list[Color] = [ ] -def line_center(nodes: list[OSMNode], flinger: Flinger) -> np.array: +def line_center( + nodes: list[OSMNode], flinger: Flinger +) -> (np.ndarray, np.ndarray): """ Get geometric center of nodes set. @@ -52,8 +56,9 @@ def line_center(nodes: list[OSMNode], flinger: Flinger) -> np.array: for node in nodes: boundary[0].update(node.coordinates[0]) boundary[1].update(node.coordinates[1]) - center_coordinates = np.array((boundary[0].center(), boundary[1].center())) - + center_coordinates: np.ndarray = np.array( + (boundary[0].center(), boundary[1].center()) + ) return flinger.fling(center_coordinates), center_coordinates @@ -97,7 +102,7 @@ def glue(ways: list[OSMWay]) -> list[list[OSMNode]]: other_way: Optional[OSMWay] = None for other_way in to_process: - glued = way.try_to_glue(other_way) + glued: Optional[OSMWay] = way.try_to_glue(other_way) if glued: break @@ -128,14 +133,14 @@ class Constructor: osm_data: OSMData, flinger: Flinger, scheme: Scheme, - icon_extractor: ShapeExtractor, + extractor: ShapeExtractor, configuration: MapConfiguration, ) -> None: self.osm_data: OSMData = osm_data self.flinger: Flinger = flinger self.scheme: Scheme = scheme - self.icon_extractor = icon_extractor - self.configuration = configuration + self.extractor: ShapeExtractor = extractor + self.configuration: MapConfiguration = configuration if self.configuration.level: if self.configuration.level == "overground": @@ -209,7 +214,7 @@ class Constructor: color = get_user_color(line.user, self.configuration.seed) else: # self.mode == TIME_MODE color = get_time_color(line.timestamp, self.osm_data.time) - self.draw_special_mode(inners, line, outers, color) + self.draw_special_mode(line, inners, outers, color) return if not line.tags: @@ -224,7 +229,7 @@ class Constructor: Building(line.tags, inners, outers, self.flinger, self.scheme) ) - road_matcher = self.scheme.get_road(line.tags) + road_matcher: RoadMatcher = self.scheme.get_road(line.tags) if road_matcher: self.roads.append(Road(line.tags, inners, outers, road_matcher)) return @@ -246,16 +251,17 @@ class Constructor: priority: int icon_set: IconSet icon_set, priority = self.scheme.get_icon( - self.icon_extractor, line.tags, processed + self.extractor, line.tags, processed + ) + labels: list[Label] = self.scheme.construct_text( + line.tags, "all", processed ) - labels = self.scheme.construct_text(line.tags, "all", processed) point: Point = Point( icon_set, labels, line.tags, processed, center_point, - center_coordinates, is_for_node=False, priority=priority, ) # fmt: skip @@ -278,16 +284,24 @@ class Constructor: priority: int icon_set: IconSet icon_set, priority = self.scheme.get_icon( - self.icon_extractor, line.tags, processed + self.extractor, line.tags, processed + ) + labels: list[Label] = self.scheme.construct_text( + line.tags, "all", processed ) - labels = self.scheme.construct_text(line.tags, "all", processed) point: Point = Point( icon_set, labels, line.tags, processed, center_point, - center_coordinates, is_for_node=False, priority=priority, + is_for_node=False, priority=priority, ) # fmt: skip self.points.append(point) - def draw_special_mode(self, inners, line, outers, color) -> None: + def draw_special_mode( + self, + line: Union[OSMWay, OSMRelation], + inners: list[list[OSMNode]], + outers: list[list[OSMNode]], + color: Color, + ) -> None: """Add figure for special mode: time or author.""" style: dict[str, Any] = { "fill": "none", @@ -302,7 +316,7 @@ class Constructor: """Construct Röntgen ways from OSM relations.""" for relation_id in self.osm_data.relations: relation: OSMRelation = self.osm_data.relations[relation_id] - tags = relation.tags + tags: dict[str, str] = relation.tags if not self.check_level(tags): continue if "type" not in tags or tags["type"] != "multipolygon": @@ -340,13 +354,13 @@ class Constructor: def construct_node(self, node: OSMNode) -> None: """Draw one node.""" - tags = node.tags + tags: dict[str, str] = node.tags if not self.check_level(tags): return processed: set[str] = set() - flung = self.flinger.fling(node.coordinates) + flung: np.ndarray = self.flinger.fling(node.coordinates) priority: int icon_set: IconSet @@ -360,21 +374,20 @@ class Constructor: color = get_user_color(node.user, self.configuration.seed) if self.configuration.drawing_mode == DrawingMode.TIME: color = get_time_color(node.timestamp, self.osm_data.time) - dot = self.icon_extractor.get_shape(DEFAULT_SMALL_SHAPE_ID) - icon_set = IconSet( + dot: Shape = self.extractor.get_shape(DEFAULT_SMALL_SHAPE_ID) + icon_set: IconSet = IconSet( Icon([ShapeSpecification(dot, color)]), [], set() ) point: Point = Point( - icon_set, [], tags, processed, flung, node.coordinates, - draw_outline=False - ) # fmt: skip + icon_set, [], tags, processed, flung, draw_outline=False + ) self.points.append(point) return icon_set, priority = self.scheme.get_icon( - self.icon_extractor, tags, processed + self.extractor, tags, processed ) - labels = self.scheme.construct_text(tags, "all", processed) + labels: list[Label] = self.scheme.construct_text(tags, "all", processed) self.scheme.process_ignored(tags, processed) if node.get_tag("natural") == "tree" and ( @@ -386,16 +399,16 @@ class Constructor: if "direction" in node.tags or "camera:direction" in node.tags: self.direction_sectors.append(DirectionSector(tags, flung)) point: Point = Point( - icon_set, labels, tags, processed, flung, node.coordinates, + icon_set, labels, tags, processed, flung, priority=priority, draw_outline=draw_outline ) # fmt: skip self.points.append(point) -def check_level_number(tags: dict[str, Any], level: float): +def check_level_number(tags: dict[str, Any], level: float) -> bool: """Check if element described by tags is no the specified level.""" if "level" in tags: - levels = map(float, tags["level"].replace(",", ".").split(";")) + levels: map = map(float, tags["level"].replace(",", ".").split(";")) if level not in levels: return False else: @@ -407,7 +420,7 @@ def check_level_overground(tags: dict[str, Any]) -> bool: """Check if element described by tags is overground.""" if "level" in tags: try: - levels = map(float, tags["level"].replace(",", ".").split(";")) + levels: map = map(float, tags["level"].replace(",", ".").split(";")) for level in levels: if level <= 0: return False @@ -415,7 +428,7 @@ def check_level_overground(tags: dict[str, Any]) -> bool: pass if "layer" in tags: try: - levels = map(float, tags["layer"].replace(",", ".").split(";")) + levels: map = map(float, tags["layer"].replace(",", ".").split(";")) for level in levels: if level <= 0: return False diff --git a/roentgen/direction.py b/roentgen/direction.py index 67d18fb..56b003d 100644 --- a/roentgen/direction.py +++ b/roentgen/direction.py @@ -16,12 +16,7 @@ SMALLEST_ANGLE: float = np.pi / 15 DEFAULT_ANGLE: float = np.pi / 30 -def degree_to_radian(degree: float) -> float: - """Convert value in degrees to radians.""" - return degree / 180 * np.pi - - -def parse_vector(text: str) -> Optional[np.array]: +def parse_vector(text: str) -> Optional[np.ndarray]: """ Parse vector from text representation: compass points or 360-degree notation. E.g. "NW", "270". @@ -30,13 +25,13 @@ def parse_vector(text: str) -> Optional[np.array]: :return: parsed normalized vector """ try: - radians: float = degree_to_radian(float(text)) + SHIFT + radians: float = np.radians(float(text)) + SHIFT return np.array((np.cos(radians), np.sin(radians))) except ValueError: pass try: - radians: float = degree_to_radian(middle(text)) + SHIFT + radians: float = np.radians(middle(text)) + SHIFT return np.array((np.cos(radians), np.sin(radians))) except KeyError: pass @@ -44,7 +39,7 @@ def parse_vector(text: str) -> Optional[np.array]: return None -def rotation_matrix(angle) -> np.array: +def rotation_matrix(angle: float) -> np.ndarray: """ Get a matrix to rotate 2D vector by the angle. @@ -65,9 +60,9 @@ class Sector: :param text: sector text representation (e.g. "70-210", "N-NW") :param angle: angle in degrees """ - self.start: Optional[np.array] = None - self.end: Optional[np.array] = None - self.main_direction: Optional[np.array] = None + self.start: Optional[np.ndarray] = None + self.end: Optional[np.ndarray] = None + self.main_direction: Optional[np.ndarray] = None if "-" in text: parts: list[str] = text.split("-") @@ -79,16 +74,16 @@ class Sector: if angle is None: result_angle = DEFAULT_ANGLE else: - result_angle = max(SMALLEST_ANGLE, degree_to_radian(angle) / 2) + result_angle = max(SMALLEST_ANGLE, np.radians(angle) / 2) - vector: Optional[np.array] = parse_vector(text) + vector: Optional[np.ndarray] = parse_vector(text) self.main_direction = vector if vector is not None: self.start = np.dot(rotation_matrix(result_angle), vector) self.end = np.dot(rotation_matrix(-result_angle), vector) - def draw(self, center: np.array, radius: float) -> Optional[PathCommands]: + def draw(self, center: np.ndarray, radius: float) -> Optional[PathCommands]: """ Construct SVG path commands for arc element. @@ -99,8 +94,8 @@ class Sector: if self.start is None or self.end is None: return None - start: np.array = center + radius * self.end - end: np.array = center + radius * self.start + start: np.ndarray = center + radius * self.end + end: np.ndarray = center + radius * self.start return ["L", start, "A", radius, radius, 0, "0", 0, end] @@ -137,7 +132,7 @@ class DirectionSet: def __str__(self) -> str: return ", ".join(map(str, self.sectors)) - def draw(self, center: np.array, radius: float) -> Iterator[PathCommands]: + def draw(self, center: np.ndarray, radius: float) -> Iterator[PathCommands]: """ Construct SVG "d" for arc elements. diff --git a/roentgen/drawing.py b/roentgen/drawing.py index 9d058d2..b776b0b 100644 --- a/roentgen/drawing.py +++ b/roentgen/drawing.py @@ -9,6 +9,8 @@ import cairo import numpy as np import svgwrite from colour import Color +from cairo import Context, ImageSurface +from svgwrite.base import BaseElement from svgwrite.path import Path as SVGPath from svgwrite.shapes import Rect from svgwrite.text import Text @@ -29,7 +31,7 @@ class Style: stroke: Optional[Color] = None width: float = 1 - def update_svg_element(self, element) -> None: + def update_svg_element(self, element: BaseElement) -> None: """Set style for SVG element.""" if self.fill is not None: element.update({"fill": self.fill}) @@ -38,14 +40,14 @@ class Style: if self.stroke is not None: element.update({"stroke": self.stroke, "stroke-width": self.width}) - def draw_png_fill(self, context) -> None: + def draw_png_fill(self, context: Context) -> None: """Set style for context and draw fill.""" context.set_source_rgba( self.fill.get_red(), self.fill.get_green(), self.fill.get_blue(), 1 ) context.fill() - def draw_png_stroke(self, context) -> None: + def draw_png_stroke(self, context: Context) -> None: """Set style for context and draw stroke.""" context.set_source_rgba( self.stroke.get_red(), @@ -81,7 +83,9 @@ class Drawing: """Draw path.""" raise NotImplementedError - def text(self, text: str, point: np.ndarray, color: Color = Color("black")): + def text( + self, text: str, point: np.ndarray, color: Color = Color("black") + ) -> None: """Draw text.""" raise NotImplementedError @@ -97,7 +101,9 @@ class SVGDrawing(Drawing): def __init__(self, file_path: Path, width: int, height: int) -> None: super().__init__(file_path, width, height) - self.image = svgwrite.Drawing(str(file_path), (width, height)) + self.image: svgwrite.Drawing = svgwrite.Drawing( + str(file_path), (width, height) + ) def rectangle( self, point_1: np.ndarray, point_2: np.ndarray, style: Style @@ -120,11 +126,13 @@ class SVGDrawing(Drawing): def path(self, commands: PathCommands, style: Style) -> None: """Draw path.""" - path = SVGPath(d=commands) + path: SVGPath = SVGPath(d=commands) style.update_svg_element(path) self.image.add(path) - def text(self, text: str, point: np.ndarray, color: Color = Color("black")): + def text( + self, text: str, point: np.ndarray, color: Color = Color("black") + ) -> None: """Draw text.""" self.image.add( Text(text, (float(point[0]), float(point[1])), fill=color) @@ -143,8 +151,10 @@ class PNGDrawing(Drawing): def __init__(self, file_path: Path, width: int, height: int) -> None: super().__init__(file_path, width, height) - self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - self.context = cairo.Context(self.surface) + self.surface: ImageSurface = ImageSurface( + cairo.FORMAT_ARGB32, width, height + ) + self.context: Context = Context(self.surface) def rectangle( self, point_1: np.ndarray, point_2: np.ndarray, style: Style @@ -181,7 +191,7 @@ class PNGDrawing(Drawing): index: int = 0 while index < len(commands): - element = commands[index] + element: Union[float, str, np.ndarray] = commands[index] if isinstance(element, str): is_absolute: bool = element.lower() != element @@ -192,10 +202,11 @@ class PNGDrawing(Drawing): start_point = None elif command in "ml": + point: np.ndarray if is_absolute: - point: np.ndarray = commands[index] + point = commands[index] else: - point: np.ndarray = current + commands[index] + point = current + commands[index] current = point if command == "m": self.context.move_to(point[0], point[1]) @@ -225,6 +236,7 @@ class PNGDrawing(Drawing): elif command in "vh": assert isinstance(commands[index], float) + point: np.ndarray if is_absolute: if command == "v": point = np.array((0, commands[index])) @@ -254,7 +266,9 @@ class PNGDrawing(Drawing): self._do_path(commands) style.draw_png_stroke(self.context) - def text(self, text: str, point: np.ndarray, color: Color = Color("black")): + def text( + self, text: str, point: np.ndarray, color: Color = Color("black") + ) -> None: """Draw text.""" self.context.set_source_rgb( color.get_red(), color.get_green(), color.get_blue() @@ -282,7 +296,7 @@ def parse_path(path: str) -> PathCommands: result.append(float(part)) else: if "," in part: - elements = part.split(",") + elements: list[str] = part.split(",") result.append(np.array(list(map(float, elements)))) else: result.append(np.array((float(part), float(parts[index + 1])))) diff --git a/roentgen/element.py b/roentgen/element.py index 058d27f..9999fb3 100644 --- a/roentgen/element.py +++ b/roentgen/element.py @@ -7,10 +7,12 @@ from pathlib import Path import numpy as np import svgwrite +from svgwrite.path import Path as SVGPath from roentgen.icon import ShapeExtractor from roentgen.point import Point from roentgen.scheme import LineStyle, Scheme +from roentgen.text import Label from roentgen.workspace import workspace __author__ = "Sergey Vartanov" @@ -19,12 +21,17 @@ __email__ = "me@enzet.ru" def draw_element(options: argparse.Namespace) -> None: """Draw single node, line, or area.""" + target: str + tags_description: str if options.node: - target: str = "node" + target = "node" tags_description = options.node + elif options.way: + target = "way" + tags_description = options.way else: - # Not implemented yet. - exit(1) + target = "area" + tags_description = options.area tags: dict[str, str] = dict( [x.split("=") for x in tags_description.split(",")] @@ -36,26 +43,27 @@ def draw_element(options: argparse.Namespace) -> None: processed: set[str] = set() icon, priority = scheme.get_icon(extractor, tags, processed) is_for_node: bool = target == "node" - labels = scheme.construct_text(tags, "all", processed) - point = Point( + labels: list[Label] = scheme.construct_text(tags, "all", processed) + point: Point = Point( icon, labels, tags, processed, np.array((32, 32)), - None, is_for_node=is_for_node, draw_outline=is_for_node, ) - border: np.array = np.array((16, 16)) - size: np.array = point.get_size() + border + border: np.ndarray = np.array((16, 16)) + size: np.ndarray = point.get_size() + border point.point = np.array((size[0] / 2, 16 / 2 + border[1] / 2)) output_file_path: Path = workspace.output_path / "element.svg" - svg = svgwrite.Drawing(str(output_file_path), size.astype(float)) + svg: svgwrite.Drawing = svgwrite.Drawing( + str(output_file_path), size.astype(float) + ) for style in scheme.get_style(tags): style: LineStyle - path = svg.path(d="M 0,0 L 64,0 L 64,64 L 0,64 L 0,0 Z") + path: SVGPath = svg.path(d="M 0,0 L 64,0 L 64,64 L 0,64 L 0,0 Z") path.update(style.style) svg.add(path) point.draw_main_shapes(svg) diff --git a/roentgen/figure.py b/roentgen/figure.py index 37a080b..8953dc8 100644 --- a/roentgen/figure.py +++ b/roentgen/figure.py @@ -1,14 +1,16 @@ """ Figures displayed on the map. """ -from typing import Any, Optional +from typing import Any, Iterator, Optional import numpy as np from colour import Color from svgwrite import Drawing +from svgwrite.container import Group from svgwrite.path import Path from roentgen.direction import DirectionSet, Sector +from roentgen.drawing import PathCommands from roentgen.flinger import Flinger from roentgen.osm_reader import OSMNode, Tagged from roentgen.road import Lane @@ -41,7 +43,7 @@ class Figure(Tagged): self.outers.append(make_counter_clockwise(outer_nodes)) def get_path( - self, flinger: Flinger, shift: np.array = np.array((0, 0)) + self, flinger: Flinger, shift: np.ndarray = np.array((0, 0)) ) -> str: """ Get SVG path commands. @@ -79,13 +81,13 @@ class Building(Figure): "fill": scheme.get_color("building_color").hex, "stroke": scheme.get_color("building_border_color").hex, } - self.line_style = LineStyle(style) - self.parts = [] + 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.array = flinger.fling(nodes[i].coordinates) - flung_2: np.array = flinger.fling(nodes[i + 1].coordinates) + 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) @@ -109,20 +111,20 @@ class Building(Figure): if height: self.min_height = height - def draw(self, svg: Drawing, flinger: Flinger): + 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, flinger: Flinger) -> None: + 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.array((scale * self.min_height, 0)) - shift_2 = np.array((scale * self.height, 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: Path = Path( d=commands, fill="#000000", stroke="#000000", stroke_width=1 ) building_shade.add(path) @@ -130,7 +132,7 @@ class Building(Figure): for i in range(len(nodes) - 1): flung_1 = flinger.fling(nodes[i].coordinates) flung_2 = flinger.fling(nodes[i + 1].coordinates) - command = ( + command: PathCommands = [ "M", np.add(flung_1, shift_1), "L", @@ -138,8 +140,8 @@ class Building(Figure): np.add(flung_2, shift_2), np.add(flung_1, shift_2), "Z", - ) - path = Path( + ] + path: Path = Path( command, fill="#000000", stroke="#000000", stroke_width=1 ) building_shade.add(path) @@ -148,9 +150,10 @@ class Building(Figure): self, svg: Drawing, height: float, previous_height: float, scale: float ) -> None: """Draw building walls.""" - shift_1 = [0, -previous_height * scale] - shift_2 = [0, -height * scale] + 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: @@ -169,7 +172,7 @@ class Building(Figure): segment.point_1 + shift_1, "Z", ) - path = svg.path( + path: Path = svg.path( d=command, fill=fill.hex, stroke=fill.hex, @@ -178,7 +181,7 @@ class Building(Figure): ) svg.add(path) - def draw_roof(self, svg: Drawing, flinger: Flinger, scale: float): + 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])) @@ -201,7 +204,7 @@ class StyledFigure(Figure): line_style: LineStyle, ) -> None: super().__init__(tags, inners, outers) - self.line_style = line_style + self.line_style: LineStyle = line_style class Road(Figure): @@ -229,6 +232,7 @@ class Road(Figure): 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:]] @@ -249,13 +253,13 @@ class Tree(Tagged): """ def __init__( - self, tags: dict[str, str], coordinates: np.array, point: np.array + self, tags: dict[str, str], coordinates: np.ndarray, point: np.ndarray ) -> None: super().__init__(tags) - self.coordinates: np.array = coordinates - self.point: np.array = point + self.coordinates: np.ndarray = coordinates + self.point: np.ndarray = point - def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme): + def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme) -> None: """Draw crown and trunk.""" scale: float = flinger.get_scale(self.coordinates) radius: float @@ -276,14 +280,17 @@ class DirectionSector(Tagged): Sector that represents direction. """ - def __init__(self, tags: dict[str, str], point) -> None: + def __init__(self, tags: dict[str, str], point: np.ndarray) -> None: super().__init__(tags) - self.point = point + self.point: np.ndarray = point - def draw(self, svg: Drawing, scheme: Scheme): + def draw(self, svg: Drawing, scheme: Scheme) -> None: """Draw gradient sector.""" - angle = None + 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") @@ -291,24 +298,25 @@ class DirectionSector(Tagged): angle = float(self.get_tag("camera:angle")) if "angle" in self.tags: angle = float(self.get_tag("angle")) - direction_radius: float = 25 - direction_color: Color = scheme.get_color("direction_camera_color") + direction_radius = 25 + direction_color = scheme.get_color("direction_camera_color") elif self.get_tag("traffic_sign") == "stop": direction = self.get_tag("direction") - direction_radius: float = 25 - direction_color: Color = Color("red") + direction_radius = 25 + direction_color = Color("red") else: direction = self.get_tag("direction") - direction_radius: float = 50 - direction_color: Color = scheme.get_color("direction_view_color") + direction_radius = 50 + direction_color = scheme.get_color("direction_view_color") is_revert_gradient = True if not direction: return - point = (self.point.astype(int)).astype(float) + point: np.ndarray = (self.point.astype(int)).astype(float) - if angle: + paths: Iterator[PathCommands] + if angle is not None: paths = [Sector(direction, angle).draw(point, direction_radius)] else: paths = DirectionSet(direction).draw(point, direction_radius) @@ -344,12 +352,12 @@ class Segment: Line segment. """ - def __init__(self, point_1: np.array, point_2: np.array) -> None: - self.point_1: np.array = point_1 - self.point_2: np.array = point_2 + 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.array = point_2 - point_1 - vector: np.array = difference / np.linalg.norm(difference) + 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: @@ -392,12 +400,12 @@ def make_counter_clockwise(polygon: list[OSMNode]) -> list[OSMNode]: return polygon if not is_clockwise(polygon) else list(reversed(polygon)) -def get_path(nodes: list[OSMNode], shift: np.array, flinger: Flinger) -> str: +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 = flinger.fling(node.coordinates) + shift + 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]: diff --git a/roentgen/flinger.py b/roentgen/flinger.py index bfe5b21..d4458bd 100644 --- a/roentgen/flinger.py +++ b/roentgen/flinger.py @@ -13,7 +13,7 @@ __email__ = "me@enzet.ru" EQUATOR_LENGTH: float = 40_075_017 # meters -def pseudo_mercator(coordinates: np.array) -> np.array: +def pseudo_mercator(coordinates: np.ndarray) -> np.ndarray: """ Use spherical pseudo-Mercator projection to convert geo coordinates into plane. @@ -47,32 +47,33 @@ class Flinger: self, geo_boundaries: BoundaryBox, scale: float = 18, - border: np.array = np.array((0, 0)), + border: np.ndarray = np.array((0, 0)), ) -> None: """ :param geo_boundaries: minimum and maximum latitude and longitude :param scale: OSM zoom level + :param border: size of padding in pixels """ self.geo_boundaries: BoundaryBox = geo_boundaries - self.border = border + self.border: np.ndarray = border self.ratio: float = ( osm_zoom_level_to_pixels_per_meter(scale) * EQUATOR_LENGTH / 360 ) - self.size: np.array = border * 2 + self.ratio * ( + self.size: np.ndarray = border * 2 + self.ratio * ( pseudo_mercator(self.geo_boundaries.max_()) - pseudo_mercator(self.geo_boundaries.min_()) ) - self.pixels_per_meter = osm_zoom_level_to_pixels_per_meter(scale) + self.pixels_per_meter: float = osm_zoom_level_to_pixels_per_meter(scale) - self.size: np.array = self.size.astype(int).astype(float) + self.size: np.ndarray = self.size.astype(int).astype(float) - def fling(self, coordinates: np.array) -> np.array: + def fling(self, coordinates: np.ndarray) -> np.ndarray: """ Convert geo coordinates into SVG position points. :param coordinates: vector to fling """ - result: np.array = self.border + self.ratio * ( + result: np.ndarray = self.border + self.ratio * ( pseudo_mercator(coordinates) - pseudo_mercator(self.geo_boundaries.min_()) ) @@ -82,7 +83,7 @@ class Flinger: return result - def get_scale(self, coordinates: Optional[np.array] = None) -> float: + def get_scale(self, coordinates: Optional[np.ndarray] = None) -> float: """ Return pixels per meter ratio for the given geo coordinates. diff --git a/roentgen/grid.py b/roentgen/grid.py index 3589add..ea9a1cc 100644 --- a/roentgen/grid.py +++ b/roentgen/grid.py @@ -4,11 +4,12 @@ Icon grid drawing. import logging from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Union import numpy as np from colour import Color from svgwrite import Drawing +from svgwrite.shapes import Rect from roentgen.icon import Icon, Shape, ShapeExtractor, ShapeSpecification from roentgen.scheme import NodeMatcher, Scheme @@ -51,8 +52,8 @@ class IconCollection: def add() -> Icon: """Construct icon and add it to the list.""" - specifications = [ - ShapeSpecification.from_structure(x, extractor, scheme) + specifications: list[ShapeSpecification] = [ + scheme.get_shape_specification(x, extractor) for x in current_set ] constructed_icon: Icon = Icon(specifications) @@ -62,6 +63,8 @@ class IconCollection: return constructed_icon + current_set: list[Union[str, dict[str, str]]] + for matcher in scheme.node_matchers: matcher: NodeMatcher if matcher.shapes: @@ -129,7 +132,7 @@ class IconCollection: color: Optional[Color] = None, outline: bool = False, outline_opacity: float = 1.0, - ): + ) -> None: """ :param output_directory: path to the directory to store individual SVG files for icons @@ -140,13 +143,13 @@ class IconCollection: """ if by_name: - def get_file_name(x) -> str: + def get_file_name(x: Icon) -> str: """Generate human-readable file name.""" return f"Röntgen {' + '.join(x.get_names())}.svg" else: - def get_file_name(x) -> str: + def get_file_name(x: Icon) -> str: """Generate file name with unique identifier.""" return f"{'___'.join(x.get_shape_ids())}.svg" @@ -164,7 +167,7 @@ class IconCollection: columns: int = 16, step: float = 24, background_color: Color = Color("white"), - ): + ) -> None: """ Draw icons in the form of table. @@ -173,7 +176,7 @@ class IconCollection: :param step: horizontal and vertical distance between icons in grid :param background_color: background color """ - point: np.array = np.array((step / 2, step / 2)) + point: np.ndarray = np.array((step / 2, step / 2)) width: float = step * columns height: int = int(int(len(self.icons) / (width / step) + 1) * step) @@ -182,7 +185,7 @@ class IconCollection: for icon in self.icons: icon: Icon - rectangle = svg.rect( + rectangle: Rect = svg.rect( point - np.array((10, 10)), (20, 20), fill=background_color.hex ) svg.add(rectangle) diff --git a/roentgen/icon.py b/roentgen/icon.py index 2ca4fd9..a6a63e4 100644 --- a/roentgen/icon.py +++ b/roentgen/icon.py @@ -7,14 +7,15 @@ import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional -from xml.dom.minidom import Document, Element, parse +from xml.etree import ElementTree +from xml.etree.ElementTree import Element import numpy as np import svgwrite from colour import Color from svgwrite import Drawing from svgwrite.container import Group -from svgwrite.path import Path as SvgPath +from svgwrite.path import Path as SVGPath from roentgen.color import is_bright @@ -25,10 +26,11 @@ DEFAULT_COLOR: Color = Color("#444444") DEFAULT_SHAPE_ID: str = "default" DEFAULT_SMALL_SHAPE_ID: str = "default_small" -STANDARD_INKSCAPE_ID_MATCHER = re.compile( - "^((circle|defs|ellipse|metadata|path|rect|use)[\\d-]+|base)$" +STANDARD_INKSCAPE_ID_MATCHER: re.Pattern = re.compile( + "^((circle|defs|ellipse|grid|guide|marker|metadata|path|rect|use)" + "[\\d-]+|base)$" ) -PATH_MATCHER = re.compile("[Mm] ([0-9.e-]*)[, ]([0-9.e-]*)") +PATH_MATCHER: re.Pattern = re.compile("[Mm] ([0-9.e-]*)[, ]([0-9.e-]*)") GRID_STEP: int = 16 @@ -40,7 +42,7 @@ class Shape: """ path: str # SVG icon path - offset: np.array # vector that should be used to shift the path + offset: np.ndarray # vector that should be used to shift the path id_: str # shape identifier name: Optional[str] = None # icon description is_right_directed: Optional[bool] = None @@ -52,7 +54,7 @@ class Shape: cls, structure: dict[str, Any], path: str, - offset: np.array, + offset: np.ndarray, id_: str, name: Optional[str] = None, ) -> "Shape": @@ -65,7 +67,7 @@ class Shape: :param id_: shape unique identifier :param name: shape text description """ - shape = cls(path, offset, id_, name) + shape: "Shape" = cls(path, offset, id_, name) if "directed" in structure: if structure["directed"] == "right": @@ -91,10 +93,10 @@ class Shape: def get_path( self, - point: np.array, - offset: np.array = np.array((0, 0)), - scale: np.array = np.array((1, 1)), - ) -> SvgPath: + point: np.ndarray, + offset: np.ndarray = np.array((0, 0)), + scale: np.ndarray = np.array((1, 1)), + ) -> SVGPath: """ Draw icon into SVG file. @@ -103,7 +105,7 @@ class Shape: :param scale: scale resulting image """ transformations: list[str] = [] - shift: np.array = point + offset + shift: np.ndarray = point + offset transformations.append(f"translate({shift[0]},{shift[1]})") @@ -124,7 +126,7 @@ def parse_length(text: str) -> float: return float(text) -def verify_sketch_element(element, id_: str) -> bool: +def verify_sketch_element(element: Element, id_: str) -> bool: """ Verify sketch SVG element from icon file. @@ -132,11 +134,11 @@ def verify_sketch_element(element, id_: str) -> bool: :param id_: element `id` attribute :return: True iff SVG element has right style """ - if not element.getAttribute("style"): + if "style" not in element.attrib or not element.attrib["style"]: return True - style: dict = dict( - [x.split(":") for x in element.getAttribute("style").split(";")] + style: dict[str, str] = dict( + [x.split(":") for x in element.attrib["style"].split(";")] ) if ( style["fill"] == "none" @@ -181,14 +183,8 @@ class ShapeExtractor: self.configuration: dict[str, Any] = json.load( configuration_file_name.open() ) - with svg_file_name.open() as input_file: - content: Document = parse(input_file) - for element in content.childNodes: - if element.nodeName != "svg": - continue - for node in element.childNodes: - if isinstance(node, Element): - self.parse(node) + root: Element = ElementTree.parse(svg_file_name).getroot() + self.parse(root) def parse(self, node: Element) -> None: """ @@ -196,41 +192,40 @@ class ShapeExtractor: :param node: XML node that contains icon """ - if node.nodeName == "g": - for sub_node in node.childNodes: - if isinstance(sub_node, Element): - self.parse(sub_node) + if node.tag.endswith("}g") or node.tag.endswith("}svg"): + for sub_node in node: + self.parse(sub_node) return - if not node.hasAttribute("id") or not node.getAttribute("id"): + if "id" not in node.attrib or not node.attrib["id"]: return - id_: str = node.getAttribute("id") + id_: str = node.attrib["id"] if STANDARD_INKSCAPE_ID_MATCHER.match(id_) is not None: if not verify_sketch_element(node, id_): logging.warning(f"Not verified SVG element `{id_}`.") return - if node.hasAttribute("d"): - path: str = node.getAttribute("d") + if "d" in node.attrib and node.attrib["d"]: + path: str = node.attrib["d"] matcher = PATH_MATCHER.match(path) if not matcher: return name: Optional[str] = None - def get_offset(value: str): + def get_offset(value: str) -> float: """Get negated icon offset from the origin.""" return ( -int(float(value) / GRID_STEP) * GRID_STEP - GRID_STEP / 2 ) - point: np.array = np.array( + point: np.ndarray = np.array( (get_offset(matcher.group(1)), get_offset(matcher.group(2))) ) - for child_node in node.childNodes: + for child_node in node: if isinstance(child_node, Element): - name = child_node.childNodes[0].nodeValue + name = child_node.text break configuration: dict[str, Any] = ( @@ -242,7 +237,7 @@ class ShapeExtractor: else: logging.error(f"Not standard ID {id_}.") - def get_shape(self, id_: str) -> Optional[Shape]: + def get_shape(self, id_: str) -> Shape: """ Get shape or None if there is no shape with such identifier. @@ -262,60 +257,11 @@ class ShapeSpecification: shape: Shape color: Color = DEFAULT_COLOR - offset: np.array = np.array((0, 0)) + offset: np.ndarray = np.array((0, 0)) flip_horizontally: bool = False flip_vertically: bool = False use_outline: bool = True - @classmethod - def from_structure( - cls, - structure: Any, - extractor: ShapeExtractor, - scheme, - color: Color = DEFAULT_COLOR, - ) -> "ShapeSpecification": - """ - Parse shape specification from structure, that is just shape string - identifier or dictionary with keys: shape (required), color (optional), - and offset (optional). - """ - shape: Shape = extractor.get_shape(DEFAULT_SHAPE_ID) - color: Color = color - offset: np.array = np.array((0, 0)) - flip_horizontally: bool = False - flip_vertically: bool = False - use_outline: bool = True - - if isinstance(structure, str): - shape = extractor.get_shape(structure) - elif isinstance(structure, dict): - if "shape" in structure: - shape = extractor.get_shape(structure["shape"]) - else: - logging.error( - "Invalid shape specification: `shape` key expected." - ) - if "color" in structure: - color = scheme.get_color(structure["color"]) - if "offset" in structure: - offset = np.array(structure["offset"]) - if "flip_horizontally" in structure: - flip_horizontally = structure["flip_horizontally"] - if "flip_vertically" in structure: - flip_vertically = structure["flip_vertically"] - if "outline" in structure: - use_outline = structure["outline"] - - return cls( - shape, - color, - offset, - flip_horizontally, - flip_vertically, - use_outline, - ) - def is_default(self) -> bool: """Check whether shape is default.""" return self.shape.id_ == DEFAULT_SHAPE_ID @@ -323,7 +269,7 @@ class ShapeSpecification: def draw( self, svg: Drawing, - point: np.array, + point: np.ndarray, tags: dict[str, Any] = None, outline: bool = False, outline_opacity: float = 1.0, @@ -337,14 +283,14 @@ class ShapeSpecification: :param outline: draw outline for the shape :param outline_opacity: opacity of the outline """ - scale: np.array = np.array((1, 1)) + scale: np.ndarray = np.array((1, 1)) if self.flip_vertically: scale = np.array((1, -1)) if self.flip_horizontally: scale = np.array((-1, 1)) - point = np.array(list(map(int, point))) - path = self.shape.get_path(point, self.offset, scale) + point: np.ndarray = np.array(list(map(int, point))) + path: SVGPath = self.shape.get_path(point, self.offset, scale) path.update({"fill": self.color.hex}) if outline and self.use_outline: @@ -398,7 +344,7 @@ class Icon: def draw( self, svg: svgwrite.Drawing, - point: np.array, + point: np.ndarray, tags: dict[str, Any] = None, outline: bool = False, ) -> None: @@ -427,7 +373,7 @@ class Icon: color: Optional[Color] = None, outline: bool = False, outline_opacity: float = 1.0, - ): + ) -> None: """ Draw icon to the SVG file. @@ -442,13 +388,16 @@ class Icon: if color: shape_specification.color = color shape_specification.draw( - svg, (8, 8), outline=outline, outline_opacity=outline_opacity + svg, + np.array((8, 8)), + outline=outline, + outline_opacity=outline_opacity, ) for shape_specification in self.shape_specifications: if color: shape_specification.color = color - shape_specification.draw(svg, (8, 8)) + shape_specification.draw(svg, np.array((8, 8))) with file_name.open("w") as output_file: svg.write(output_file) diff --git a/roentgen/mapcss.py b/roentgen/mapcss.py index 8ce46dc..62cbb9b 100644 --- a/roentgen/mapcss.py +++ b/roentgen/mapcss.py @@ -116,7 +116,7 @@ class MapCSSWriter: if opacity is not None: elements["icon-opacity"] = f"{opacity:.2f}" - style = matcher.get_style() + style: dict[str, str] = matcher.get_style() if style: if "fill" in style: elements["fill-color"] = style["fill"] diff --git a/roentgen/mapper.py b/roentgen/mapper.py index 585bfea..c2532ed 100644 --- a/roentgen/mapper.py +++ b/roentgen/mapper.py @@ -4,7 +4,7 @@ Simple OpenStreetMap renderer. import argparse import logging from pathlib import Path -from typing import Any, Iterator +from typing import Any, Iterator, Optional import numpy as np import svgwrite @@ -15,13 +15,13 @@ from svgwrite.shapes import Rect from roentgen.boundary_box import BoundaryBox from roentgen.constructor import Constructor -from roentgen.figure import Road +from roentgen.figure import Road, StyledFigure from roentgen.flinger import Flinger from roentgen.icon import ShapeExtractor from roentgen.map_configuration import LabelMode, MapConfiguration from roentgen.osm_getter import NetworkError, get_osm from roentgen.osm_reader import OSMData, OSMNode, OSMReader, OverpassReader -from roentgen.point import Occupied +from roentgen.point import Occupied, Point from roentgen.road import Intersection, RoadPart from roentgen.scheme import Scheme from roentgen.ui import BuildingMode, progress_bar @@ -57,13 +57,15 @@ class Map: self.svg.add( Rect((0, 0), self.flinger.size, fill=self.background_color) ) - ways = sorted(constructor.figures, key=lambda x: x.line_style.priority) + ways: list[StyledFigure] = sorted( + constructor.figures, key=lambda x: x.line_style.priority + ) ways_length: int = len(ways) for index, way in enumerate(ways): progress_bar(index, ways_length, step=10, text="Drawing ways") path_commands: str = way.get_path(self.flinger) if path_commands: - path = SVGPath(d=path_commands) + path: SVGPath = SVGPath(d=path_commands) path.update(way.line_style.style) self.svg.add(path) progress_bar(-1, 0, text="Drawing ways") @@ -86,6 +88,7 @@ class Map: # All other points + occupied: Optional[Occupied] if self.configuration.overlap == 0: occupied = None else: @@ -95,7 +98,9 @@ class Map: self.configuration.overlap, ) - nodes = sorted(constructor.points, key=lambda x: -x.priority) + nodes: list[Point] = sorted( + constructor.points, key=lambda x: -x.priority + ) steps: int = len(nodes) for index, node in enumerate(nodes): @@ -158,13 +163,14 @@ class Map: ) -> None: """Draw road as simple SVG path.""" self.flinger.get_scale() + width: float if road.width is not None: width = road.width else: width = road.matcher.default_width - scale = self.flinger.get_scale(road.outers[0][0].coordinates) + scale: float = self.flinger.get_scale(road.outers[0][0].coordinates) path_commands: str = road.get_path(self.flinger) - path = SVGPath(d=path_commands) + path: SVGPath = SVGPath(d=path_commands) style: dict[str, Any] = { "fill": "none", "stroke": color.hex, @@ -183,8 +189,8 @@ class Map: for index in range(len(road.outers[0]) - 1): node_1: OSMNode = road.outers[0][index] node_2: OSMNode = road.outers[0][index + 1] - point_1: np.array = self.flinger.fling(node_1.coordinates) - point_2: np.array = self.flinger.fling(node_2.coordinates) + point_1: np.ndarray = self.flinger.fling(node_1.coordinates) + point_2: np.ndarray = self.flinger.fling(node_2.coordinates) scale: float = self.flinger.get_scale(node_1.coordinates) part_1: RoadPart = RoadPart(point_1, point_2, road.lanes, scale) part_2: RoadPart = RoadPart(point_2, point_1, road.lanes, scale) @@ -198,7 +204,7 @@ class Map: nodes[node_2].add(part_2) for node in nodes: - parts = nodes[node] + parts: set[RoadPart] = nodes[node] if len(parts) < 4: continue intersection: Intersection = Intersection(list(parts)) @@ -239,8 +245,8 @@ def ui(options: argparse.Namespace) -> None: input_file_names = [cache_file_path] scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) - min_: np.array - max_: np.array + min_: np.ndarray + max_: np.ndarray osm_data: OSMData view_box: BoundaryBox @@ -268,7 +274,7 @@ def ui(options: argparse.Namespace) -> None: view_box = osm_data.view_box flinger: Flinger = Flinger(view_box, options.scale) - size: np.array = flinger.size + size: np.ndarray = flinger.size svg: svgwrite.Drawing = svgwrite.Drawing( options.output_file_name, size=size @@ -281,7 +287,7 @@ def ui(options: argparse.Namespace) -> None: osm_data=osm_data, flinger=flinger, scheme=scheme, - icon_extractor=icon_extractor, + extractor=icon_extractor, configuration=configuration, ) constructor.construct() diff --git a/roentgen/moire_manager.py b/roentgen/moire_manager.py index fd38a5c..b74e344 100644 --- a/roentgen/moire_manager.py +++ b/roentgen/moire_manager.py @@ -68,7 +68,7 @@ class ArgumentParser(argparse.ArgumentParser): Return Moire table with "Option" and "Description" columns filled with arguments. """ - table = [[["Option"], ["Description"]]] + table: Code = [[["Option"], ["Description"]]] for option in self.arguments: if option["arguments"][0] == "-h": diff --git a/roentgen/osm_reader.py b/roentgen/osm_reader.py index fbd034b..02c61e5 100644 --- a/roentgen/osm_reader.py +++ b/roentgen/osm_reader.py @@ -8,6 +8,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Optional from xml.etree import ElementTree +from xml.etree.ElementTree import Element import numpy as np @@ -51,8 +52,7 @@ class Tagged: """ def __init__(self, tags: dict[str, str] = None) -> None: - self.tags: dict[str, str] - self.tags = {} if tags is None else tags + self.tags: dict[str, str] = {} if tags is None else tags def get_tag(self, key: str) -> Optional[str]: """ @@ -87,7 +87,7 @@ class Tagged: (KILOMETERS_PATTERN, 1000.0), (MILES_PATTERN, 1609.344), ]: - matcher = pattern.match(value) + matcher: re.Match = pattern.match(value) if matcher: float_value: float = parse_float(matcher.group("value")) if float_value is not None: @@ -116,7 +116,9 @@ class OSMNode(Tagged): self.uid: Optional[str] = None @classmethod - def from_xml_structure(cls, element, is_full: bool = False) -> "OSMNode": + def from_xml_structure( + cls, element: Element, is_full: bool = False + ) -> "OSMNode": """Parse node from OSM XML `` element.""" node: "OSMNode" = cls() attributes = element.attrib @@ -174,7 +176,9 @@ class OSMWay(Tagged): self.uid: Optional[str] = None @classmethod - def from_xml_structure(cls, element, nodes, is_full: bool) -> "OSMWay": + def from_xml_structure( + cls, element: Element, nodes: dict[int, OSMNode], is_full: bool + ) -> "OSMWay": """Parse way from OSM XML `` element.""" way = cls(int(element.attrib["id"])) if is_full: @@ -193,7 +197,7 @@ class OSMWay(Tagged): return way def parse_from_structure( - self, structure: dict[str, Any], nodes + self, structure: dict[str, Any], nodes: dict[int, OSMNode] ) -> "OSMWay": """ Parse way from Overpass-like structure. @@ -245,7 +249,9 @@ class OSMRelation(Tagged): self.timestamp: Optional[datetime] = None @classmethod - def from_xml_structure(cls, element, is_full: bool) -> "OSMRelation": + def from_xml_structure( + cls, element: Element, is_full: bool + ) -> "OSMRelation": """Parse relation from OSM XML `` element.""" attributes = element.attrib relation = cls(int(attributes["id"])) @@ -412,11 +418,11 @@ class OSMReader: """ return self.parse_osm(ElementTree.fromstring(text)) - def parse_osm(self, root) -> OSMData: + def parse_osm(self, root: Element) -> OSMData: """ Parse OSM XML data. - :param root: root of XML data + :param root: top element of XML data :return: parsed map """ for element in root: @@ -437,7 +443,7 @@ class OSMReader: ) return self.osm_data - def parse_bounds(self, element) -> None: + def parse_bounds(self, element: Element) -> None: """Parse view box from XML element.""" attributes = element.attrib self.osm_data.view_box = BoundaryBox( diff --git a/roentgen/point.py b/roentgen/point.py index b88b554..852dd81 100644 --- a/roentgen/point.py +++ b/roentgen/point.py @@ -24,19 +24,19 @@ class Occupied: texts, shapes). """ - def __init__(self, width: int, height: int, overlap: float) -> None: + def __init__(self, width: int, height: int, overlap: int) -> None: self.matrix = np.full((int(width), int(height)), False, dtype=bool) self.width: float = width self.height: float = height - self.overlap: float = overlap + self.overlap: int = overlap - def check(self, point: np.array) -> bool: + def check(self, point: np.ndarray) -> bool: """Check whether point is already occupied by other elements.""" if 0 <= point[0] < self.width and 0 <= point[1] < self.height: return self.matrix[point[0], point[1]] return True - def register(self, point) -> None: + def register(self, point: np.ndarray) -> None: """Register that point is occupied by an element.""" if 0 <= point[0] < self.width and 0 <= point[1] < self.height: self.matrix[point[0], point[1]] = True @@ -56,8 +56,7 @@ class Point(Tagged): labels: list[Label], tags: dict[str, str], processed: set[str], - point: np.array, - coordinates: np.array, + point: np.ndarray, priority: float = 0, is_for_node: bool = True, draw_outline: bool = True, @@ -70,8 +69,7 @@ class Point(Tagged): self.labels: list[Label] = labels self.tags: dict[str, str] = tags self.processed: set[str] = processed - self.point: np.array = point - self.coordinates: np.array = coordinates + self.point: np.ndarray = point self.priority: float = priority self.layer: float = 0 self.is_for_node: bool = is_for_node @@ -110,9 +108,10 @@ class Point(Tagged): if occupied: left: float = -(len(self.icon_set.extra_icons) - 1) * 8 for _ in self.icon_set.extra_icons: - if occupied.check( + point: np.ndarray = np.array( (int(self.point[0] + left), int(self.point[1] + self.y)) - ): + ) + if occupied.check(point): is_place_for_extra = False break left += 16 @@ -120,7 +119,7 @@ class Point(Tagged): if is_place_for_extra: left: float = -(len(self.icon_set.extra_icons) - 1) * 8 for icon in self.icon_set.extra_icons: - point: np.array = self.point + np.array((left, self.y)) + point: np.ndarray = self.point + np.array((left, self.y)) self.draw_point_shape(svg, icon, point, occupied=occupied) left += 16 if self.icon_set.extra_icons: @@ -130,13 +129,13 @@ class Point(Tagged): self, svg: svgwrite.Drawing, icon: Icon, - position, - occupied, + position: np.ndarray, + occupied: Occupied, tags: Optional[dict[str, str]] = None, ) -> bool: """Draw one combined icon and its outline.""" # Down-cast floats to integers to make icons pixel-perfect. - position = list(map(int, position)) + position: np.ndarray = np.array((int(position[0]), int(position[1]))) if occupied and occupied.check(position): return False @@ -188,11 +187,11 @@ class Point(Tagged): self, svg: svgwrite.Drawing, text: str, - point, + point: np.ndarray, occupied: Optional[Occupied], fill: Color, size: float = 10.0, - out_fill=Color("white"), + out_fill: Color = Color("white"), out_opacity: float = 0.5, out_fill_2: Optional[Color] = None, out_opacity_2: float = 1.0, @@ -207,12 +206,15 @@ class Point(Tagged): #------# ###### """ - length = len(text) * 6 + length: int = len(text) * 6 # FIXME if occupied: is_occupied: bool = False for i in range(-int(length / 2), int(length / 2)): - if occupied.check((int(point[0] + i), int(point[1] - 4))): + text_position: np.ndarray = np.array( + (int(point[0] + i), int(point[1] - 4)) + ) + if occupied.check(text_position): is_occupied = True break @@ -249,7 +251,7 @@ class Point(Tagged): self.y += 11 - def get_size(self) -> np.array: + def get_size(self) -> np.ndarray: """ Get width and height of the point visual representation if there is space for all elements. diff --git a/roentgen/road.py b/roentgen/road.py index 6bcef58..144b3dc 100644 --- a/roentgen/road.py +++ b/roentgen/road.py @@ -6,9 +6,8 @@ from typing import Optional import numpy as np import svgwrite +from svgwrite.path import Path -from roentgen.flinger import Flinger -from roentgen.osm_reader import OSMNode from roentgen.vector import Line, compute_angle, norm, turn_by_angle __author__ = "Sergey Vartanov" @@ -33,7 +32,7 @@ class Lane: """If true, lane is forward, otherwise it's backward.""" self.is_forward = is_forward - def get_width(self, scale: float): + def get_width(self, scale: float) -> float: """Get lane width. We use standard 3.7 m lane.""" if self.width is None: return 3.7 * scale @@ -47,18 +46,18 @@ class RoadPart: def __init__( self, - point_1: np.array, - point_2: np.array, + point_1: np.ndarray, + point_2: np.ndarray, lanes: list[Lane], - scale: False, + scale: float, ) -> None: """ :param point_1: start point of the road part :param point_2: end point of the road part :param lanes: lane specification """ - self.point_1: np.array = point_1 - self.point_2: np.array = point_2 + self.point_1: np.ndarray = point_1 + self.point_2: np.ndarray = point_2 self.lanes: list[Lane] = lanes if lanes: self.width = sum(map(lambda x: x.get_width(scale), lanes)) @@ -67,35 +66,21 @@ class RoadPart: self.left_offset: float = self.width / 2 self.right_offset: float = self.width / 2 - self.turned: np.array = norm( + self.turned: np.ndarray = norm( turn_by_angle(self.point_2 - self.point_1, np.pi / 2) ) - self.right_vector: np.array = self.turned * self.right_offset - self.left_vector: np.array = -self.turned * self.left_offset + self.right_vector: np.ndarray = self.turned * self.right_offset + self.left_vector: np.ndarray = -self.turned * self.left_offset - self.right_connection: np.array = None - self.left_connection: np.array = None - self.right_projection: np.array = None - self.left_projection: np.array = None + self.right_connection: Optional[np.ndarray] = None + self.left_connection: Optional[np.ndarray] = None + self.right_projection: Optional[np.ndarray] = None + self.left_projection: Optional[np.ndarray] = None - self.left_outer = None - self.right_outer = None - self.point_a = None - self.point_middle = None - - @classmethod - def from_nodes( - cls, node_1: OSMNode, node_2: OSMNode, flinger: Flinger, road, scale - ) -> "RoadPart": - """Construct road part from OSM nodes.""" - lanes = [Lane(road.width / road.lanes)] * road.lanes - - return cls( - flinger.fling(node_1.coordinates), - flinger.fling(node_2.coordinates), - lanes, - scale, - ) + self.left_outer: Optional[np.ndarray] = None + self.right_outer: Optional[np.ndarray] = None + self.point_a: Optional[np.ndarray] = None + self.point_middle: Optional[np.ndarray] = None def update(self) -> None: """Compute additional points.""" @@ -136,9 +121,9 @@ class RoadPart: """Get an angle between line and x axis.""" return compute_angle(self.point_2 - self.point_1) - def draw_normal(self, drawing: svgwrite.Drawing): + def draw_normal(self, drawing: svgwrite.Drawing) -> None: """Draw some debug lines.""" - line = drawing.path( + line: Path = drawing.path( ("M", self.point_1, "L", self.point_2), fill="none", stroke="#8888FF", @@ -146,15 +131,15 @@ class RoadPart: ) drawing.add(line) - def draw_debug(self, drawing: svgwrite.Drawing): + def draw_debug(self, drawing: svgwrite.Drawing) -> None: """Draw some debug lines.""" - line = drawing.path( + line: Path = drawing.path( ("M", self.point_1, "L", self.point_2), fill="none", stroke="#000000", ) drawing.add(line) - line = drawing.path( + line: Path = drawing.path( ( "M", self.point_1 + self.right_vector, "L", self.point_2 + self.right_vector, @@ -227,7 +212,7 @@ class RoadPart: # self.draw_entrance(drawing, True) - def draw(self, drawing: svgwrite.Drawing): + def draw(self, drawing: svgwrite.Drawing) -> None: """Draw road part.""" if self.left_connection is not None: path_commands = [ @@ -239,7 +224,9 @@ class RoadPart: ] # fmt: skip drawing.add(drawing.path(path_commands, fill="#CCCCCC")) - def draw_entrance(self, drawing: svgwrite.Drawing, is_debug: bool = False): + def draw_entrance( + self, drawing: svgwrite.Drawing, is_debug: bool = False + ) -> None: """Draw intersection entrance part.""" if ( self.left_connection is not None @@ -263,7 +250,7 @@ class RoadPart: else: drawing.add(drawing.path(path_commands, fill="#88FF88")) - def draw_lanes(self, drawing: svgwrite.Drawing, scale: float): + def draw_lanes(self, drawing: svgwrite.Drawing, scale: float) -> None: """Draw lane delimiters.""" for lane in self.lanes: shift = self.right_vector - self.turned * lane.get_width(scale) @@ -298,7 +285,7 @@ class Intersection: part_2.point_1 + part_2.left_vector, part_2.point_2 + part_2.left_vector, ) - intersection: np.array = line_1.get_intersection_point(line_2) + intersection: np.ndarray = line_1.get_intersection_point(line_2) # if np.linalg.norm(intersection - part_1.point_2) < 300: part_1.right_connection = intersection part_2.left_connection = intersection diff --git a/roentgen/scheme.py b/roentgen/scheme.py index b9e73e7..83ebad7 100644 --- a/roentgen/scheme.py +++ b/roentgen/scheme.py @@ -1,11 +1,13 @@ """ Röntgen drawing scheme. """ +import logging from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any, Optional, Union +import numpy as np import yaml from colour import Color @@ -15,6 +17,7 @@ from roentgen.icon import ( DEFAULT_SHAPE_ID, Icon, IconSet, + Shape, ShapeExtractor, ShapeSpecification, ) @@ -23,6 +26,8 @@ from roentgen.text import Label, get_address, get_text __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" +IconDescription = list[Union[str, dict[str, str]]] + @dataclass class LineStyle: @@ -170,27 +175,27 @@ class NodeMatcher(Matcher): if "draw" in structure: self.draw = structure["draw"] - self.shapes = None + self.shapes: Optional[IconDescription] = None if "shapes" in structure: self.shapes = structure["shapes"] - self.over_icon = None + self.over_icon: Optional[IconDescription] = None if "over_icon" in structure: self.over_icon = structure["over_icon"] - self.add_shapes = None + self.add_shapes: Optional[IconDescription] = None if "add_shapes" in structure: self.add_shapes = structure["add_shapes"] - self.set_main_color = None + self.set_main_color: Optional[str] = None if "set_main_color" in structure: self.set_main_color = structure["set_main_color"] - self.under_icon = None + self.under_icon: Optional[IconDescription] = None if "under_icon" in structure: self.under_icon = structure["under_icon"] - self.with_icon = None + self.with_icon: Optional[IconDescription] = None if "with_icon" in structure: self.with_icon = structure["with_icon"] @@ -369,23 +374,21 @@ class Scheme: processed |= matcher_tags if matcher.shapes: specifications = [ - ShapeSpecification.from_structure(x, extractor, self) + self.get_shape_specification(x, extractor) for x in matcher.shapes ] main_icon = Icon(specifications) processed |= matcher_tags if matcher.over_icon and main_icon: specifications = [ - ShapeSpecification.from_structure(x, extractor, self) + self.get_shape_specification(x, extractor) for x in matcher.over_icon ] main_icon.add_specifications(specifications) processed |= matcher_tags if matcher.add_shapes: specifications = [ - ShapeSpecification.from_structure( - x, extractor, self, Color("#888888") - ) + self.get_shape_specification(x, extractor, Color("#888888")) for x in matcher.add_shapes ] extra_icons += [Icon(specifications)] @@ -436,7 +439,7 @@ class Scheme: return returned, priority - def get_style(self, tags: dict[str, Any]): + def get_style(self, tags: dict[str, Any]) -> list[LineStyle]: """Get line style based on tags and scale.""" line_styles = [] @@ -548,3 +551,50 @@ class Scheme: :param processed: processed set """ [processed.add(tag) for tag in tags if self.is_no_drawable(tag)] + + def get_shape_specification( + self, + structure: Union[str, dict[str, Any]], + extractor: ShapeExtractor, + color: Color = DEFAULT_COLOR, + ) -> ShapeSpecification: + """ + Parse shape specification from structure, that is just shape string + identifier or dictionary with keys: shape (required), color (optional), + and offset (optional). + """ + shape: Shape = extractor.get_shape(DEFAULT_SHAPE_ID) + color: Color = color + offset: np.ndarray = np.array((0, 0)) + flip_horizontally: bool = False + flip_vertically: bool = False + use_outline: bool = True + + if isinstance(structure, str): + shape = extractor.get_shape(structure) + elif isinstance(structure, dict): + if "shape" in structure: + shape = extractor.get_shape(structure["shape"]) + else: + logging.error( + "Invalid shape specification: `shape` key expected." + ) + if "color" in structure: + color = self.get_color(structure["color"]) + if "offset" in structure: + offset = np.array(structure["offset"]) + if "flip_horizontally" in structure: + flip_horizontally = structure["flip_horizontally"] + if "flip_vertically" in structure: + flip_vertically = structure["flip_vertically"] + if "outline" in structure: + use_outline = structure["outline"] + + return ShapeSpecification( + shape, + color, + offset, + flip_horizontally, + flip_vertically, + use_outline, + ) diff --git a/roentgen/server.py b/roentgen/server.py index 76aca53..4d344c0 100644 --- a/roentgen/server.py +++ b/roentgen/server.py @@ -26,7 +26,10 @@ class _Handler(SimpleHTTPRequestHandler): options = None def __init__( - self, request: bytes, client_address: tuple[str, int], server + self, + request: bytes, + client_address: tuple[str, int], + server: HTTPServer, ) -> None: super().__init__(request, client_address, server) diff --git a/roentgen/tile.py b/roentgen/tile.py index 5780971..d9f8b22 100644 --- a/roentgen/tile.py +++ b/roentgen/tile.py @@ -44,7 +44,7 @@ class Tile: scale: int @classmethod - def from_coordinates(cls, coordinates: np.array, scale: int): + def from_coordinates(cls, coordinates: np.ndarray, scale: int) -> "Tile": """ Code from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames @@ -57,7 +57,7 @@ class Tile: y: int = int((1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * n) return cls(x, y, scale) - def get_coordinates(self) -> np.array: + def get_coordinates(self) -> np.ndarray: """ Return geo coordinates of the north-west corner of the tile. @@ -69,7 +69,7 @@ class Tile: lat_deg: np.ndarray = np.degrees(lat_rad) return np.array((lat_deg, lon_deg)) - def get_boundary_box(self) -> tuple[np.array, np.array]: + def get_boundary_box(self) -> tuple[np.ndarray, np.ndarray]: """ Get geographical boundary box of the tile: north-west and south-east points. @@ -81,8 +81,8 @@ class Tile: def get_extended_boundary_box(self) -> BoundaryBox: """Same as get_boundary_box, but with extended boundaries.""" - point_1: np.array = self.get_coordinates() - point_2: np.array = Tile( + point_1: np.ndarray = self.get_coordinates() + point_2: np.ndarray = Tile( self.x + 1, self.y + 1, self.scale ).get_coordinates() @@ -152,7 +152,7 @@ class Tile: flinger: Flinger = Flinger( BoundaryBox(left, bottom, right, top), self.scale ) - size: np.array = flinger.size + size: np.ndarray = flinger.size output_file_name: Path = self.get_file_name(directory_name) @@ -196,7 +196,9 @@ class Tiles: boundary_box: BoundaryBox @classmethod - def from_boundary_box(cls, boundary_box: BoundaryBox, scale: int): + def from_boundary_box( + cls, boundary_box: BoundaryBox, scale: int + ) -> "Tiles": """ Create minimal set of tiles that cover boundary box. diff --git a/roentgen/ui.py b/roentgen/ui.py index c9a44fe..58090c3 100644 --- a/roentgen/ui.py +++ b/roentgen/ui.py @@ -14,7 +14,7 @@ BOXES: str = " ▏▎▍▌▋▊▉" BOXES_LENGTH: int = len(BOXES) -def parse_options(args) -> argparse.Namespace: +def parse_options(args: argparse.Namespace) -> argparse.Namespace: """Parse Röntgen command-line options.""" parser: argparse.ArgumentParser = argparse.ArgumentParser( description="Röntgen. OpenStreetMap renderer with custom icon set" diff --git a/roentgen/vector.py b/roentgen/vector.py index 08a3db8..add72e4 100644 --- a/roentgen/vector.py +++ b/roentgen/vector.py @@ -7,7 +7,7 @@ __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" -def compute_angle(vector: np.array): +def compute_angle(vector: np.ndarray) -> float: """ For the given vector compute an angle between it and (1, 0) vector. The result is in [0, 2π]. @@ -23,7 +23,7 @@ def compute_angle(vector: np.array): return np.arctan(vector[1] / vector[0]) -def turn_by_angle(vector: np.array, angle: float): +def turn_by_angle(vector: np.ndarray, angle: float) -> np.ndarray: """Turn vector by an angle.""" return np.array( ( @@ -33,7 +33,7 @@ def turn_by_angle(vector: np.array, angle: float): ) -def norm(vector: np.array) -> np.array: +def norm(vector: np.ndarray) -> np.ndarray: """Compute vector with the same direction and length 1.""" return vector / np.linalg.norm(vector) @@ -41,26 +41,26 @@ def norm(vector: np.array) -> np.array: class Line: """Infinity line: Ax + By + C = 0.""" - def __init__(self, start: np.array, end: np.array) -> None: + def __init__(self, start: np.ndarray, end: np.ndarray) -> None: # if start.near(end): # util.error("cannot create line by one point") self.a: float = start[1] - end[1] self.b: float = end[0] - start[0] self.c: float = start[0] * end[1] - end[0] * start[1] - def parallel_shift(self, shift: np.array): + def parallel_shift(self, shift: np.ndarray) -> None: """ Shift current vector according with shift. :param shift: shift vector """ - self.c -= self.a * shift.x + self.b * shift.y + self.c -= self.a * shift[0] + self.b * shift[1] def is_parallel(self, other: "Line") -> bool: """If lines are parallel or equal.""" return np.allclose(other.a * self.b - self.a * other.b, 0) - def get_intersection_point(self, other: "Line") -> np.array: + def get_intersection_point(self, other: "Line") -> np.ndarray: """Get point of intersection current line with other.""" if other.a * self.b - self.a * other.b == 0: return np.array((0, 0)) diff --git a/test/test_icons.py b/test/test_icons.py index a37f404..99fed53 100644 --- a/test/test_icons.py +++ b/test/test_icons.py @@ -17,17 +17,17 @@ def init_collection() -> IconCollection: return IconCollection.from_scheme(SCHEME, SHAPE_EXTRACTOR) -def test_grid(init_collection) -> None: +def test_grid(init_collection: IconCollection) -> None: """Test grid drawing.""" init_collection.draw_grid(workspace.output_path / "grid.svg") -def test_icons_by_id(init_collection) -> None: +def test_icons_by_id(init_collection: IconCollection) -> None: """Test individual icons drawing.""" init_collection.draw_icons(workspace.get_icons_by_id_path(), by_name=False) -def test_icons_by_name(init_collection) -> None: +def test_icons_by_name(init_collection: IconCollection) -> None: """Test drawing individual icons that have names.""" init_collection.draw_icons(workspace.get_icons_by_name_path(), by_name=True) diff --git a/test/test_label.py b/test/test_label.py index 6219e27..57292de 100644 --- a/test/test_label.py +++ b/test/test_label.py @@ -8,7 +8,7 @@ __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" -def construct_labels(tags) -> list[Label]: +def construct_labels(tags: dict[str, str]) -> list[Label]: """Construct labels from OSM node tags.""" processed: set[str] = set() return SCHEME.construct_text(tags, "all", processed)