Adding features to find public toilets and shopping streets #41
| @@ -47,7 +47,7 @@ def new_trip(preferences: Preferences, | |||||||
|         raise HTTPException(status_code=406, |         raise HTTPException(status_code=406, | ||||||
|                             detail="Start coordinates not provided") |                             detail="Start coordinates not provided") | ||||||
|     if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180): |     if not (-90 <= start[0] <= 90 or -180 <= start[1] <= 180): | ||||||
|         raise HTTPException(status_code=423, |         raise HTTPException(status_code=422, | ||||||
|                             detail="Start coordinates not in range") |                             detail="Start coordinates not in range") | ||||||
|     if end is None: |     if end is None: | ||||||
|         end = start |         end = start | ||||||
|   | |||||||
| @@ -6,8 +6,11 @@ from sklearn.cluster import DBSCAN | |||||||
| from sklearn.decomposition import PCA | from sklearn.decomposition import PCA | ||||||
| import matplotlib.pyplot as plt | import matplotlib.pyplot as plt | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
|  | from OSMPythonTools.overpass import Overpass, overpassQueryBuilder | ||||||
|  | from math import sin, cos, sqrt, atan2, radians | ||||||
|  |  | ||||||
|  |  | ||||||
|  | EARTH_RADIUS_KM = 6373 | ||||||
|  |  | ||||||
| class ShoppingLocation(BaseModel): | class ShoppingLocation(BaseModel): | ||||||
|     type: Literal['street', 'area'] |     type: Literal['street', 'area'] | ||||||
| @@ -15,10 +18,34 @@ class ShoppingLocation(BaseModel): | |||||||
|     centroid: tuple |     centroid: tuple | ||||||
|     start: Optional[list] = None |     start: Optional[list] = None | ||||||
|     end: Optional[list] = None |     end: Optional[list] = None | ||||||
|     end: Optional[tuple] = None |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Output to frontend | ||||||
|  | class Landmark(BaseModel) : | ||||||
|  |     # Properties of the landmark | ||||||
|  |     name : str | ||||||
|  |     type: Literal['sightseeing', 'nature', 'shopping', 'start', 'finish'] | ||||||
|  |     location : tuple | ||||||
|  |     osm_type : str | ||||||
|  |     osm_id : int | ||||||
|  |     attractiveness : int | ||||||
|  |     n_tags : int | ||||||
|  |     image_url : Optional[str] = None | ||||||
|  |     website_url : Optional[str] = None | ||||||
|  |     description : Optional[str] = None                          # TODO future | ||||||
|  |     duration : Optional[int] = 0 | ||||||
|  |     name_en : Optional[str] = None | ||||||
|  |  | ||||||
|  |     # Additional properties depending on specific tour | ||||||
|  |     must_do : Optional[bool] = False | ||||||
|  |     must_avoid : Optional[bool] = False | ||||||
|  |     is_secondary : Optional[bool] = False | ||||||
|  |  | ||||||
|  |     time_to_reach_next : Optional[int] = 0 | ||||||
|  |     next_uuid : Optional[str] = None | ||||||
|  |  | ||||||
|  |  | ||||||
| def extract_points(filestr: str) : | def extract_points(filestr: str) : | ||||||
|     """ |     """ | ||||||
|     Extract points from geojson file. |     Extract points from geojson file. | ||||||
| @@ -44,6 +71,37 @@ def extract_points(filestr: str) : | |||||||
|     return np.array(points) |     return np.array(points) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int: | ||||||
|  |     """ | ||||||
|  |     Calculate the time in minutes to travel from one location to another. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         p1 (Tuple[float, float]): Coordinates of the starting location. | ||||||
|  |         p2 (Tuple[float, float]): Coordinates of the destination. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |         int: Time to travel from p1 to p2 in minutes. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     if p1 == p2: | ||||||
|  |         return 0 | ||||||
|  |     else: | ||||||
|  |         # Compute the distance in km along the surface of the Earth | ||||||
|  |         # (assume spherical Earth) | ||||||
|  |         # this is the haversine formula, stolen from stackoverflow | ||||||
|  |         # in order to not use any external libraries | ||||||
|  |         lat1, lon1 = radians(p1[0]), radians(p1[1]) | ||||||
|  |         lat2, lon2 = radians(p2[0]), radians(p2[1]) | ||||||
|  |  | ||||||
|  |         dlon = lon2 - lon1 | ||||||
|  |         dlat = lat2 - lat1 | ||||||
|  |  | ||||||
|  |         a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 | ||||||
|  |         c = 2 * atan2(sqrt(a), sqrt(1 - a)) | ||||||
|  |  | ||||||
|  |         return EARTH_RADIUS_KM * c | ||||||
|  |  | ||||||
| def filter_clusters(cluster_points, cluster_labels): | def filter_clusters(cluster_points, cluster_labels): | ||||||
|     """ |     """ | ||||||
|     Remove clusters of less importance. |     Remove clusters of less importance. | ||||||
| @@ -113,16 +171,16 @@ def fit_lines(points, labels): | |||||||
|         if np.linalg.norm(t) <= 0.0045 : |         if np.linalg.norm(t) <= 0.0045 : | ||||||
|             loc = ShoppingLocation( |             loc = ShoppingLocation( | ||||||
|                 type='area', |                 type='area', | ||||||
|                 centroid=tuple(centroid), |                 centroid=tuple((centroid[1], centroid[0])), | ||||||
|                 importance = len(cluster_points) |                 importance = len(cluster_points), | ||||||
|             ) |             ) | ||||||
|         else : |         else : | ||||||
|             loc = ShoppingLocation( |             loc = ShoppingLocation( | ||||||
|                 type='street', |                 type='street', | ||||||
|                 centroid=tuple(centroid), |                 centroid=tuple(centroid), | ||||||
|  |                 importance = len(cluster_points), | ||||||
|                 start=start_point, |                 start=start_point, | ||||||
|                 end=end_point, |                 end=end_point | ||||||
|                 importance = len(cluster_points) |  | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         locations.append(loc) |         locations.append(loc) | ||||||
| @@ -137,16 +195,73 @@ def fit_lines(points, labels): | |||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_landmark(shopping_location: ShoppingLocation): | ||||||
|  |  | ||||||
|  |     # Define the bounding box for a given radius around the coordinates | ||||||
|  |     lat, lon = shopping_location.centroid | ||||||
|  |     bbox = ("around:500", str(lat), str(lon)) | ||||||
|  |      | ||||||
|  |     overpass = Overpass() | ||||||
|  |  | ||||||
|  |     # Query neighborhoods and shopping malls | ||||||
|  |     query = overpassQueryBuilder( | ||||||
|  |         bbox = bbox, | ||||||
|  |         elementType = ['node'], | ||||||
|  |         selector = ['"place"="suburb"'], | ||||||
|  |         includeCenter = True, | ||||||
|  |         out = 'body' | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         result = overpass.query(query) | ||||||
|  |         print(f'query OK with {len(result.elements())} elements') | ||||||
|  |     except Exception as e: | ||||||
|  |         raise Exception("query unsuccessful") | ||||||
|  |  | ||||||
|  |     min_dist = float('inf') | ||||||
|  |     new_name = 'Shopping Area' | ||||||
|  |     new_name_en = None | ||||||
|  |     osm_id = 0 | ||||||
|  |     osm_type = 'node' | ||||||
|  |  | ||||||
|  |     for elem in result.elements(): | ||||||
|  |         location = (elem.centerLat(), elem.centerLon()) | ||||||
|  |         print(f"Distance : {get_distance(shopping_location.centroid, location)}") | ||||||
|  |         if get_distance(shopping_location.centroid, location) < min_dist : | ||||||
|  |             new_name = elem.tag('name') | ||||||
|  |             osm_type = elem.type()              # Add type: 'way' or 'relation' | ||||||
|  |             osm_id = elem.id()                  # Add OSM id  | ||||||
|  |  | ||||||
|  |             print("closer thing found") | ||||||
|  |  | ||||||
|  |             # add english name if it exists | ||||||
|  |             try : | ||||||
|  |                 new_name_en = elem.tag('name:en') | ||||||
|  |             except: | ||||||
|  |                 pass  | ||||||
|  |  | ||||||
|  |     return Landmark( | ||||||
|  |         name=new_name, | ||||||
|  |         type='shopping', | ||||||
|  |         location=shopping_location.centroid,              # TODO: use the fact the we can also recognize streets. | ||||||
|  |         attractiveness=shopping_location.importance, | ||||||
|  |         n_tags=0, | ||||||
|  |         osm_id=osm_id, | ||||||
|  |         osm_type=osm_type, | ||||||
|  |         name_en=new_name_en | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Extract points | # Extract points | ||||||
| points = extract_points('strasbourg_data.json') | points = extract_points('lyon_data.json') | ||||||
|  |  | ||||||
| # Create a figure with 1 row and 3 columns for side-by-side plots | ######## Create a figure with 1 row and 3 columns for side-by-side plots | ||||||
| fig, axes = plt.subplots(1, 3, figsize=(15, 5)) | fig, axes = plt.subplots(1, 3, figsize=(15, 5)) | ||||||
|  | # Plot Raw data points | ||||||
| # Plot 0: Raw data points |  | ||||||
| axes[0].set_title('Raw Data') | axes[0].set_title('Raw Data') | ||||||
| axes[0].scatter(points[:, 0], points[:, 1], color='blue', s=20) | axes[0].scatter(points[:, 0], points[:, 1], color='blue', s=20) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Apply DBSCAN to find clusters. Choose different settings for different cities. | # Apply DBSCAN to find clusters. Choose different settings for different cities. | ||||||
| if len(points) > 400 : | if len(points) > 400 : | ||||||
|     dbscan = DBSCAN(eps=0.00118, min_samples=15, algorithm='kd_tree')  # for large cities |     dbscan = DBSCAN(eps=0.00118, min_samples=15, algorithm='kd_tree')  # for large cities | ||||||
| @@ -160,40 +275,41 @@ clustered_points = points[labels != -1] | |||||||
| clustered_labels = labels[labels != -1] | clustered_labels = labels[labels != -1] | ||||||
| noise_points = points[labels == -1]  | noise_points = points[labels == -1]  | ||||||
|  |  | ||||||
| # Plot n°1: DBSCAN Clustering Results | ######## Plot n°1: DBSCAN Clustering Results | ||||||
| axes[1].set_title('DBSCAN Clusters') | axes[1].set_title('DBSCAN Clusters') | ||||||
| axes[1].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=20) | axes[1].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=20) | ||||||
| axes[1].scatter(noise_points[:, 0], noise_points[:, 1], c='blue', s=7, label='Noise') | axes[1].scatter(noise_points[:, 0], noise_points[:, 1], c='blue', s=7, label='Noise') | ||||||
|  |  | ||||||
| clustered_points, clustered_labels = filter_clusters(clustered_points, clustered_labels) | clustered_points, clustered_labels = filter_clusters(clustered_points, clustered_labels) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Fit lines | # Fit lines | ||||||
| corners, locations = fit_lines(clustered_points, clustered_labels) | corners, locations = fit_lines(clustered_points, clustered_labels) | ||||||
| (xmin, xmax, ymin, ymax) = corners | (xmin, xmax, ymin, ymax) = corners | ||||||
|  |  | ||||||
|  |  | ||||||
| # Plot clustered points in normal size and noise points separately | ######## Plot clustered points in normal size and noise points separately | ||||||
| axes[2].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=30) | axes[2].scatter(clustered_points[:, 0], clustered_points[:, 1], c=clustered_labels, cmap='rainbow', s=30) | ||||||
| # axes[2].scatter(noise_points[:, 0], noise_points[:, 1], c='blue', s=10, label='Noise') |  | ||||||
|  |  | ||||||
| # Step 4: Plot the detected lines in the final plot |  | ||||||
|  | # Create a list of Landmarks for the shopping things | ||||||
|  | shopping_landmarks = [] | ||||||
| for loc in locations : | for loc in locations : | ||||||
|  |     landmark = create_landmark(loc) | ||||||
|  |     shopping_landmarks.append(landmark) | ||||||
|  |     print(f"{landmark.name} is a shopping area with a score of {landmark.attractiveness}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ####### Plot the detected lines in the final plot ####### | ||||||
|  | for loc in locations: | ||||||
|     if loc.type == 'street' : |     if loc.type == 'street' : | ||||||
|         line_x = loc.start |         line_x = loc.start | ||||||
|         line_y = loc.end |         line_y = loc.end | ||||||
|         axes[2].plot(line_x, line_y, color='lime', linewidth=3) |         axes[2].plot(line_x, line_y, color='lime', linewidth=3) | ||||||
|     else : |     else : | ||||||
|         axes[2].scatter(loc.centroid[0], loc.centroid[1], color='None', edgecolors='lime', s=200, linewidth=3) |         axes[2].scatter(loc.centroid[0], loc.centroid[1], color='None', edgecolors='lime', s=200, linewidth=3) | ||||||
|     # print(8) |  | ||||||
|  |  | ||||||
| axes[2].set_title('PCA Fitted Lines on Clusters') | axes[2].set_title('PCA Fitted Lines on Clusters') | ||||||
|  |  | ||||||
| # print(all_x) |  | ||||||
|  |  | ||||||
| # Adjust the axis limit for previous plots |  | ||||||
| axes[0].set_xlim(xmin-0.01, xmax+0.01) | axes[0].set_xlim(xmin-0.01, xmax+0.01) | ||||||
| axes[0].set_ylim(ymin-0.01, ymax+0.01) | axes[0].set_ylim(ymin-0.01, ymax+0.01) | ||||||
|  |  | ||||||
| @@ -203,8 +319,5 @@ axes[1].set_ylim(ymin-0.01, ymax+0.01) | |||||||
| axes[2].set_xlim(xmin-0.01, xmax+0.01) | axes[2].set_xlim(xmin-0.01, xmax+0.01) | ||||||
| axes[2].set_ylim(ymin-0.01, ymax+0.01) | axes[2].set_ylim(ymin-0.01, ymax+0.01) | ||||||
|  |  | ||||||
| # Adjust layout for better spacing | # plt.tight_layout() | ||||||
| plt.tight_layout() | # plt.show() | ||||||
|  |  | ||||||
| # Show the plots |  | ||||||
| plt.show() |  | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								backend/src/structs/shopping_location.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								backend/src/structs/shopping_location.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | from typing import Literal, Optional | ||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ShoppingLocation(BaseModel): | ||||||
|  |     """" | ||||||
|  |     A classe representing an interesting area for shopping. | ||||||
|  |      | ||||||
|  |     It can represent either a general area or a specifc route with start and end point. | ||||||
|  |     The importance represents the number of shops found in this cluster. | ||||||
|  |      | ||||||
|  |     Attributes: | ||||||
|  |         type :       either a 'street' or 'area' (representing a denser field of shops). | ||||||
|  |         importance : size of the cluster (number of points). | ||||||
|  |         centroid :   center of the cluster. | ||||||
|  |         start :      if the type is a street it goes from here... | ||||||
|  |         end :        ...to here | ||||||
|  |     """ | ||||||
|  |     type: Literal['street', 'area'] | ||||||
|  |     importance: int | ||||||
|  |     centroid: tuple | ||||||
|  |     start: Optional[list] = None | ||||||
|  |     end: Optional[list] = None | ||||||
							
								
								
									
										0
									
								
								backend/src/utils/cluster_processing.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								backend/src/utils/cluster_processing.py
									
									
									
									
									
										Normal file
									
								
							| @@ -48,3 +48,35 @@ def get_time(p1: tuple[float, float], p2: tuple[float, float]) -> int: | |||||||
|     walk_time = walk_distance / AVERAGE_WALKING_SPEED * 60 |     walk_time = walk_distance / AVERAGE_WALKING_SPEED * 60 | ||||||
|  |  | ||||||
|     return round(walk_time) |     return round(walk_time) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_distance(p1: tuple[float, float], p2: tuple[float, float]) -> int: | ||||||
|  |     """ | ||||||
|  |     Calculate the time in minutes to travel from one location to another. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         p1 (Tuple[float, float]): Coordinates of the starting location. | ||||||
|  |         p2 (Tuple[float, float]): Coordinates of the destination. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |         int: Time to travel from p1 to p2 in minutes. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     if p1 == p2: | ||||||
|  |         return 0 | ||||||
|  |     else: | ||||||
|  |         # Compute the distance in km along the surface of the Earth | ||||||
|  |         # (assume spherical Earth) | ||||||
|  |         # this is the haversine formula, stolen from stackoverflow | ||||||
|  |         # in order to not use any external libraries | ||||||
|  |         lat1, lon1 = radians(p1[0]), radians(p1[1]) | ||||||
|  |         lat2, lon2 = radians(p2[0]), radians(p2[1]) | ||||||
|  |  | ||||||
|  |         dlon = lon2 - lon1 | ||||||
|  |         dlat = lat2 - lat1 | ||||||
|  |  | ||||||
|  |         a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 | ||||||
|  |         c = 2 * atan2(sqrt(a), sqrt(1 - a)) | ||||||
|  |  | ||||||
|  |         return EARTH_RADIUS_KM * c | ||||||
| @@ -79,7 +79,8 @@ class LandmarkManager: | |||||||
|         # use set to avoid duplicates, this requires some __methods__ to be set in Landmark |         # use set to avoid duplicates, this requires some __methods__ to be set in Landmark | ||||||
|         all_landmarks = set() |         all_landmarks = set() | ||||||
|  |  | ||||||
|         bbox = self.create_bbox(center_coordinates, reachable_bbox_side) |         # Create a bbox using the around | ||||||
|  |         bbox = tuple((f"around:{reachable_bbox_side/2}", str(center_coordinates[0]), str(center_coordinates[1]))) | ||||||
|         # list for sightseeing |         # list for sightseeing | ||||||
|         if preferences.sightseeing.score != 0: |         if preferences.sightseeing.score != 0: | ||||||
|             score_function = lambda score: score * 10 * preferences.sightseeing.score / 5 |             score_function = lambda score: score * 10 * preferences.sightseeing.score / 5 | ||||||
| @@ -97,7 +98,7 @@ class LandmarkManager: | |||||||
|             score_function = lambda score: score * 10 * preferences.shopping.score / 5 |             score_function = lambda score: score * 10 * preferences.shopping.score / 5 | ||||||
|             current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, score_function) |             current_landmarks = self.fetch_landmarks(bbox, self.amenity_selectors['shopping'], preferences.shopping.type, score_function) | ||||||
|             # set time for all shopping activites : |             # set time for all shopping activites : | ||||||
|             for landmark in current_landmarks : landmark.duration = 45 |             for landmark in current_landmarks : landmark.duration = 30 | ||||||
|             all_landmarks.update(current_landmarks) |             all_landmarks.update(current_landmarks) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -151,36 +152,24 @@ class LandmarkManager: | |||||||
|             return 0 |             return 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|     def create_bbox(self, coordinates: tuple[float, float], reachable_bbox_side: int) -> tuple[float, float, float, float]: |     # def create_bbox(self, coordinates: tuple[float, float], reachable_bbox_side: int) -> tuple[float, float, float, float]: | ||||||
|         """ |     #     """ | ||||||
|         Create a bounding box around the given coordinates. |     #     Create a bounding box around the given coordinates. | ||||||
|  |  | ||||||
|         Args: |     #     Args: | ||||||
|             coordinates (tuple[float, float]): The latitude and longitude of the center of the bounding box. |     #         coordinates (tuple[float, float]): The latitude and longitude of the center of the bounding box. | ||||||
|             reachable_bbox_side (int): The side length of the bounding box in meters. |     #         reachable_bbox_side (int): The side length of the bounding box in meters. | ||||||
|  |  | ||||||
|         Returns: |     #     Returns: | ||||||
|             tuple[float, float, float, float]: The minimum latitude, minimum longitude, maximum latitude, and maximum longitude |     #         tuple[float, float, float, float]: The minimum latitude, minimum longitude, maximum latitude, and maximum longitude | ||||||
|                                                 defining the bounding box. |     #                                             defining the bounding box. | ||||||
|         """ |     #     """ | ||||||
|  |  | ||||||
|         lat = coordinates[0] |     #     # Half the side length in m (since it's a square bbox) | ||||||
|         lon = coordinates[1] |     #     half_side_length_m = reachable_bbox_side / 2 | ||||||
|  |  | ||||||
|         # Half the side length in km (since it's a square bbox) |     #     return tuple((f"around:{half_side_length_m}", str(coordinates[0]), str(coordinates[1]))) | ||||||
|         half_side_length_km = reachable_bbox_side / 2 / 1000 |  | ||||||
|  |  | ||||||
|         # Convert distance to degrees |  | ||||||
|         lat_diff = half_side_length_km / 111  # 1 degree latitude is approximately 111 km |  | ||||||
|         lon_diff = half_side_length_km / (111 * math.cos(math.radians(lat)))  # Adjust for longitude based on latitude |  | ||||||
|  |  | ||||||
|         # Calculate bbox |  | ||||||
|         min_lat = lat - lat_diff |  | ||||||
|         max_lat = lat + lat_diff |  | ||||||
|         min_lon = lon - lon_diff |  | ||||||
|         max_lon = lon + lon_diff |  | ||||||
|  |  | ||||||
|         return min_lat, min_lon, max_lat, max_lon |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     def fetch_landmarks(self, bbox: tuple, amenity_selector: dict, landmarktype: str, score_function: callable) -> list[Landmark]: |     def fetch_landmarks(self, bbox: tuple, amenity_selector: dict, landmarktype: str, score_function: callable) -> list[Landmark]: | ||||||
| @@ -212,7 +201,9 @@ class LandmarkManager: | |||||||
|         for sel in dict_to_selector_list(amenity_selector): |         for sel in dict_to_selector_list(amenity_selector): | ||||||
|             self.logger.debug(f"Current selector: {sel}") |             self.logger.debug(f"Current selector: {sel}") | ||||||
|  |  | ||||||
|             query_conditions = ['count_tags()>5'] |             # query_conditions = ['count_tags()>5'] | ||||||
|  |             # if landmarktype == 'shopping' :       # use this later for shopping clusters | ||||||
|  |             #     element_types = ['node'] | ||||||
|             element_types = ['way', 'relation'] |             element_types = ['way', 'relation'] | ||||||
|  |  | ||||||
|             if 'viewpoint' in sel : |             if 'viewpoint' in sel : | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user