diff --git a/roentgen.py b/roentgen.py index e667d30..5287d6c 100644 --- a/roentgen.py +++ b/roentgen.py @@ -18,31 +18,32 @@ from roentgen.grid import draw_icons from roentgen.icon import ShapeExtractor from roentgen.mapper import ( AUTHOR_MODE, - CREATION_TIME_MODE, - Painter, + TIME_MODE, + Map, check_level_number, check_level_overground, ) from roentgen.osm_getter import NetworkError, get_osm -from roentgen.osm_reader import Map, OSMReader, OverpassReader +from roentgen.osm_reader import OSMData, OSMReader, OverpassReader from roentgen.point import Point from roentgen.scheme import LineStyle, Scheme from roentgen.ui import BoundaryBox, parse_options from roentgen.util import MinMax -from roentgen.workspace import workspace +from roentgen.workspace import Workspace def main(options) -> None: """ Röntgen entry point. - :param argv: command-line arguments + :param options: command-line arguments """ + if not options.boundary_box and not options.input_file_name: + logging.fatal("Specify either --boundary-box, or --input.") + exit(1) + if options.boundary_box: - box: List[float] = list( - map(float, options.boundary_box.replace(" ", "").split(",")) - ) - boundary_box = BoundaryBox(box[0], box[1], box[2], box[3]) + boundary_box: BoundaryBox = BoundaryBox.from_text(options.boundary_box) cache_path: Path = Path(options.cache) cache_path.mkdir(parents=True, exist_ok=True) @@ -53,28 +54,35 @@ def main(options) -> None: input_file_names = list(map(Path, options.input_file_name)) else: try: - get_osm(boundary_box, cache_path) + cache_file_path: Path = ( + cache_path / f"{boundary_box.get_format()}.osm" + ) + get_osm(boundary_box, cache_file_path) except NetworkError as e: logging.fatal(e.message) sys.exit(1) - input_file_names = [cache_path / f"{options.boundary_box}.osm"] + input_file_names = [cache_file_path] scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) min_: np.array max_: np.array - map_: Map + osm_data: OSMData if input_file_names[0].name.endswith(".json"): reader: OverpassReader = OverpassReader() reader.parse_json_file(input_file_names[0]) - map_ = reader.map_ + osm_data = reader.osm_data view_box = MinMax( - np.array((map_.boundary_box[0].min_, map_.boundary_box[1].min_)), - np.array((map_.boundary_box[0].max_, map_.boundary_box[1].max_)), + np.array( + (osm_data.boundary_box[0].min_, osm_data.boundary_box[1].min_) + ), + np.array( + (osm_data.boundary_box[0].max_, osm_data.boundary_box[1].max_) + ), ) else: - is_full: bool = options.mode in [AUTHOR_MODE, CREATION_TIME_MODE] + is_full: bool = options.mode in [AUTHOR_MODE, TIME_MODE] osm_reader = OSMReader(is_full=is_full) for file_name in input_file_names: @@ -84,24 +92,19 @@ def main(options) -> None: osm_reader.parse_osm_file(file_name) - map_ = osm_reader.map_ + osm_data = osm_reader.osm_data if options.boundary_box: - boundary_box: List[float] = list( - map(float, options.boundary_box.split(",")) - ) view_box = MinMax( - np.array((boundary_box[1], boundary_box[0])), - np.array((boundary_box[3], boundary_box[2])), + np.array((boundary_box.bottom, boundary_box.left)), + np.array((boundary_box.top, boundary_box.right)), ) else: - view_box = map_.view_box + view_box = osm_data.view_box flinger: Flinger = Flinger(view_box, options.scale) size: np.array = flinger.size - Path("out").mkdir(parents=True, exist_ok=True) - svg: svgwrite.Drawing = svgwrite.Drawing( options.output_file_name, size=size ) @@ -131,7 +134,7 @@ def main(options) -> None: return True constructor: Constructor = Constructor( - map_, + osm_data, flinger, scheme, icon_extractor, @@ -141,17 +144,14 @@ def main(options) -> None: ) constructor.construct() - painter: Painter = Painter( + painter: Map = Map( overlap=options.overlap, mode=options.mode, label_mode=options.label_mode, - map_=map_, flinger=flinger, svg=svg, - icon_extractor=icon_extractor, scheme=scheme, ) - painter.draw(constructor) print(f"Writing output SVG to {options.output_file_name}...") @@ -164,16 +164,18 @@ def draw_element(options): Draw single node, line, or area. """ if options.node: - target = "node" + target: str = "node" tags_description = options.node else: # Not implemented yet. sys.exit(1) - tags = dict([x.split("=") for x in tags_description.split(",")]) - scheme: Scheme = Scheme(Path("scheme/default.yml")) + tags: dict[str, str] = dict( + [x.split("=") for x in tags_description.split(",")] + ) + scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) extractor: ShapeExtractor = ShapeExtractor( - Path("icons/icons.svg"), Path("icons/config.json") + workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH ) processed: Set[str] = set() icon, priority = scheme.get_icon(extractor, tags, processed) @@ -193,8 +195,8 @@ def draw_element(options): size: np.array = point.get_size() + border point.point = np.array((size[0] / 2, 16 / 2 + border[1] / 2)) - Path("out").mkdir(parents=True, exist_ok=True) - svg = svgwrite.Drawing("out/element.svg", size.astype(float)) + output_file_path: Path = workspace.output_path / "element.svg" + svg = svgwrite.Drawing(str(output_file_path), size.astype(float)) for style in scheme.get_style(tags, 18): style: LineStyle path = svg.path(d="M 0,0 L 64,0 L 64,64 L 0,64 L 0,0 Z") @@ -203,44 +205,47 @@ def draw_element(options): point.draw_main_shapes(svg) point.draw_extra_shapes(svg) point.draw_texts(svg) - svg.write(open("out/element.svg", "w+")) + with output_file_path.open("w+") as output_file: + svg.write(output_file) + logging.info(f"Element is written to {output_file_path}.") def init_scheme() -> Scheme: - return Scheme(Path("scheme/default.yml")) + return Scheme(workspace.DEFAULT_SCHEME_PATH) if __name__ == "__main__": logging.basicConfig(format="%(levelname)s %(message)s", level=logging.INFO) + workspace: Workspace = Workspace(Path("out")) - options: argparse.Namespace = parse_options(sys.argv) + arguments: argparse.Namespace = parse_options(sys.argv) - if options.command == "render": - main(options) + if arguments.command == "render": + main(arguments) - elif options.command == "tile": + elif arguments.command == "tile": from roentgen import tile - tile.ui(options) + tile.ui(arguments) - elif options.command == "icons": + elif arguments.command == "icons": draw_icons() - elif options.command == "mapcss": + elif arguments.command == "mapcss": from roentgen import mapcss - mapcss.ui(options) + mapcss.ui(arguments) - elif options.command == "element": - draw_element(options) + elif arguments.command == "element": + draw_element(arguments) - elif options.command == "server": + elif arguments.command == "server": from roentgen import server - server.ui(options) + server.ui(arguments) - elif options.command == "taginfo": + elif arguments.command == "taginfo": from roentgen.taginfo import write_taginfo_project_file write_taginfo_project_file(init_scheme()) diff --git a/roentgen/color.py b/roentgen/color.py index bd2c705..7f563f9 100644 --- a/roentgen/color.py +++ b/roentgen/color.py @@ -37,6 +37,7 @@ def get_gradient_color( range_coefficient: float = ( 0 if bounds.is_empty() else (value - bounds.min_) / bounds.delta() ) + # If value is out of range, set it to boundary value. range_coefficient = min(1.0, max(0.0, range_coefficient)) index: int = int(range_coefficient * color_length) coefficient: float = ( diff --git a/roentgen/constructor.py b/roentgen/constructor.py index 3fee833..a58979a 100644 --- a/roentgen/constructor.py +++ b/roentgen/constructor.py @@ -4,31 +4,33 @@ Construct Röntgen nodes and ways. import logging from datetime import datetime from hashlib import sha256 -from typing import Any, Dict, Iterator, List, Optional, Set +from typing import Any, Iterator, Optional, Union import numpy as np from colour import Color from roentgen import ui from roentgen.color import get_gradient_color -from roentgen.figure import Building, Road, StyledFigure +from roentgen.figure import Building, Road, StyledFigure, Tree, DirectionSector from roentgen.flinger import Flinger # fmt: off from roentgen.icon import ( DEFAULT_SMALL_SHAPE_ID, Icon, IconSet, ShapeExtractor, ShapeSpecification ) -from roentgen.osm_reader import Map, OSMNode, OSMRelation, OSMWay, Tagged +from roentgen.osm_reader import OSMData, OSMNode, OSMRelation, OSMWay from roentgen.point import Point from roentgen.scheme import DEFAULT_COLOR, LineStyle, Scheme +from roentgen.ui import TIME_MODE, AUTHOR_MODE from roentgen.util import MinMax + # fmt: on __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" DEBUG: bool = False -TIME_COLOR_SCALE: List[Color] = [ +TIME_COLOR_SCALE: list[Color] = [ Color("#581845"), Color("#900C3F"), Color("#C70039"), @@ -38,14 +40,14 @@ TIME_COLOR_SCALE: List[Color] = [ ] -def line_center(nodes: List[OSMNode], flinger: Flinger) -> np.array: +def line_center(nodes: list[OSMNode], flinger: Flinger) -> np.array: """ Get geometric center of nodes set. :param nodes: node list :param flinger: flinger that remap geo positions """ - boundary: List[MinMax] = [MinMax(), MinMax()] + boundary: list[MinMax] = [MinMax(), MinMax()] for node in nodes: boundary[0].update(node.coordinates[0]) @@ -74,14 +76,14 @@ def get_time_color(time: Optional[datetime], boundaries: MinMax) -> Color: return get_gradient_color(time, boundaries, TIME_COLOR_SCALE) -def glue(ways: List[OSMWay]) -> List[List[OSMNode]]: +def glue(ways: list[OSMWay]) -> list[list[OSMNode]]: """ Try to glue ways that share nodes. :param ways: ways to glue """ - result: List[List[OSMNode]] = [] - to_process: Set[OSMWay] = set() + result: list[list[OSMNode]] = [] + to_process: set[OSMWay] = set() for way in ways: if way.is_cycle(): @@ -112,9 +114,7 @@ def glue(ways: List[OSMWay]) -> List[List[OSMNode]]: def is_cycle(nodes) -> bool: - """ - Is way a cycle way or an area boundary. - """ + """Is way a cycle way or an area boundary.""" return nodes[0] == nodes[-1] @@ -125,7 +125,7 @@ class Constructor: def __init__( self, - map_: Map, + osm_data: OSMData, flinger: Flinger, scheme: Scheme, icon_extractor: ShapeExtractor, @@ -136,59 +136,51 @@ class Constructor: self.check_level = check_level self.mode: str = mode self.seed: str = seed - self.map_: Map = map_ + self.osm_data: OSMData = osm_data self.flinger: Flinger = flinger self.scheme: Scheme = scheme self.icon_extractor = icon_extractor - self.points: List[Point] = [] - self.figures: List[StyledFigure] = [] - self.buildings: List[Building] = [] - self.roads: List[Road] = [] + self.points: list[Point] = [] + self.figures: list[StyledFigure] = [] + self.buildings: list[Building] = [] + self.roads: list[Road] = [] + self.trees: list[Tree] = [] + self.direction_sectors: list[DirectionSector] = [] - self.heights: Set[float] = {2, 4} + self.heights: set[float] = {2, 4} def add_building(self, building: Building) -> None: - """ - Add building and update levels. - """ + """Add building and update levels.""" self.buildings.append(building) self.heights.add(building.height) self.heights.add(building.min_height) def construct(self) -> None: - """ - Construct nodes, ways, and relations. - """ + """Construct nodes, ways, and relations.""" self.construct_ways() self.construct_relations() self.construct_nodes() def construct_ways(self) -> None: - """ - Construct Röntgen ways. - """ - way_number: int = 0 - for way_id in self.map_.ways: + """Construct Röntgen ways.""" + for index, way_id in enumerate(self.osm_data.ways): ui.progress_bar( - way_number, - len(self.map_.ways), + index, + len(self.osm_data.ways), step=10, text="Constructing ways", ) - way_number += 1 - way: OSMWay = self.map_.ways[way_id] - if not self.check_level(way.tags): - continue + way: OSMWay = self.osm_data.ways[way_id] self.construct_line(way, [], [way.nodes]) - ui.progress_bar(-1, len(self.map_.ways), text="Constructing ways") + ui.progress_bar(-1, len(self.osm_data.ways), text="Constructing ways") def construct_line( self, - line: Optional[Tagged], - inners: List[List[OSMNode]], - outers: List[List[OSMNode]], + line: Union[OSMWay, OSMRelation], + inners: list[list[OSMNode]], + outers: list[list[OSMNode]], ) -> None: """ Way or relation construction. @@ -199,22 +191,22 @@ class Constructor: """ assert len(outers) >= 1 + if not self.check_level(line.tags): + return + center_point, center_coordinates = line_center(outers[0], self.flinger) - if self.mode in ["user-coloring", "time"]: - if self.mode == "user-coloring": + if self.mode in [AUTHOR_MODE, TIME_MODE]: + color: Color + if self.mode == AUTHOR_MODE: color = get_user_color(line.user, self.seed) - else: # self.mode == "time": - color = get_time_color(line.timestamp, self.map_.time) + else: # self.mode == TIME_MODE + color = get_time_color(line.timestamp, self.osm_data.time) self.draw_special_mode(inners, line, outers, color) return if not line.tags: return - scale: float = self.flinger.get_scale(center_coordinates) - - line_styles: List[LineStyle] = self.scheme.get_style(line.tags, scale) - if "building:part" in line.tags or "building" in line.tags: self.add_building( Building(line.tags, inners, outers, self.flinger, self.scheme) @@ -225,6 +217,9 @@ class Constructor: self.roads.append(Road(line.tags, inners, outers, road_matcher)) return + scale: float = self.flinger.get_scale(center_coordinates) + line_styles: list[LineStyle] = self.scheme.get_style(line.tags, scale) + for line_style in line_styles: self.figures.append( StyledFigure(line.tags, inners, outers, line_style) @@ -235,7 +230,7 @@ class Constructor: and line.get_tag("area") != "no" and self.scheme.is_area(line.tags) ): - processed: Set[str] = set() + processed: set[str] = set() priority: int icon_set: IconSet @@ -257,7 +252,7 @@ class Constructor: if not line_styles: if DEBUG: - style: Dict[str, Any] = { + style: dict[str, Any] = { "fill": "none", "stroke": Color("red").hex, "stroke-width": 1, @@ -267,7 +262,7 @@ class Constructor: ) self.figures.append(figure) - processed: Set[str] = set() + processed: set[str] = set() priority: int icon_set: IconSet @@ -285,7 +280,7 @@ class Constructor: """ Add figure for special mode: time or author. """ - style: Dict[str, Any] = { + style: dict[str, Any] = { "fill": "none", "stroke": color.hex, "stroke-width": 1, @@ -298,84 +293,94 @@ class Constructor: """ Construct Röntgen ways from OSM relations. """ - for relation_id in self.map_.relations: - relation: OSMRelation = self.map_.relations[relation_id] + for relation_id in self.osm_data.relations: + relation: OSMRelation = self.osm_data.relations[relation_id] tags = relation.tags if not self.check_level(tags): continue if "type" not in tags or tags["type"] != "multipolygon": continue - inner_ways: List[OSMWay] = [] - outer_ways: List[OSMWay] = [] + inner_ways: list[OSMWay] = [] + outer_ways: list[OSMWay] = [] for member in relation.members: if member.type_ == "way": if member.role == "inner": - if member.ref in self.map_.ways: - inner_ways.append(self.map_.ways[member.ref]) + if member.ref in self.osm_data.ways: + inner_ways.append(self.osm_data.ways[member.ref]) elif member.role == "outer": - if member.ref in self.map_.ways: - outer_ways.append(self.map_.ways[member.ref]) + if member.ref in self.osm_data.ways: + outer_ways.append(self.osm_data.ways[member.ref]) else: logging.warning(f'Unknown member role "{member.role}".') if outer_ways: - inners_path: List[List[OSMNode]] = glue(inner_ways) - outers_path: List[List[OSMNode]] = glue(outer_ways) + inners_path: list[list[OSMNode]] = glue(inner_ways) + outers_path: list[list[OSMNode]] = glue(outer_ways) self.construct_line(relation, inners_path, outers_path) def construct_nodes(self) -> None: """ Draw nodes. """ - node_number: int = 0 - sorted_node_ids: Iterator[int] = sorted( - self.map_.nodes.keys(), - key=lambda x: -self.map_.nodes[x].coordinates[0], + self.osm_data.nodes.keys(), + key=lambda x: -self.osm_data.nodes[x].coordinates[0], ) - for node_id in sorted_node_ids: - processed: Set[str] = set() - - node_number += 1 + for index, node_id in enumerate(sorted_node_ids): ui.progress_bar( - node_number, len(self.map_.nodes), text="Constructing nodes" + index, len(self.osm_data.nodes), text="Constructing nodes" ) - node: OSMNode = self.map_.nodes[node_id] - flung = self.flinger.fling(node.coordinates) - tags = node.tags + self.construct_node(self.osm_data.nodes[node_id]) + ui.progress_bar(-1, len(self.osm_data.nodes), text="Constructing nodes") - if not self.check_level(tags): - continue + def construct_node(self, node: OSMNode) -> None: + tags = node.tags + if not self.check_level(tags): + return - priority: int - icon_set: IconSet - draw_outline: bool = True + processed: set[str] = set() - if self.mode in ["time", "user-coloring"]: - if not tags: - continue - color = DEFAULT_COLOR - if self.mode == "user-coloring": - color = get_user_color(node.user, self.seed) - if self.mode == "time": - color = get_time_color(node.timestamp, self.map_.time) - dot = self.icon_extractor.get_shape(DEFAULT_SMALL_SHAPE_ID) - icon_set = IconSet( - Icon([ShapeSpecification(dot, color)]), [], set() - ) - priority = 0 - draw_outline = False - labels = [] - else: - icon_set, priority = self.scheme.get_icon( - self.icon_extractor, tags, processed - ) - labels = self.scheme.construct_text(tags, "all", processed) - self.scheme.process_ignored(tags, processed) + flung = self.flinger.fling(node.coordinates) + + priority: int + icon_set: IconSet + draw_outline: bool = True + + if self.mode in [TIME_MODE, AUTHOR_MODE]: + if not tags: + return + color: Color = DEFAULT_COLOR + if self.mode == AUTHOR_MODE: + color = get_user_color(node.user, self.seed) + if self.mode == TIME_MODE: + color = get_time_color(node.timestamp, self.osm_data.time) + dot = self.icon_extractor.get_shape(DEFAULT_SMALL_SHAPE_ID) + icon_set = IconSet( + Icon([ShapeSpecification(dot, color)]), [], set() + ) point: Point = Point( - icon_set, labels, tags, processed, flung, node.coordinates, - priority=priority, draw_outline=draw_outline + icon_set, [], tags, processed, flung, node.coordinates, + priority=0, draw_outline=False ) # fmt: skip self.points.append(point) + return - ui.progress_bar(-1, len(self.map_.nodes), text="Constructing nodes") + icon_set, priority = self.scheme.get_icon( + self.icon_extractor, tags, processed + ) + labels = self.scheme.construct_text(tags, "all", processed) + self.scheme.process_ignored(tags, processed) + + if node.get_tag("natural") == "tree" and ( + "diameter_crown" in node.tags or "circumference" in node.tags + ): + self.trees.append(Tree(tags, node.coordinates, flung)) + return + + 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, + priority=priority, draw_outline=draw_outline + ) # fmt: skip + self.points.append(point) diff --git a/roentgen/direction.py b/roentgen/direction.py index cefc260..0be51b5 100644 --- a/roentgen/direction.py +++ b/roentgen/direction.py @@ -1,7 +1,7 @@ """ Direction tag support. """ -from typing import Iterator, List, Optional, Union +from typing import Iterator, Optional, Union import numpy as np from portolan import middle @@ -72,7 +72,7 @@ class Sector: self.main_direction: Optional[np.array] = None if "-" in text: - parts: List[str] = text.split("-") + parts: list[str] = text.split("-") self.start = parse_vector(parts[0]) self.end = parse_vector(parts[1]) self.main_direction = (self.start + self.end) / 2 @@ -90,7 +90,7 @@ class Sector: 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[List[SVGPath]]: + def draw(self, center: np.array, radius: float) -> Optional[list[SVGPath]]: """ Construct SVG path commands for arc element. @@ -139,7 +139,7 @@ class DirectionSet: def __str__(self) -> str: return ", ".join(map(str, self.sectors)) - def draw(self, center: np.array, radius: float) -> Iterator[List[SVGPath]]: + def draw(self, center: np.array, radius: float) -> Iterator[list[SVGPath]]: """ Construct SVG "d" for arc elements. @@ -159,7 +159,7 @@ class DirectionSet: :return: true if direction is right, false if direction is left, and None otherwise. """ - result: List[bool] = [x.is_right() for x in self.sectors] + result: list[bool] = [x.is_right() for x in self.sectors] if result == [True] * len(result): return True if result == [False] * len(result): diff --git a/roentgen/drawing.py b/roentgen/drawing.py index 39104b5..e71f282 100644 --- a/roentgen/drawing.py +++ b/roentgen/drawing.py @@ -9,10 +9,13 @@ import cairo import numpy as np import svgwrite from colour import Color -from svgwrite.shapes import Rect from svgwrite.path import Path as SVGPath +from svgwrite.shapes import Rect from svgwrite.text import Text +__author__ = "Sergey Vartanov" +__email__ = "me@enzet.ru" + @dataclass class Style: diff --git a/roentgen/figure.py b/roentgen/figure.py index 5aaa70e..022b745 100644 --- a/roentgen/figure.py +++ b/roentgen/figure.py @@ -4,7 +4,11 @@ Figures displayed on the map. from typing import Any, Dict, List, Optional import numpy as np +from colour import Color +from svgwrite import Drawing +from svgwrite.path import Path +from roentgen.direction import Sector, DirectionSet from roentgen.flinger import Flinger from roentgen.osm_reader import OSMNode, Tagged from roentgen.road import Lane @@ -97,14 +101,85 @@ class Building(Figure): if levels: self.min_height = float(levels) * 2.5 - height: Optional[str] = self.get_length("height") + height: Optional[float] = self.get_length("height") if height: self.height = height - height: Optional[str] = self.get_length("min_height") + height: Optional[float] = self.get_length("min_height") if height: self.min_height = height + def draw_shade(self, building_shade, 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)) + commands: str = self.get_path(flinger, shift_1) + 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 = ( + "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( + 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 = [0, -previous_height * scale] + shift_2 = [0, -height * scale] + for segment in self.parts: + 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 = 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): + """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): """ @@ -161,14 +236,110 @@ class Road(Figure): pass +class Tree(Tagged): + """ + Tree on the map. + """ + + def __init__( + self, tags: dict[str, str], coordinates: np.array, point: np.array + ): + super().__init__(tags) + self.coordinates: np.array = coordinates + self.point: np.array = point + + def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme): + """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): + super().__init__(tags) + self.point = point + + def draw(self, svg: Drawing, scheme: Scheme): + """Draw gradient sector.""" + angle = None + is_revert_gradient: bool = False + + 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: float = 25 + direction_color: 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") + else: + direction = self.get_tag("direction") + direction_radius: float = 50 + direction_color: Color = scheme.get_color("direction_view_color") + is_revert_gradient = True + + if not direction: + return + + point = (self.point.astype(int)).astype(float) + + if angle: + 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.array, point_2: np.array): - self.point_1 = point_1 - self.point_2 = point_2 + self.point_1: np.array = point_1 + self.point_2: np.array = point_2 difference: np.array = point_2 - point_1 vector: np.array = difference / np.linalg.norm(difference) @@ -215,9 +386,7 @@ def make_counter_clockwise(polygon: List[OSMNode]) -> List[OSMNode]: def get_path(nodes: List[OSMNode], shift: np.array, flinger: Flinger) -> str: - """ - Construct SVG path commands from nodes. - """ + """Construct SVG path commands from nodes.""" path: str = "" prev_node: Optional[OSMNode] = None for node in nodes: diff --git a/roentgen/flinger.py b/roentgen/flinger.py index 396a935..e6f709f 100644 --- a/roentgen/flinger.py +++ b/roentgen/flinger.py @@ -27,10 +27,13 @@ def pseudo_mercator(coordinates: np.array) -> np.array: return np.array((coordinates[1], y)) -def osm_zoom_level_to_pixels_per_meter(zoom_level: float): +def osm_zoom_level_to_pixels_per_meter(zoom_level: float) -> float: """ - Convert OSM zoom level (see https://wiki.openstreetmap.org/wiki/Zoom_levels) - to pixels per meter on Equator. + Convert OSM zoom level to pixels per meter on Equator. See + https://wiki.openstreetmap.org/wiki/Zoom_levels + + :param zoom_level: integer number usually not bigger than 20, but this + function allows any non-negative float value """ return 2 ** zoom_level / 156415 diff --git a/roentgen/grid.py b/roentgen/grid.py index 2f47e71..4ae6479 100644 --- a/roentgen/grid.py +++ b/roentgen/grid.py @@ -4,7 +4,7 @@ Icon grid drawing. import logging from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Set +from typing import Optional import numpy as np from colour import Color @@ -24,7 +24,7 @@ class IconCollection: Collection of icons. """ - icons: List[Icon] + icons: list[Icon] @classmethod def from_scheme( @@ -45,7 +45,7 @@ class IconCollection: :param add_unused: create icons from shapes that have no corresponding tags """ - icons: List[Icon] = [] + icons: list[Icon] = [] def add() -> Icon: """ @@ -80,7 +80,7 @@ class IconCollection: continue for icon_id in matcher.under_icon: for icon_2_id in matcher.with_icon: - current_set: List[str] = ( + current_set: list[str] = ( [icon_id] + [icon_2_id] + matcher.over_icon ) add() @@ -99,7 +99,7 @@ class IconCollection: ): add() - specified_ids: Set[str] = set() + specified_ids: set[str] = set() for icon in icons: specified_ids |= set(icon.get_shape_ids()) @@ -199,17 +199,18 @@ def draw_icons() -> None: Draw all possible icon shapes combinations as grid in one SVG file and as individual SVG files. """ - icons_by_id_path: Path = workspace.get_icons_by_id_path() - icons_by_name_path: Path = workspace.get_icons_by_name_path() - scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) extractor: ShapeExtractor = ShapeExtractor( workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH ) collection: IconCollection = IconCollection.from_scheme(scheme, extractor) + icon_grid_path: Path = workspace.get_icon_grid_path() collection.draw_grid(icon_grid_path) logging.info(f"Icon grid is written to {icon_grid_path}.") + + icons_by_id_path: Path = workspace.get_icons_by_id_path() + icons_by_name_path: Path = workspace.get_icons_by_name_path() collection.draw_icons(icons_by_id_path) collection.draw_icons(icons_by_name_path, by_name=True) logging.info( diff --git a/roentgen/icon.py b/roentgen/icon.py index 34a9b5d..6137927 100644 --- a/roentgen/icon.py +++ b/roentgen/icon.py @@ -6,7 +6,7 @@ import logging import re from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any, Optional from xml.dom.minidom import Document, Element, parse import numpy as np @@ -44,13 +44,13 @@ class Shape: id_: str # shape identifier name: Optional[str] = None # icon description is_right_directed: Optional[bool] = None - emojis: Set[str] = field(default_factory=set) + emojis: set[str] = field(default_factory=set) is_part: bool = False @classmethod def from_structure( cls, - structure: Dict[str, Any], + structure: dict[str, Any], path: str, offset: np.array, id_: str, @@ -102,7 +102,7 @@ class Shape: :param offset: additional offset :param scale: scale resulting image """ - transformations: List[str] = [] + transformations: list[str] = [] shift: np.array = point + offset transformations.append(f"translate({shift[0]},{shift[1]})") @@ -137,7 +137,7 @@ def verify_sketch_element(element, id_: str) -> bool: if not element.getAttribute("style"): return True - style: Dict = dict( + style: dict = dict( [x.split(":") for x in element.getAttribute("style").split(";")] ) if ( @@ -177,8 +177,8 @@ class ShapeExtractor: :param svg_file_name: input SVG file name with icons. File may contain any other irrelevant graphics. """ - self.shapes: Dict[str, Shape] = {} - self.configuration: Dict[str, Any] = json.load( + self.shapes: dict[str, Shape] = {} + self.configuration: dict[str, Any] = json.load( configuration_file_name.open() ) with svg_file_name.open() as input_file: @@ -233,7 +233,7 @@ class ShapeExtractor: name = child_node.childNodes[0].nodeValue break - configuration: Dict[str, Any] = ( + configuration: dict[str, Any] = ( self.configuration[id_] if id_ in self.configuration else {} ) self.shapes[id_] = Shape.from_structure( @@ -326,7 +326,7 @@ class ShapeSpecification: self, svg, point: np.array, - tags: Dict[str, Any] = None, + tags: dict[str, Any] = None, outline: bool = False, ) -> None: """ @@ -351,7 +351,7 @@ class ShapeSpecification: bright: bool = is_bright(self.color) color: Color = Color("black") if bright else Color("white") - style: Dict[str, Any] = { + style: dict[str, Any] = { "fill": color.hex, "stroke": color.hex, "stroke-width": 2.2, @@ -381,15 +381,15 @@ class Icon: Icon that consists of (probably) multiple shapes. """ - shape_specifications: List[ShapeSpecification] + shape_specifications: list[ShapeSpecification] - def get_shape_ids(self) -> List[str]: + def get_shape_ids(self) -> list[str]: """ Get all shape identifiers in the icon. """ return [x.shape.id_ for x in self.shape_specifications] - def get_names(self) -> List[str]: + def get_names(self) -> list[str]: """ Gat all shape names in the icon. """ @@ -402,7 +402,7 @@ class Icon: self, svg: svgwrite.Drawing, point: np.array, - tags: Dict[str, Any] = None, + tags: dict[str, Any] = None, outline: bool = False, ) -> None: """ @@ -469,7 +469,7 @@ class Icon: shape_specification.color = color def add_specifications( - self, specifications: List[ShapeSpecification] + self, specifications: list[ShapeSpecification] ) -> None: """ Add shape specifications to the icon. @@ -494,8 +494,8 @@ class IconSet: """ main_icon: Icon - extra_icons: List[Icon] + extra_icons: list[Icon] # Tag keys that were processed to create icon set (other tag keys should be # displayed by text or ignored) - processed: Set[str] + processed: set[str] diff --git a/roentgen/mapcss.py b/roentgen/mapcss.py index ca11f98..712b5a9 100644 --- a/roentgen/mapcss.py +++ b/roentgen/mapcss.py @@ -3,7 +3,7 @@ MapCSS scheme creation. """ import logging from pathlib import Path -from typing import Dict, List, Optional, TextIO +from typing import Optional, TextIO from colour import Color @@ -83,8 +83,8 @@ class MapCSSWriter: self.add_icons_for_lifecycle: bool = add_icons_for_lifecycle self.icon_directory_name: str = icon_directory_name - self.point_matchers: List[Matcher] = scheme.node_matchers - self.line_matchers: List[Matcher] = scheme.way_matchers + self.point_matchers: list[Matcher] = scheme.node_matchers + self.line_matchers: list[Matcher] = scheme.way_matchers def add_selector( self, @@ -102,7 +102,7 @@ class MapCSSWriter: :param opacity: icon opacity :return: """ - elements: Dict[str, str] = {} + elements: dict[str, str] = {} clean_shapes = matcher.get_clean_shapes() if clean_shapes: diff --git a/roentgen/mapper.py b/roentgen/mapper.py index 7f5cc76..5c6a995 100644 --- a/roentgen/mapper.py +++ b/roentgen/mapper.py @@ -1,7 +1,7 @@ """ Simple OpenStreetMap renderer. """ -from typing import Any, Dict, Iterator, Set +from typing import Any, Iterator import numpy as np import svgwrite @@ -12,33 +12,27 @@ from svgwrite.shapes import Rect from roentgen import ui from roentgen.constructor import Constructor -from roentgen.direction import DirectionSet, Sector from roentgen.figure import Road from roentgen.flinger import Flinger -from roentgen.icon import ShapeExtractor -from roentgen.osm_reader import Map, OSMNode +from roentgen.osm_reader import OSMNode from roentgen.point import Occupied from roentgen.road import Intersection, RoadPart from roentgen.scheme import Scheme +from roentgen.ui import AUTHOR_MODE, TIME_MODE __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" -AUTHOR_MODE = "user-coloring" -CREATION_TIME_MODE = "time" - -class Painter: +class Map: """ Map drawing. """ def __init__( self, - map_: Map, flinger: Flinger, svg: svgwrite.Drawing, - icon_extractor: ShapeExtractor, scheme: Scheme, overlap: int = 12, mode: str = "normal", @@ -48,20 +42,16 @@ class Painter: self.mode: str = mode self.label_mode: str = label_mode - self.map_: Map = map_ self.flinger: Flinger = flinger self.svg: svgwrite.Drawing = svg - self.icon_extractor = icon_extractor self.scheme: Scheme = scheme self.background_color: Color = self.scheme.get_color("background_color") - if self.mode in [AUTHOR_MODE, CREATION_TIME_MODE]: + if self.mode in [AUTHOR_MODE, TIME_MODE]: self.background_color: Color = Color("#111111") def draw(self, constructor: Constructor) -> None: - """ - Draw map. - """ + """Draw map.""" self.svg.add( Rect((0, 0), self.flinger.size, fill=self.background_color) ) @@ -84,9 +74,13 @@ class Painter: for road in roads: self.draw_road(road, road.matcher.color) - self.draw_trees(constructor) + for tree in constructor.trees: + tree.draw(self.svg, self.flinger, self.scheme) + + for direction_sector in constructor.direction_sectors: + direction_sector.draw(self.svg, self.scheme) + self.draw_buildings(constructor) - self.draw_direction(constructor) # All other points @@ -101,10 +95,6 @@ class Painter: steps: int = len(nodes) for index, node in enumerate(nodes): - if node.get_tag("natural") == "tree" and ( - "diameter_crown" in node.tags or "circumference" in node.tags - ): - continue ui.progress_bar( index, steps * 3, step=10, text="Drawing main icons" ) @@ -121,207 +111,39 @@ class Painter: steps * 2 + index, steps * 3, step=10, text="Drawing texts" ) if ( - self.mode not in [CREATION_TIME_MODE, AUTHOR_MODE] + self.mode not in [TIME_MODE, AUTHOR_MODE] and self.label_mode != "no" ): point.draw_texts(self.svg, occupied, self.label_mode) ui.progress_bar(-1, len(nodes), step=10, text="Drawing nodes") - def draw_trees(self, constructor) -> None: - """ - Draw trunk and circumference. - """ - for node in constructor.points: - if not ( - node.get_tag("natural") == "tree" - and ( - "diameter_crown" in node.tags - or "circumference" in node.tags - ) - ): - continue - - scale: float = self.flinger.get_scale(node.coordinates) - - if "circumference" in node.tags: - if "diameter_crown" in node.tags: - opacity = 0.7 - radius = float(node.tags["diameter_crown"]) / 2 - else: - opacity = 0.3 - radius = 2 - self.svg.add( - self.svg.circle( - node.point, - radius * scale, - fill=self.scheme.get_color("evergreen_color"), - opacity=opacity, - ) - ) - radius = float(node.tags["circumference"]) / 2 / np.pi - self.svg.add( - self.svg.circle(node.point, radius * scale, fill="#B89A74") - ) - def draw_buildings(self, constructor: Constructor) -> None: - """ - Draw buildings: shade, walls, and roof. - """ - # Draw shade. - + """Draw buildings: shade, walls, and roof.""" building_shade: Group = Group(opacity=0.1) scale: float = self.flinger.get_scale() / 3.0 for building in constructor.buildings: - shift_1 = np.array((scale * building.min_height, 0)) - shift_2 = np.array((scale * building.height, 0)) - commands: str = building.get_path(self.flinger, shift_1) - path = Path( - d=commands, fill="#000000", stroke="#000000", stroke_width=1 - ) - building_shade.add(path) - for nodes in building.inners + building.outers: - for i in range(len(nodes) - 1): - flung_1 = self.flinger.fling(nodes[i].coordinates) - flung_2 = self.flinger.fling(nodes[i + 1].coordinates) - command = ( - "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( - command, - fill="#000000", - stroke="#000000", - stroke_width=1, - ) - building_shade.add(path) + building.draw_shade(building_shade, self.flinger) self.svg.add(building_shade) - # Draw buildings. - previous_height: float = 0 count: int = len(constructor.heights) for index, height in enumerate(sorted(constructor.heights)): ui.progress_bar(index, count, step=1, text="Drawing buildings") fill: Color() - for way in constructor.buildings: - if way.height < height or way.min_height > height: + for building in constructor.buildings: + if building.height < height or building.min_height > height: continue - shift_1 = [0, -previous_height * scale] - shift_2 = [0, -height * scale] - for segment in way.parts: - 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)) + building.draw_walls(self.svg, height, previous_height, scale) - 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 = self.svg.path( - d=command, - fill=fill.hex, - stroke=fill.hex, - stroke_width=1, - stroke_linejoin="round", - ) - self.svg.add(path) - - # Draw building roofs. - - for way in constructor.buildings: - if way.height == height: - shift = np.array([0, -way.height * scale]) - path_commands: str = way.get_path(self.flinger, shift) - path = Path(d=path_commands, opacity=1) - path.update(way.line_style.style) - path.update({"stroke-linejoin": "round"}) - self.svg.add(path) + for building in constructor.buildings: + if building.height == height: + building.draw_roof(self.svg, self.flinger, scale) previous_height = height + ui.progress_bar(-1, count, step=1, text="Drawing buildings") - def draw_direction(self, constructor) -> None: - """ - Draw gradient sectors for directions. - """ - for node in constructor.points: - - angle = None - is_revert_gradient: bool = False - - if node.get_tag("man_made") == "surveillance": - direction = node.get_tag("camera:direction") - if "camera:angle" in node.tags: - angle = float(node.get_tag("camera:angle")) - if "angle" in node.tags: - angle = float(node.get_tag("angle")) - direction_radius: float = 25 - direction_color: Color = self.scheme.get_color( - "direction_camera_color" - ) - elif node.get_tag("traffic_sign") == "stop": - direction = node.get_tag("direction") - direction_radius: float = 25 - direction_color: Color = Color("red") - else: - direction = node.get_tag("direction") - direction_radius: float = 50 - direction_color: Color = self.scheme.get_color( - "direction_view_color" - ) - is_revert_gradient = True - - if not direction: - continue - - point = (node.point.astype(int)).astype(float) - - if angle: - paths = [Sector(direction, angle).draw(point, direction_radius)] - else: - paths = DirectionSet(direction).draw(point, direction_radius) - - for path in paths: - radial_gradient = self.svg.radialGradient( - center=point, - r=direction_radius, - gradientUnits="userSpaceOnUse", - ) - gradient = self.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 = self.svg.path( - d=["M", point] + path + ["L", point, "Z"], - fill=gradient.get_paint_server(), - ) - self.svg.add(path) - def draw_road( self, road: Road, color: Color, extra_width: float = 0 ) -> None: @@ -336,7 +158,7 @@ class Painter: scale = self.flinger.get_scale(road.outers[0][0].coordinates) path_commands: str = road.get_path(self.flinger) path = Path(d=path_commands) - style: Dict[str, Any] = { + style: dict[str, Any] = { "fill": "none", "stroke": color.hex, "stroke-linecap": "round", @@ -350,7 +172,7 @@ class Painter: """ Draw road as simple SVG path. """ - nodes: Dict[OSMNode, Set[RoadPart]] = {} + nodes: dict[OSMNode, set[RoadPart]] = {} for road in roads: for index in range(len(road.outers[0]) - 1): @@ -379,7 +201,7 @@ class Painter: intersection.draw(self.svg, scale, True) -def check_level_number(tags: Dict[str, Any], level: float): +def check_level_number(tags: dict[str, Any], level: float): """ Check if element described by tags is no the specified level. """ @@ -392,7 +214,7 @@ def check_level_number(tags: Dict[str, Any], level: float): return True -def check_level_overground(tags: Dict[str, Any]) -> bool: +def check_level_overground(tags: dict[str, Any]) -> bool: """ Check if element described by tags is overground. """ diff --git a/roentgen/moire_manager.py b/roentgen/moire_manager.py index a9924a6..f38e5f3 100644 --- a/roentgen/moire_manager.py +++ b/roentgen/moire_manager.py @@ -4,7 +4,7 @@ Moire markup extension for Röntgen. import argparse from abc import ABC from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Union import yaml from moire.default import Default, DefaultHTML, DefaultMarkdown, DefaultWiki @@ -17,8 +17,8 @@ from roentgen.workspace import workspace __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" -Arguments = List[Any] -Code = Union[str, Tag, List] +Arguments = list[Any] +Code = Union[str, Tag, list] PREFIX: str = "https://wiki.openstreetmap.org/wiki/" @@ -29,13 +29,13 @@ class ArgumentParser(argparse.ArgumentParser): """ def __init__(self, *args, **kwargs): - self.arguments: List[Dict[str, Any]] = [] + self.arguments: list[dict[str, Any]] = [] super(ArgumentParser, self).__init__(*args, **kwargs) def add_argument(self, *args, **kwargs) -> None: """Just store argument with options.""" super(ArgumentParser, self).add_argument(*args, **kwargs) - argument: Dict[str, Any] = {"arguments": [x for x in args]} + argument: dict[str, Any] = {"arguments": [x for x in args]} for key in kwargs: argument[key] = kwargs[key] @@ -57,7 +57,7 @@ class ArgumentParser(argparse.ArgumentParser): row: Code = [[x for y in array for x in y][:-1]] if "help" in option: - help_value: List = [option["help"]] + help_value: list = [option["help"]] if ( "default" in option and option["default"] @@ -91,13 +91,13 @@ class TestConfiguration: """ def __init__(self, test_config: Path): - self.steps: Dict[str, Any] = {} + self.steps: dict[str, Any] = {} with test_config.open() as input_file: - content: Dict[str, Any] = yaml.load( + content: dict[str, Any] = yaml.load( input_file, Loader=yaml.FullLoader ) - steps: List[Dict[str, Any]] = content["jobs"]["build"]["steps"] + steps: list[dict[str, Any]] = content["jobs"]["build"]["steps"] for step in steps: if "name" not in step: continue @@ -177,7 +177,8 @@ class RoentgenHTML(RoentgenMoire, DefaultHTML): Simple HTML. """ - images = {} + def __init__(self): + self.images: dict = {} def color(self, args: Arguments) -> str: """Simple color sample.""" @@ -202,10 +203,11 @@ class RoentgenOSMWiki(RoentgenMoire, DefaultWiki): See https://wiki.openstreetmap.org/wiki/Main_Page """ - images = {} - extractor = ShapeExtractor( - workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH - ) + def __init__(self): + self.images: dict = {} + self.extractor: ShapeExtractor = ShapeExtractor( + workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH + ) def osm(self, args: Arguments) -> str: """OSM tag key or key–value pair of tag.""" diff --git a/roentgen/osm_getter.py b/roentgen/osm_getter.py index 8e79d98..955839d 100644 --- a/roentgen/osm_getter.py +++ b/roentgen/osm_getter.py @@ -4,8 +4,8 @@ Getting OpenStreetMap data from the web. import logging import time import urllib +from dataclasses import dataclass from pathlib import Path -from typing import Dict, Optional import urllib3 @@ -14,44 +14,44 @@ from roentgen.ui import BoundaryBox __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" +SLEEP_TIME_BETWEEN_REQUESTS: float = 2.0 + +@dataclass class NetworkError(Exception): """Failed network request.""" - def __init__(self, message: str): - super().__init__() - self.message: str = message + message: str def get_osm( - boundary_box: BoundaryBox, cache_path: Path, to_update: bool = False + boundary_box: BoundaryBox, cache_file_path: Path, to_update: bool = False ) -> str: """ Download OSM data from the web or get if from the cache. :param boundary_box: borders of the map part to download - :param cache_path: cache directory to store downloaded OSM files + :param cache_file_path: cache file to store downloaded OSM data :param to_update: update cache files """ - result_file_name: Path = cache_path / f"{boundary_box.get_format()}.osm" + if not to_update and cache_file_path.is_file(): + with cache_file_path.open() as output_file: + return output_file.read() - if not to_update and result_file_name.is_file(): - return result_file_name.open().read() - - content: Optional[str] = get_data( + content: str = get_data( "api.openstreetmap.org/api/0.6/map", {"bbox": boundary_box.get_format()}, is_secure=True, ).decode("utf-8") - with result_file_name.open("w+") as output_file: + with cache_file_path.open("w+") as output_file: output_file.write(content) return content def get_data( - address: str, parameters: Dict[str, str], is_secure: bool = False + address: str, parameters: dict[str, str], is_secure: bool = False ) -> bytes: """ Construct Internet page URL and get its descriptor. @@ -61,7 +61,7 @@ def get_data( :param is_secure: https or http :return: connection descriptor """ - url: str = "http" + ("s" if is_secure else "") + "://" + address + url: str = f"http{('s' if is_secure else '')}://{address}" if len(parameters) > 0: url += f"?{urllib.parse.urlencode(parameters)}" logging.info(f"Getting {url}...") @@ -74,5 +74,5 @@ def get_data( raise NetworkError("Cannot download data: too many attempts.") pool_manager.clear() - time.sleep(2) + time.sleep(SLEEP_TIME_BETWEEN_REQUESTS) return result.data diff --git a/roentgen/osm_reader.py b/roentgen/osm_reader.py index eb0ee13..8d10904 100644 --- a/roentgen/osm_reader.py +++ b/roentgen/osm_reader.py @@ -6,7 +6,7 @@ import re from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any, Optional from xml.etree import ElementTree import numpy as np @@ -23,7 +23,8 @@ KILOMETERS_PATTERN = re.compile("^(?P\\d*\\.?\\d*)\\s*km$") MILES_PATTERN = re.compile("^(?P\\d*\\.?\\d*)\\s*mi$") -STAGES_OF_DECAY: List[str] = [ +# See https://wiki.openstreetmap.org/wiki/Lifecycle_prefix#Stages_of_decay +STAGES_OF_DECAY: list[str] = [ "disused", "abandoned", "ruins", @@ -50,8 +51,9 @@ class Tagged: OpenStreetMap element (node, way or relation) with tags. """ - def __init__(self): - self.tags: Dict[str, str] = {} + def __init__(self, tags: dict[str, str] = None): + self.tags: dict[str, str] + self.tags = {} if tags is None else tags def get_tag(self, key: str) -> Optional[str]: """ @@ -139,7 +141,7 @@ class OSMNode(Tagged): node.tags[subattributes["k"]] = subattributes["v"] return node - def parse_from_structure(self, structure: Dict[str, Any]) -> "OSMNode": + def parse_from_structure(self, structure: dict[str, Any]) -> "OSMNode": """ Parse node from Overpass-like structure. @@ -160,11 +162,11 @@ class OSMWay(Tagged): See https://wiki.openstreetmap.org/wiki/Way """ - def __init__(self, id_: int = 0, nodes: Optional[List[OSMNode]] = None): + def __init__(self, id_: int = 0, nodes: Optional[list[OSMNode]] = None): super().__init__() self.id_: int = id_ - self.nodes: List[OSMNode] = [] if nodes is None else nodes + self.nodes: list[OSMNode] = [] if nodes is None else nodes self.visible: Optional[str] = None self.changeset: Optional[str] = None @@ -194,7 +196,7 @@ class OSMWay(Tagged): return way def parse_from_structure( - self, structure: Dict[str, Any], nodes + self, structure: dict[str, Any], nodes ) -> "OSMWay": """ Parse way from Overpass-like structure. @@ -245,7 +247,7 @@ class OSMRelation(Tagged): super().__init__() self.id_: int = id_ - self.members: List["OSMMember"] = [] + self.members: list["OSMMember"] = [] self.user: Optional[str] = None self.timestamp: Optional[datetime] = None @@ -275,7 +277,7 @@ class OSMRelation(Tagged): relation.tags[subelement.attrib["k"]] = subelement.attrib["v"] return relation - def parse_from_structure(self, structure: Dict[str, Any]) -> "OSMRelation": + def parse_from_structure(self, structure: dict[str, Any]) -> "OSMRelation": """ Parse relation from Overpass-like structure. @@ -305,19 +307,19 @@ class OSMMember: role: str = "" -class Map: +class OSMData: """ The whole OpenStreetMap information about nodes, ways, and relations. """ def __init__(self): - self.nodes: Dict[int, OSMNode] = {} - self.ways: Dict[int, OSMWay] = {} - self.relations: Dict[int, OSMRelation] = {} + self.nodes: dict[int, OSMNode] = {} + self.ways: dict[int, OSMWay] = {} + self.relations: dict[int, OSMRelation] = {} - self.authors: Set[str] = set() + self.authors: set[str] = set() self.time: MinMax = MinMax() - self.boundary_box: List[MinMax] = [MinMax(), MinMax()] + self.boundary_box: list[MinMax] = [MinMax(), MinMax()] self.view_box = None def add_node(self, node: OSMNode) -> None: @@ -355,9 +357,9 @@ class OverpassReader: """ def __init__(self): - self.map_ = Map() + self.osm_data = OSMData() - def parse_json_file(self, file_name: Path) -> Map: + def parse_json_file(self, file_name: Path) -> OSMData: """ Parse JSON structure from the file and construct map. """ @@ -371,18 +373,18 @@ class OverpassReader: if element["type"] == "node": node = OSMNode().parse_from_structure(element) node_map[node.id_] = node - self.map_.add_node(node) + self.osm_data.add_node(node) for element in structure["elements"]: if element["type"] == "way": way = OSMWay().parse_from_structure(element, node_map) way_map[way.id_] = way - self.map_.add_way(way) + self.osm_data.add_way(way) for element in structure["elements"]: if element["type"] == "relation": relation = OSMRelation().parse_from_structure(element) - self.map_.add_relation(relation) + self.osm_data.add_relation(relation) - return self.map_ + return self.osm_data class OSMReader: @@ -406,13 +408,13 @@ class OSMReader: :param is_full: whether metadata should be parsed: tags `visible`, `changeset`, `timestamp`, `user`, `uid` """ - self.map_ = Map() + self.osm_data = OSMData() self.parse_nodes: bool = parse_nodes self.parse_ways: bool = parse_ways self.parse_relations: bool = parse_relations self.is_full: bool = is_full - def parse_osm_file(self, file_name: Path) -> Map: + def parse_osm_file(self, file_name: Path) -> OSMData: """ Parse OSM XML file. @@ -421,7 +423,7 @@ class OSMReader: """ return self.parse_osm(ElementTree.parse(file_name).getroot()) - def parse_osm_text(self, text: str) -> Map: + def parse_osm_text(self, text: str) -> OSMData: """ Parse OSM XML data from text representation. @@ -430,7 +432,7 @@ class OSMReader: """ return self.parse_osm(ElementTree.fromstring(text)) - def parse_osm(self, root) -> Map: + def parse_osm(self, root) -> OSMData: """ Parse OSM XML data. @@ -442,25 +444,25 @@ class OSMReader: self.parse_bounds(element) if element.tag == "node" and self.parse_nodes: node = OSMNode.from_xml_structure(element, self.is_full) - self.map_.add_node(node) + self.osm_data.add_node(node) if element.tag == "way" and self.parse_ways: - self.map_.add_way( + self.osm_data.add_way( OSMWay.from_xml_structure( - element, self.map_.nodes, self.is_full + element, self.osm_data.nodes, self.is_full ) ) if element.tag == "relation" and self.parse_relations: - self.map_.add_relation( + self.osm_data.add_relation( OSMRelation.from_xml_structure(element, self.is_full) ) - return self.map_ + return self.osm_data def parse_bounds(self, element) -> None: """ Parse view box from XML element. """ attributes = element.attrib - self.map_.view_box = MinMax( + self.osm_data.view_box = MinMax( np.array( (float(attributes["minlat"]), float(attributes["minlon"])) ), diff --git a/roentgen/point.py b/roentgen/point.py index f3142d4..7477fed 100644 --- a/roentgen/point.py +++ b/roentgen/point.py @@ -1,7 +1,7 @@ """ Point: node representation on the map. """ -from typing import Dict, List, Optional, Set +from typing import Optional import numpy as np import svgwrite @@ -56,9 +56,9 @@ class Point(Tagged): def __init__( self, icon_set: IconSet, - labels: List[Label], - tags: Dict[str, str], - processed: Set[str], + labels: list[Label], + tags: dict[str, str], + processed: set[str], point: np.array, coordinates: np.array, priority: float = 0, @@ -70,9 +70,9 @@ class Point(Tagged): assert point is not None self.icon_set: IconSet = icon_set - self.labels: List[Label] = labels - self.tags: Dict[str, str] = tags - self.processed: Set[str] = processed + 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.priority: float = priority @@ -139,7 +139,7 @@ class Point(Tagged): icon: Icon, position, occupied, - tags: Optional[Dict[str, str]] = None, + tags: Optional[dict[str, str]] = None, ) -> bool: """ Draw one combined icon and its outline. @@ -176,7 +176,7 @@ class Point(Tagged): """ Draw all labels. """ - labels: List[Label] + labels: list[Label] if label_mode == "main": labels = self.labels[:1] diff --git a/roentgen/raster.py b/roentgen/raster.py index b37f010..db16baa 100644 --- a/roentgen/raster.py +++ b/roentgen/raster.py @@ -5,7 +5,6 @@ import logging import os import subprocess from pathlib import Path -from typing import List __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" @@ -25,7 +24,7 @@ def rasterize(from_: Path, to_: Path, area: str = "", dpi: float = 90) -> None: f"Inkscape and set the variable to be able to rasterize SVG files." ) - commands: List[str] = [os.environ[INKSCAPE_BIN]] + commands: list[str] = [os.environ[INKSCAPE_BIN]] commands += ["--export-png", to_.absolute()] commands += ["--export-dpi", str(dpi)] if area: diff --git a/roentgen/road.py b/roentgen/road.py index d97db04..28de82f 100644 --- a/roentgen/road.py +++ b/roentgen/road.py @@ -2,7 +2,7 @@ WIP: road shape drawing. """ from dataclasses import dataclass -from typing import List, Optional +from typing import Optional import numpy as np import svgwrite @@ -48,7 +48,7 @@ class RoadPart: self, point_1: np.array, point_2: np.array, - lanes: List[Lane], + lanes: list[Lane], scale: False, ): """ @@ -58,7 +58,7 @@ class RoadPart: """ self.point_1: np.array = point_1 self.point_2: np.array = point_2 - self.lanes: List[Lane] = lanes + self.lanes: list[Lane] = lanes if lanes: self.width = sum(map(lambda x: x.get_width(scale), lanes)) else: @@ -297,8 +297,8 @@ class Intersection: points of the road parts should be the same. """ - def __init__(self, parts: List[RoadPart]): - self.parts: List[RoadPart] = sorted(parts, key=lambda x: x.get_angle()) + def __init__(self, parts: list[RoadPart]): + self.parts: list[RoadPart] = sorted(parts, key=lambda x: x.get_angle()) for index in range(len(self.parts)): next_index: int = 0 if index == len(self.parts) - 1 else index + 1 diff --git a/roentgen/scheme.py b/roentgen/scheme.py index 7066826..95f2cae 100644 --- a/roentgen/scheme.py +++ b/roentgen/scheme.py @@ -4,7 +4,7 @@ Röntgen drawing scheme. from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Optional, Tuple, Union import yaml from colour import Color @@ -30,7 +30,7 @@ class LineStyle: SVG line style and its priority. """ - style: Dict[str, Union[int, float, str]] + style: dict[str, Union[int, float, str]] priority: float = 0.0 @@ -48,7 +48,7 @@ class MatchingType(Enum): def is_matched_tag( matcher_tag_key: str, matcher_tag_value: Union[str, list], - tags: Dict[str, str], + tags: dict[str, str], ) -> MatchingType: """ Check whether element tags contradict tag matcher. @@ -89,10 +89,10 @@ class Matcher: Tag matching. """ - def __init__(self, structure: Dict[str, Any]): - self.tags: Dict[str, str] = structure["tags"] + def __init__(self, structure: dict[str, Any]): + self.tags: dict[str, str] = structure["tags"] - self.exception: Dict[str, str] = {} + self.exception: dict[str, str] = {} if "exception" in structure: self.exception = structure["exception"] @@ -100,11 +100,11 @@ class Matcher: if "replace_shapes" in structure: self.replace_shapes = structure["replace_shapes"] - self.location_restrictions: Dict[str, str] = {} + self.location_restrictions: dict[str, str] = {} if "location_restrictions" in structure: self.location_restrictions = structure["location_restrictions"] - def is_matched(self, tags: Dict[str, str]) -> bool: + def is_matched(self, tags: dict[str, str]) -> bool: """ Check whether element tags matches tag matcher. @@ -157,7 +157,7 @@ class NodeMatcher(Matcher): Tag specification matcher. """ - def __init__(self, structure: Dict[str, Any]): + def __init__(self, structure: dict[str, Any]): # Dictionary with tag keys and values, value lists, or "*" super().__init__(structure) @@ -200,11 +200,11 @@ class WayMatcher(Matcher): Special tag matcher for ways. """ - def __init__(self, structure: Dict[str, Any], scheme: "Scheme"): + def __init__(self, structure: dict[str, Any], scheme: "Scheme"): super().__init__(structure) - self.style: Dict[str, Any] = {"fill": "none"} + self.style: dict[str, Any] = {"fill": "none"} if "style" in structure: - style: Dict[str, Any] = structure["style"] + style: dict[str, Any] = structure["style"] for key in style: if str(style[key]).endswith("_color"): self.style[key] = scheme.get_color(style[key]).hex.upper() @@ -223,7 +223,7 @@ class RoadMatcher(Matcher): Special tag matcher for highways. """ - def __init__(self, structure: Dict[str, Any], scheme: "Scheme"): + def __init__(self, structure: dict[str, Any], scheme: "Scheme"): super().__init__(structure) self.border_color: Color = Color( scheme.get_color(structure["border_color"]) @@ -250,33 +250,33 @@ class Scheme: specification """ with file_name.open() as input_file: - content: Dict[str, Any] = yaml.load( + content: dict[str, Any] = yaml.load( input_file.read(), Loader=yaml.FullLoader ) - self.node_matchers: List[NodeMatcher] = [] + self.node_matchers: list[NodeMatcher] = [] for group in content["node_icons"]: for element in group["tags"]: self.node_matchers.append(NodeMatcher(element)) - self.colors: Dict[str, str] = content["colors"] - self.material_colors: Dict[str, str] = content["material_colors"] + self.colors: dict[str, str] = content["colors"] + self.material_colors: dict[str, str] = content["material_colors"] - self.way_matchers: List[WayMatcher] = [ + self.way_matchers: list[WayMatcher] = [ WayMatcher(x, self) for x in content["ways"] ] - self.road_matchers: List[RoadMatcher] = [ + self.road_matchers: list[RoadMatcher] = [ RoadMatcher(x, self) for x in content["roads"] ] - self.area_matchers: List[Matcher] = [ + self.area_matchers: list[Matcher] = [ Matcher(x) for x in content["area_tags"] ] - self.tags_to_write: List[str] = content["tags_to_write"] - self.prefix_to_write: List[str] = content["prefix_to_write"] - self.tags_to_skip: List[str] = content["tags_to_skip"] - self.prefix_to_skip: List[str] = content["prefix_to_skip"] + self.tags_to_write: list[str] = content["tags_to_write"] + self.prefix_to_write: list[str] = content["prefix_to_write"] + self.tags_to_skip: list[str] = content["tags_to_skip"] + self.prefix_to_skip: list[str] = content["prefix_to_skip"] # Storage for created icon sets. - self.cache: Dict[str, Tuple[IconSet, int]] = {} + self.cache: dict[str, Tuple[IconSet, int]] = {} def get_color(self, color: str) -> Color: """ @@ -327,8 +327,8 @@ class Scheme: def get_icon( self, extractor: ShapeExtractor, - tags: Dict[str, Any], - processed: Set[str], + tags: dict[str, Any], + processed: set[str], for_: str = "node", ) -> Tuple[IconSet, int]: """ @@ -347,7 +347,7 @@ class Scheme: return self.cache[tags_hash] main_icon: Optional[Icon] = None - extra_icons: List[Icon] = [] + extra_icons: list[Icon] = [] priority: int = 0 index: int = 0 @@ -358,7 +358,7 @@ class Scheme: matched: bool = matcher.is_matched(tags) if not matched: continue - matcher_tags: Set[str] = set(matcher.tags.keys()) + matcher_tags: set[str] = set(matcher.tags.keys()) priority = len(self.node_matchers) - index if not matcher.draw: processed |= matcher_tags @@ -437,7 +437,7 @@ class Scheme: return returned, priority - def get_style(self, tags: Dict[str, Any], scale): + def get_style(self, tags: dict[str, Any], scale): """ Get line style based on tags and scale. """ @@ -451,7 +451,7 @@ class Scheme: return line_styles - def get_road(self, tags: Dict[str, Any]) -> Optional[RoadMatcher]: + def get_road(self, tags: dict[str, Any]) -> Optional[RoadMatcher]: for matcher in self.road_matchers: if not matcher.is_matched(tags): continue @@ -459,12 +459,12 @@ class Scheme: return None def construct_text( - self, tags: Dict[str, str], draw_captions: str, processed: Set[str] - ) -> List[Label]: + self, tags: dict[str, str], draw_captions: str, processed: set[str] + ) -> list[Label]: """ Construct labels for not processed tags. """ - texts: List[Label] = [] + texts: list[Label] = [] name = None alt_name = None @@ -490,7 +490,7 @@ class Scheme: alt_name = "" alt_name += "ex " + tags["old_name"] - address: List[str] = get_address(tags, draw_captions, processed) + address: list[str] = get_address(tags, draw_captions, processed) if name: texts.append(Label(name, Color("black"))) @@ -535,7 +535,7 @@ class Scheme: texts.append(Label(tags[tag])) return texts - def is_area(self, tags: Dict[str, str]) -> bool: + def is_area(self, tags: dict[str, str]) -> bool: """ Check whether way described by tags is area. """ @@ -545,7 +545,7 @@ class Scheme: return False def process_ignored( - self, tags: Dict[str, str], processed: Set[str] + self, tags: dict[str, str], processed: set[str] ) -> None: """ Mark all ignored tag as processed. @@ -553,6 +553,4 @@ class Scheme: :param tags: input tag dictionary :param processed: processed set """ - for tag in tags: - if self.is_no_drawable(tag): - processed.add(tag) + [processed.add(tag) for tag in tags if self.is_no_drawable(tag)] diff --git a/roentgen/server.py b/roentgen/server.py index c79bd25..d2cdc43 100644 --- a/roentgen/server.py +++ b/roentgen/server.py @@ -12,26 +12,23 @@ __email__ = "me@enzet.ru" class Handler(BaseHTTPRequestHandler): - - update_cache: bool = False + """ + HTTP request handler that process sloppy map tile requests. + """ def __init__(self, request, client_address, server): super().__init__(request, client_address, server) self.cache: Path = Path("cache") + self.update_cache: bool = False - def write(self, message): - if isinstance(message, bytes): - self.wfile.write(message) - else: - self.wfile.write(message.encode("utf-8")) - - def do_GET(self): - parts = self.path.split("/") + def do_GET(self) -> None: + """Serve a GET request.""" + parts: list[str] = self.path.split("/") if not (len(parts) == 5 and not parts[0] and parts[1] == "tiles"): return - zoom = int(parts[2]) - x = int(parts[3]) - y = int(parts[4]) + zoom: int = int(parts[2]) + x: int = int(parts[3]) + y: int = int(parts[4]) tile_path: Path = workspace.get_tile_path() png_path = tile_path / f"tile_{zoom}_{x}_{y}.png" if self.update_cache: @@ -48,7 +45,7 @@ class Handler(BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-type", "image/png") self.end_headers() - self.write(input_file.read()) + self.wfile.write(input_file.read()) return @@ -56,7 +53,7 @@ def ui(options): server: Optional[HTTPServer] = None try: port: int = 8080 - server = HTTPServer(("", port), Handler) + server: HTTPServer = HTTPServer(("", port), Handler) server.cache_path = Path(options.cache) server.serve_forever() logging.info(f"Server started on port {port}.") diff --git a/roentgen/taginfo.py b/roentgen/taginfo.py index 3a999a7..e694191 100644 --- a/roentgen/taginfo.py +++ b/roentgen/taginfo.py @@ -7,7 +7,6 @@ import json import logging from datetime import datetime from pathlib import Path -from typing import List from roentgen import ( __author__, @@ -55,7 +54,7 @@ class TaginfoProjectFile: ): key: str = list(matcher.tags.keys())[0] value: str = matcher.tags[key] - ids: List[str] = [ + ids: list[str] = [ (x if isinstance(x, str) else x["shape"]) for x in matcher.shapes ] diff --git a/roentgen/text.py b/roentgen/text.py index b874f8c..0d78fc2 100644 --- a/roentgen/text.py +++ b/roentgen/text.py @@ -2,7 +2,7 @@ OSM address tag processing. """ from dataclasses import dataclass -from typing import Any, Dict, List, Set +from typing import Any from colour import Color @@ -24,15 +24,15 @@ class Label: def get_address( - tags: Dict[str, Any], draw_captions_mode: str, processed: Set[str] -) -> List[str]: + tags: dict[str, Any], draw_captions_mode: str, processed: set[str] +) -> list[str]: """ Construct address text list from the tags. :param tags: OSM node, way or relation tags :param draw_captions_mode: captions mode ("all", "main", or "no") """ - address: List[str] = [] + address: list[str] = [] if draw_captions_mode == "address": if "addr:postcode" in tags: @@ -80,12 +80,12 @@ def format_frequency(value: str) -> str: return f"{value} " -def get_text(tags: Dict[str, Any], processed: Set[str]) -> List[Label]: +def get_text(tags: dict[str, Any], processed: set[str]) -> list[Label]: """ Get text representation of writable tags. """ - texts: List[Label] = [] - values: List[str] = [] + texts: list[Label] = [] + values: list[str] = [] if "voltage:primary" in tags: values.append(tags["voltage:primary"]) diff --git a/roentgen/tile.py b/roentgen/tile.py index f41c559..a79820d 100644 --- a/roentgen/tile.py +++ b/roentgen/tile.py @@ -7,7 +7,7 @@ import logging import sys from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Tuple +from typing import Optional, Tuple import numpy as np import svgwrite @@ -15,9 +15,9 @@ import svgwrite from roentgen.constructor import Constructor from roentgen.flinger import Flinger from roentgen.icon import ShapeExtractor -from roentgen.mapper import Painter +from roentgen.mapper import Map from roentgen.osm_getter import NetworkError, get_osm -from roentgen.osm_reader import Map, OSMReader +from roentgen.osm_reader import OSMData, OSMReader from roentgen.raster import rasterize from roentgen.scheme import Scheme from roentgen.ui import BoundaryBox @@ -34,7 +34,7 @@ class Tiles: Collection of tiles. """ - tiles: List["Tile"] + tiles: list["Tile"] tile_1: "Tile" tile_2: "Tile" scale: int @@ -43,7 +43,7 @@ class Tiles: @classmethod def from_boundary_box(cls, boundary_box: BoundaryBox, scale: int): """Create minimal set of tiles that cover boundary box.""" - tiles: List["Tile"] = [] + tiles: list["Tile"] = [] tile_1 = Tile.from_coordinates(boundary_box.get_left_top(), scale) tile_2 = Tile.from_coordinates(boundary_box.get_right_bottom(), scale) @@ -69,15 +69,16 @@ class Tiles: :param directory: directory for tiles :param cache_path: directory for temporary OSM files """ - get_osm(self.boundary_box, cache_path) - - map_ = OSMReader().parse_osm_file( - cache_path / (self.boundary_box.get_format() + ".osm") + cache_file_path: Path = ( + cache_path / f"{self.boundary_box.get_format()}.osm" ) + get_osm(self.boundary_box, cache_file_path) + + osm_data: OSMData = OSMReader().parse_osm_file(cache_file_path) for tile in self.tiles: file_path: Path = tile.get_file_name(directory) if not file_path.exists(): - tile.draw_for_map(map_, directory) + tile.draw_for_map(osm_data, directory) else: logging.info(f"File {file_path} already exists.") @@ -97,14 +98,12 @@ class Tiles: self.boundary_box.get_format() + ".svg" ) if not output_path.exists(): - content = get_osm(self.boundary_box, cache_path) - if not content: - logging.error("Cannot download OSM data.") - return None - - map_: Map = OSMReader().parse_osm_file( - cache_path / (self.boundary_box.get_format() + ".osm") + cache_file_path: Path = ( + cache_path / f"{self.boundary_box.get_format()}.osm" ) + get_osm(self.boundary_box, cache_file_path) + + osm_data: OSMData = OSMReader().parse_osm_file(cache_file_path) lat_2, lon_1 = self.tile_1.get_coordinates() lat_1, lon_2 = Tile( self.tile_2.x + 1, self.tile_2.y + 1, self.scale @@ -113,26 +112,20 @@ class Tiles: max_ = np.array((lat_2, lon_2)) flinger: Flinger = Flinger(MinMax(min_, max_), self.scale) - icon_extractor: ShapeExtractor = ShapeExtractor( + extractor: ShapeExtractor = ShapeExtractor( workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH ) scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) constructor: Constructor = Constructor( - map_, flinger, scheme, icon_extractor + osm_data, flinger, scheme, extractor ) constructor.construct() svg: svgwrite.Drawing = svgwrite.Drawing( str(output_path), size=flinger.size ) - painter: Painter = Painter( - map_=map_, - flinger=flinger, - svg=svg, - icon_extractor=icon_extractor, - scheme=scheme, - ) - painter.draw(constructor) + map_: Map = Map(flinger=flinger, svg=svg, scheme=scheme) + map_.draw(constructor) logging.info(f"Writing output SVG {output_path}...") with output_path.open("w+") as output_file: @@ -204,18 +197,18 @@ class Tile: point_1[1], point_2[0], point_2[1], point_1[0] ).round() - def load_map(self, cache_path: Path) -> Map: + def load_osm_data(self, cache_path: Path) -> OSMData: """ Construct map data from extended boundary box. :param cache_path: directory to store OSM data files """ - boundary_box: BoundaryBox = self.get_extended_boundary_box() - get_osm(boundary_box, cache_path) - - return OSMReader().parse_osm_file( - cache_path / f"{boundary_box.get_format()}.osm" + cache_file_path: Path = ( + cache_path / f"{self.get_extended_boundary_box().get_format()}.osm" ) + get_osm(self.get_extended_boundary_box(), cache_file_path) + + return OSMReader().parse_osm_file(cache_file_path) def get_file_name(self, directory_name: Path) -> Path: """ @@ -239,13 +232,13 @@ class Tile: :param cache_path: directory to store SVG and PNG tiles """ try: - map_: Map = self.load_map(cache_path) + osm_data: OSMData = self.load_osm_data(cache_path) except NetworkError as e: raise NetworkError(f"Map does not loaded. {e.message}") - self.draw_for_map(map_, directory_name) + self.draw_for_map(osm_data, directory_name) - def draw_for_map(self, map_: Map, directory_name: Path) -> None: + def draw_for_map(self, osm_data: OSMData, directory_name: Path) -> None: """Draw tile using existing map.""" lat1, lon1 = self.get_coordinates() lat2, lon2 = Tile(self.x + 1, self.y + 1, self.scale).get_coordinates() @@ -266,17 +259,11 @@ class Tile: ) scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH) constructor: Constructor = Constructor( - map_, flinger, scheme, icon_extractor + osm_data, flinger, scheme, icon_extractor ) constructor.construct() - painter: Painter = Painter( - map_=map_, - flinger=flinger, - svg=svg, - icon_extractor=icon_extractor, - scheme=scheme, - ) + painter: Map = Map(flinger=flinger, svg=svg, scheme=scheme) painter.draw(constructor) logging.info(f"Writing output SVG {output_file_name}...") @@ -291,7 +278,7 @@ def ui(options) -> None: directory: Path = workspace.get_tile_path() if options.coordinates: - coordinates: List[float] = list( + coordinates: list[float] = list( map(float, options.coordinates.strip().split(",")) ) tile: Tile = Tile.from_coordinates(np.array(coordinates), options.scale) diff --git a/roentgen/ui.py b/roentgen/ui.py index 39abc5c..aac3907 100644 --- a/roentgen/ui.py +++ b/roentgen/ui.py @@ -13,50 +13,48 @@ from dataclasses import dataclass import numpy as np +from roentgen.osm_reader import STAGES_OF_DECAY + BOXES: str = " ▏▎▍▌▋▊▉" BOXES_LENGTH: int = len(BOXES) AUTHOR_MODE: str = "author" TIME_MODE: str = "time" +LATITUDE_MAX_DIFFERENCE: float = 0.5 +LONGITUDE_MAX_DIFFERENCE: float = 0.5 + def parse_options(args) -> argparse.Namespace: - """ - Parse Röntgen command-line options. - """ - parser = argparse.ArgumentParser( + """Parse Röntgen command-line options.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( description="Röntgen. OpenStreetMap renderer with custom icon set" ) subparser = parser.add_subparsers(dest="command") - render = subparser.add_parser("render") - subparser.add_parser("icons") - mapcss = subparser.add_parser("mapcss") - subparser.add_parser("taginfo") - tile = subparser.add_parser("tile") - element = subparser.add_parser("element") - server = subparser.add_parser("server") + add_render_arguments(subparser.add_parser("render")) + add_tile_arguments(subparser.add_parser("tile")) + add_server_arguments(subparser.add_parser("server")) + add_element_arguments(subparser.add_parser("element")) + add_mapcss_arguments(subparser.add_parser("mapcss")) - add_render_arguments(render) - add_tile_arguments(tile) - add_server_arguments(server) - add_element_arguments(element) - add_mapcss_arguments(mapcss) + subparser.add_parser("icons") + subparser.add_parser("taginfo") arguments: argparse.Namespace = parser.parse_args(args[1:]) return arguments -def add_tile_arguments(tile) -> None: +def add_tile_arguments(parser: argparse.ArgumentParser) -> None: """Add arguments for tile command.""" - tile.add_argument( + parser.add_argument( "-c", "--coordinates", metavar=",", help="coordinates of any location inside the tile", ) - tile.add_argument( + parser.add_argument( "-s", "--scale", type=int, @@ -64,19 +62,19 @@ def add_tile_arguments(tile) -> None: help="OSM zoom level", default=18, ) - tile.add_argument( + parser.add_argument( "-t", "--tile", metavar="//", help="tile specification", ) - tile.add_argument( + parser.add_argument( "--cache", help="path for temporary OSM files", default="cache", metavar="", ) - tile.add_argument( + parser.add_argument( "-b", "--boundary-box", help="construct the minimum amount of tiles that cover requested " @@ -85,9 +83,9 @@ def add_tile_arguments(tile) -> None: ) -def add_server_arguments(tile) -> None: +def add_server_arguments(parser: argparse.ArgumentParser) -> None: """Add arguments for server command.""" - tile.add_argument( + parser.add_argument( "--cache", help="path for temporary OSM files", default="cache", @@ -95,16 +93,16 @@ def add_server_arguments(tile) -> None: ) -def add_element_arguments(element) -> None: +def add_element_arguments(parser: argparse.ArgumentParser) -> None: """Add arguments for element command.""" - element.add_argument("-n", "--node") - element.add_argument("-w", "--way") - element.add_argument("-r", "--relation") + parser.add_argument("-n", "--node") + parser.add_argument("-w", "--way") + parser.add_argument("-r", "--relation") -def add_render_arguments(render) -> None: +def add_render_arguments(parser: argparse.ArgumentParser) -> None: """Add arguments for render command.""" - render.add_argument( + parser.add_argument( "-i", "--input", dest="input_file_name", @@ -113,7 +111,7 @@ def add_render_arguments(render) -> None: help="input XML file name or names (if not specified, file will be " "downloaded using OpenStreetMap API)", ) - render.add_argument( + parser.add_argument( "-o", "--output", dest="output_file_name", @@ -121,14 +119,14 @@ def add_render_arguments(render) -> None: default="out/map.svg", help="output SVG file name", ) - render.add_argument( + parser.add_argument( "-b", "--boundary-box", metavar=",,,", help='geo boundary box, use space before "-" if the first value is ' "negative", ) - render.add_argument( + parser.add_argument( "-s", "--scale", metavar="", @@ -136,61 +134,63 @@ def add_render_arguments(render) -> None: default=18, type=float, ) - render.add_argument( + parser.add_argument( "--cache", help="path for temporary OSM files", default="cache", metavar="", ) - render.add_argument( + parser.add_argument( "--labels", help="label drawing mode: `no`, `main`, or `all`", dest="label_mode", default="main", ) - render.add_argument( + parser.add_argument( "--overlap", dest="overlap", default=12, type=int, help="how many pixels should be left around icons and text", ) - render.add_argument( + parser.add_argument( "--mode", default="normal", help="map drawing mode", ) - render.add_argument( + parser.add_argument( "--seed", default="", help="seed for random", ) - render.add_argument( + parser.add_argument( "--level", default=None, help="display only this floor level", ) -def add_mapcss_arguments(mapcss) -> None: +def add_mapcss_arguments(parser: argparse.ArgumentParser) -> None: """Add arguments for mapcss command.""" - mapcss.add_argument( + parser.add_argument( "--icons", action=argparse.BooleanOptionalAction, default=True, help="add icons for nodes and areas", ) - mapcss.add_argument( + parser.add_argument( "--ways", action=argparse.BooleanOptionalAction, default=True, help="add style for ways and relations", ) - mapcss.add_argument( + parser.add_argument( "--lifecycle", action=argparse.BooleanOptionalAction, default=True, - help="add icons for lifecycle tags", + help="add icons for lifecycle tags; be careful: this will increase the " + f"number of node and area selectors by {len(STAGES_OF_DECAY) + 1} " + f"times", ) @@ -245,6 +245,8 @@ class BoundaryBox: ,,, or simply ,,,. """ + boundary_box = boundary_box.replace(" ", "") + matcher = re.match( "(?P[0-9.-]*),(?P[0-9.-]*)," + "(?P[0-9.-]*),(?P[0-9.-]*)", @@ -256,10 +258,10 @@ class BoundaryBox: return None try: - left = float(matcher.group("left")) - bottom = float(matcher.group("bottom")) - right = float(matcher.group("right")) - top = float(matcher.group("top")) + left: float = float(matcher.group("left")) + bottom: float = float(matcher.group("bottom")) + right: float = float(matcher.group("right")) + top: float = float(matcher.group("top")) except ValueError: logging.fatal("Invalid boundary box.") return None @@ -270,7 +272,10 @@ class BoundaryBox: if bottom >= top: logging.error("Negative vertical boundary.") return None - if right - left > 0.5 or top - bottom > 0.5: + if ( + right - left > LONGITUDE_MAX_DIFFERENCE + or top - bottom > LATITUDE_MAX_DIFFERENCE + ): logging.error("Boundary box is too big.") return None @@ -285,9 +290,7 @@ class BoundaryBox: return self.bottom, self.right def round(self) -> "BoundaryBox": - """ - Round boundary box. - """ + """Round boundary box.""" self.left = round(self.left * 1000) / 1000 - 0.001 self.bottom = round(self.bottom * 1000) / 1000 - 0.001 self.right = round(self.right * 1000) / 1000 + 0.001 diff --git a/roentgen/util.py b/roentgen/util.py index 5d58e80..0128d9b 100644 --- a/roentgen/util.py +++ b/roentgen/util.py @@ -18,28 +18,20 @@ class MinMax: max_: Any = None def update(self, value: Any) -> None: - """ - Update minimum and maximum with new value. - """ + """Update minimum and maximum with new value.""" self.min_ = value if not self.min_ or value < self.min_ else self.min_ self.max_ = value if not self.max_ or value > self.max_ else self.max_ def delta(self) -> Any: - """ - Difference between maximum and minimum. - """ + """Difference between maximum and minimum.""" return self.max_ - self.min_ def center(self) -> Any: - """ - Get middle point between minimum and maximum. - """ + """Get middle point between minimum and maximum.""" return (self.min_ + self.max_) / 2 def is_empty(self) -> bool: - """ - Check if interval is empty. - """ + """Check if interval is empty.""" return self.min_ == self.max_ def __repr__(self) -> str: diff --git a/roentgen/vector.py b/roentgen/vector.py index 56fb22b..3236e3f 100644 --- a/roentgen/vector.py +++ b/roentgen/vector.py @@ -12,12 +12,15 @@ def compute_angle(vector: np.array): For the given vector compute an angle between it and (1, 0) vector. The result is in [0, 2π]. """ + if vector[0] == 0: + if vector[1] > 0: + return np.pi / 2 + return np.pi + np.pi / 2 if vector[0] < 0: return np.arctan(vector[1] / vector[0]) + np.pi if vector[1] < 0: return np.arctan(vector[1] / vector[0]) + 2 * np.pi - else: - return np.arctan(vector[1] / vector[0]) + return np.arctan(vector[1] / vector[0]) def turn_by_angle(vector: np.array, angle: float): diff --git a/roentgen/workspace.py b/roentgen/workspace.py index eccd8bb..3eddece 100644 --- a/roentgen/workspace.py +++ b/roentgen/workspace.py @@ -15,6 +15,10 @@ def check_and_create(directory: Path) -> Path: class Workspace: + """ + Project file and directory paths and generated files and directories. + """ + # Project directories and files, that are the part of the repository. SCHEME_PATH: Path = Path("scheme") diff --git a/test/test_boundary_box.py b/test/test_boundary_box.py index 88ff49a..8f229e3 100644 --- a/test/test_boundary_box.py +++ b/test/test_boundary_box.py @@ -8,9 +8,10 @@ __email__ = "me@enzet.ru" def test_round_zero_coordinates() -> None: - box: BoundaryBox = BoundaryBox(0, 0, 0, 0).round() - - assert box.get_format() == "-0.001,-0.001,0.001,0.001" + assert ( + BoundaryBox(0, 0, 0, 0).round().get_format() + == "-0.001,-0.001,0.001,0.001" + ) def test_round_coordinates() -> None: diff --git a/test/test_color.py b/test/test_color.py index d3d843a..5e5caea 100644 --- a/test/test_color.py +++ b/test/test_color.py @@ -11,9 +11,7 @@ __email__ = "me@enzet.ru" def test_is_bright() -> None: - """ - Test detecting color brightness. - """ + """Test detecting color brightness.""" assert is_bright(Color("white")) assert is_bright(Color("yellow")) assert not is_bright(Color("brown")) @@ -21,9 +19,7 @@ def test_is_bright() -> None: def test_gradient() -> None: - """ - Test color picking from gradient. - """ + """Test color picking from gradient.""" color: Color = get_gradient_color( 0.5, MinMax(0, 1), [Color("black"), Color("white")] ) diff --git a/test/test_flinger.py b/test/test_flinger.py index a647ad1..c6de953 100644 --- a/test/test_flinger.py +++ b/test/test_flinger.py @@ -10,9 +10,7 @@ __email__ = "me@enzet.ru" def test_pseudo_mercator() -> None: - """ - Test pseudo-Mercator projection. - """ + """Test pseudo-Mercator projection.""" assert np.allclose(pseudo_mercator(np.array((0, 0))), np.array((0, 0))) assert np.allclose(pseudo_mercator(np.array((0, 10))), np.array((10, 0))) assert np.allclose( @@ -21,9 +19,7 @@ def test_pseudo_mercator() -> None: def test_osm_zoom_level_to_pixels_per_meter() -> None: - """ - Test scale computation. - """ + """Test scale computation.""" assert np.allclose( osm_zoom_level_to_pixels_per_meter(18), 1.6759517949045808 ) diff --git a/test/test_icons.py b/test/test_icons.py index de1d982..a37f404 100644 --- a/test/test_icons.py +++ b/test/test_icons.py @@ -1,8 +1,6 @@ """ Test icon generation for nodes. """ -from typing import Dict, Set - import pytest from roentgen.grid import IconCollection @@ -34,11 +32,9 @@ def test_icons_by_name(init_collection) -> None: init_collection.draw_icons(workspace.get_icons_by_name_path(), by_name=True) -def get_icon(tags: Dict[str, str]) -> IconSet: - """ - Construct icon from tags. - """ - processed: Set[str] = set() +def get_icon(tags: dict[str, str]) -> IconSet: + """Construct icon from tags.""" + processed: set[str] = set() icon, _ = SCHEME.get_icon(SHAPE_EXTRACTOR, tags, processed) return icon diff --git a/test/test_label.py b/test/test_label.py index 041ea84..6219e27 100644 --- a/test/test_label.py +++ b/test/test_label.py @@ -1,8 +1,6 @@ """ Test label generation for nodes. """ -from typing import List, Set - from roentgen.text import Label from test import SCHEME @@ -10,18 +8,14 @@ __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" -def construct_labels(tags) -> List[Label]: - """ - Construct labels from OSM node tags. - """ - processed: Set[str] = set() +def construct_labels(tags) -> list[Label]: + """Construct labels from OSM node tags.""" + processed: set[str] = set() return SCHEME.construct_text(tags, "all", processed) def test_1_label() -> None: - """ - Test tags that should be converted into single label. - """ + """Test tags that should be converted into single label.""" labels = construct_labels({"name": "Name"}) assert len(labels) == 1 assert labels[0].text == "Name" @@ -37,9 +31,7 @@ def test_1_label_unknown_tags() -> None: def test_2_labels() -> None: - """ - Test tags that should be converted into two labels. - """ + """Test tags that should be converted into two labels.""" labels = construct_labels({"name": "Name", "ref": "5"}) assert len(labels) == 2 assert labels[0].text == "Name" diff --git a/test/test_mapcss.py b/test/test_mapcss.py index aa0d5e9..2ff8d98 100644 --- a/test/test_mapcss.py +++ b/test/test_mapcss.py @@ -10,9 +10,7 @@ __email__ = "me@enzet.ru" def test_mapcss() -> None: - """ - Test MapCSS generation. - """ + """Test MapCSS generation.""" writer: MapCSSWriter = MapCSSWriter(SCHEME, "icons") matcher: NodeMatcher = NodeMatcher( {"tags": {"natural": "tree"}, "shapes": ["tree"]} diff --git a/test/test_osm_reader.py b/test/test_osm_reader.py index f3d6216..6298689 100644 --- a/test/test_osm_reader.py +++ b/test/test_osm_reader.py @@ -3,35 +3,31 @@ Test OSM XML parsing. """ import numpy as np -from roentgen.osm_reader import OSMNode, OSMReader, OSMRelation, OSMWay +from roentgen.osm_reader import OSMNode, OSMReader, OSMRelation, OSMWay, OSMData __author__ = "Sergey Vartanov" __email__ = "me@enzet.ru" def test_node() -> None: - """ - Test OSM node parsing from XML. - """ - reader = OSMReader() - map_ = reader.parse_osm_text( + """Test OSM node parsing from XML.""" + reader: OSMReader = OSMReader() + osm_data: OSMData = reader.parse_osm_text( """ """ ) - assert 42 in map_.nodes - node: OSMNode = map_.nodes[42] + assert 42 in osm_data.nodes + node: OSMNode = osm_data.nodes[42] assert node.id_ == 42 assert np.allclose(node.coordinates, np.array([10, 5])) def test_node_with_tag() -> None: - """ - Test OSM node parsing from XML. - """ + """Test OSM node parsing from XML.""" reader = OSMReader() - map_ = reader.parse_osm_text( + osm_data: OSMData = reader.parse_osm_text( """ @@ -39,35 +35,31 @@ def test_node_with_tag() -> None: """ ) - assert 42 in map_.nodes - node: OSMNode = map_.nodes[42] + assert 42 in osm_data.nodes + node: OSMNode = osm_data.nodes[42] assert node.id_ == 42 assert np.allclose(node.coordinates, np.array([10, 5])) assert node.tags["key"] == "value" def test_way() -> None: - """ - Test OSM way parsing from XML. - """ - reader = OSMReader() - map_ = reader.parse_osm_text( + """Test OSM way parsing from XML.""" + reader: OSMReader = OSMReader() + osm_data: OSMData = reader.parse_osm_text( """ """ ) - assert 42 in map_.ways - way: OSMWay = map_.ways[42] + assert 42 in osm_data.ways + way: OSMWay = osm_data.ways[42] assert way.id_ == 42 def test_nodes() -> None: - """ - Test OSM node parsing from XML. - """ + """Test OSM node parsing from XML.""" reader = OSMReader() - map_ = reader.parse_osm_text( + osm_data: OSMData = reader.parse_osm_text( """ @@ -77,18 +69,16 @@ def test_nodes() -> None: """ ) - way: OSMWay = map_.ways[2] + way: OSMWay = osm_data.ways[2] assert len(way.nodes) == 1 assert way.nodes[0].id_ == 1 assert way.tags["key"] == "value" def test_relation() -> None: - """ - Test OSM node parsing from XML. - """ - reader = OSMReader() - map_ = reader.parse_osm_text( + """Test OSM node parsing from XML.""" + reader: OSMReader = OSMReader() + osm_data: OSMData = reader.parse_osm_text( """ @@ -101,8 +91,8 @@ def test_relation() -> None: """ ) - assert 3 in map_.relations - relation: OSMRelation = map_.relations[3] + assert 3 in osm_data.relations + relation: OSMRelation = osm_data.relations[3] assert relation.id_ == 3 assert relation.tags["key"] == "value" assert len(relation.members) == 1 diff --git a/test/test_style.py b/test/test_style.py index b35ca7a..3722897 100644 --- a/test/test_style.py +++ b/test/test_style.py @@ -8,23 +8,17 @@ __email__ = "me@enzet.ru" def test_style_empty() -> None: - """ - Test constructing style of empty tags. - """ + """Test constructing style of empty tags.""" assert SCHEME.get_style({}, 18) == [] def test_style_unknown() -> None: - """ - Test constructing style of unknown tags. - """ + """Test constructing style of unknown tags.""" assert SCHEME.get_style({"aaa": "bbb"}, 18) == [] def test_style_area() -> None: - """ - Test constructing style of landuse=grass. - """ + """Test constructing style of landuse=grass.""" style = SCHEME.get_style({"landuse": "grass"}, 18) assert len(style) == 1 assert style[0].style == {"fill": "#CFE0A8", "stroke": "#BFD098"} diff --git a/test/test_tag.py b/test/test_tag.py index e5b365a..03c5ca7 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -10,18 +10,14 @@ __email__ = "me@enzet.ru" def check_length(value: str, expected: Optional[float]) -> None: - """ - Assert that constructed value is equals to an expected one. - """ + """Assert that constructed value is equals to an expected one.""" tagged = Tagged() tagged.tags["a"] = value assert tagged.get_length("a") == expected def test_meters() -> None: - """ - Test length in meters processing. - """ + """Test length in meters processing.""" check_length("50m", 50.0) check_length("50.m", 50.0) check_length("50.05m", 50.05) @@ -31,16 +27,12 @@ def test_meters() -> None: def test_kilometers() -> None: - """ - Test length in meters processing. - """ + """Test length in meters processing.""" check_length("50km", 50_000.0) check_length("50 km", 50_000.0) def test_miles() -> None: - """ - Test length in meters processing. - """ + """Test length in meters processing.""" check_length("1mi", 1609.344) check_length("50 mi", 50 * 1609.344) diff --git a/test/test_text.py b/test/test_text.py index f152d09..156c719 100644 --- a/test/test_text.py +++ b/test/test_text.py @@ -8,8 +8,6 @@ __email__ = "me@enzet.ru" def test_voltage() -> None: - """ - Test voltage tag value processing. - """ + """Test voltage tag value processing.""" assert format_voltage("42") == "42 V" assert format_voltage("42000") == "42 kV"