├── prepare ├── requirements.txt ├── config.toml ├── graphhopper.yaml └── 15minute.py ├── .gitignore ├── LICENSE ├── README.md └── display └── index.html /prepare/requirements.txt: -------------------------------------------------------------------------------- 1 | shapely 2 | requests 3 | pyproj 4 | osmium 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | __pycache__/ 3 | .venv/ 4 | old/ 5 | *.json 6 | *.geojson 7 | *.csv 8 | *.pbf 9 | .eslint* 10 | config-*.toml 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Ilya Zverev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /prepare/config.toml: -------------------------------------------------------------------------------- 1 | [openstreetmap] 2 | relation_id = 351671 # Nõmme 3 | #relation_id = 2164745 # Tallinn 4 | # Alternatives: 5 | # way_id = 45453752 6 | # bbox = [8.373212, 47.31758, 8.388319, 47.328053] 7 | 8 | poi_area_buffer = 1000 # meters 9 | building_buffer = 50 # meters 10 | building_min_hole_area = 5000 # m² 11 | simplify = 10 # meters 12 | 13 | [layers] 14 | # Lists of tags (as k=v) for each layer 15 | shops = ['shop=convenience', 'shop=supermarket', 'shop=variety_store', 'shop=general'] 16 | health = ['amenity=clinic', 'amenity=hospital', 'amenity=doctors', 'amenity=pharmacy'] 17 | cafe = ['amenity=cafe', 'amenity=fast_food', 'amenity=restaurant', 'amenity=bar', 'amenity=pub'] 18 | community = ['amenity=library', 'amenity=social_centre', 'amenity=community_centre'] 19 | school = ['amenity=school', 'amenity=university', 'amenity=college'] 20 | childcare = ['amenity=kindergarten', 'amenity=childcare'] 21 | culture = ['amenity=theatre', 'amenity=cinema', 'amenity=event_venue', 'tourism=museum', 'tourism=zoo', 'tourism=theme_park'] 22 | # transport = ['highway=bus_stop', 'railway=halt', 'railway=station'] 23 | 24 | [isochrones] 25 | graphhopper = 'http://localhost:8989/isochrone' 26 | 27 | # Name = [ profile name, distance in minutes] 28 | bike = ['bike', 10] 29 | foot = ['foot', 15] 30 | foot10 = ['foot', 10] 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 15 Minute City 2 | 3 | This is a script and a visualization page for presenting a 15-minute 4 | city calculation. Should be pretty simple, no installation required. 5 | 6 | For an example of an output, see [github.io](https://zverik.github.io/15minute/). 7 | 8 | ## Preparing the data 9 | 10 | Open the terminal in the `prepare` directory. 11 | 12 | ### Aquiring the data 13 | 14 | First, download an `.osm.pbf` file for your country from [Geofabrik](https://download.geofabrik.de/). 15 | This all will work faster if you trim it with [Osmium Tool](https://osmcode.org/): 16 | open [the bounding box tool](https://boundingbox.klokantech.com/), choose an area, copy the CSV code, 17 | and paste it into the command line: 18 | 19 | osmium extract --bbox -o city.osm.pbf country.osm.pbf 20 | 21 | Now, find the area object on OpenStreetMap: use the query tool (the question mark button) and tap somewhere 22 | inside. Scroll the results down and find the area object you need: a city, or a suburb. Tap it 23 | and copy the identifier into `relation_id` or `way_id` in `config.toml`. 24 | 25 | Adjust other settings in the configuration file if you need. 26 | 27 | ### First run 28 | 29 | Then install Python dependencies: 30 | 31 | python3 -m venv .venv 32 | .venv/bin/pip install -r requirements.txt 33 | 34 | And extract the data with the script: 35 | 36 | ./15minute.py -i city.osm.pbf -a area.json -p poi.json -b buildings.json 37 | 38 | Having downloaded and extracted everything, you might want to filter buildings. For example, 39 | remove those in industrial areas or cemeteries. Those tend to attract unwanted attention, 40 | and people there rarely need the amenities. 41 | 42 | To filter buildings, I use [QGIS](https://qgis.org). Add an XYZ Tiles OpenStreetMap layer, 43 | then add a vector layer from `buildings.json`, turn on the editing mode and use the freehand 44 | selection tool. Having finished, save the layer. 45 | 46 | ### Second run 47 | 48 | Now, on to isochrone calculation. For that you would need Java and GraphHopper. Install the 49 | former, and download [the latest jar](https://github.com/graphhopper/graphhopper/releases) 50 | file for the latter. 51 | 52 | The configuration for GraphHopper is in the `graphhopper.yaml`. Look for 53 | the `datareader.file` line at the top: change the value to your pbf file name. 54 | Everything else can stay the same. If GH gives an error on start-up, 55 | it usually tells you what to fix. 56 | 57 | Run the router using the appropriate jar file name: 58 | 59 | java -jar graphhopper-web-10.0.jar server graphhopper.yaml 60 | 61 | When you see `Started Server@451882b2{STARTING}`, that means it's up and waiting 62 | for requests. Open another tab and then build the isochrones: 63 | 64 | ./15minute.py -a area.json -p poi.json -b buildings.json -O 15minute.json 65 | 66 | When everything is over, you will get a `15minute.json` file with all the data required 67 | for visualization. Alternatively, you can use `-o` key for producing isochrone polygons 68 | as a GeoJSON, and `-B` for producing a multipolygon with all the buildings in one, 69 | buffered and simplified. Try those to see what the script outputs. 70 | 71 | ## Publishing the visualization 72 | 73 | It's simple. Copy or move the json file you've built in the last step near the 74 | `display/index.html`. Then open the html file and replace `nomme15` with the 75 | name of your file (which should have the `.json` extension). 76 | 77 | Optionally you can also tweak the initial `center` and `zoom` of the map, and initial 78 | values of layers and profile: 79 | 80 | var currentLayers = new Set(['shops']); 81 | var currentProfile = 'bike'; 82 | 83 | To check how it looks, run `python -m http.server` in the directory with the files, 84 | and open the URL it shows (try http://127.0.0.1:8000/) when started. 85 | To publish, just deploy the two files (html and json) somewhere, e.g. on GitHub Pages. 86 | 87 | If you've got more files, you can use the single html file with a query parameter: 88 | e.g. `https://somewhere/?tallinn` to show data from `tallinn.json` in the same directory. 89 | 90 | ### What do things mean 91 | 92 | On the interactive map: 93 | 94 | * Choose layers and profiles in the top left box. 95 | * Red polygons mark buildings that are outside of a 15-minute area. 96 | * Toggle isochrones in the box, so they replace buildings and are drawn as light blue polygons. 97 | * Yellow dots are points of interest for the layer. Hover the mouse cursor to see their names. 98 | 99 | ## Author and License 100 | 101 | Written by Ilya Zverev, published under the ISC license. Feel free to do anything, 102 | and I'll be happy to hear that you used the tool. 103 | -------------------------------------------------------------------------------- /display/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15-minute city 6 | 7 | 8 | 9 | 10 | 11 | 55 | 56 | 57 |
58 |
59 |
60 |
61 | 358 | 359 | 360 | -------------------------------------------------------------------------------- /prepare/graphhopper.yaml: -------------------------------------------------------------------------------- 1 | graphhopper: 2 | 3 | # OpenStreetMap input file PBF or XML, can be changed via command line -Ddw.graphhopper.datareader.file=some.pbf 4 | datareader.file: "tallinn.osm.pbf" 5 | # Local folder used by graphhopper to store its data 6 | graph.location: graph-cache 7 | 8 | 9 | ##### Routing Profiles #### 10 | 11 | # Routing can be done only for profiles listed below. For more information about profiles and custom profiles have a 12 | # look into the documentation at docs/core/profiles.md or the examples under web/src/test/java/com/graphhopper/application/resources/ 13 | # or the CustomWeighting class for the raw details. 14 | # 15 | # In general a profile consists of the following 16 | # - name (required): a unique string identifier for the profile 17 | # - weighting (optional): by default 'custom' 18 | # - turn_costs (optional): 19 | # vehicle_types: [motorcar, motor_vehicle] (vehicle types used for vehicle-specific turn restrictions) 20 | # u_turn_costs: 60 (time-penalty for doing a u-turn in seconds) 21 | # 22 | # Depending on the above fields there are other properties that can be used, e.g. 23 | # - custom_model_files: when you specified "weighting: custom" you need to set one or more json files which are searched in 24 | # custom_models.directory or the working directory that defines the custom_model. If you want an empty model you can 25 | # set "custom_model_files: [] 26 | # You can also use the `custom_model` field instead and specify your custom model in the profile directly. 27 | # 28 | # To prevent long running routing queries you should usually enable either speed or hybrid mode for all the given 29 | # profiles (see below). Or at least limit the number of `routing.max_visited_nodes`. 30 | 31 | profiles: 32 | - name: foot 33 | custom_model_files: [foot.json, foot_elevation.json] 34 | 35 | - name: bike 36 | custom_model_files: [bike.json, bike_elevation.json] 37 | # 38 | # - name: racingbike 39 | # custom_model_files: [racingbike.json, bike_elevation.json] 40 | # 41 | # - name: mtb 42 | # custom_model_files: [mtb.json, bike_elevation.json] 43 | # 44 | # # See the bus.json for more details. 45 | # - name: bus 46 | # turn_costs: 47 | # vehicle_types: [bus, motor_vehicle] 48 | # u_turn_costs: 60 49 | # custom_model_files: [bus.json] 50 | # 51 | # Other custom models not listed here are: car4wd.json, motorcycle.json, truck.json or cargo-bike.json. You might need to modify and test them before production usage. 52 | # See ./core/src/main/resources/com/graphhopper/custom_models and let us know if you customize them, improve them or create new onces! 53 | # Also there is the curvature.json custom model which might be useful for a motorcyle profile or the opposite for a truck profile. 54 | # Then specify a folder where to find your own custom model files: 55 | # custom_models.directory: custom_models 56 | 57 | 58 | # Speed mode: 59 | # It's possible to speed up routing by doing a special graph preparation (Contraction Hierarchies, CH). This requires 60 | # more RAM/disk space for holding the prepared graph but also means less memory usage per request. Using the following 61 | # list you can define for which of the above routing profiles such preparation shall be performed. Note that to support 62 | # profiles with `turn_costs` a more elaborate preparation is required (longer preparation time and more memory 63 | # usage) and the routing will also be slower than with `turn_costs: false`. 64 | profiles_ch: 65 | - profile: foot 66 | - profile: bike 67 | 68 | # Hybrid mode: 69 | # Similar to speed mode, the hybrid mode (Landmarks, LM) also speeds up routing by doing calculating auxiliary data 70 | # in advance. Its not as fast as speed mode, but more flexible. 71 | # 72 | # Advanced usage: It is possible to use the same preparation for multiple profiles which saves memory and preparation 73 | # time. To do this use e.g. `preparation_profile: my_other_profile` where `my_other_profile` is the name of another 74 | # profile for which an LM profile exists. Important: This only will give correct routing results if the weights 75 | # calculated for the profile are equal or larger (for every edge) than those calculated for the profile that was used 76 | # for the preparation (`my_other_profile`) 77 | profiles_lm: [] 78 | 79 | 80 | #### Encoded Values #### 81 | 82 | # Add additional information to every edge. Used for path details (#1548) and custom models (docs/core/custom-models.md) 83 | # Default values are: road_class,road_class_link,road_environment,max_speed,road_access 84 | # More are: surface,smoothness,max_width,max_height,max_weight,max_weight_except,hgv,max_axle_load,max_length, 85 | # hazmat,hazmat_tunnel,hazmat_water,lanes,osm_way_id,toll,track_type,mtb_rating,hike_rating,horse_rating, 86 | # country,curvature,average_slope,max_slope,car_temporal_access,bike_temporal_access,foot_temporal_access 87 | graph.encoded_values: foot_access,hike_rating,foot_priority,foot_average_speed,average_slope,bike_priority,bike_access,roundabout,bike_average_speed,mtb_rating 88 | 89 | #### Speed, hybrid and flexible mode #### 90 | 91 | # To make CH preparation faster for multiple profiles you can increase the default threads if you have enough RAM. 92 | # Change this setting only if you know what you are doing and if the default worked for you. 93 | # prepare.ch.threads: 1 94 | 95 | # To tune the performance vs. memory usage for the hybrid mode use 96 | # prepare.lm.landmarks: 16 97 | 98 | # Make landmark preparation parallel if you have enough RAM. Change this only if you know what you are doing and if 99 | # the default worked for you. 100 | # prepare.lm.threads: 1 101 | 102 | 103 | #### Elevation #### 104 | 105 | # To populate your graph with elevation data use SRTM, default is noop (no elevation). Read more about it in docs/core/elevation.md 106 | # graph.elevation.provider: srtm 107 | 108 | # default location for cache is /tmp/srtm 109 | # graph.elevation.cache_dir: ./srtmprovider/ 110 | 111 | # If you have a slow disk or plenty of RAM change the default MMAP to: 112 | # graph.elevation.dataaccess: RAM_STORE 113 | 114 | # To enable bilinear interpolation when sampling elevation at points (default uses nearest neighbor): 115 | # graph.elevation.interpolate: bilinear 116 | 117 | # Reduce ascend/descend per edge without changing the maximum slope: 118 | # graph.elevation.edge_smoothing: ramer 119 | # removes elevation fluctuations up to max_elevation (in meter) and replaces the elevation with a value based on the average slope 120 | # graph.elevation.edge_smoothing.ramer.max_elevation: 5 121 | # Using an averaging approach for smoothing will reveal values not affected by outliers and realistic slopes and total altitude values (up and down) 122 | # graph.elevation.edge_smoothing: moving_average 123 | # window size in meter along a way used for averaging a node's elevation 124 | # graph.elevation.edge_smoothing.moving_average.window_size: 150 125 | 126 | 127 | # To increase elevation profile resolution, use the following two parameters to tune the extra resolution you need 128 | # against the additional storage space used for edge geometries. You should enable bilinear interpolation when using 129 | # these features (see #1953 for details). 130 | # - first, set the distance (in meters) at which elevation samples should be taken on long edges 131 | # graph.elevation.long_edge_sampling_distance: 60 132 | # - second, set the elevation tolerance (in meters) to use when simplifying polylines since the default ignores 133 | # elevation and will remove the extra points that long edge sampling added 134 | # graph.elevation.way_point_max_distance: 10 135 | 136 | 137 | #### Country-dependent defaults for max speeds #### 138 | 139 | # This features sets a maximum speed in 'max_speed' encoded value if no maxspeed tag was found. It is country-dependent 140 | # and based on several rules. See https://github.com/westnordost/osm-legal-default-speeds 141 | # To use it uncomment the following, then enable urban density below and add 'country' to graph.encoded_values 142 | # max_speed_calculator.enabled: true 143 | 144 | 145 | #### Urban density (built-up areas) #### 146 | 147 | # This feature allows classifying roads into 'rural', 'residential' and 'city' areas (encoded value 'urban_density') 148 | # Use 1 or more threads to enable the feature 149 | # graph.urban_density.threads: 8 150 | # Use higher/lower sensitivities if too little/many roads fall into the according categories. 151 | # Using smaller radii will speed up the classification, but only change these values if you know what you are doing. 152 | # If you do not need the (rather slow) city classification set city_radius to zero. 153 | # graph.urban_density.residential_radius: 400 154 | # graph.urban_density.residential_sensitivity: 6000 155 | # graph.urban_density.city_radius: 1500 156 | # graph.urban_density.city_sensitivity: 1000 157 | 158 | 159 | #### Subnetworks #### 160 | 161 | # In many cases the road network consists of independent components without any routes going in between. In 162 | # the most simple case you can imagine an island without a bridge or ferry connection. The following parameter 163 | # allows setting a minimum size (number of edges) for such detached components. This can be used to reduce the number 164 | # of cases where a connection between locations might not be found. 165 | prepare.min_network_size: 200 166 | prepare.subnetworks.threads: 1 167 | 168 | #### Routing #### 169 | 170 | # You can define the maximum visited nodes when routing. This may result in not found connections if there is no 171 | # connection between two points within the given visited nodes. The default is Integer.MAX_VALUE. Useful for flexibility mode 172 | # routing.max_visited_nodes: 1000000 173 | 174 | # The maximum time in milliseconds after which a routing request will be aborted. This has some routing algorithm 175 | # specific caveats, but generally it should allow the prevention of long-running requests. The default is Long.MAX_VALUE 176 | # routing.timeout_ms: 300000 177 | 178 | # Control how many active landmarks are picked per default, this can improve query performance 179 | # routing.lm.active_landmarks: 4 180 | 181 | # You can limit the max distance between two consecutive waypoints of flexible routing requests to be less or equal 182 | # the given distance in meter. Default is set to 1000km. 183 | routing.non_ch.max_waypoint_distance: 1000000 184 | 185 | 186 | #### Storage #### 187 | 188 | # Excludes certain types of highways during the OSM import to speed up the process and reduce the size of the graph. 189 | # A typical application is excluding 'footway','cycleway','path' and maybe 'pedestrian' and 'track' highways for 190 | # motorized vehicles. This leads to a smaller and less dense graph, because there are fewer ways (obviously), 191 | # but also because there are fewer crossings between highways (=junctions). 192 | # Another typical example is excluding 'motorway', 'trunk' and maybe 'primary' highways for bicycle or pedestrian routing. 193 | # import.osm.ignored_highways: footway,cycleway,path,pedestrian,steps # typically useful for motorized-only routing 194 | import.osm.ignored_highways: motorway,trunk # typically useful for non-motorized routing 195 | 196 | # configure the memory access, use RAM_STORE for well equipped servers (default and recommended) 197 | graph.dataaccess.default_type: RAM_STORE 198 | 199 | # will write way names in the preferred language (language code as defined in ISO 639-1 or ISO 639-2): 200 | # datareader.preferred_language: en 201 | 202 | #### Custom Areas #### 203 | 204 | # GraphHopper reads GeoJSON polygon files including their properties from this directory and makes them available 205 | # to all tag parsers and custom models. All GeoJSON Features require to have the "id" property. 206 | # Country borders are included automatically (see countries.geojson). 207 | # custom_areas.directory: path/to/custom_areas 208 | 209 | 210 | #### Country Rules #### 211 | 212 | # GraphHopper applies country-specific routing rules during import (not enabled by default). 213 | # You need to redo the import for changes to take effect. 214 | # country_rules.enabled: true 215 | 216 | # Dropwizard server configuration 217 | server: 218 | application_connectors: 219 | - type: http 220 | port: 8989 221 | # for security reasons bind to localhost 222 | bind_host: localhost 223 | # increase GET request limit - not necessary if /maps UI is not used or used without custom models 224 | max_request_header_size: 50k 225 | request_log: 226 | appenders: [] 227 | admin_connectors: 228 | - type: http 229 | port: 8990 230 | bind_host: localhost 231 | # See https://www.dropwizard.io/en/latest/manual/core.html#logging 232 | logging: 233 | appenders: 234 | - type: file 235 | time_zone: UTC 236 | current_log_filename: logs/graphhopper.log 237 | log_format: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" 238 | archive: true 239 | archived_log_filename_pattern: ./logs/graphhopper-%d.log.gz 240 | archived_file_count: 30 241 | never_block: true 242 | - type: console 243 | time_zone: UTC 244 | log_format: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" 245 | loggers: 246 | "com.graphhopper.osm_warnings": 247 | level: DEBUG 248 | additive: false 249 | appenders: 250 | - type: file 251 | currentLogFilename: logs/osm_warnings.log 252 | archive: false 253 | logFormat: '[%level] %msg%n' 254 | -------------------------------------------------------------------------------- /prepare/15minute.py: -------------------------------------------------------------------------------- 1 | #!.venv/bin/python 2 | import argparse 3 | import json 4 | import math 5 | import os 6 | import osmium 7 | import sys 8 | import pyproj 9 | import requests 10 | import shapely 11 | import tomllib 12 | from collections import defaultdict 13 | from shapely.geometry import shape, mapping 14 | from shapely.ops import transform as shapely_transform 15 | from typing import Any 16 | 17 | 18 | config: dict = {} 19 | 20 | 21 | class Transformer3857: 22 | def __init__(self): 23 | self._to_3857 = pyproj.Transformer.from_crs( 24 | pyproj.CRS('epsg:4326'), pyproj.CRS('epsg:3857'), always_xy=True) 25 | self._to_4326 = pyproj.Transformer.from_crs( 26 | pyproj.CRS('epsg:3857'), pyproj.CRS('epsg:4326'), always_xy=True) 27 | 28 | def to_3857(self, shape: shapely.Geometry) -> shapely.Geometry: 29 | return shapely_transform(self._to_3857.transform, shape) 30 | 31 | def to_4326(self, shape: shapely.Geometry) -> shapely.Geometry: 32 | return shapely_transform(self._to_4326.transform, shape) 33 | 34 | 35 | class Area: 36 | def __init__(self, area: shapely.Geometry | None = None): 37 | self._transformer = Transformer3857() 38 | self._shape: shapely.Geometry | None = None 39 | if area: 40 | self.set_shape(area) 41 | 42 | @property 43 | def shape(self) -> shapely.Geometry: 44 | return self._shape 45 | 46 | def set_shape(self, shape: shapely.Geometry): 47 | self._shape = shape 48 | shapely.prepare(self._shape) 49 | 50 | def __len__(self) -> int: 51 | return 0 if not self._shape else 1 52 | 53 | @property 54 | def lat_multiplier(self) -> float: 55 | lat = shapely.get_y(shapely.centroid(self._shape)) 56 | return math.cos(math.radians(lat)) 57 | 58 | def intersects(self, other: shapely.Geometry) -> bool: 59 | return shapely.intersects(self._shape, other) 60 | 61 | def buffered(self, buffer: float): 62 | if buffer <= 0: 63 | return self 64 | buffer /= self.lat_multiplier 65 | transformed = self._transformer.to_3857(self._shape) 66 | transformed = shapely.buffer(transformed, buffer) 67 | return Area(self._transformer.to_4326(transformed)) 68 | 69 | def simplified(self, tolerance: float): 70 | if tolerance <= 0: 71 | return self 72 | tolerance /= self.lat_multiplier 73 | transformed = self._transformer.to_3857(self._shape) 74 | transformed = shapely.simplify(transformed, tolerance) 75 | return Area(self._transformer.to_4326(transformed)) 76 | 77 | def load(self, filename: str | None): 78 | if filename and os.path.exists(filename): 79 | try: 80 | with open(filename, 'r') as f1: 81 | self.set_shape(shape(json.load(f1))) 82 | except Exception: 83 | pass 84 | 85 | def save(self, filename: str | None): 86 | if filename: 87 | with open(filename, 'w') as f2: 88 | json.dump(mapping(self._shape), f2) 89 | 90 | 91 | class POI: 92 | def __init__(self, coords: tuple[float, float], typ: str, 93 | name: str | None): 94 | self.coords = coords 95 | self.typ = typ 96 | self.name = name 97 | 98 | def to_feature(self, props: dict[str, Any] | None = None) -> dict: 99 | feature: dict[str, Any] = { 100 | 'type': 'Feature', 101 | 'geometry': { 102 | 'type': 'Point', 103 | 'coordinates': self.coords, 104 | }, 105 | 'properties': { 106 | 'type': self.typ, 107 | }, 108 | } 109 | if self.name: 110 | feature['properties']['name'] = self.name 111 | if props: 112 | feature['properties'].update(props) 113 | return feature 114 | 115 | 116 | class BuildingsAndPOI: 117 | def __init__(self, area: Area): 118 | self.area = area 119 | self.poi: defaultdict[str, list[POI]] = defaultdict(list) 120 | self.buildings: list[shapely.Geometry] = [] 121 | self._all_buildings: shapely.Geometry | None = None 122 | self.transformer = Transformer3857() 123 | 124 | def load_poi(self, data: dict): 125 | self.poi.clear() 126 | for feature in data['features']: 127 | self.poi[feature['properties']['layer']].append(POI( 128 | feature['geometry']['coordinates'], 129 | feature['properties']['type'], 130 | feature['properties'].get('name'), 131 | )) 132 | 133 | def load_buildings(self, data: dict): 134 | self.buildings.clear() 135 | for f in data['features']: 136 | self.buildings.append(shape(f['geometry'])) 137 | self._all_buildings = None 138 | 139 | def save_poi(self) -> dict: 140 | features: list[dict] = [] 141 | for layer, pois in self.poi.items(): 142 | features.extend(p.to_feature({'layer': layer}) for p in pois) 143 | return { 144 | 'type': 'FeatureCollection', 145 | 'features': features, 146 | } 147 | 148 | def save_buildings(self) -> dict: 149 | features: list[dict] = [] 150 | for b in self.buildings: 151 | features.append({ 152 | 'type': 'Feature', 153 | 'properties': {}, 154 | 'geometry': mapping(b), 155 | }) 156 | return { 157 | 'type': 'FeatureCollection', 158 | 'features': features, 159 | } 160 | 161 | def load_all(self, poi, buildings): 162 | if poi and os.path.exists(poi): 163 | try: 164 | with open(poi, 'r') as f3: 165 | data = json.load(f3) 166 | self.load_poi(data) 167 | except Exception: 168 | pass 169 | 170 | if buildings and os.path.exists(buildings): 171 | try: 172 | with open(buildings, 'r') as f3: 173 | data = json.load(f3) 174 | self.load_buildings(data) 175 | except Exception: 176 | pass 177 | 178 | def save_all(self, poi, buildings): 179 | if self.poi and poi: 180 | with open(poi, 'w') as f4: 181 | json.dump(self.save_poi(), f4, ensure_ascii=False) 182 | if self.buildings and buildings: 183 | with open(buildings, 'w') as f5: 184 | json.dump(self.save_buildings(), f5) 185 | 186 | def add_building(self, building): 187 | self.buildings.append(building) 188 | self._all_buildings = None 189 | 190 | @property 191 | def need_reading(self): 192 | return not self.poi or not self.buildings 193 | 194 | def remove_small_holes(self, geom: shapely.Geometry, 195 | min_size: int) -> shapely.Geometry: 196 | parts = shapely.get_parts(geom) # TODO: test on a single Polygon 197 | for i in range(len(parts)): 198 | p = parts[i] 199 | if not isinstance(p, shapely.Polygon): 200 | continue 201 | 202 | if len(p.interiors) > 0: 203 | fixed_rings = [r for r in p.interiors 204 | if shapely.area(shapely.Polygon(r)) > min_size] 205 | if len(fixed_rings) < len(p.interiors): 206 | parts[i] = shapely.Polygon(p.exterior, fixed_rings) 207 | if len(parts) == 1: 208 | return parts[0] 209 | return shapely.multipolygons(parts) 210 | 211 | @property 212 | def all_buildings(self) -> shapely.Geometry: 213 | global config 214 | lm = self.area.lat_multiplier 215 | simplify_tolerance = config['openstreetmap'].get( 216 | 'simplify', 0) / lm 217 | buffer = config['openstreetmap'].get('building_buffer', 0) / lm 218 | hole_size = config['openstreetmap'].get( 219 | 'building_min_hole_area', 0) / lm / lm 220 | if not self._all_buildings and self.buildings: 221 | transformed = [self.transformer.to_3857(b) 222 | for b in self.buildings] 223 | if buffer > 0: 224 | for i in range(len(transformed)): 225 | transformed[i] = shapely.buffer(transformed[i], buffer) 226 | 227 | self._all_buildings = shapely.union_all(transformed) 228 | transformed.clear() 229 | self._all_buildings = shapely.simplify( 230 | self._all_buildings, simplify_tolerance) 231 | if hole_size: 232 | self._all_buildings = self.remove_small_holes( 233 | self._all_buildings, hole_size) 234 | self._all_buildings = self.transformer.to_4326( 235 | self._all_buildings) 236 | return self._all_buildings 237 | 238 | def add_poi(self, layer: str, poi: POI): 239 | self.poi[layer].append(poi) 240 | 241 | 242 | def isochrone(point: tuple[float, float], profile: str, 243 | minutes: int) -> shapely.Geometry: 244 | global config 245 | gp_endpoint = config['isochrones']['graphhopper'] 246 | resp = requests.get(gp_endpoint, { 247 | 'profile': profile, 248 | 'reverse_flow': 'true', 249 | 'time_limit': minutes * 60, 250 | 'point': f'{point[1]},{point[0]}', 251 | }) 252 | if resp.status_code != 200: 253 | # raise Exception(f'Failed to query {resp.url}: {resp.text}') 254 | sys.stderr.write(f'Failed to query {resp.url}: {resp.status_code} ' 255 | f'{resp.text}\n') 256 | return shapely.Polygon() 257 | data = resp.json() 258 | return shape(data['polygons'][0]['geometry']) 259 | 260 | 261 | def isochrones(points: list[tuple[float, float]], profile: str, 262 | minutes: int) -> shapely.Geometry: 263 | global config 264 | simplify_tolerance = config['openstreetmap'].get('simplify', 10) 265 | polys = [isochrone(p, profile, minutes) for p in points] 266 | shape = Area(shapely.union_all(polys)) 267 | return shape.simplified(simplify_tolerance).shape 268 | 269 | 270 | def download_area() -> shapely.Geometry: 271 | global config 272 | osm_cfg = config['openstreetmap'] 273 | area_id: int = 0 274 | OSM_API = 'https://api.openstreetmap.org/api/0.6' 275 | if osm_cfg.get('relation_id'): 276 | resp = requests.get( 277 | f'{OSM_API}/relation/{osm_cfg["relation_id"]}/full') 278 | area_id = osm_cfg['relation_id'] * 2 + 1 279 | elif osm_cfg.get('way_id'): 280 | resp = requests.get( 281 | f'{OSM_API}/way/{osm_cfg["way_id"]}/full') 282 | area_id = osm_cfg['way_id'] * 2 283 | elif osm_cfg.get('bbox'): 284 | return shapely.box(*osm_cfg['bbox']) 285 | if resp.status_code != 200: 286 | raise Exception( 287 | f'Could not request area from OSM: {resp.status_code}') 288 | 289 | for obj in osmium.FileProcessor(osmium.io.FileBuffer(resp.content, 'osm'))\ 290 | .with_areas()\ 291 | .with_filter(osmium.filter.GeoInterfaceFilter(True)): 292 | if obj.is_area() and obj.id == area_id: 293 | return shape(obj.__geo_interface__['geometry']) 294 | raise Exception(f'OpenStreetMap did not return a proper area: {resp.url}') 295 | 296 | 297 | def scan_buildings_and_poi(area: Area, bap: BuildingsAndPOI, osmfile: str): 298 | global config 299 | 300 | tags_to_layer: defaultdict[str, list[str]] = defaultdict(list) 301 | for layer, tags in config['layers'].items(): 302 | for tag in tags: 303 | tags_to_layer[tag].append(layer) 304 | 305 | poi_buffer = config['openstreetmap'].get('poi_area_buffer', 0) 306 | poi_area = area.buffered(poi_buffer) 307 | need_buildings = not bap.buildings 308 | 309 | for obj in osmium.FileProcessor(osmfile)\ 310 | .with_areas()\ 311 | .with_filter(osmium.filter.GeoInterfaceFilter(True)): 312 | if obj.is_node() or obj.is_area(): 313 | if need_buildings and 'building' in obj.tags: 314 | shp = shape(obj.__geo_interface__['geometry']) 315 | if area.intersects(shp): 316 | if obj.is_node(): 317 | shp = shapely.buffer(shp, 0.0001) 318 | bap.add_building(shp) 319 | for k, v in obj.tags: 320 | kv = f'{k}={v}' 321 | layers = tags_to_layer.get(kv) 322 | if layers: 323 | center = shapely.centroid(shape( 324 | obj.__geo_interface__['geometry'])) 325 | if poi_area.intersects(center): 326 | coords = (shapely.get_x(center), shapely.get_y(center)) 327 | for layer in layers: 328 | bap.add_poi(layer, POI( 329 | coords, kv, obj.tags.get('name'))) 330 | return bap 331 | 332 | 333 | if __name__ == '__main__': 334 | parser = argparse.ArgumentParser( 335 | description='Downloads and prepares data for a 15-minute city ' 336 | 'assessment') 337 | parser.add_argument( 338 | '-c', '--config', default='config.toml', 339 | help='TOML configuration file name, default=config.toml') 340 | parser.add_argument( 341 | '-a', '--area', 342 | help='JSON file for the area polygon (not downloaded if exists)') 343 | parser.add_argument( 344 | '-i', '--input', 345 | help='OSM file for extracting buildings and POI') 346 | parser.add_argument( 347 | '-p', '--poi', help='Storage for POI') 348 | parser.add_argument( 349 | '-b', '--buildings', help='GeoJSON storage for buildings') 350 | parser.add_argument( 351 | '-B', '--all-buildings', type=argparse.FileType('w'), 352 | help='GeoJSON with all buildings merged into a single polygon') 353 | parser.add_argument( 354 | '-o', '--output', type=argparse.FileType('w'), 355 | help='GeoJSON with isochrones by layer') 356 | parser.add_argument( 357 | '--coverage', type=argparse.FileType('w'), 358 | help='GeoJSON with non-covered buildings by layer') 359 | parser.add_argument( 360 | '-O', '--package', type=argparse.FileType('w'), 361 | help='Export JSON for the display tool with everything') 362 | options = parser.parse_args() 363 | 364 | with open(options.config, 'rb') as f: 365 | config = tomllib.load(f) 366 | 367 | # Read or download the area. 368 | area = Area() 369 | area.load(options.area) 370 | if not area: 371 | area.set_shape(download_area()) 372 | area.save(options.area) 373 | 374 | # Read or download buildings and POI. 375 | bap = BuildingsAndPOI(area) 376 | bap.load_all(options.poi, options.buildings) 377 | 378 | if not bap.poi and not options.input: 379 | raise Exception('Please specify input OSM file.') 380 | 381 | if options.input and bap.need_reading: 382 | scan_buildings_and_poi(area, bap, options.input) 383 | bap.save_all(options.poi, options.buildings) 384 | 385 | if options.all_buildings and bap.buildings: 386 | json.dump(mapping(bap.all_buildings), options.all_buildings) 387 | 388 | profiles: list[str] = [] 389 | iso_features: list[dict] = [] 390 | if options.output or options.coverage or options.package: 391 | # Build isochrones for each layer. 392 | na_buildings: list[dict] = [] 393 | for k, v in config['isochrones'].items(): 394 | if isinstance(v, list) and len(v) == 2 and isinstance(v[0], str): 395 | profiles.append(k) 396 | for layer, coords in bap.poi.items(): 397 | iso = isochrones([p.coords for p in coords], v[0], v[1]) 398 | if options.output or options.package: 399 | iso_features.append({ 400 | 'type': 'Feature', 401 | 'geometry': mapping(iso), 402 | 'properties': { 403 | 'layer': layer, 404 | 'profile': k, 405 | 'amenities': len(coords), 406 | }, 407 | }) 408 | 409 | if options.coverage: 410 | no_buildings = shapely.difference( 411 | bap.all_buildings, iso) 412 | na_buildings.append({ 413 | 'type': 'Feature', 414 | 'geometry': mapping(no_buildings), 415 | 'properties': { 416 | 'layer': layer, 417 | 'profile': k, 418 | 'amenities': len(coords), 419 | }, 420 | }) 421 | 422 | if options.output: 423 | json.dump({ 424 | 'type': 'FeatureCollection', 425 | 'features': iso_features, 426 | }, options.output) 427 | 428 | if options.coverage: 429 | json.dump({ 430 | 'type': 'FeatureCollection', 431 | 'features': na_buildings, 432 | }, options.output) 433 | 434 | if options.package: 435 | layers: dict[str, dict[str, Any]] = {} 436 | for layer, pois in bap.poi.items(): 437 | isolines = [f for f in iso_features 438 | if f['properties']['layer'] == layer] 439 | if isolines: 440 | layers[layer] = { 441 | 'poi': [p.to_feature() for p in pois], 442 | 'isochrones': {p['properties']['profile']: p 443 | for p in isolines}, 444 | } 445 | json.dump({ 446 | 'area': mapping(area.shape), 447 | 'buildings': mapping(bap.all_buildings), 448 | 'layers': layers, 449 | 'profiles': profiles, 450 | }, options.package) 451 | --------------------------------------------------------------------------------