diff --git a/.travis.yml b/.travis.yml index dbb3174..7460084 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ language: python install: - pip install -r requirements.txt -script: pytest -v test.py +script: pytest -v diff --git a/requirements.txt b/requirements.txt index 763f986..271ebcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -svgwrite numpy>=1.18.1 -PyYAML>=4.2b1 +portolan +pyyaml>=4.2b1 +svgwrite urllib3>=1.25.6 diff --git a/roentgen/constructor.py b/roentgen/constructor.py index 01d8fb9..3a0acbe 100644 --- a/roentgen/constructor.py +++ b/roentgen/constructor.py @@ -1,8 +1,7 @@ import numpy as np -from hashlib import sha256 - from datetime import datetime +from hashlib import sha256 from typing import Any, Dict, List, Optional, Set from roentgen import ui @@ -30,6 +29,11 @@ class Node: self.layer = 0 self.is_for_node = is_for_node + def get_tag(self, key: str): + if key in self.tags: + return self.tags[key] + return None + class Way: """ @@ -95,13 +99,12 @@ def get_user_color(text: str, seed: str): return "#" + "0" * (6 - len(h)) + h -def get_time_color(time): +def get_time_color(time: datetime): """ Generate color based on time. """ if not time: return "#000000" - time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%SZ") delta = (datetime.now() - time).total_seconds() time_color = hex(0xFF - min(0xFF, int(delta / 500000.)))[2:] i_time_color = hex(min(0xFF, int(delta / 500000.)))[2:] diff --git a/roentgen/direction.py b/roentgen/direction.py index c5dd8f3..3a709aa 100644 --- a/roentgen/direction.py +++ b/roentgen/direction.py @@ -3,42 +3,51 @@ Direction tag support. Author: Sergey Vartanov (me@enzet.ru). """ -import math +from typing import Iterator, List, Optional, Union + import numpy as np - -from typing import Dict, List, Optional, Iterator - - -DIRECTIONS: Dict[str, np.array] = { - "N": np.array((0, -1)), - "E": np.array((1, 0)), - "W": np.array((-1, 0)), - "S": np.array((0, 1)), -} +from portolan import middle def parse_vector(text: str) -> np.array: """ - Parse vector from text representation: letters N, E, W, S or 360-degree - notation. E.g. NW, 270. + Parse vector from text representation: compass points or 360-degree + notation. E.g. "NW", "270". :param text: vector text representation :return: parsed normalized vector """ + def degree_to_radian(degree: float): + """ Convert value in degrees to radians. """ + return degree / 180 * np.pi - np.pi / 2 + try: - degree: float = float(text) / 180 * math.pi - math.pi / 2 - return np.array((math.cos(degree), math.sin(degree))) - except ValueError as e: - vector: np.array = np.array((0, 0)) - for char in text: # type: str - if char not in DIRECTIONS: - return None - vector += DIRECTIONS[char] - return vector / np.linalg.norm(vector) + radians: float = degree_to_radian(float(text)) + return np.array((np.cos(radians), np.sin(radians))) + except ValueError: + radians: float = degree_to_radian(middle(text)) + return np.array((np.cos(radians), np.sin(radians))) + + +def rotation_matrix(angle): + """ + Get a matrix to rotate 2D vector by the angle. + + :param angle: angle in radians + """ + return np.array([ + [np.cos(angle), np.sin(angle)], + [-np.sin(angle), np.cos(angle)]]) class Sector: + """ + Sector described by two vectors. + """ def __init__(self, text: str): + """ + :param text: sector text representation. E.g. "70-210", "N-NW" + """ self.start: Optional[np.array] self.end: Optional[np.array] @@ -47,16 +56,23 @@ class Sector: self.start = parse_vector(parts[0]) self.end = parse_vector(parts[1]) else: - self.start = parse_vector(text) - self.end = None + vector = parse_vector(text) + angle = np.pi / 12 + self.start = np.dot(rotation_matrix(angle), vector) + self.end = np.dot(rotation_matrix(-angle), vector) - def draw(self, center: np.array, radius: float) -> List: + def draw(self, center: np.array, radius: float) \ + -> Optional[List[Union[float, str, np.array]]]: """ - Construct SVG "d" for arc element. + Construct SVG path commands for arc element. - :param center: arc center + :param center: arc center point :param radius: arc radius + :return: SVG path commands """ + if self.start is None or self.end is None: + return None + start: np.array = center + radius * self.end end: np.array = center + radius * self.start @@ -79,5 +95,14 @@ class DirectionSet: def __str__(self): return ", ".join(map(str, self.sectors)) - def draw(self, center: np.array, radius: float) -> Iterator[str]: - return map(lambda x: x.draw(center, radius), self.sectors) + def draw(self, center: np.array, radius: float) -> Iterator[List]: + """ + Construct SVG "d" for arc elements. + + :param center: center point of all arcs + :param radius: radius of all arcs + :return: list of "d" values + """ + return filter( + lambda x: x is not None, + map(lambda x: x.draw(center, radius), self.sectors)) diff --git a/roentgen/flinger.py b/roentgen/flinger.py index c9d8124..7dbcac6 100644 --- a/roentgen/flinger.py +++ b/roentgen/flinger.py @@ -5,6 +5,8 @@ import math import numpy as np from typing import Optional +from roentgen.util import MinMax + def get_ratio(maximum, minimum, ratio: float = 1): return (maximum[0] - minimum[0]) * ratio / (maximum[1] - minimum[1]) diff --git a/roentgen/grid.py b/roentgen/grid.py index b230e62..d1901fc 100644 --- a/roentgen/grid.py +++ b/roentgen/grid.py @@ -1,10 +1,10 @@ """ +Icon grid drawing. + Author: Sergey Vartanov (me@enzet.ru). """ import numpy as np -import os -import random -import svgwrite +from svgwrite import Drawing import yaml from roentgen.extract_icon import Icon, IconExtractor @@ -12,32 +12,21 @@ from roentgen.extract_icon import Icon, IconExtractor from typing import List -def draw_grid(): +def draw_grid(step: float = 24, columns: int = 16): + """ + Draw all possible icon combinations in grid. + + :param step: horizontal and vertical distance between icons + :param columns: the number of columns in grid + """ tags_file_name = "data/tags.yml" scheme = yaml.load(open(tags_file_name), Loader=yaml.FullLoader) icons_file_name = "icons/icons.svg" icon_grid_file_name = "icon_grid.svg" - icon_colors_file_name = "data/icon_colors" - - icon_colors = [("FFFFFF", "444444")] - - if os.path.isfile(icon_colors_file_name): - icon_colors_file = open(icon_colors_file_name) - for line in icon_colors_file.read().split("\n"): - background_color = \ - hex(int(line[0:3]))[2:] + hex(int(line[3:6]))[2:] + \ - hex(int(line[6:9]))[2:] - foreground_color = \ - hex(int(line[10:13]))[2:] + hex(int(line[13:16]))[2:] + \ - hex(int(line[16:19]))[2:] - icon_colors.append((background_color, foreground_color)) - - step: float = 24 - - width: float = 24 * 16 + width: float = step * columns point: np.array = np.array((step / 2, step / 2)) to_draw = [] @@ -89,20 +78,20 @@ def draw_grid(): icons.append(icon_set) number += 1 - height = int(number / (width / step) + 1) * step + height: int = int(int(number / (width / step) + 1) * step) - svg = svgwrite.Drawing(icon_grid_file_name, (width, height)) + svg: Drawing = Drawing(icon_grid_file_name, (width, height)) svg.add(svg.rect((0, 0), (width, height), fill="#FFFFFF")) for icon in icons: - background_color, foreground_color = random.choice(icon_colors) + background_color, foreground_color = "#FFFFFF", "#444444" svg.add(svg.rect( point - np.array((-10, -10)), (20, 20), - fill=f"#{background_color}")) + fill=background_color)) for i in icon: # type: Icon path = i.get_path(svg, point) - path.update({"fill": f"#{foreground_color}"}) + path.update({"fill": foreground_color}) svg.add(path) point += np.array((step, 0)) if point[0] > width - 8: @@ -112,4 +101,5 @@ def draw_grid(): print(f"Icons: {number}.") - svg.write(open(icon_grid_file_name, "w")) + with open(icon_grid_file_name, "w") as output_file: + svg.write(output_file) diff --git a/roentgen/mapper.py b/roentgen/mapper.py index 4cadcbb..49c7e58 100644 --- a/roentgen/mapper.py +++ b/roentgen/mapper.py @@ -15,6 +15,7 @@ from svgwrite.text import Text from typing import Dict, List from roentgen import ui +from roentgen.address import get_address from roentgen.constructor import Constructor, get_path, Node, Way from roentgen.flinger import GeoFlinger, Geo from roentgen.grid import draw_grid @@ -39,7 +40,7 @@ class Painter: """ def __init__( self, show_missing_tags: bool, overlap: int, draw_nodes: bool, - mode: str, draw_captions: bool, map_: Map, flinger: GeoFlinger, + mode: str, draw_captions: str, map_: Map, flinger: GeoFlinger, svg: svgwrite.Drawing, icon_extractor: IconExtractor, scheme: Scheme): @@ -47,7 +48,7 @@ class Painter: self.overlap: int = overlap self.draw_nodes: bool = draw_nodes self.mode: str = mode - self.draw_captions = draw_captions + self.draw_captions: str = draw_captions self.map_: Map = map_ self.flinger: GeoFlinger = flinger @@ -150,7 +151,6 @@ class Painter: Construct labels for not processed tags. """ texts = [] - address: List[str] = [] name = None alt_name = None if "name" in tags: @@ -179,24 +179,9 @@ class Painter: else: alt_name = "" alt_name += "бывш. " + tags["old_name"] - if "addr:postcode" in tags and self.draw_captions != "main": - address.append(tags["addr:postcode"]) - tags.pop("addr:postcode", None) - if "addr:country" in tags and self.draw_captions != "main": - address.append(tags["addr:country"]) - tags.pop("addr:country", None) - if "addr:city" in tags and self.draw_captions != "main": - address.append(tags["addr:city"]) - tags.pop("addr:city", None) - if "addr:street" in tags and self.draw_captions != "main": - street = tags["addr:street"] - if street.startswith("улица "): - street = "ул. " + street[len("улица "):] - address.append(street) - tags.pop("addr:street", None) - if "addr:housenumber" in tags: - address.append(tags["addr:housenumber"]) - tags.pop("addr:housenumber", None) + + address = get_address(tags, self.draw_captions) + if name: texts.append({"text": name, "fill": "#000000"}) if alt_name: @@ -338,8 +323,7 @@ class Painter: # Trees for node in nodes: - if not("natural" in node.tags and - node.tags["natural"] == "tree" and + if not(node.get_tag("natural") == "tree" and "diameter_crown" in node.tags): continue self.svg.add(Circle( @@ -348,23 +332,27 @@ class Painter: # Directions - for node in nodes: - if not ("tourism" in node.tags and - node.tags["tourism"] == "viewpoint" and - "direction" in node.tags): + for node in nodes: # type: Node + direction = None + if node.get_tag("tourism") == "viewpoint": + direction = node.get_tag("direction") + if node.get_tag("man_made") == "surveillance": + direction = node.get_tag("camera:direction") + + if not direction: continue - DIRECTION_RADIUS: int = 50 + DIRECTION_RADIUS: int = 25 DIRECTION_COLOR: str = self.scheme.get_color("direction_color") - for path in DirectionSet(node.tags["direction"])\ + for path in DirectionSet(direction)\ .draw(node.point, DIRECTION_RADIUS): gradient = self.svg.defs.add(self.svg.radialGradient( center=node.point, r=DIRECTION_RADIUS, gradientUnits="userSpaceOnUse")) gradient\ .add_stop_color(0, DIRECTION_COLOR, opacity=0)\ - .add_stop_color(1, DIRECTION_COLOR, opacity=0.7) + .add_stop_color(1, DIRECTION_COLOR, opacity=0.4) self.svg.add(self.svg.path( d=["M", node.point] + path + ["L", node.point, "Z"], fill=gradient.get_paint_server())) @@ -372,12 +360,14 @@ class Painter: # All other nodes nodes = sorted(nodes, key=lambda x: x.layer) - for node in nodes: # type: Node + for index, node in enumerate(nodes): # type: int, Node if "natural" in node.tags and \ node.tags["natural"] == "tree" and \ "diameter_crown" in node.tags: continue + ui.progress_bar(index, len(nodes), step=10) self.draw_shapes(node, points) + ui.progress_bar(-1, len(nodes), step=10) if self.draw_captions == "no": return @@ -405,7 +395,7 @@ class Painter: point = np.array(list(map(lambda x: int(x), point))) title: str = "\n".join(map(lambda x: x + ": " + tags[x], tags)) - path = icon.get_path(self.svg, point) + path: svgwrite.path.Path = icon.get_path(self.svg, point) path.update({"fill": fill}) path.set_desc(title=title) self.svg.add(path) @@ -518,15 +508,14 @@ def main(): min1 = Geo(boundary_box[1], boundary_box[0]) max1 = Geo(boundary_box[3], boundary_box[2]) - authors = {} missing_tags = {} points = [] - scheme = Scheme(TAGS_FILE_NAME, COLORS_FILE_NAME) + scheme: Scheme = Scheme(TAGS_FILE_NAME, COLORS_FILE_NAME) - flinger = GeoFlinger(min1, max1, [0, 0], [w, h]) + flinger: GeoFlinger = GeoFlinger(min1, max1, [0, 0], [w, h]) - icon_extractor = IconExtractor(ICONS_FILE_NAME) + icon_extractor: IconExtractor = IconExtractor(ICONS_FILE_NAME) def check_level(x): """ Draw objects on all levels. """ @@ -544,14 +533,14 @@ def main(): """ Draw objects on the specified level. """ return not check_level_number(x, float(options.level)) - constructor = Constructor( + constructor: Constructor = Constructor( check_level, options.mode, options.seed, map_, flinger, scheme) if options.draw_ways: constructor.construct_ways() constructor.construct_relations() constructor.construct_nodes() - painter = Painter( + painter: Painter = Painter( show_missing_tags=options.show_missing_tags, overlap=options.overlap, draw_nodes=options.draw_nodes, mode=options.mode, draw_captions=options.draw_captions, @@ -569,57 +558,11 @@ def main(): (w - flinger.space[0], 0), (flinger.space[0], h), fill="#FFFFFF")) if options.show_index: - print(min1.lon, max1.lon) - print(min1.lat, max1.lat) - - lon_step = 0.001 - lat_step = 0.001 - - matrix = [] - - lat_number = int((max1.lat - min1.lat) / lat_step) + 1 - lon_number = int((max1.lon - min1.lon) / lon_step) + 1 - - for i in range(lat_number): - row = [] - for j in range(lon_number): - row.append(0) - matrix.append(row) - - for node_id in map_.node_map: - node = map_.node_map[node_id] - i = int((node.lat - min1.lat) / lat_step) - j = int((node.lon - min1.lon) / lon_step) - if (0 <= i < lat_number) and (0 <= j < lon_number): - matrix[i][j] += 1 - if "tags" in node: - matrix[i][j] += len(node.tags) - - for way_id in map_.way_map: - way = map_.way_map[way_id] - if "tags" in way: - for node_id in way.nodes: - node = map_.node_map[node_id] - i = int((node.lat - min1.lat) / lat_step) - j = int((node.lon - min1.lon) / lon_step) - if (0 <= i < lat_number) and (0 <= j < lon_number): - matrix[i][j] += len(way.tags) / float( - len(way.nodes)) - - for i in range(lat_number): - for j in range(lon_number): - t1 = flinger.fling(Geo( - min1.lat + i * lat_step, min1.lon + j * lon_step)) - t2 = flinger.fling(Geo( - min1.lat + (i + 1) * lat_step, - min1.lon + (j + 1) * lon_step)) - svg.add(Text( - str(int(matrix[i][j])), - (((t1 + t2) * 0.5)[0], ((t1 + t2) * 0.5)[1] + 40), - font_size=80, fill="440000", - opacity=0.1, align="center")) + draw_index(flinger, map_, max1, min1, svg) + print("Writing output SVG...") svg.write(open(options.output_file_name, "w")) + print("Done") top_missing_tags = \ sorted(missing_tags.keys(), key=lambda x: -missing_tags[x]) @@ -629,6 +572,47 @@ def main(): f'- {{tag: "{tag}", count: {missing_tags[tag]}}}\n') missing_tags_file.close() - top_authors = sorted(authors.keys(), key=lambda x: -authors[x]) - for author in top_authors: - print(f"{author}: {authors[author]}") + +def draw_index(flinger, map_, max1, min1, svg): + print(min1.lon, max1.lon) + print(min1.lat, max1.lat) + lon_step = 0.001 + lat_step = 0.001 + matrix = [] + lat_number = int((max1.lat - min1.lat) / lat_step) + 1 + lon_number = int((max1.lon - min1.lon) / lon_step) + 1 + for i in range(lat_number): + row = [] + for j in range(lon_number): + row.append(0) + matrix.append(row) + for node_id in map_.node_map: # type: int + node = map_.node_map[node_id] + i = int((node.lat - min1.lat) / lat_step) + j = int((node.lon - min1.lon) / lon_step) + if (0 <= i < lat_number) and (0 <= j < lon_number): + matrix[i][j] += 1 + if "tags" in node: + matrix[i][j] += len(node.tags) + for way_id in map_.way_map: # type: int + way = map_.way_map[way_id] + if "tags" in way: + for node_id in way.nodes: + node = map_.node_map[node_id] + i = int((node.lat - min1.lat) / lat_step) + j = int((node.lon - min1.lon) / lon_step) + if (0 <= i < lat_number) and (0 <= j < lon_number): + matrix[i][j] += len(way.tags) / float( + len(way.nodes)) + for i in range(lat_number): + for j in range(lon_number): + t1 = flinger.fling(Geo( + min1.lat + i * lat_step, min1.lon + j * lon_step)) + t2 = flinger.fling(Geo( + min1.lat + (i + 1) * lat_step, + min1.lon + (j + 1) * lon_step)) + svg.add(Text( + str(int(matrix[i][j])), + (((t1 + t2) * 0.5)[0], ((t1 + t2) * 0.5)[1] + 40), + font_size=80, fill="440000", + opacity=0.1, align="center")) diff --git a/roentgen/osm_reader.py b/roentgen/osm_reader.py index 89509c3..5044e9e 100644 --- a/roentgen/osm_reader.py +++ b/roentgen/osm_reader.py @@ -3,6 +3,7 @@ Reading OpenStreetMap data from XML file. Author: Sergey Vartanov """ +from datetime import datetime from typing import Dict, List, Optional from roentgen import ui @@ -61,7 +62,7 @@ class OSMWay: self.visible: Optional[str] = None self.changeset: Optional[str] = None self.user: Optional[str] = None - self.timestamp: Optional[str] = None + self.timestamp: Optional[datetime] = None self.uid: Optional[str] = None def parse_from_xml(self, text: str, is_full: bool = False) -> "OSMWay": @@ -76,7 +77,8 @@ class OSMWay: if is_full: self.visible = get_value("visible", text) self.changeset = get_value("changeset", text) - self.timestamp = get_value("timestamp", text) + self.timestamp = datetime.strptime( + get_value("timestamp", text), "%Y-%m-%dT%H:%M:%SZ") self.user = get_value("user", text) self.uid = get_value("uid", text) @@ -190,8 +192,7 @@ class OSMReader: if not parse_nodes: if parse_ways or parse_relations: continue - else: - break + break if line[-3] == "/": node: OSMNode = OSMNode().parse_from_xml(line[7:-3], full) self.map_.node_map[node.id_] = node @@ -206,8 +207,7 @@ class OSMReader: if not parse_ways: if parse_relations: continue - else: - break + break if line[-3] == '/': way = OSMWay().parse_from_xml(line[6:-3], full) self.map_.way_map[way.id_] = way diff --git a/roentgen/ui.py b/roentgen/ui.py index 210000d..d28964b 100644 --- a/roentgen/ui.py +++ b/roentgen/ui.py @@ -39,7 +39,8 @@ def parse_options(args): "-s", "--size", metavar=",", help="output SVG file size in pixels", - dest="size") + dest="size", + required=True) parser.add_argument( "-nn", "--no-draw-nodes", dest="draw_nodes", diff --git a/roentgen/util.py b/roentgen/util.py index 932ab9e..2e834f3 100644 --- a/roentgen/util.py +++ b/roentgen/util.py @@ -5,4 +5,4 @@ class MinMax: def add(self, 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_ \ No newline at end of file + self.max_ = value if not self.max_ or value > self.max_ else self.max_ diff --git a/test/test_direction.py b/test/test_direction.py new file mode 100644 index 0000000..00c5a19 --- /dev/null +++ b/test/test_direction.py @@ -0,0 +1,22 @@ +""" +Test direction processing. + +Author: Sergey Vartanov (me@enzet.ru). +""" +import numpy as np + +from roentgen.direction import parse_vector + + +def test_compass_points_1(): + assert np.allclose(parse_vector("N"), np.array([0, -1])) + + +def test_compass_points_2(): + root: np.float64 = -np.sqrt(2) / 2 + assert np.allclose(parse_vector("NW"), np.array([root, root])) + + +def test_compass_points_3(): + assert np.allclose( + parse_vector("SSW"), np.array([-0.38268343, 0.92387953])) diff --git a/test.py b/test/test_icons.py similarity index 85% rename from test.py rename to test/test_icons.py index 4bc5542..8b97a6a 100644 --- a/test.py +++ b/test/test_icons.py @@ -2,10 +2,6 @@ Author: Sergey Vartanov (me@enzet.ru). """ -import os -import random -import yaml - from roentgen.flinger import map_ from roentgen.grid import draw_grid diff --git a/test_main.py b/test_main.py new file mode 100644 index 0000000..3a7c3c0 --- /dev/null +++ b/test_main.py @@ -0,0 +1,3 @@ +def test_main(): + assert True +