Merge main.

This commit is contained in:
Sergey Vartanov 2022-04-13 22:17:51 +03:00
commit 155b1e21c7
112 changed files with 17854 additions and 5686 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
Dockerfile
.git/
tests/

View file

@ -6,12 +6,12 @@ Thank you for your interest in the Map Machine project. Since the primary goal o
Suggest a tag to support
------------------------
Please, create an issue with `icon` label.
Please, create an issue describing how you would like the feature to be visualized.
Report a bug
------------
Please, create an issue with `bug` and `generator` labels.
Please, create an issue describing the current behavior, expected behavior, and environment (most importantly, the OS version and Python version if it was not the recommended one).
Fix a typo in documentation
---------------------------
@ -21,14 +21,36 @@ This action is not that easy as it supposed to be. We use [Moire](http://github.
Modify the code
---------------
First of all, configure your workspace.
### First configure your workspace ###
* Install formatter, linter and test system: `pip install black flake8 pytest`.
* Be sure to run `git config --local core.hooksPath data/githooks` to enable Git hooks.
Make sure you have Python 3.9 development tools. E.g., for Ubuntu, run `apt install python3.9-dev python3.9-venv`.
Activate virtual environment. E.g. for fish shell, run `source venv/bin/activate.fish`.
Install the project in editable mode:
```shell
pip install -e .
```
Install formatter, linter and test system: `pip install black flake8 mypy pytest pytest-cov`.
Be sure to enable Git hooks:
```shell
git config --local core.hooksPath data/githooks
```
If you are using PyCharm, you may want to set up user dictionary as well:
* `cp data/dictionary.xml .idea/dictionaries/<user name>.xml`
* in `.idea/dictionaries/<user name>.xml` change `%USERNAME%` to your username,
* restart PyCharm if it is launched.
### Code style ###
We use [Black](http://github.com/psf/black) code formatter with maximum 80 characters line lenght for all Python files within the project. Reformat a file is as simple as `black -l 80 <file name>`.
We use [Black](http://github.com/psf/black) code formatter with maximum 80 characters line length for all Python files within the project. Reformat a file is as simple as `black -l 80 <file name>`. Reformat everything with `black -l 80 map_machine tests`.
If you create new Python file, make sure you add `__author__ = "<first name> <second name>"` and `__email__ = "<author e-mail>"` string variables.

View file

@ -46,7 +46,7 @@ jobs:
pip install .
- name: Check code style with Black
run: |
black -l 80 --check map_machine tests
black -l 80 --check map_machine setup.py tests
- name: Lint with Flake8
run: |
flake8 --max-line-length=80 --ignore=E203,W503

11
.gitignore vendored
View file

@ -1,10 +1,6 @@
# Generated files
dist/
doc/*.html
doc/*.svg
doc/*.wiki
missed_tags.yml
out/
# Cache
@ -22,3 +18,10 @@ cache/
work
precommit.py
.idea
build
temp
venv*
taginfo

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.9-slim-bullseye
WORKDIR /app
COPY . /app/
RUN \
apt update && \
apt install -y --no-install-recommends gcc libcairo2-dev libgeos-dev && \
pip install --upgrade pip && \
pip install . && \
mkdir -p /maps/cache
VOLUME ["/maps"]
ENTRYPOINT ["map-machine"]

184
README.md
View file

@ -2,105 +2,124 @@
**Map Machine** project consists of
* Python [OpenStreetMap](http://openstreetmap.org) renderer and tile generator (see [usage](#usage-example), [renderer documentation](#map-generation), [tile generation](#tile-generation)),
* [Röntgen icon set](#icon-set): unique CC-BY 4.0 icons.
The idea behind the Map Machine project is to **show all the richness of the OpenStreetMap data**: to have a possibility to *display any map feature* represented by OpenStreetMap data tags by means of colors, shapes, and icons. Map Machine is created for OpenStreetMap contributors: to display all changes one made on the map even if they are small, and for users: to dig down into the map and find every detail that was mapped.
* Python [OpenStreetMap](http://openstreetmap.org) renderer:
* SVG [map generation](#map-generation),
* SVG and PNG [tile generation](#tile-generation),
* [Röntgen](#röntgen-icon-set) icon set: unique CC-BY 4.0 map icons.
Unlike standard OpenStreetMap layers, **Map Machine is a playground for experiments** where one can easily try to support proposed tags, tags with little or even single usage, deprecated tags.
The idea behind the Map Machine project is to **show all the richness of the OpenStreetMap data**: to have a possibility to display any map feature represented by OpenStreetMap data tags by means of colors, shapes, and icons. Map Machine is created both for map contributors: to display all changes one made on the map even if they are small, and for map users: to dig down into the map and find every detail that was mapped.
Map Machine is intended to be highly configurable, so it can generate precise but messy maps for OSM contributors as well as pretty and clean maps for OSM users, can use slow algorithms for some experimental features.
Unlike standard OpenStreetMap layers, **Map Machine is a playground for experiments** where one can easily try to support any unsupported tag, proposed tagging scheme, tags with little or even single usage, deprecated ones that are still in use.
Map Machine is intended to be highly configurable, so it can generate precise but messy maps for OSM contributors as well as pretty and clean maps for OSM users. It can also use some slow algorithms for experimental features.
See
* [installation instructions](#installation),
* [map features](#map-features),
* [using Röntgen as JOSM style](#use-röntgen-as-josm-map-paint-style).
Usage example
-------------
```bash
map-machine render -b 2.284,48.860,2.290,48.865
map-machine render -b=2.284,48.860,2.290,48.865
```
will automatically download OSM data and write output SVG map of the specified area to `out/map.svg`. See [Map generation](#map-generation).
```bash
map-machine tile -b 2.361,48.871,2.368,48.875
map-machine tile -b=2.361,48.871,2.368,48.875
```
will automatically download OSM data and write output PNG tiles that cover the specified area to `out/tiles` directory. See [Tile generation](#tile-generation).
will automatically download OSM data and write output PNG tiles that cover the specified area to the `out/tiles` directory. See [Tile generation](#tile-generation).
Röntgen icon set
----------------
The central feature of the project is Röntgen icon set. It is a set of monochrome 14 × 14 px pixel-aligned icons specially created for Map Machine project. Unlike the Map Machine source code, which is under MIT license, all icons are under [CC BY](http://creativecommons.org/licenses/by/4.0/) license. So, with the appropriate credit icon set can be used outside the project. Some icons can be used as emoji symbols.
All icons tend to support a common design style, which is heavily inspired by [Maki](https://github.com/mapbox/maki), [Osmic](https://github.com/gmgeo/osmic), and [Temaki](https://github.com/ideditor/temaki).
![Icons](doc/grid.svg)
Feel free to request new icons via issues for whatever you want to see on the map. No matter how frequently the tag is used in OpenStreetMap since the final goal is to cover all tags. However, commonly used tags have priority, other things being equal.
Generate icon grid and sets of individual icons with `map-machine icons`. It will update `doc/grid.svg` file, and create SVG files in `out/icons_by_id` directory where files are named using shape identifiers (e.g. `power_tower_portal_2_level.svg`) and in `icons_by_name` directory where files are named using shape names (e.g. `Röntgen portal two-level transmission tower.svg`). Files from the last directory are used in OpenStreetMap wiki (e.g. [`File:Röntgen_portal_two-level_transmission_tower.svg`](https://wiki.openstreetmap.org/wiki/File:R%C3%B6ntgen_portal_two-level_transmission_tower.svg)).
Map features
------------
### Extra icons ###
Map Machine uses icons to visualize tags for nodes and areas. But unlike other renderers, Map Machine can use more than one icon to visualize an entity and use colors to visualize [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) value or other entity properties (like [`material`](https://wiki.openstreetmap.org/wiki/Key:material) or [`genus`](https://wiki.openstreetmap.org/wiki/Key:genus)).
### Isometric building shapes ###
With `--buildings isometric` or `--buildings isometric-no-parts` (not set by default), buildings are drawn using isometric shapes for walls and shade in proportion to [`building:levels`](https://wiki.openstreetmap.org/wiki/Key:building:levels), [`building:min_level`](https://wiki.openstreetmap.org/wiki/Key:building:min_level), [`height`](https://wiki.openstreetmap.org/wiki/Key:height) and [`min_height`](https://wiki.openstreetmap.org/wiki/Key:min_height) values.
With `--buildings isometric` or `--buildings isometric-no-parts` (not set by default), buildings are drawn using isometric shapes for walls and shade in proportion to [`building:levels`](https://wiki.openstreetmap.org/wiki/Key:building:levels), [`building:min_level`](https://wiki.openstreetmap.org/wiki/Key:building:min_level), [`height`](https://wiki.openstreetmap.org/wiki/Key:height), and [`min_height`](https://wiki.openstreetmap.org/wiki/Key:min_height) values.
![3D buildings](doc/buildings.png)
![3D buildings](doc/buildings.svg)
### Road lanes ###
To determine road width Map Machine uses the [`width`](https://wiki.openstreetmap.org/wiki/Key:width) tag value or estimates it based on the [`lanes`](https://wiki.openstreetmap.org/wiki/Key:lanes) value.
To determine road width Map Machine uses the [`width`](https://wiki.openstreetmap.org/wiki/Key:width) tag value or estimates it based on the [`lanes`](https://wiki.openstreetmap.org/wiki/Key:lanes) value. If lane value is specified, it also draws lane separators. This map style is highly inspired by Christoph Hormann's post [Navigating the Maze](http://blog.imagico.de/navigating-the-maze-part-2/).
![Road lanes](doc/lanes.png)
![Road lanes](doc/lanes.svg)
### Trees ###
Visualization of tree leaf types (broadleaved or needleleaved) and genus or taxon by means of icon shapes and leaf cycles (deciduous or evergreen) by means of color.
Visualization of tree leaf types (broadleaved or needle-leaved) and genus or taxon by means of icon shapes and leaf cycles (deciduous or evergreen) by means of color.
![Trees](doc/trees.png)
![Trees](doc/trees.svg)
### Viewpoint and camera direction ###
Visualize [`direction`](https://wiki.openstreetmap.org/wiki/Key:direction) tag for [`tourism`](https://wiki.openstreetmap.org/wiki/Key:tourism)=[`viewpoint`](https://wiki.openstreetmap.org/wiki/Tag:tourism=viewpoint) and [`camera:direction`](https://wiki.openstreetmap.org/wiki/Key:camera:direction) for [`man_made`](https://wiki.openstreetmap.org/wiki/Key:man_made)=[`surveillance`](https://wiki.openstreetmap.org/wiki/Tag:man_made=surveillance).
[`direction`](https://wiki.openstreetmap.org/wiki/Key:direction) tag values for [`tourism`](https://wiki.openstreetmap.org/wiki/Key:tourism) = [`viewpoint`](https://wiki.openstreetmap.org/wiki/Tag:tourism=viewpoint) and [`camera:direction`](https://wiki.openstreetmap.org/wiki/Key:camera:direction) for [`man_made`](https://wiki.openstreetmap.org/wiki/Key:man_made) = [`surveillance`](https://wiki.openstreetmap.org/wiki/Tag:man_made=surveillance) are rendered with sectors displaying the direction and angle (15º if angle is not specified) or the whole circle for panorama view. Radial gradient is used for surveillance and inverted radial gradient is used for viewpoints.
![Surveillance](doc/surveillance.png)
![Surveillance](doc/surveillance.svg)
![Viewpoints](doc/viewpoints.png)
![Viewpoints](doc/viewpoints.svg)
### Power tower design ###
Visualize [`design`](https://wiki.openstreetmap.org/wiki/Key:design) values used with [`power`](https://wiki.openstreetmap.org/wiki/Key:power) = [`tower`](https://wiki.openstreetmap.org/wiki/Tag:power=tower) tag.
![Power tower design](doc/power_tower_design.png)
![Power tower design](doc/icons_power.svg)
![Power tower design](doc/power.png)
![Power tower design](doc/power.svg)
### Colors ###
Map icons have [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) tag value if it is present, otherwise icons displayed with dark grey color by default, purple color for shop nodes, red color for emergency features, and special colors for natural features. Map Machine also takes into account [`building:colour`](https://wiki.openstreetmap.org/wiki/Key:building:colour), [`roof:colour`](https://wiki.openstreetmap.org/wiki/Key:roof:colour) and other `*:colour` tags. We also use [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) tag value to paint subway lines.
Map icons have [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) tag value if it is present, otherwise, icons are displayed with dark grey color by default, purple color for shop nodes, red color for emergency features, and special colors for natural features. Map Machine also takes into account [`building:colour`](https://wiki.openstreetmap.org/wiki/Key:building:colour), [`roof:colour`](https://wiki.openstreetmap.org/wiki/Key:roof:colour) and other `*:colour` tags, and uses [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) tag value to paint subway lines.
E.g. [`building:colour`](https://wiki.openstreetmap.org/wiki/Key:building:colour) visualization:
![Building colors](doc/colors.png)
![Building colors](doc/colors.svg)
### Emergency ###
![Emergency](doc/emergency.png)
![Emergency](doc/icons_emergency.svg)
### Japanese map symbols ###
There are [special symbols](https://en.wikipedia.org/wiki/List_of_Japanese_map_symbols) appearing on Japanese maps.
Japanese maps usually use [special symbols](https://en.wikipedia.org/wiki/List_of_Japanese_map_symbols) called *chizukigou* (地図記号) which are different from standard map symbols used in other countries. They can be enabled with `--country jp` option.
![Japanese map symbols](doc/japanese.png)
Icon set
--------
The central feature of the project is Röntgen icon set. It is a set of monochrome 14 × 14 px pixel-aligned icons. Unlike the Map Machine source code, which is under MIT license, all icons are under [CC BY](http://creativecommons.org/licenses/by/4.0/) license. So, with the appropriate credit icon set can be used outside the project. Some icons can be used as emoji symbols.
All icons tend to support common design style, which is heavily inspired by [Maki](https://github.com/mapbox/maki), [Osmic](https://github.com/gmgeo/osmic), and [Temaki](https://github.com/ideditor/temaki).
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Map Machine can use more than one icon to visualize an entity and use colors to visualize [`colour`](https://wiki.openstreetmap.org/wiki/Key:colour) value or other entity properties (like [`material`](https://wiki.openstreetmap.org/wiki/Key:material) or [`genus`](https://wiki.openstreetmap.org/wiki/Key:genus)).
![Icons](doc/grid.png)
Feel free to request new icons via issues for whatever you want to see on the map. No matter how frequently the tag is used in OpenStreetMap since final goal is to cover all tags. However, common used tags have priority, other things being equal.
Generate icon grid and sets of individual icons with `map-machine icons`. It will create `out/icon_grid.svg` file, and SVG files in `out/icons_by_id` directory where files are named using shape identifiers (e.g. `power_tower_portal_2_level.svg`) and in `icons_by_name` directory where files are named using shape names (e.g. `Röntgen portal two-level transmission tower.svg`). Files from the last directory are used in OpenStreetMap wiki (e.g. [`File:Röntgen_portal_two-level_transmission_tower.svg`](https://wiki.openstreetmap.org/wiki/File:R%C3%B6ntgen_portal_two-level_transmission_tower.svg)).
![Japanese map symbols](doc/icons_japanese.svg)
### Shape combination ###
Map Machine constructs icons from the shapes extracted from the sketch SVG file. Some icons consists of just one shape, to construct other it may be necessary to combine two or more shapes.
One of the key features of Map Machine is constructing icons from the several shapes.
![Bus stop icon combination](doc/bus_stop.png)
#### Masts ####
For [`man_made`](https://wiki.openstreetmap.org/wiki/Key:man_made) = [`mast`](https://wiki.openstreetmap.org/wiki/Tag:man_made=mast) distinguish types (communication, lighting, monitoring, and siren) and construction (freestanding or lattice, and using of guys) are rendered by combining 7 unique icon shapes.
![Mast types](doc/mast.svg)
#### Volcanoes ####
For [`natural`](https://wiki.openstreetmap.org/wiki/Key:natural) = [`volcano`](https://wiki.openstreetmap.org/wiki/Tag:natural=volcano) status (active, dormant, extinct, or unspecified) and type (stratovolcano, shield, or scoria) are rendered by combining 7 unique icon shapes.
![Volcano types](doc/volcano.svg)
Wireframe view
--------------
@ -109,38 +128,40 @@ Wireframe view
Visualize element creation time with `--mode time`.
![Creation time mode](doc/time.png)
![Creation time mode](doc/time.svg)
### Author mode ###
Every way and node displayed with the random color picked for each author with `--mode author`.
![Author mode](doc/author.png)
![Author mode](doc/author.svg)
Installation
------------
Requirements: Python 3.8.
* Install [cairo 2D graphic library](https://www.cairographics.org/download/),
* install Python packages:
* install [GEOS library](https://libgeos.org),
* install Python packages with the command:
```bash
pip install -r requirements.txt
pip install .
```shell
pip install git+https://github.com/enzet/map-machine
```
For more detailed instructions, see [instructions](doc/INSTALL.md).
Map generation
--------------
Command `render` is used to generates SVG map from OpenStreetMap data. You can run it using:
Command `render` is used to generate SVG map from OpenStreetMap data. You can run it using:
```bash
map-machine render \
-b <min longitude>,<min latitude>,<max longitude>,<max latitude> \
-o <output file name> \
-z <OSM zoom level> \
-b=<min longitude>,<min latitude>,<max longitude>,<max latitude> \
-o=<output file name> \
-z=<OSM zoom level> \
<other arguments>
```
@ -148,11 +169,11 @@ map-machine render \
```bash
map-machine render \
--boundary-box 2.284,48.860,2.290,48.865 \
--output out/esplanade_du_trocadéro.svg
--boundary-box=2.284,48.860,2.290,48.865 \
--output=out/esplanade_du_trocadéro.svg
```
will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and write output SVG map of the specified area to `out/esplanade_du_trocadéro.svg`.
will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and write an output SVG map of the specified area to `out/esplanade_du_trocadéro.svg`.
### Arguments ###
@ -160,10 +181,10 @@ will download OSM data to `cache/2.284,48.860,2.290,48.865.osm` and write output
|---|---|
| <span style="white-space: nowrap;">`-i`</span>, <span style="white-space: nowrap;">`--input`</span> `<path>` | input XML file name or names (if not specified, file will be downloaded using OpenStreetMap API) |
| <span style="white-space: nowrap;">`-o`</span>, <span style="white-space: nowrap;">`--output`</span> `<path>` | output SVG file name, default value: `out/map.svg` |
| <span style="white-space: nowrap;">`-b`</span>, <span style="white-space: nowrap;">`--boundary-box`</span> `<lon1>,<lat1>,<lon2>,<lat2>` | geo boundary box; if first value is negative, enclose the value with quotes and use space before `-` |
| <span style="white-space: nowrap;">`-b`</span>, <span style="white-space: nowrap;">`--boundary-box`</span> `<lon1>,<lat1>,<lon2>,<lat2>` | geo boundary box; if the first value is negative, use `=` sign or enclose the value with quotes and use space before `-`, e.g. `-b=-84.752,39.504,-84.749,39.508` or `-b " -84.752,39.504,-84.749,39.508"` |
| <span style="white-space: nowrap;">`--cache`</span> `<path>` | path for temporary OSM files, default value: `cache` |
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<integer>` | OSM zoom level, default value: 18 |
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location inside the tile |
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<float>` | OSM zoom level, default value: 18.0 |
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location inside the tile; if the first value is negative, use `=` sign or enclose the value with quotes and use space before `-`, e.g. `-c=-84.752,39.504` or `-c " -84.752,39.504"` |
| <span style="white-space: nowrap;">`-s`</span>, <span style="white-space: nowrap;">`--size`</span> `<width>,<height>` | resulted image size |
plus [map configuration options](#map-options)
@ -175,12 +196,12 @@ Command `tile` is used to generate PNG tiles for [slippy maps](https://wiki.open
| Option | Description |
|---|---|
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location inside the tile |
| <span style="white-space: nowrap;">`-c`</span>, <span style="white-space: nowrap;">`--coordinates`</span> `<latitude>,<longitude>` | coordinates of any location inside the tile; if the first value is negative, use `=` sign or enclose the value with quotes and use space before `-`, e.g. `-c=-84.752,39.504` or `-c " -84.752,39.504"` |
| <span style="white-space: nowrap;">`-t`</span>, <span style="white-space: nowrap;">`--tile`</span> `<zoom level>/<x>/<y>` | tile specification |
| <span style="white-space: nowrap;">`--cache`</span> `<path>` | path for temporary OSM files, default value: `cache` |
| <span style="white-space: nowrap;">`-b`</span>, <span style="white-space: nowrap;">`--boundary-box`</span> `<lon1>,<lat1>,<lon2>,<lat2>` | construct the minimum amount of tiles that cover requested boundary box |
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<integer>` | OSM zoom levels; can be list of numbers or ranges, e.g. `16-18`, `16,17,18`, or `16,18-20`, default value: `18` |
| <span style="white-space: nowrap;">`-i`</span>, <span style="white-space: nowrap;">`--input`</span> `<path>` | input OSM XML file name (if not specified, file will be downloaded using OpenStreetMap API) |
| <span style="white-space: nowrap;">`-b`</span>, <span style="white-space: nowrap;">`--boundary-box`</span> `<lon1>,<lat1>,<lon2>,<lat2>` | construct the minimum amount of tiles that cover the requested boundary box; if the first value is negative, use `=` sign or enclose the value with quotes and use space before `-`, e.g. `-b=-84.752,39.504,-84.749,39.508` or `-b " -84.752,39.504,-84.749,39.508"` |
| <span style="white-space: nowrap;">`-z`</span>, <span style="white-space: nowrap;">`--zoom`</span> `<range>` | OSM zoom levels; can be list of numbers or ranges, e.g. `16-18`, `16,17,18`, or `16,18-20`, default value: `18` |
| <span style="white-space: nowrap;">`-i`</span>, <span style="white-space: nowrap;">`--input`</span> `<path>` | input OSM XML file name (if not specified, the file will be downloaded using OpenStreetMap API) |
plus [map configuration options](#map-options)
@ -196,8 +217,8 @@ or specify any geographical coordinates inside a tile:
```bash
map-machine tile \
--coordinates <latitude>,<longitude> \
--zoom <OSM zoom levels>
--coordinates=<latitude>,<longitude> \
--zoom=<OSM zoom levels>
```
Tile will be stored as SVG file `out/tiles/tile_<zoom level>_<x>_<y>.svg` and PNG file `out/tiles/tile_<zoom level>_<x>_<y>.svg`, where `x` and `y` are tile coordinates. `--zoom` option will be ignored if it is used with `--tile` option.
@ -205,7 +226,7 @@ Tile will be stored as SVG file `out/tiles/tile_<zoom level>_<x>_<y>.svg` and PN
Example:
```bash
map-machine tile -c 55.7510637,37.6270761 -z 18
map-machine tile -c=55.7510637,37.6270761 -z=18
```
will generate SVG file `out/tiles/tile_18_158471_81953.svg` and PNG file `out/tiles/tile_18_158471_81953.png`.
@ -216,16 +237,16 @@ Specify boundary box to get the minimal set of tiles that covers the area:
```bash
map-machine tile \
--boundary-box <min longitude>,<min latitude>,<max longitude>,<max latitude> \
--zoom <OSM zoom levels>
--boundary-box=<min longitude>,<min latitude>,<max longitude>,<max latitude> \
--zoom=<OSM zoom levels>
```
Boundary box will be extended to the boundaries of the minimal tile set that covers the area, then it will be extended a bit more to avoid some artifacts on the edges rounded to 3 digits after the decimal point. Map with new boundary box coordinates will be written to the cache directory as SVG and PNG files. All tiles will be stored as SVG files `out/tiles/tile_<zoom level>_<x>_<y>.svg` and PNG files `out/tiles/tile_<zoom level>_<x>_<y>.svg`, where `x` and `y` are tile coordinates.
The boundary box will be extended to the boundaries of the minimal tileset that covers the area, then it will be extended a bit more to avoid some artifacts on the edges rounded to 3 digits after the decimal point. Map with new boundary box coordinates will be written to the cache directory as SVG and PNG files. All tiles will be stored as SVG files `out/tiles/tile_<zoom level>_<x>_<y>.svg` and PNG files `out/tiles/tile_<zoom level>_<x>_<y>.svg`, where `x` and `y` are tile coordinates.
Example:
```bash
map-machine tile -b 2.361,48.871,2.368,48.875
map-machine tile -b=2.361,48.871,2.368,48.875
```
will generate 36 PNG tiles at zoom level 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files `cache/2.360,48.869,2.370,48.877_18.svg` and `cache/2.360,48.869,2.370,48.877_18.png`.
@ -239,7 +260,7 @@ Command `server` is used to run tile server for slippy maps.
map-machine server
```
Stop server interrupting process with <kbd>Ctrl</kbd> + <kbd>C</kbd>.
Stop server interrupting the process with <kbd>Ctrl</kbd> + <kbd>C</kbd>.
| Option | Description |
|---|---|
@ -251,7 +272,7 @@ Stop server interrupting process with <kbd>Ctrl</kbd> + <kbd>C</kbd>.
Create a minimal amount of tiles that cover specified boundary box for zoom levels 16, 17, 18, and 19:
```bash
map-machine tile -b 2.364,48.854,2.367,48.857 -z 16-19
map-machine tile -b=2.364,48.854,2.367,48.857 -z=16-19
```
Run tile server on 127.0.0.1:8080:
@ -290,14 +311,18 @@ Map configuration options used by `render` and `tile` commands:
| Option | Description |
|---|---|
| <span style="white-space: nowrap;">`--buildings`</span> `<mode>` | building drawing mode: flat, isometric, isometric-no-parts, default value: `flat` |
| <span style="white-space: nowrap;">`--mode`</span> `<string>` | map drawing mode: normal, author, time, default value: `normal` |
| <span style="white-space: nowrap;">`--buildings`</span> `<mode>` | building drawing mode: no, flat, isometric, isometric-no-parts, default value: `flat` |
| <span style="white-space: nowrap;">`--mode`</span> `<string>` | map drawing mode: normal, author, time, white, black, default value: `normal` |
| <span style="white-space: nowrap;">`--overlap`</span> `<integer>` | how many pixels should be left around icons and text, default value: 12 |
| <span style="white-space: nowrap;">`--labels`</span> `<string>` | label drawing mode: no, main, all, default value: `main` |
| <span style="white-space: nowrap;">`--labels`</span> `<string>` | label drawing mode: no, main, all, address, default value: `main` |
| <span style="white-space: nowrap;">`--level`</span> | display only this floor level, default value: `overground` |
| <span style="white-space: nowrap;">`--seed`</span> `<string>` | seed for random |
| <span style="white-space: nowrap;">`--show-tooltips`</span> | add tooltips with tags for icons in SVG files |
| <span style="white-space: nowrap;">`--tooltips`</span> | add tooltips with tags for icons in SVG files |
| <span style="white-space: nowrap;">`--country`</span> | two-letter code (ISO 3166-1 alpha-2) of country, that should be used for location restrictions, default value: `world` |
| <span style="white-space: nowrap;">`--ignore-level-matching`</span> | draw all map features ignoring the current level |
| <span style="white-space: nowrap;">`--roofs`</span> | draw building roofs, set by default |
| <span style="white-space: nowrap;">`--building-colors`</span> | paint walls (if isometric mode is enabled) and roofs with specified colors |
| <span style="white-space: nowrap;">`--show-overlapped`</span> | show hidden nodes with a dot |
MapCSS 0.2 generation
---------------------
@ -312,7 +337,8 @@ To create MapCSS with Map Machine style also for ways and relations, run `map-ma
| <span style="white-space: nowrap;">`--ways`</span> | add style for ways and relations |
| <span style="white-space: nowrap;">`--lifecycle`</span> | add icons for lifecycle tags; be careful: this will increase the number of node and area selectors by 9 times, set by default |
### Use Map Machine as JOSM map paint style ###
### Use Röntgen as JOSM map paint style ###
* Run `map-machine mapcss`.
* Open [JOSM](https://josm.openstreetmap.de/).
@ -326,7 +352,7 @@ To enable / disable Map Machine map paint style go to <kbd>View</kbd> → <kbd>M
![JOSM example](doc/josm.png)
Example of using Röntgen icons on top of Mapnik style in JOSM. Map Paint Styles look like:
Example of using Röntgen icons on top of Mapnik style in JOSM. Map Paint Styles look like this:
* ✓ Mapnik (true)
* ✓ Map Machine

188
data/collections.json Normal file
View file

@ -0,0 +1,188 @@
[
{
"page": "Tag:roof:shape=skillion",
"id": "skillion_roof",
"tags": {"building": "apartments", "roof:shape": "skillion"},
"row_key": "building:levels",
"row_values": ["1", "2", "3", "4", "5"]
},
{
"page": "Tag:roof:shape=flat",
"id": "flat_roof",
"tags": {"building": "apartments", "roof:shape": "flat"},
"row_key": "building:levels",
"row_values": ["1", "2", "3", "4", "5"]
},
{
"page": "Tag:roof:shape=gabled",
"id": "gabled_roof",
"tags": {"building": "apartments", "roof:shape": "gabled"},
"row_key": "building:levels",
"row_values": ["1", "2", "3", "4", "5"]
},
{
"name": "Apartments",
"page": "Key:building:levels",
"id": "apartments",
"tags": {"building": "apartments"},
"row_key": "roof:shape",
"row_values": ["gabled", "flat", "hipped", "pyramidal", "skillion"],
"column_key": "building:levels",
"column_values": ["1", "2", "3", "4", "5"]
},
{
"name": "Outdoor seating",
"page": "Tag:leisure=outdoor_seating",
"id": "outdoor_seating",
"tags": {"leisure": "outdoor_seating"},
"row_key": "weather_protection",
"row_values": ["parasol", "roof", "awning", "pavilion", "pergola"]
},
{
"page": "Tag:artwork_type=statue",
"id": "statue",
"tags": {"tourism": "artwork", "artwork_type": "statue"},
"row_tags": [
{},
{"amenity": "bench"}
]
},
{
"name": "Tank trap",
"page": "Key:tank_trap",
"id": "tank_trap",
"tags": {},
"row_key": "tank_trap",
"row_values": ["czech_hedgehog", "dragons_teeth", "toblerone"]
},
{
"page": "Tag:natural=tree",
"id": "trees",
"tags": {"natural": "tree"},
"row_key": "leaf_type",
"row_values": ["broadleaved", "needleleaved", "palm"],
"column_key": "denotation",
"column_values": ["", "urban", "avenue"]
},
{
"name": "Surveillance",
"page": "Tag:man_made=surveillance",
"id": "surveillance",
"tags": {"man_made": "surveillance"},
"row_tags": [
{},
{"camera:type": "dome", "camera:mount": "ceiling"},
{"camera:type": "dome", "camera:mount": "wall"}
]
},
{
"page": "Tag:artwork_type=stone",
"id": "artwork_stone",
"tags": {},
"row_tags": [
{"tourism": "artwork", "artwork_type": "stone"},
{"tourism": "artwork", "artwork_type": "stone", "inscription": "*"}
]
},
{
"name": "Bench",
"page": "Tag:memorial=bench",
"id": "bench",
"tags": {},
"row_tags": [
{"amenity": "bench"},
{"memorial": "bench"}
]
},
{
"name": "Mast",
"page": "Tag:man_made=mast",
"id": "mast",
"tags": {"man_made": "mast"},
"row_key": "tower:construction",
"row_values": ["freestanding", "lattice", "guyed_tube", "guyed_lattice"],
"column_key": "tower:type",
"column_values": ["", "communication", "lighting", "monitoring", "siren"]
},
{
"name": "Volcano",
"page": "Tag:natural=volcano",
"id": "volcano",
"tags": {"natural": "volcano"},
"row_key": "volcano:type",
"row_values": ["stratovolcano", "shield", "scoria"],
"column_key": "volcano:status",
"column_values": ["", "active", "dormant", "extinct"]
},
{
"id": "volcano_status",
"tags": {"natural": "volcano"},
"row_key": "volcano:status",
"row_values": ["", "active", "dormant", "extinct"]
},
{
"page": "Tag:tower:construction=guyed_tube",
"tags": {"man_made": "mast", "tower:construction": "guyed_tube"},
"row_key": "tower:type",
"row_values": ["", "communication", "lighting", "monitoring", "siren"]
},
{
"page": "Tag:tower:construction=guyed_lattice",
"tags": {"man_made": "mast", "tower:construction": "guyed_lattice"},
"row_key": "tower:type",
"row_values": ["", "communication", "lighting", "monitoring", "siren"]
},
{
"page": "Key:communication:mobile_phone",
"tags": {"communication:mobile_phone": "yes"}
},
{
"name": "Traffic calming",
"page": "Key:traffic_calming",
"tags": {},
"row_key": "traffic_calming",
"row_values": [
"bump", "mini_bumps", "hump", "table", "cushion", "rumble_strip",
"dip", "double_dip"
]
},
{
"page": "Key:crane:type",
"tags": {"man_made": "crane"},
"column_key": "crane:type",
"column_values": [
"gantry_crane", "floor-mounted_crane", "portal_crane",
"travel_lift", "tower_crane"
]
},
{
"tags": {},
"name": "Power tower and pole",
"row_key": "power",
"row_values": ["tower", "pole"],
"column_key": "design",
"column_values": [
"one-level", "two-level", "three-level", "four-level", "asymmetric",
"triangle", "flag", "delta", "delta_two-level", "delta_three-level"
]
},
{
"tags": {},
"name": "Power tower",
"row_key": "power",
"row_values": ["tower"],
"column_key": "design",
"column_values": [
"donau", "donau_inverse", "barrel", "y-frame", "x-frame", "h-frame",
"guyed_h-frame", "portal", "portal_two-level", "portal_three-level"
]
},
{
"name": "Diving tower",
"page": "Tag:tower:type=diving",
"id": "diving",
"tags": {"man_made": "tower", "tower:type": "diving"},
"row_key": "tower:platforms",
"row_values": ["", "1", "2", "3", "4"]
}
]

51
data/dictionary.xml Normal file
View file

@ -0,0 +1,51 @@
<component name="ProjectDictionaryState">
<dictionary name="%USERNAME%">
<words>
<w>addr</w>
<w>aeroway</w>
<w>arecaceae</w>
<w>betula</w>
<w>carto</w>
<w>changeset</w>
<w>cladr</w>
<w>dasharray</w>
<w>defs</w>
<w>dharmachakra</w>
<w>enzet</w>
<w>flinger</w>
<w>githooks</w>
<w>housenumber</w>
<w>josm</w>
<w>landuse</w>
<w>levelname</w>
<w>linalg</w>
<w>linecap</w>
<w>linejoin</w>
<w>lunokhod</w>
<w>mapcss</w>
<w>maxlat</w>
<w>maxlon</w>
<w>maxspeed</w>
<w>minlat</w>
<w>minlon</w>
<w>needleleaved</w>
<w>noninfringement</w>
<w>osmic</w>
<w>portolan</w>
<w>rasterize</w>
<w>rasterized</w>
<w>röntgen</w>
<w>scoria</w>
<w>subattributes</w>
<w>subelement</w>
<w>subparser</w>
<w>subtile</w>
<w>subtiles</w>
<w>svgwrite</w>
<w>taginfo</w>
<w>temaki</w>
<w>tileset</w>
<w>vartanov</w>
</words>
</dictionary>
</component>

View file

@ -1,4 +1,4 @@
#!/usr/local/bin/python3
#!/usr/bin/env python3
"""
Commit message checking.
"""
@ -12,30 +12,25 @@ SHORT_MESSAGE_MAX_LENGTH: int = 50
MESSAGE_MAX_LENGTH: int = 72
def error(message: str):
"""Print error message and return exit code 1."""
print(f"Error: {message}")
sys.exit(1)
def check_file(file_name: str) -> Optional[str]:
"""
Exit program with exit code 1 if commit message does not conform the rules.
:param file_name: commit message file name
"""
with open(file_name) as input_file:
with open(file_name, encoding="utf-8") as input_file:
parts: List[str] = list(map(lambda x: x[:-1], input_file.readlines()))
return check_commit_message(parts)
def check_commit_message(parts: List[str]) -> Optional[str]:
"""Check whether the commit message is well-formed."""
short_message: str = parts[0]
if short_message[0] != short_message[0].upper():
return (
short_message + "\n^"
short_message
+ "\n^"
+ "\nCommit message short description should start with uppercase "
+ "letter."
)
@ -77,7 +72,8 @@ def check_commit_message(parts: List[str]) -> Optional[str]:
+ '\nCommit message should end with ".".'
)
def up(text: str):
def first_letter_uppercase(text: str):
"""Change first letter to upper case."""
return text[0].upper() + text[1:]
verbs_1 = ["add", "fix", "check", "refactor"]
@ -99,28 +95,31 @@ def check_commit_message(parts: List[str]) -> Optional[str]:
for verb in verbs_2:
verbs[verb + "d"] = verb
for verb in verbs:
if short_message.startswith(f"{verb} ") or short_message.startswith(
f"{up(verb)} "
):
for wrong_verb, right_verb in verbs.items():
if short_message.startswith(
f"{wrong_verb} "
) or short_message.startswith(f"{first_letter_uppercase(wrong_verb)} "):
return (
f'Commit message should start with the verb in infinitive '
f'form. Please, use "{up(verbs[verb])} ..." instead of '
f'"{up(verb)} ...".'
f"Commit message should start with the verb in infinitive "
f"form. Please, use "
f'"{first_letter_uppercase(right_verb)} ..." instead of '
f'"{first_letter_uppercase(wrong_verb)} ...".'
)
def check(commit_message):
def check(commit_message: str) -> None:
"""Print commit_message and checking result."""
print("\033[33m" + commit_message + "\033[0m")
print(check_commit_message(commit_message.split("\n")))
def test():
"""Test rules."""
check("start with lowercase letter.")
check("Added foo.")
check("Created foo.")
check("Doesn't end with dot")
check("Tooooooooooooooooooooooooooooooooooooooooooooo long")
check("To-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o long")
if __name__ == "__main__":

View file

@ -1,8 +1,10 @@
#!/bin/sh
python_files="map_machine setup.py tests data/githooks/commit-msg"
echo "Checking code format with Black..."
if ! black -l 80 --check tests map_machine; then
black -l 80 --diff --color tests map_machine
if ! black -l 80 --check ${python_files}; then
black -l 80 --diff --color ${python_files}
echo "FAIL"
exit 1
fi
@ -13,5 +15,5 @@ echo "Lint with Flake8..."
flake8 \
--max-line-length=80 \
--ignore=E203,W503,ANN002,ANN003,ANN101,ANN102 \
--exclude=work,python3.8 \
${python_files} \
|| { echo "FAIL"; exit 1; }

View file

@ -6,6 +6,7 @@ if [ ${files} == 0 ] ; then
echo "OK"
else
echo "FAIL"
git status
exit 1
fi
@ -14,6 +15,6 @@ data/githooks/pre-commit || { echo "FAIL"; exit 1; }
# Unit tests with pytest.
echo "Run tests with pytest..."
pytest -v || { echo "FAIL"; exit 1; }
pytest -vv || { echo "FAIL"; exit 1; }
exit 0

18
data/pylintrc Normal file
View file

@ -0,0 +1,18 @@
[MASTER]
disable=
C0415,
R0902,
R0903,
R0912,
R0913,
R0914,
W0511,
W1203,
too-many-return-statements
good-names=i,j,x,y,a,b,c,n
[SIMILARITIES]
ignore-imports=yes

25
doc/INSTALL.md Normal file
View file

@ -0,0 +1,25 @@
Install
-------
Map Machine requires [Python](https://www.python.org) 3.9, [pip](https://pip.pypa.io/en/stable/installation/), and two libraries:
* [cairo 2D graphic library](https://www.cairographics.org/download/),
* [GEOS library](https://libgeos.org).
Installation examples
---------------------
### Ubuntu ###
```shell
apt install libcairo2-dev libgeos-dev
pip install git+https://github.com/enzet/map-machine
```
### macOS ###
```shell
brew install cairo geos
pip install git+https://github.com/enzet/map-machine
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

2
doc/author.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

2
doc/buildings.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

2
doc/colors.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 MiB

View file

@ -4,7 +4,7 @@ Thank you for your interest in the Map Machine project. Since the primary goal
\2 {Suggest a tag to support} {}
Please, create an issue with \m {icon} label.
Please, create an issue describing how you would like the feature to be visualized.
/*
\2 {Add an icon} {}
@ -12,7 +12,7 @@ Please, create an issue with \m {icon} label.
\2 {Report a bug} {}
Please, create an issue with \m {bug} and \m {generator} labels.
Please, create an issue describing the current behavior, expected behavior, and environment (most importantly, the OS version and Python version if it was not the recommended one).
\2 {Fix a typo in documentation} {}
@ -20,14 +20,31 @@ This action is not that easy as it supposed to be. We use \ref {http://github.c
\2 {Modify the code} {}
First of all, configure your workspace.
\3 {First configure your workspace}
Make sure you have Python 3.9 development tools. E.g., for Ubuntu, run \m {apt install python3.9-dev python3.9-venv}.
Activate virtual environment. E.g. for fish shell, run \m {source venv/bin/activate.fish}.
Install the project in editable mode:
\code {pip install -e .} {shell}
Install formatter, linter and test system\: \m {pip install black flake8 mypy pytest pytest-cov}.
Be sure to enable Git hooks:
\code {git config --local core.hooksPath data/githooks} {shell}
If you are using PyCharm, you may want to set up user dictionary as well:
\list
{Install formatter, linter and test system\: \m {pip install black flake8 pytest}.}
{Be sure to run \m {git config --local core.hooksPath data/githooks} to enable Git hooks.}
{\m {cp data/dictionary.xml .idea/dictionaries/<user name>.xml}}
{in \m {.idea/dictionaries/<user name>.xml} change \m {%USERNAME%} to your username,}
{restart PyCharm if it is launched.}
\3 {Code style} {code-style}
We use \ref {http://github.com/psf/black} {Black} code formatter with maximum 80 characters line lenght for all Python files within the project. Reformat a file is as simple as \m {black -l 80 \formal {file name}}.
We use \ref {http://github.com/psf/black} {Black} code formatter with maximum 80 characters line length for all Python files within the project. Reformat a file is as simple as \m {black -l 80 \formal {file name}}. Reformat everything with \m {black -l 80 map_machine tests}.
If you create new Python file, make sure you add \m {__author__ = "\formal {first name} \formal {second name}"} and \m {__email__ = "\formal {author e-mail}"} string variables.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

2
doc/grid.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 424 KiB

2
doc/icons_emergency.svg Normal file
View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg baseProfile="full" height="96" version="1.1" width="768.0" xmlns="http://www.w3.org/2000/svg" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xlink="http://www.w3.org/1999/xlink"><defs /><rect fill="#fff" height="96" width="768.0" x="0" y="0" /><g opacity="1.0"><path d="m 532,497.5 a 2.5,2.5 0 0 0 -2.5,2.5 2.5,2.5 0 0 0 0.7344,1.76758 l 4.2656,4.26758 4.2422,-4.24219 a 2.5,2.5 0 0 0 0.023,-0.0254 l 0,0 A 2.5,2.5 0 0 0 539.5,500 2.5,2.5 0 0 0 537,497.5 2.5,2.5 0 0 0 534.5,500 2.5,2.5 0 0 0 532,497.5 Z m 8,7 -2.5,4 2.5,-0.5 0,2.5 2.5,-4 -2.5,0.5 z" fill="#d22" transform="translate(48.0,48.0) scale(4.0,4.0) translate(-536.0,-504.0)" /></g><g opacity="1.0"><path d="m 119,562 c -0.0693,0 -0.12764,0.006 -0.1875,0.0312 C 117.24696,562.13217 116,563.40897 116,565 l -1,7 2,0 0,-7 c 0,-1.10457 0.89543,-2 2,-2 l 1.5,0 c 0.277,0 0.5,-0.223 0.5,-0.5 0,-0.277 -0.223,-0.5 -0.5,-0.5 z m 1,2 c -1.108,0 -2,0.892 -2,2 l 0,0.5 0,1.5 0,5.5 c 0,0.277 0.223,0.5 0.5,0.5 l 3,0 c 0.277,0 0.5,-0.223 0.5,-0.5 l 0,-5.5 0,-1.5 0,-0.5 c 0,-1.108 -0.892,-2 -2,-2 z" fill="#d22" transform="translate(144.0,48.0) scale(4.0,4.0) translate(-120.0,-568.0)" /></g><g opacity="1.0"><path d="m 136,369 c -1.662,0 -3,1.338 -3,3 l -0.5,0 c -0.277,0 -0.5,0.223 -0.5,0.5 0,0.277 0.223,0.5 0.5,0.5 l 0.5,0 0,1 -1.5,0 c -0.277,0 -0.5,0.223 -0.5,0.5 l 0,0.5 -0.5,0 c -0.277,0 -0.5,0.223 -0.5,0.5 l 0,1 c 0,0.277 0.223,0.5 0.5,0.5 l 0.5,0 0,0.5 c 0,0.277 0.223,0.5 0.5,0.5 l 1.5,0 0,4 -1.5,0 c -0.277,0 -0.5,0.223 -0.5,0.5 0,0.277 0.223,0.5 0.5,0.5 l 9,0 c 0.277,0 0.5,-0.223 0.5,-0.5 0,-0.277 -0.223,-0.5 -0.5,-0.5 l -1.5,0 0,-4 1.5,0 c 0.277,0 0.5,-0.223 0.5,-0.5 l 0,-0.5 0.5,0 c 0.277,0 0.5,-0.223 0.5,-0.5 l 0,-1 c 0,-0.277 -0.223,-0.5 -0.5,-0.5 l -0.5,0 0,-0.5 c 0,-0.277 -0.223,-0.5 -0.5,-0.5 l -1.5,0 0,-1 0.5,0 c 0.277,0 0.5,-0.223 0.5,-0.5 0,-0.277 -0.223,-0.5 -0.5,-0.5 l -0.5,0 c 0,-1.662 -1.338,-3 -3,-3 z m 0,5 c 1.10457,0 2,0.89543 2,2 0,1.10457 -0.89543,2 -2,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.10457 0.89543,-2 2,-2 z" fill="#d22" transform="translate(240.0,48.0) scale(4.0,4.0) translate(-136.0,-376.0)" /></g><g opacity="1.0"><path d="m 520,338 a 6,6 0 0 0 -6,6 6,6 0 0 0 6,6 6,6 0 0 0 6,-6 6,6 0 0 0 -6,-6 z m 0,1 a 5,5 0 0 1 5,5 h -2 a 3,3 0 0 0 -3,-3 z m 0,3 a 2,2 0 0 1 2,2 2,2 0 0 1 -2,2 2,2 0 0 1 -2,-2 2,2 0 0 1 2,-2 z m -5,2 h 2 a 3,3 0 0 0 3,3 v 2 a 5,5 0 0 1 -5,-5 z" fill="#d22" transform="translate(336.0,48.0) scale(4.0,4.0) translate(-520.0,-344.0)" /></g><g opacity="1.0"><path d="m 71.5,562 c -2.75,0 -5.5,1 -5.5,3 l 3,0 c 0,-2 5,-2 5,0 l 3,0 c 0,-2 -2.75,-3 -5.5,-3 z m -5.5,4 c 0,0.554 0.446,1 1,1 l 1,0 c 0.554,0 1,-0.446 1,-1 z m 8,0 c 0,0.554 0.446,1 1,1 l 1,0 c 0.554,0 1,-0.446 1,-1 z m -6.5,3 c -0.82251,0 -1.5,0.67749 -1.5,1.5 0,0.82251 0.67749,1.5 1.5,1.5 0.28206,0 0.5,0.21795 0.5,0.5 0,0.28205 -0.21794,0.5 -0.5,0.5 l -1,0 a 0.50005,0.50005 0 1 0 0,1 l 1,0 c 0.82251,0 1.5,-0.67749 1.5,-1.5 0,-0.82251 -0.67749,-1.5 -1.5,-1.5 -0.28205,0 -0.5,-0.21795 -0.5,-0.5 0,-0.28205 0.21795,-0.5 0.5,-0.5 l 1,0 a 0.50005,0.50005 0 1 0 0,-1 z m 4,0 c -0.82251,0 -1.5,0.67749 -1.5,1.5 l 0,2 c 0,0.82251 0.67749,1.5 1.5,1.5 0.68503,0 1.17533,-0.51422 1.35156,-1.14648 A 0.50005,0.50005 0 0 0 73,572.5 l 0,-2 c 0,-0.82251 -0.67749,-1.5 -1.5,-1.5 z m 4,0 c -0.82251,0 -1.5,0.67749 -1.5,1.5 0,0.82251 0.67749,1.5 1.5,1.5 0.28206,0 0.5,0.21795 0.5,0.5 0,0.28205 -0.21794,0.5 -0.5,0.5 l -1,0 a 0.50005,0.50005 0 1 0 0,1 l 1,0 c 0.82251,0 1.5,-0.67749 1.5,-1.5 0,-0.82251 -0.67749,-1.5 -1.5,-1.5 -0.28205,0 -0.5,-0.21795 -0.5,-0.5 0,-0.28205 0.21795,-0.5 0.5,-0.5 l 1,0 a 0.50005,0.50005 0 1 0 0,-1 z m -4,1 c 0.28206,0 0.5,0.21795 0.5,0.5 l 0,2 c 0,0.28205 -0.21794,0.5 -0.5,0.5 -0.28205,0 -0.5,-0.21795 -0.5,-0.5 l 0,-2 c 0,-0.28205 0.21795,-0.5 0.5,-0.5 z" fill="#d22" transform="translate(432.0,48.0) scale(4.0,4.0) translate(-72.0,-568.0)" /></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

2
doc/icons_japanese.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

2
doc/icons_power.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

23
doc/install.moi Normal file
View file

@ -0,0 +1,23 @@
\2 {Install} {install}
Map Machine requires \ref {https://www.python.org} {Python} 3.9, \ref {https://pip.pypa.io/en/stable/installation/} {pip}, and two libraries\:
\list
{\ref {https://www.cairographics.org/download/} {cairo 2D graphic library},}
{\ref {https://libgeos.org} {GEOS library}.}
\2 {Installation examples} {installation-examples}
\3 {Ubuntu} {ubuntu}
\code {
apt install libcairo2-dev libgeos-dev
pip install git+https://github.com/enzet/map-machine
} {shell}
\3 {macOS} {macos}
\code {
brew install cairo geos
pip install git+https://github.com/enzet/map-machine
} {shell}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

2
doc/lanes.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 342 KiB

2
doc/mast.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

2
doc/power.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,49 +1,73 @@
\title {Map Machine}
\page_icon {out/icons_by_id/book.svg}
\b {Map Machine} project consists of
\list
{Python \ref {http://openstreetmap.org} {OpenStreetMap} renderer and tile generator (see \ref {#usage-example} {usage}, \ref {#map-generation} {renderer documentation}, \ref {#tile-generation} {tile generation}),}
{\ref {#icon-set} {Röntgen icon set}\: unique CC-BY 4.0 icons.}
{Python \ref {http://openstreetmap.org} {OpenStreetMap} renderer:
\list
{SVG \ref {#map-generation} {map generation},}
{SVG and PNG \ref {#tile-generation} {tile generation},}}
{\ref {#röntgen-icon-set} {Röntgen} icon set\: unique CC-BY 4.0 map icons.}
The idea behind the Map Machine project is to \b {show all the richness of the OpenStreetMap data}\: to have a possibility to \i {display any map feature} represented by OpenStreetMap data tags by means of colors, shapes, and icons. Map Machine is created for OpenStreetMap contributors\: to display all changes one made on the map even if they are small, and for users\: to dig down into the map and find every detail that was mapped.
The idea behind the Map Machine project is to \b {show all the richness of the OpenStreetMap data}\: to have a possibility to display any map feature represented by OpenStreetMap data tags by means of colors, shapes, and icons. Map Machine is created both for map contributors\: to display all changes one made on the map even if they are small, and for map users\: to dig down into the map and find every detail that was mapped.
Unlike standard OpenStreetMap layers, \b {Map Machine is a playground for experiments} where one can easily try to support proposed tags, tags with little or even single usage, deprecated tags.
Unlike standard OpenStreetMap layers, \b {Map Machine is a playground for experiments} where one can easily try to support any unsupported tag, proposed tagging scheme, tags with little or even single usage, deprecated ones that are still in use.
Map Machine is intended to be highly configurable, so it can generate precise but messy maps for OSM contributors as well as pretty and clean maps for OSM users, can use slow algorithms for some experimental features.
Map Machine is intended to be highly configurable, so it can generate precise but messy maps for OSM contributors as well as pretty and clean maps for OSM users. It can also use some slow algorithms for experimental features.
See
\list
{\ref {#installation} {installation instructions},}
{\ref {#map-features} {map features},}
{\ref {#use-röntgen-as-josm-map-paint-style} {using Röntgen as JOSM style}.}
\2 {Usage example} {usage-example}
\code {map-machine render -b 2.284,48.860,2.290,48.865} {bash}
\code {map-machine render -b=2.284,48.860,2.290,48.865} {bash}
will automatically download OSM data and write output SVG map of the specified area to \m {out/map.svg}. See \ref {#map-generation} {Map generation}.
\code {map-machine tile -b 2.361,48.871,2.368,48.875} {bash}
\code {map-machine tile -b=2.361,48.871,2.368,48.875} {bash}
will automatically download OSM data and write output PNG tiles that cover the specified area to \m {out/tiles} directory. See \ref {#tile-generation} {Tile generation}.
will automatically download OSM data and write output PNG tiles that cover the specified area to the \m {out/tiles} directory. See \ref {#tile-generation} {Tile generation}.
\2 {Map features} {features}
\2 {Röntgen icon set} {röntgen-icon-set}
\3 {Isometric building shapes} {levels}
The central feature of the project is Röntgen icon set. It is a set of monochrome 14 × 14 px pixel-aligned icons specially created for Map Machine project. Unlike the Map Machine source code, which is under MIT license, all icons are under \ref {http://creativecommons.org/licenses/by/4.0/} {CC BY} license. So, with the appropriate credit icon set can be used outside the project. Some icons can be used as emoji symbols.
With \m {--buildings isometric} or \m {--buildings isometric-no-parts} (not set by default), buildings are drawn using isometric shapes for walls and shade in proportion to \osm {building:levels}, \osm {building:min_level}, \osm {height} and \osm {min_height} values.
All icons tend to support a common design style, which is heavily inspired by \ref {https://github.com/mapbox/maki} {Maki}, \ref {https://github.com/gmgeo/osmic} {Osmic}, and \ref {https://github.com/ideditor/temaki} {Temaki}.
\image {doc/buildings.png} {3D buildings}
\image {doc/grid.svg} {Icons}
Feel free to request new icons via issues for whatever you want to see on the map. No matter how frequently the tag is used in OpenStreetMap since the final goal is to cover all tags. However, commonly used tags have priority, other things being equal.
Generate icon grid and sets of individual icons with \m {\command {icons}}. It will update \m {doc/grid.svg} file, and create SVG files in \m {out/icons_by_id} directory where files are named using shape identifiers (e.g. \m {power_tower_portal_2_level.svg}) and in \m {icons_by_name} directory where files are named using shape names (e.g. \m {Röntgen portal two-level transmission tower.svg}). Files from the last directory are used in OpenStreetMap wiki (e.g. \ref {https://wiki.openstreetmap.org/wiki/File:R%C3%B6ntgen_portal_two-level_transmission_tower.svg} {\m {File:Röntgen_portal_two-level_transmission_tower.svg}}).
\2 {Map features} {map-features}
\3 {Extra icons} {extra-icons}
Map Machine uses icons to visualize tags for nodes and areas. But unlike other renderers, Map Machine can use more than one icon to visualize an entity and use colors to visualize \osm {colour} value or other entity properties (like \osm {material} or \osm {genus}).
\3 {Isometric building shapes} {isometric-building-shapes}
With \m {--buildings isometric} or \m {--buildings isometric-no-parts} (not set by default), buildings are drawn using isometric shapes for walls and shade in proportion to \osm {building:levels}, \osm {building:min_level}, \osm {height}, and \osm {min_height} values.
\image {doc/buildings.svg} {3D buildings}
\3 {Road lanes} {road-lanes}
To determine road width Map Machine uses the \osm {width} tag value or estimates it based on the \osm {lanes} value.
To determine road width Map Machine uses the \osm {width} tag value or estimates it based on the \osm {lanes} value. If lane value is specified, it also draws lane separators. This map style is highly inspired by Christoph Hormann's post \ref {http://blog.imagico.de/navigating-the-maze-part-2/} {Navigating the Maze}.
\image {doc/lanes.png} {Road lanes}
\image {doc/lanes.svg} {Road lanes}
\3 {Trees} {trees}
Visualization of tree leaf types (broadleaved or needleleaved) and genus or taxon by means of icon shapes and leaf cycles (deciduous or evergreen) by means of color.
Visualization of tree leaf types (broadleaved or needle-leaved) and genus or taxon by means of icon shapes and leaf cycles (deciduous or evergreen) by means of color.
/*
Visualization of tree \icon {tree} leaf types (broadleaved \icon {tree_with_leaf} or needleleaved \icon {needleleaved_tree}) and genus or taxon by means of icon shapes and leaf cycles (unknown \color {#98AC64}, deciduous \color {#fcaf3e} or evergreen \color {#688C44}) by means of color. If diameter, circumference, and/or diameter_crown are specified, we draw crown and trunk as circles. We also have special icons for some genus and taxons\: birch (\i {Betula}) \icon {betula}, palm (\i {Arecaceae}) \icon {palm}, maple (\i {Acer}) \icon {tree}\icon {leaf_maple}.
Visualization of tree \icon {tree} leaf types (broadleaved \icon {tree_with_leaf} or needle-leaved \icon {needleleaved_tree}) and genus or taxon by means of icon shapes and leaf cycles (unknown \color {#98AC64}, deciduous \color {#fcaf3e} or evergreen \color {#688C44}) by means of color. If diameter, circumference, and/or diameter_crown are specified, we draw crown and trunk as circles. We also have special icons for some genus and taxons\: birch (\i {Betula}) \icon {betula}, palm (\i {Arecaceae}) \icon {palm}, maple (\i {Acer}) \icon {tree}\icon {leaf_maple}.
\table
{{\osm {natural=tree}} {\icon {tree}}}
@ -59,64 +83,63 @@ Visualization of tree \icon {tree} leaf types (broadleaved \icon {tree_with_leaf
*/
\image {doc/trees.png} {Trees}
\image {doc/trees.svg} {Trees}
\3 {Viewpoint and camera direction} {direction}
\3 {Viewpoint and camera direction} {viewpoint-and-camera-direction}
Visualize \osm {direction} tag for \osm {tourism=viewpoint} and \osm {camera:direction} for \osm {man_made=surveillance}.
\osm {direction} tag values for \osm {tourism=viewpoint} and \osm {camera:direction} for \osm {man_made=surveillance} are rendered with sectors displaying the direction and angle (15º if angle is not specified) or the whole circle for panorama view. Radial gradient is used for surveillance and inverted radial gradient is used for viewpoints.
\image {doc/surveillance.png} {Surveillance}
\image {doc/surveillance.svg} {Surveillance}
\image {doc/viewpoints.png} {Viewpoints}
\image {doc/viewpoints.svg} {Viewpoints}
\3 {Power tower design} {power-tower-design}
Visualize \osm {design} values used with \osm {power=tower} tag.
\image {doc/power_tower_design.png} {Power tower design}
\image {doc/power.png} {Power tower design}
\image {doc/icons_power.svg} {Power tower design}
\image {doc/power.svg} {Power tower design}
\3 {Colors} {colors}
Map icons have \osm {colour} tag value if it is present, otherwise icons displayed with dark grey color by default, purple color for shop nodes, red color for emergency features, and special colors for natural features. Map Machine also takes into account \osm {building:colour}, \osm {roof:colour} and other \m {*:colour} tags. We also use \osm {colour} tag value to paint subway lines.
Map icons have \osm {colour} tag value if it is present, otherwise, icons are displayed with dark grey color by default, purple color for shop nodes, red color for emergency features, and special colors for natural features. Map Machine also takes into account \osm {building:colour}, \osm {roof:colour} and other \m {*:colour} tags, and uses \osm {colour} tag value to paint subway lines.
E.g. \osm {building:colour} visualization\:
\image {doc/colors.png} {Building colors}
\image {doc/colors.svg} {Building colors}
\3 {Emergency} {emergency}
\image {doc/emergency.png} {Emergency}
\image {doc/icons_emergency.svg} {Emergency}
\3 {Japanese map symbols} {japanese-map-symbols}
There are \ref {https://en.wikipedia.org/wiki/List_of_Japanese_map_symbols} {special symbols} appearing on Japanese maps.
Japanese maps usually use \ref {https://en.wikipedia.org/wiki/List_of_Japanese_map_symbols} {special symbols} called \i {chizukigou} (地図記号) which are different from standard map symbols used in other countries. They can be enabled with \m {--country jp} option.
\image {doc/japanese.png} {Japanese map symbols}
\image {doc/icons_japanese.svg} {Japanese map symbols}
\2 {Icon set} {icon-set}
\3 {Shape combination} {shape-combination}
The central feature of the project is Röntgen icon set. It is a set of monochrome 14 × 14 px pixel-aligned icons. Unlike the Map Machine source code, which is under MIT license, all icons are under \ref {http://creativecommons.org/licenses/by/4.0/} {CC BY} license. So, with the appropriate credit icon set can be used outside the project. Some icons can be used as emoji symbols.
One of the key features of Map Machine is constructing icons from the several shapes.
All icons tend to support common design style, which is heavily inspired by \ref {https://github.com/mapbox/maki} {Maki}, \ref {https://github.com/gmgeo/osmic} {Osmic}, and \ref {https://github.com/ideditor/temaki} {Temaki}.
/* Some icons consist of just one shape, to construct others it may be necessary to combine two or more shapes. */
Icons are used to visualize tags for nodes and areas. Unlike other renderers, Map Machine can use more than one icon to visualize an entity and use colors to visualize \osm {colour} value or other entity properties (like \osm {material} or \osm {genus}).
\4 {Masts} {masts}
\image {doc/grid.png} {Icons}
For \osm {man_made=mast} distinguish types (communication, lighting, monitoring, and siren) and construction (freestanding or lattice, and using of guys) are rendered by combining 7 unique icon shapes.
Feel free to request new icons via issues for whatever you want to see on the map. No matter how frequently the tag is used in OpenStreetMap since final goal is to cover all tags. However, common used tags have priority, other things being equal.
\image {doc/mast.svg} {Mast types}
Generate icon grid and sets of individual icons with \m {\command {icons}}. It will create \m {out/icon_grid.svg} file, and SVG files in \m {out/icons_by_id} directory where files are named using shape identifiers (e.g. \m {power_tower_portal_2_level.svg}) and in \m {icons_by_name} directory where files are named using shape names (e.g. \m {Röntgen portal two-level transmission tower.svg}). Files from the last directory are used in OpenStreetMap wiki (e.g. \ref {https://wiki.openstreetmap.org/wiki/File:R%C3%B6ntgen_portal_two-level_transmission_tower.svg} {\m {File:Röntgen_portal_two-level_transmission_tower.svg}}).
\4 {Volcanoes} {volcanoes}
\3 {Shape combination} {shape_combination}
For \osm {natural=volcano} status (active, dormant, extinct, or unspecified) and type (stratovolcano, shield, or scoria) are rendered by combining 7 unique icon shapes.
Map Machine constructs icons from the shapes extracted from the sketch SVG file. Some icons consists of just one shape, to construct other it may be necessary to combine two or more shapes.
\image {doc/bus_stop.png} {Bus stop icon combination}
\image {doc/volcano.svg} {Volcano types}
/*
\3 {Icon settings} {icon_settings}
\image {doc/bus_stop.png} {Bus stop icon combination}
\3 {Icon settings} {icon-settings}
\4 {Japanese map symbols} {japanese-map-symbols}
@ -154,17 +177,17 @@ Countries with right-to-left script direction\:
\2 {Wireframe view} {wireframe-view}
\3 {Creation time mode} {time_mode}
\3 {Creation time mode} {creation-time-mode}
Visualize element creation time with \m {--mode time}.
\image {doc/time.png} {Creation time mode}
\image {doc/time.svg} {Creation time mode}
\3 {Author mode} {author_mode}
Every way and node displayed with the random color picked for each author with \m {--mode author}.
\image {doc/author.png} {Author mode}
\image {doc/author.svg} {Author mode}
\2 {Installation} {installation}
@ -172,28 +195,30 @@ Requirements\: Python 3.8/* or higher*/.
\list
{Install \ref {https://www.cairographics.org/download/} {cairo 2D graphic library},}
{install Python packages\:}
{install \ref {https://libgeos.org} {GEOS library},}
{install Python packages with the command\:}
\code {pip install -r requirements.txt
pip install .} {bash}
\code {pip install git+https://github.com/enzet/map-machine} {shell}
For more detailed instructions, see \ref {doc/INSTALL.md} {instructions}.
\2 {Map generation} {map-generation}
Command \m {render} is used to generates SVG map from OpenStreetMap data. You can run it using\:
Command \m {render} is used to generate SVG map from OpenStreetMap data. You can run it using\:
\code {map-machine render \\
-b \formal {min longitude},\formal {min latitude},\formal {max longitude},\formal {max latitude} \\
-o \formal {output file name} \\
-z \formal {OSM zoom level} \\
-b=\formal {min longitude},\formal {min latitude},\formal {max longitude},\formal {max latitude} \\
-o=\formal {output file name} \\
-z=\formal {OSM zoom level} \\
\formal {other arguments}} {bash}
\3 {Example} {example-2}
\3 {Example} {example}
\code {map-machine render \\
--boundary-box 2.284,48.860,2.290,48.865 \\
--output out/esplanade_du_trocadéro.svg} {bash}
--boundary-box=2.284,48.860,2.290,48.865 \\
--output=out/esplanade_du_trocadéro.svg} {bash}
will download OSM data to \m {cache/2.284,48.860,2.290,48.865.osm} and write output SVG map of the specified area to \m {out/esplanade_du_trocadéro.svg}.
will download OSM data to \m {cache/2.284,48.860,2.290,48.865.osm} and write an output SVG map of the specified area to \m {out/esplanade_du_trocadéro.svg}.
\3 {Arguments} {arguments}
@ -218,14 +243,14 @@ Specify tile coordinates\:
or specify any geographical coordinates inside a tile\:
\code {map-machine tile \\
--coordinates \formal {latitude},\formal {longitude} \\
--zoom \formal {OSM zoom levels}} {bash}
--coordinates=\formal {latitude},\formal {longitude} \\
--zoom=\formal {OSM zoom levels}} {bash}
Tile will be stored as SVG file \m {out/tiles/tile_<zoom level>_<x>_<y>.svg} and PNG file \m {out/tiles/tile_<zoom level>_<x>_<y>.svg}, where \m {x} and \m {y} are tile coordinates. \m {--zoom} option will be ignored if it is used with \m {--tile} option.
Example\:
\code {map-machine tile -c 55.7510637,37.6270761 -z 18} {bash}
\code {map-machine tile -c=55.7510637,37.6270761 -z=18} {bash}
will generate SVG file \m {out/tiles/tile_18_158471_81953.svg} and PNG file \m {out/tiles/tile_18_158471_81953.png}.
@ -234,14 +259,14 @@ will generate SVG file \m {out/tiles/tile_18_158471_81953.svg} and PNG file \m {
Specify boundary box to get the minimal set of tiles that covers the area\:
\code {map-machine tile \\
--boundary-box \formal {min longitude},\formal {min latitude},\formal {max longitude},\formal {max latitude} \\
--zoom \formal {OSM zoom levels}} {bash}
--boundary-box=\formal {min longitude},\formal {min latitude},\formal {max longitude},\formal {max latitude} \\
--zoom=\formal {OSM zoom levels}} {bash}
Boundary box will be extended to the boundaries of the minimal tile set that covers the area, then it will be extended a bit more to avoid some artifacts on the edges rounded to 3 digits after the decimal point. Map with new boundary box coordinates will be written to the cache directory as SVG and PNG files. All tiles will be stored as SVG files \m {out/tiles/tile_<zoom level>_<x>_<y>.svg} and PNG files \m {out/tiles/tile_<zoom level>_<x>_<y>.svg}, where \m {x} and \m {y} are tile coordinates.
The boundary box will be extended to the boundaries of the minimal tileset that covers the area, then it will be extended a bit more to avoid some artifacts on the edges rounded to 3 digits after the decimal point. Map with new boundary box coordinates will be written to the cache directory as SVG and PNG files. All tiles will be stored as SVG files \m {out/tiles/tile_<zoom level>_<x>_<y>.svg} and PNG files \m {out/tiles/tile_<zoom level>_<x>_<y>.svg}, where \m {x} and \m {y} are tile coordinates.
Example\:
\code {map-machine tile -b 2.361,48.871,2.368,48.875} {bash}
\code {map-machine tile -b=2.361,48.871,2.368,48.875} {bash}
will generate 36 PNG tiles at zoom level 18 from tile 18/132791/90164 all the way to 18/132796/90169 and two cached files \m {cache/2.360,48.869,2.370,48.877_18.svg} and \m {cache/2.360,48.869,2.370,48.877_18.png}.
@ -251,15 +276,15 @@ Command \m {server} is used to run tile server for slippy maps.
\code {map-machine server}
Stop server interrupting process with \kbd {Ctrl} + \kbd {C}.
Stop server interrupting the process with \kbd {Ctrl} + \kbd {C}.
\options {server}
\3 {Example} {example}
\3 {Example} {example-2}
Create a minimal amount of tiles that cover specified boundary box for zoom levels 16, 17, 18, and 19\:
\code {map-machine tile -b 2.364,48.854,2.367,48.857 -z 16-19} {bash}
\code {map-machine tile -b=2.364,48.854,2.367,48.857 -z=16-19} {bash}
Run tile server on 127.0.0.1\:8080\:
@ -286,7 +311,7 @@ HTML code\:
\2 {Map options} {map-options}
Map configuration options used by \m {render} and \m {tile} commands:
Map configuration options used by \m {render} and \m {tile} commands\:
\options {map}
@ -298,7 +323,7 @@ To create MapCSS with Map Machine style also for ways and relations, run \m {map
\options {mapcss}
\3 {Use Map Machine as JOSM map paint style}
\3 {Use Röntgen as JOSM map paint style} {use-rntgen-as-josm-map-paint-style}
\list
{Run \m {\command {mapcss}}.}
@ -309,11 +334,11 @@ To create MapCSS with Map Machine style also for ways and relations, run \m {map
To enable/disable Map Machine map paint style go to \kbd {View} → \kbd {Map Paint Styles} → \kbd {Map Machine}.
\4 {Example}
\4 {Example} {example-3}
\image {doc/josm.png} {JOSM example}
Example of using Röntgen icons on top of Mapnik style in JOSM. Map Paint Styles look like\:
Example of using Röntgen icons on top of Mapnik style in JOSM. Map Paint Styles look like this\:
\list
{✓ Mapnik (true)}
{✓ Map Machine}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

2
doc/surveillance.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

2
doc/time.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

2
doc/trees.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

2
doc/viewpoints.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 207 KiB

2
doc/volcano.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -19,7 +19,7 @@ REQUIREMENTS = [
"numpy>=1.18.1",
"Pillow>=8.2.0",
"portolan>=1.0.1",
"pycairo",
"pycairo>=1.20.1",
"pytest>=6.2.2",
"PyYAML>=4.2b1",
"setuptools>=51.0.0",

View file

@ -13,7 +13,7 @@ __email__ = "me@enzet.ru"
def is_bright(color: Color) -> bool:
"""
Check whether color bright enough to have black outline instead of white.
Check whether color is bright enough to have black outline instead of white.
"""
return (
0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue
@ -35,7 +35,7 @@ def get_gradient_color(
scale: List[Color] = colors + [Color("black")]
range_coefficient: float = (
0 if bounds.is_empty() else (value - bounds.min_) / bounds.delta()
0.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))

View file

@ -2,6 +2,7 @@
Construct Map Machine nodes and ways.
"""
import logging
import sys
from datetime import datetime
from hashlib import sha256
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union
@ -9,18 +10,26 @@ from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Union
import numpy as np
from colour import Color
from map_machine import ui
from map_machine.color import get_gradient_color
from map_machine.feature.building import Building, BUILDING_SCALE
from map_machine.feature.crater import Crater
from map_machine.feature.direction import DirectionSector
from map_machine.feature.road import Road, Roads
from map_machine.figure import (
Building,
Crater,
DirectionSector,
StyledFigure,
Tree,
)
from map_machine.road import Road, Roads
from map_machine.flinger import Flinger
from map_machine.icon import (
from map_machine.feature.tree import Tree
from map_machine.geometry.flinger import Flinger
from map_machine.map_configuration import DrawingMode, MapConfiguration
from map_machine.osm.osm_reader import (
OSMData,
OSMNode,
OSMRelation,
OSMWay,
parse_levels,
Tags,
)
from map_machine.pictogram.icon import (
DEFAULT_SMALL_SHAPE_ID,
Icon,
IconSet,
@ -28,18 +37,10 @@ from map_machine.icon import (
ShapeExtractor,
ShapeSpecification,
)
from map_machine.map_configuration import DrawingMode, MapConfiguration
from map_machine.osm_reader import (
OSMData,
OSMNode,
OSMRelation,
OSMWay,
parse_levels,
)
from map_machine.point import Point
from map_machine.scheme import DEFAULT_COLOR, LineStyle, RoadMatcher, Scheme
from map_machine.text import Label
from map_machine.ui import BuildingMode
from map_machine.pictogram.point import Point
from map_machine.scheme import LineStyle, RoadMatcher, Scheme
from map_machine.text import Label, TextConstructor
from map_machine.ui.cli import BuildingMode
from map_machine.util import MinMax
__author__ = "Sergey Vartanov"
@ -92,7 +93,9 @@ def get_time_color(time: Optional[datetime], boundaries: MinMax) -> Color:
:param time: current element creation time
:param boundaries: minimum and maximum element creation time on the map
"""
return get_gradient_color(time, boundaries, TIME_COLOR_SCALE)
return get_gradient_color(
time if time else boundaries.max_, boundaries, TIME_COLOR_SCALE
)
def glue(ways: List[OSMWay]) -> List[List[OSMNode]]:
@ -153,9 +156,7 @@ def try_to_glue(
class Constructor:
"""
Map Machine node and way constructor.
"""
"""Map Machine node and way constructor."""
def __init__(
self,
@ -170,6 +171,7 @@ class Constructor:
self.scheme: Scheme = scheme
self.extractor: ShapeExtractor = extractor
self.configuration: MapConfiguration = configuration
self.text_constructor: TextConstructor = TextConstructor(self.scheme)
if self.configuration.level == "all":
self.check_level = lambda x: True
@ -190,7 +192,7 @@ class Constructor:
self.craters: List[Crater] = []
self.direction_sectors: List[DirectionSector] = []
self.heights: Set[float] = {2, 4}
self.heights: Set[float] = {0.25 / BUILDING_SCALE, 0.5 / BUILDING_SCALE}
def add_building(self, building: Building) -> None:
"""Add building and update levels."""
@ -206,18 +208,11 @@ class Constructor:
def construct_ways(self) -> None:
"""Construct Map Machine ways."""
for index, way_id in enumerate(self.osm_data.ways):
ui.progress_bar(
index,
len(self.osm_data.ways),
step=10,
text="Constructing ways",
)
logging.info("Constructing ways...")
for way_id in self.osm_data.ways:
way: OSMWay = self.osm_data.ways[way_id]
self.construct_line(way, [], [way.nodes])
ui.progress_bar(-1, len(self.osm_data.ways), text="Constructing ways")
def construct_line(
self,
line: Union[OSMWay, OSMRelation],
@ -225,7 +220,7 @@ class Constructor:
outers: List[List[OSMNode]],
) -> None:
"""
Way or relation construction.
Construct way or relation.
:param line: OpenStreetMap way or relation
:param inners: list of polygons that compose inner boundary
@ -233,23 +228,38 @@ class Constructor:
"""
assert len(outers) >= 1
if len(outers[0]) == 0:
return
if not self.check_level(line.tags):
return
center_point, center_coordinates = line_center(outers[0], self.flinger)
center_point, _ = line_center(outers[0], self.flinger)
if self.configuration.is_wireframe():
color: Color
if self.configuration.drawing_mode == DrawingMode.AUTHOR:
color = get_user_color(line.user, self.configuration.seed)
else: # self.mode == TIME_MODE
color = get_user_color(
line.user if line.user else "", self.configuration.seed
)
elif self.configuration.drawing_mode == DrawingMode.TIME:
color = get_time_color(line.timestamp, self.osm_data.time)
elif self.configuration.drawing_mode == DrawingMode.WHITE:
color = Color("#666666")
elif self.configuration.drawing_mode == DrawingMode.BLACK:
color = Color("#BBBBBB")
elif self.configuration.drawing_mode != DrawingMode.NORMAL:
logging.fatal(
f"Drawing mode {self.configuration.drawing_mode} is not "
f"supported."
)
sys.exit(1)
self.draw_special_mode(line, inners, outers, color)
return
if not line.tags:
return
building_mode: str = self.configuration.building_mode
building_mode: BuildingMode = self.configuration.building_mode
if "building" in line.tags or (
building_mode == BuildingMode.ISOMETRIC
and "building:part" in line.tags
@ -260,9 +270,10 @@ class Constructor:
road_matcher: RoadMatcher = self.scheme.get_road(line.tags)
if road_matcher:
self.roads.append(
Road(line.tags, outers[0], road_matcher, self.flinger)
road: Road = Road(
line.tags, outers[0], road_matcher, self.flinger, self.scheme
)
self.roads.append(road)
return
processed: Set[str] = set()
@ -283,13 +294,16 @@ class Constructor:
line_style.style
)
new_style["stroke"] = recolor.hex
line_style = LineStyle(new_style, line_style.priority)
line_style = LineStyle(
new_style, line_style.parallel_offset, line_style.priority
)
self.figures.append(
StyledFigure(line.tags, inners, outers, line_style)
)
if not (
line.get_tag("area") == "yes"
or line.get_tag("type") == "multipolygon"
or is_cycle(outers[0])
and line.get_tag("area") != "no"
and self.scheme.is_area(line.tags)
@ -302,8 +316,10 @@ class Constructor:
self.extractor, line.tags, processed, self.configuration
)
if icon_set is not None:
labels: List[Label] = self.scheme.construct_text(
line.tags, "all", processed
labels: List[Label] = self.text_constructor.construct_text(
line.tags,
processed,
self.configuration.label_mode,
)
point: Point = Point(
icon_set,
@ -317,31 +333,33 @@ class Constructor:
)
self.points.append(point)
if not line_styles:
if line_styles:
return
self.add_point_for_line(center_point, inners, line, outers)
def add_point_for_line(self, center_point, inners, line, outers) -> None:
"""Add icon at the center point of the way or relation."""
if DEBUG:
style: Dict[str, Any] = {
"fill": "none",
"stroke": Color("red").hex,
"stroke-width": 1,
"stroke-width": 1.0,
}
figure: StyledFigure = StyledFigure(
line.tags, inners, outers, LineStyle(style, 1000)
line.tags, inners, outers, LineStyle(style, 0.0, 1000.0)
)
self.figures.append(figure)
processed: Set[str] = set()
processed: set[str] = set()
priority: int
icon_set: IconSet
icon_set, priority = self.scheme.get_icon(
self.extractor,
line.tags,
processed,
self.configuration,
self.extractor, line.tags, processed, self.configuration
)
if icon_set is not None:
labels: List[Label] = self.scheme.construct_text(
line.tags, "all", processed
labels: list[Label] = self.text_constructor.construct_text(
line.tags, processed, self.configuration.label_mode
)
point: Point = Point(
icon_set,
@ -400,21 +418,20 @@ class Constructor:
def construct_nodes(self) -> None:
"""Draw nodes."""
logging.info("Constructing nodes...")
sorted_node_ids: Iterator[int] = sorted(
self.osm_data.nodes.keys(),
key=lambda x: -self.osm_data.nodes[x].coordinates[0],
)
for index, node_id in enumerate(sorted_node_ids):
ui.progress_bar(
index, len(self.osm_data.nodes), text="Constructing nodes"
)
for node_id in sorted_node_ids:
self.construct_node(self.osm_data.nodes[node_id])
ui.progress_bar(-1, len(self.osm_data.nodes), text="Constructing nodes")
def construct_node(self, node: OSMNode) -> None:
"""Draw one node."""
tags: Dict[str, str] = node.tags
if not tags:
return
if not self.check_level(tags):
return
@ -426,17 +443,21 @@ class Constructor:
icon_set: IconSet
draw_outline: bool = True
if self.configuration.is_wireframe():
if not tags:
return
color: Color = DEFAULT_COLOR
if self.configuration.drawing_mode in (
DrawingMode.AUTHOR,
DrawingMode.TIME,
):
color: Color = self.scheme.get_color("default")
if self.configuration.drawing_mode == DrawingMode.AUTHOR:
color = get_user_color(node.user, self.configuration.seed)
if self.configuration.drawing_mode == DrawingMode.TIME:
color = get_time_color(node.timestamp, self.osm_data.time)
dot: Shape = self.extractor.get_shape(DEFAULT_SMALL_SHAPE_ID)
icon_set: IconSet = IconSet(
Icon([ShapeSpecification(dot, color)]), [], set()
Icon([ShapeSpecification(dot, color)]),
[],
Icon([ShapeSpecification(dot, color)]),
set(),
)
point: Point = Point(
icon_set,
@ -450,12 +471,38 @@ class Constructor:
self.points.append(point)
return
if self.configuration.drawing_mode in (
DrawingMode.WHITE,
DrawingMode.BLACK,
):
if self.configuration.drawing_mode == DrawingMode.WHITE:
color = Color("#CCCCCC")
if self.configuration.drawing_mode == DrawingMode.BLACK:
color = Color("#444444")
icon_set, priority = self.scheme.get_icon(
self.extractor, tags, processed, self.configuration
)
icon_set.main_icon.recolor(color)
point: Point = Point(
icon_set,
[],
tags,
processed,
flung,
add_tooltips=self.configuration.show_tooltips,
)
self.points.append(point)
return
icon_set, priority = self.scheme.get_icon(
self.extractor, tags, processed, self.configuration
)
if icon_set is None:
return
labels: List[Label] = self.scheme.construct_text(tags, "all", processed)
labels: List[Label] = self.text_constructor.construct_text(
tags, processed, self.configuration.label_mode
)
self.scheme.process_ignored(tags, processed)
if node.get_tag("natural") == "tree" and (
@ -483,7 +530,7 @@ class Constructor:
self.points.append(point)
def check_level_number(tags: Dict[str, Any], level: float) -> bool:
def check_level_number(tags: Tags, level: float) -> bool:
"""Check if element described by tags is no the specified level."""
if "level" in tags:
if level not in parse_levels(tags["level"]):
@ -493,13 +540,12 @@ def check_level_number(tags: Dict[str, Any], level: float) -> bool:
return True
def check_level_overground(tags: Dict[str, Any]) -> bool:
def check_level_overground(tags: Tags) -> bool:
"""Check if element described by tags is overground."""
if "level" in tags:
try:
levels: map = map(float, tags["level"].replace(",", ".").split(";"))
for level in levels:
if level < 0:
for level in map(float, tags["level"].replace(",", ".").split(";")):
if level < 0.0:
return False
except ValueError:
pass
@ -507,4 +553,5 @@ def check_level_overground(tags: Dict[str, Any]) -> bool:
return (
tags.get("location") != "underground"
and tags.get("parking") != "underground"
and tags.get("tunnel") != "yes"
)

View file

@ -0,0 +1,3 @@
"""
Documentation utilities.
"""

View file

@ -0,0 +1,335 @@
"""
Special icon collections for documentation.
"""
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional, List, Dict, Set
import numpy as np
import svgwrite
from svgwrite import Drawing
from svgwrite.text import Text
from svgwrite.shapes import Line, Rect
from map_machine.map_configuration import MapConfiguration
from map_machine.osm.osm_reader import Tags
from map_machine.pictogram.icon import ShapeExtractor, IconSet
from map_machine.scheme import Scheme
from map_machine.workspace import Workspace
WORKSPACE: Workspace = Workspace(Path("temp"))
SCHEME: Scheme = Scheme.from_file(WORKSPACE.DEFAULT_SCHEME_PATH)
EXTRACTOR: ShapeExtractor = ShapeExtractor(
WORKSPACE.ICONS_PATH, WORKSPACE.ICONS_CONFIG_PATH
)
@dataclass
class Collection:
"""Icon collection."""
# Core tags
tags: Tags
# Tag key to be used in rows
row_key: Optional[str] = None
# List of tag values to be used in rows
row_values: List[str] = field(default_factory=list)
# Tag key to be used in columns
column_key: Optional[str] = None
# List of tag values to be used in columns
column_values: List[str] = field(default_factory=list)
# List of tags to be used in rows
row_tags: List[Tags] = field(default_factory=list)
@classmethod
def deserialize(cls, structure: Dict[str, Any]):
"""Deserialize icon collection from structure."""
row_key: Optional[str] = (
structure["row_key"] if "row_key" in structure else None
)
row_values: List[str] = (
structure["row_values"] if "row_values" in structure else []
)
column_key: Optional[str] = (
structure["column_key"] if "column_key" in structure else None
)
column_values: List[str] = (
structure["column_values"] if "column_values" in structure else []
)
row_tags: List[Tags] = (
structure["row_tags"] if "row_tags" in structure else []
)
return cls(
structure["tags"],
row_key,
row_values,
column_key,
column_values,
row_tags,
)
class SVGTable:
"""SVG table with icon combinations."""
def __init__(self, collection: Collection, svg: svgwrite.Drawing):
self.collection: Collection = collection
self.svg: svgwrite.Drawing = svg
self.border: np.ndarray = np.array((16.0, 16.0))
self.step: float = 48.0
self.icon_size: float = 32.0
self.font_size: float = 10.0
self.offset: float = 30.0
self.half_step: np.ndarray = np.array(
(self.step / 2.0, self.step / 2.0)
)
fonts: List[str] = [
"JetBrains Mono",
"Fira Code",
"Fira Mono",
"ui-monospace",
"SFMono-regular",
"SF Mono",
"Menlo",
"Consolas",
"Liberation Mono",
"monospace",
]
self.font: str = ",".join(fonts)
self.font_width: float = self.font_size * 0.7
self.size: List[float] = [
max(
max(map(len, self.collection.row_values)) * self.font_width,
len(self.collection.row_key) * self.font_width
+ (self.offset if self.collection.column_values else 0),
170.0,
)
if self.collection.row_values
else 0.0,
max(map(len, self.collection.column_values)) * self.font_width
if self.collection.column_values
else 0.0,
]
self.start_point: np.ndarray = (
2 * self.border + np.array(self.size) + self.half_step
)
def draw_table(self) -> None:
"""Draw SVG table."""
self.draw_rows()
self.draw_columns()
self.draw_delimiter()
self.draw_rectangle()
for i, row_value in enumerate(self.collection.row_values):
for j, column_value in enumerate(
(
self.collection.column_values
if self.collection.column_values
else [""]
)
):
current_tags: Tags = dict(self.collection.tags) | {
self.collection.row_key: row_value
}
if column_value:
current_tags |= {self.collection.column_key: column_value}
processed: Set[str] = set()
icon, _ = SCHEME.get_icon(
EXTRACTOR, current_tags, processed, MapConfiguration()
)
processed = icon.processed
if not icon:
print("Icon was not constructed.")
if (
icon.main_icon
and not icon.main_icon.is_default()
and (
not self.collection.column_key
or not column_value
or (self.collection.column_key in processed)
)
and (
not self.collection.row_key
or not row_value
or (self.collection.row_key in processed)
)
):
self.draw_icon(np.array((j, i)), icon)
else:
self.draw_cross(np.array((j, i)))
width, height = self.get_size()
self.svg.update({"width": width, "height": height})
def draw_rows(self) -> None:
"""Draw row texts."""
point: np.ndarray = np.array(self.start_point) - np.array(
(self.step / 2.0 + self.border[0], 0.0)
)
shift: np.ndarray = (
-self.offset if self.collection.column_values else 0.9,
2.0 - self.step / 2.0 - self.border[1],
)
if self.collection.row_key:
self.draw_text(
f"{self.collection.row_key}=*",
point + np.array(shift),
anchor="end",
weight="bold",
)
for row_value in self.collection.row_values:
if row_value:
self.draw_text(
row_value, point + np.array((0.0, 2.0)), anchor="end"
)
point += np.array((0, self.step))
def draw_columns(self) -> None:
"""Draw column texts."""
point: np.ndarray = (
self.start_point
- self.half_step
- self.border
+ np.array((0.0, 2.0 - self.offset))
)
if self.collection.column_key:
self.draw_text(
f"{self.collection.column_key}=*",
point,
anchor="end",
weight="bold",
)
point = np.array(self.start_point)
for column_value in self.collection.column_values:
text_point: np.ndarray = point + np.array(
(2.0, -self.step / 2.0 - self.border[1])
)
self.draw_text(f"{column_value}", text_point, rotate=True)
point += np.array((self.step, 0.0))
def draw_delimiter(self) -> None:
"""Draw line between column and row titles."""
if self.collection.column_values:
line: Line = self.svg.line(
self.start_point - self.half_step - self.border,
self.start_point
- self.half_step
- self.border
- np.array((15, 15)),
stroke_width=0.5,
stroke="black",
)
self.svg.add(line)
def draw_rectangle(self, color: str = "#FEA") -> None:
"""Draw rectangle beneath all cells."""
rectangle: Rect = self.svg.rect(
self.start_point - self.half_step,
np.array(
(
max(1, len(self.collection.column_values)),
len(self.collection.row_values),
)
)
* self.step,
fill=color,
)
self.svg.add(rectangle)
def draw_icon(self, position: np.ndarray, icon: IconSet) -> None:
"""Draw icon in the table cell."""
if not self.collection.column_values:
self.collection.column_values = [""]
point: np.ndarray = np.array(self.start_point) + position * self.step
icon.main_icon.draw(self.svg, point, scale=self.icon_size / 16.0)
def draw_text(
self,
text: str,
point: np.ndarray,
anchor: str = "start",
weight: str = "normal",
rotate: bool = False,
) -> None:
"""Draw text on the table."""
text: Text = self.svg.text(
text,
point,
font_family=self.font,
font_size=self.font_size,
text_anchor=anchor,
font_weight=weight,
)
if rotate:
text.update({"transform": f"rotate(270,{point[0]},{point[1]})"})
self.svg.add(text)
def draw_cross(self, position: np.ndarray, size: float = 15) -> None:
"""Draw cross in the cell."""
point: np.ndarray = self.start_point + position * self.step
for vector in np.array((1, 1)), np.array((1, -1)):
line: Line = self.svg.line(
point - size * vector,
point + size * vector,
stroke_width=0.5,
stroke="black",
)
self.svg.add(line)
def get_size(self) -> np.ndarray:
"""Get the whole picture size."""
return (
self.start_point
+ np.array(
(
max(1, len(self.collection.column_values)),
len(self.collection.row_values),
)
)
* self.step
- self.half_step
+ self.border
)
def draw_svg_tables(output_path: Path, html_file_path: Path) -> None:
"""Draw SVG tables of icon collections."""
with Path("data/collections.json").open() as input_file:
collections: List[Dict[str, Any]] = json.load(input_file)
with html_file_path.open("w+") as html_file:
for structure in collections:
if "id" not in structure:
continue
path: Path = output_path / f"{structure['id']}.svg"
svg: Drawing = svgwrite.Drawing(path.name)
collection: Collection = Collection.deserialize(structure)
table: SVGTable = SVGTable(collection, svg)
table.draw_table()
with path.open("w+") as output_file:
svg.write(output_file)
html_file.write(
f'<img src="{path}" style="border: 1px solid #DDD;" />\n'
)
if __name__ == "__main__":
draw_svg_tables(Path("doc"), Path("result.html"))

76
map_machine/doc/icons.py Normal file
View file

@ -0,0 +1,76 @@
"""
Icon grids for documentation.
"""
from pathlib import Path
from typing import Iterable
from colour import Color
from map_machine.pictogram.icon import (
Shape,
Icon,
ShapeSpecification,
ShapeExtractor,
)
from map_machine.pictogram.icon_collection import IconCollection
from map_machine.workspace import workspace
SKIP: bool = True
def draw_special_grid(all_shapes, function, path, color=None):
"""Draw special icon grid to illustrate map feature."""
icons = [
Icon([ShapeSpecification(shape)])
for shape in all_shapes
if function(shape)
]
icons = sorted(icons)
if color:
for icon in icons:
icon.recolor(color)
IconCollection(icons).draw_grid(path, 8, scale=4.0)
def draw_special_grids():
"""Draw special icon grids."""
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
all_shapes: Iterable[Shape] = extractor.shapes.values()
draw_special_grid(
all_shapes,
lambda shape: shape.id_.startswith("power_tower")
or shape.id_.startswith("power_pole"),
Path("doc/icons_power.svg"),
)
if SKIP:
draw_special_grid(
all_shapes,
lambda shape: shape.group == "root_space",
Path("doc/icons_space.svg"),
)
draw_special_grid(
all_shapes,
lambda shape: shape.group == "root_street_playground",
Path("doc/icons_playground.svg"),
)
draw_special_grid(
all_shapes,
lambda shape: "emergency" in shape.categories,
Path("doc/icons_emergency.svg"),
color=Color("#DD2222"),
)
draw_special_grid(
all_shapes,
lambda shape: shape.id_.startswith("japan"),
Path("doc/icons_japanese.svg"),
)
if __name__ == "__main__":
draw_special_grids()

View file

@ -9,9 +9,9 @@ from typing import Any, Dict, List, Union
from moire.default import Default, DefaultHTML, DefaultMarkdown, DefaultWiki
from moire.moire import Tag
from map_machine import ui
from map_machine.icon import ShapeExtractor
from map_machine.ui import COMMANDS
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.ui import cli
from map_machine.ui.cli import COMMAND_LINES
from map_machine.workspace import workspace
__author__ = "Sergey Vartanov"
@ -51,12 +51,12 @@ class ArgumentParser(argparse.ArgumentParser):
def __init__(self, *args, **kwargs) -> None:
self.arguments: List[Dict[str, Any]] = []
super(ArgumentParser, self).__init__(*args, **kwargs)
super().__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]}
super().add_argument(*args, **kwargs)
argument: Dict[str, Any] = {"arguments": args}
for key in kwargs:
argument[key] = kwargs[key]
@ -75,8 +75,8 @@ class ArgumentParser(argparse.ArgumentParser):
continue
array: Code = [
[Tag("no_wrap", [Tag("m", [x])]), ", "]
for x in option["arguments"]
[Tag("no_wrap", [Tag("m", [text])]), ", "]
for text in option["arguments"]
]
cell: Code = [x for y in array for x in y][:-1]
if "metavar" in option:
@ -116,13 +116,11 @@ class ArgumentParser(argparse.ArgumentParser):
class MapMachineMoire(Default, ABC):
"""
Moire extension stub for Map Machine.
"""
"""Moire extension stub for Map Machine."""
def osm(self, args: Arguments) -> str:
def osm(self, arg: Arguments) -> str:
"""OSM tag key or keyvalue pair of tag."""
spec: str = self.clear(args[0])
spec: str = self.clear(arg[0])
if "=" in spec:
key, tag = spec.split("=")
return (
@ -130,60 +128,58 @@ class MapMachineMoire(Default, ABC):
+ " = "
+ self.get_ref_(f"{PREFIX}Tag:{key}={tag}", self.m([tag]))
)
else:
return self.get_ref_(f"{PREFIX}Key:{spec}", self.m([spec]))
def color(self, args: Arguments) -> str:
def color(self, arg: Arguments) -> str:
"""Simple color sample."""
raise NotImplementedError("color")
def page_icon(self, args: Arguments) -> str:
def page_icon(self, arg: Arguments) -> str:
"""HTML page icon."""
return ""
def command(self, args: Arguments) -> str:
def command(self, arg: Arguments) -> str:
"""Bash command from integration tests."""
return "map-machine " + " ".join(COMMANDS[self.clear(args[0])])
return "map-machine " + " ".join(COMMAND_LINES[self.clear(arg[0])])
def icon(self, args: Arguments) -> str:
def icon(self, arg: Arguments) -> str:
"""Image with Röntgen icon."""
raise NotImplementedError("icon")
def options(self, args: Arguments) -> str:
def options(self, arg: Arguments) -> str:
"""Table with option descriptions."""
parser: ArgumentParser = ArgumentParser()
command: str = self.clear(args[0])
command: str = self.clear(arg[0])
if command == "render":
ui.add_render_arguments(parser)
cli.add_render_arguments(parser)
elif command == "server":
ui.add_server_arguments(parser)
cli.add_server_arguments(parser)
elif command == "tile":
ui.add_tile_arguments(parser)
cli.add_tile_arguments(parser)
elif command == "map":
ui.add_map_arguments(parser)
cli.add_map_arguments(parser)
elif command == "element":
ui.add_element_arguments(parser)
cli.add_element_arguments(parser)
elif command == "mapcss":
ui.add_mapcss_arguments(parser)
cli.add_mapcss_arguments(parser)
else:
raise NotImplementedError(
"no separate function for parser creation"
)
return self.parse(parser.get_moire_help())
def kbd(self, args: Arguments) -> str:
def kbd(self, arg: Arguments) -> str:
"""Keyboard key."""
return self.m(args)
return self.m(arg)
def no_wrap(self, args: Arguments) -> str:
def no_wrap(self, arg: Arguments) -> str:
"""Do not wrap text at white spaces."""
return self.parse(args[0])
return self.parse(arg[0])
class MapMachineHTML(MapMachineMoire, DefaultHTML):
"""
Simple HTML.
"""
"""Simple HTML."""
def __init__(self) -> None:
super().__init__()
@ -196,48 +192,46 @@ class MapMachineHTML(MapMachineMoire, DefaultHTML):
["<th>" + self.parse(td, in_block=True) + "</th>" for td in arg[0]]
)
content += f"<tr>{cell}</tr>"
for tr in arg[1:]:
for row in arg[1:]:
cell: str = "".join(
["<td>" + self.parse(td, in_block=True) + "</td>" for td in tr]
["<td>" + self.parse(td, in_block=True) + "</td>" for td in row]
)
content += f"<tr>{cell}</tr>"
return f"<table>{content}</table>"
def color(self, args: Arguments) -> str:
def color(self, arg: Arguments) -> str:
"""Simple color sample."""
return (
f'<span class="color" '
f'style="background-color: {self.clear(args[0])};"></span>'
f'style="background-color: {self.clear(arg[0])};"></span>'
)
def icon(self, args: Arguments) -> str:
def icon(self, arg: Arguments) -> str:
"""Image with Röntgen icon."""
size: str = self.clear(args[1]) if len(args) > 1 else 16
size: str = self.clear(arg[1]) if len(arg) > 1 else "16"
return (
f'<img class="icon" style="width: {size}px; height: {size}px;" '
f'src="out/icons_by_id/{self.clear(args[0])}.svg" />'
f'src="out/icons_by_id/{self.clear(arg[0])}.svg" />'
)
def kbd(self, args: Arguments) -> str:
def kbd(self, arg: Arguments) -> str:
"""Keyboard key."""
return f"<kbd>{self.clear(args[0])}</kbd>"
return f"<kbd>{self.clear(arg[0])}</kbd>"
def page_icon(self, args: Arguments) -> str:
def page_icon(self, arg: Arguments) -> str:
"""HTML page icon."""
return (
f'<link rel="icon" type="image/svg" href="{self.clear(args[0])}"'
f'<link rel="icon" type="image/svg" href="{self.clear(arg[0])}"'
' sizes="16x16">'
)
def no_wrap(self, args: Arguments) -> str:
def no_wrap(self, arg: Arguments) -> str:
"""Do not wrap text at white spaces."""
return (
f'<span style="white-space: nowrap;">{self.parse(args[0])}</span>'
)
return f'<span style="white-space: nowrap;">{self.parse(arg[0])}</span>'
def formal(self, args: Arguments) -> str:
def formal(self, arg: Arguments) -> str:
"""Formal variable."""
return f'<span class="formal">{self.parse(args[0])}</span>'
return f'<span class="formal">{self.parse(arg[0])}</span>'
class MapMachineOSMWiki(MapMachineMoire, DefaultWiki):
@ -254,55 +248,51 @@ class MapMachineOSMWiki(MapMachineMoire, DefaultWiki):
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
def osm(self, args: Arguments) -> str:
def osm(self, arg: Arguments) -> str:
"""OSM tag key or keyvalue pair of tag."""
spec: str = self.clear(args[0])
spec: str = self.clear(arg[0])
if "=" in spec:
key, tag = spec.split("=")
return f"{{{{Key|{key}|{tag}}}}}"
else:
return f"{{{{Tag|{spec}}}}}"
def color(self, args: Arguments) -> str:
def color(self, arg: Arguments) -> str:
"""Simple color sample."""
return f"{{{{Color box|{self.clear(args[0])}}}}}"
return f"{{{{Color box|{self.clear(arg[0])}}}}}"
def icon(self, args: Arguments) -> str:
def icon(self, arg: Arguments) -> str:
"""Image with Röntgen icon."""
size: str = self.clear(args[1]) if len(args) > 1 else 16
shape_id: str = self.clear(args[0])
size: str = self.clear(arg[1]) if len(arg) > 1 else "16"
shape_id: str = self.clear(arg[0])
name: str = self.extractor.get_shape(shape_id).name
return f"[[File:Röntgen {name}.svg|{size}px]]"
class MapMachineMarkdown(MapMachineMoire, DefaultMarkdown):
"""
GitHub flavored markdown.
"""
"""GitHub flavored markdown."""
images = {}
def color(self, args: Arguments) -> str:
def color(self, arg: Arguments) -> str:
"""Simple color sample."""
return self.clear(args[0])
return self.clear(arg[0])
def icon(self, args: Arguments) -> str:
def icon(self, arg: Arguments) -> str:
"""Image with Röntgen icon."""
return f"[{self.clear(args[0])}]"
return f"[{self.clear(arg[0])}]"
def kbd(self, args: Arguments) -> str:
def kbd(self, arg: Arguments) -> str:
"""Keyboard key."""
return f"<kbd>{self.clear(args[0])}</kbd>"
return f"<kbd>{self.clear(arg[0])}</kbd>"
def no_wrap(self, args: Arguments) -> str:
def no_wrap(self, arg: Arguments) -> str:
"""Do not wrap text at white spaces."""
return (
f'<span style="white-space: nowrap;">{self.parse(args[0])}</span>'
)
return f'<span style="white-space: nowrap;">{self.parse(arg[0])}</span>'
def formal(self, args: Arguments) -> str:
def formal(self, arg: Arguments) -> str:
"""Formal variable."""
return f"<{self.parse(args[0])}>"
return f"<{self.parse(arg[0])}>"
def convert(input_path: Path, output_path: Path) -> None:

198
map_machine/doc/preview.py Executable file
View file

@ -0,0 +1,198 @@
"""
Actions to perform before commit: generate PNG images for documentation.
"""
import logging
import sys
from pathlib import Path
from typing import Optional
import numpy as np
import svgwrite
from map_machine.geometry.boundary_box import BoundaryBox
from map_machine.constructor import Constructor
from map_machine.geometry.flinger import Flinger
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.mapper import Map
from map_machine.map_configuration import (
BuildingMode,
DrawingMode,
LabelMode,
MapConfiguration,
)
from map_machine.osm.osm_getter import get_osm
from map_machine.osm.osm_reader import OSMData
from map_machine.scheme import Scheme
doc_path: Path = Path("doc")
cache: Path = Path("cache")
cache.mkdir(exist_ok=True)
SCHEME: Scheme = Scheme.from_file(Path("map_machine/scheme/default.yml"))
EXTRACTOR: ShapeExtractor = ShapeExtractor(
Path("map_machine/icons/icons.svg"),
Path("map_machine/icons/config.json"),
)
def draw(
input_file_name: Path,
output_file_name: Path,
boundary_box: BoundaryBox,
configuration: MapConfiguration = MapConfiguration(),
) -> None:
"""Draw file."""
osm_data: OSMData = OSMData()
osm_data.parse_osm_file(input_file_name)
flinger: Flinger = Flinger(
boundary_box, configuration.zoom_level, osm_data.equator_length
)
constructor: Constructor = Constructor(
osm_data, flinger, SCHEME, EXTRACTOR, configuration
)
constructor.construct()
svg: svgwrite.Drawing = svgwrite.Drawing(
str(output_file_name), size=flinger.size
)
map_: Map = Map(flinger, svg, SCHEME, configuration)
map_.draw(constructor)
svg.write(output_file_name.open("w"))
def draw_around_point(
point: np.ndarray,
name: str,
configuration: MapConfiguration = MapConfiguration(),
size: np.ndarray = np.array((600, 400)),
get: Optional[BoundaryBox] = None,
) -> None:
"""Draw around point."""
input_path: Path = doc_path / f"{name}.svg"
boundary_box: BoundaryBox = BoundaryBox.from_coordinates(
point, configuration.zoom_level, size[0], size[1]
)
get_boundary_box = get if get else boundary_box
get_osm(get_boundary_box, cache / f"{get_boundary_box.get_format()}.osm")
draw(
cache / f"{get_boundary_box.get_format()}.osm",
input_path,
boundary_box,
configuration,
)
def main(id_: str) -> None:
"""Entry point."""
if id_ is None or id_ == "fitness":
draw_around_point(
np.array((55.75277, 37.40856)),
"fitness",
MapConfiguration(zoom_level=20.2),
np.array((300, 200)),
)
if id_ is None or id_ == "power":
draw_around_point(
np.array((52.5622, 12.94)),
"power",
configuration=MapConfiguration(zoom_level=15),
)
if id_ is None or id_ == "playground":
draw_around_point(
np.array((52.47388, 13.43826)),
"playground",
configuration=MapConfiguration(zoom_level=19),
)
# Playground: (59.91991/10.85535), (59.83627/10.83017), Oslo
# (52.47604/13.43701), (52.47388/13.43826)*, Berlin
if id_ is None or id_ == "surveillance":
draw_around_point(
np.array((52.50892, 13.3244)),
"surveillance",
MapConfiguration(
zoom_level=18.5,
ignore_level_matching=True,
),
)
if id_ is None or id_ == "viewpoints":
draw_around_point(
np.array((52.421, 13.101)),
"viewpoints",
MapConfiguration(
label_mode=LabelMode.NO,
zoom_level=15.7,
ignore_level_matching=True,
),
)
if id_ is None or id_ == "buildings":
draw_around_point(
np.array((-26.19049, 28.05605)),
"buildings",
MapConfiguration(building_mode=BuildingMode.ISOMETRIC),
)
if id_ is None or id_ == "trees":
draw_around_point(
np.array((55.751, 37.628)),
"trees",
MapConfiguration(
label_mode=LabelMode(LabelMode.ALL), zoom_level=18.1
),
get=BoundaryBox(37.624, 55.749, 37.633, 55.753),
)
# if id_ is None or id_ == "golf":
# tiles = Tiles(np.array((52.5859, 13.4644)), 17, 2, 3)
# tiles.draw()
if id_ is None or id_ == "time":
draw_around_point(
np.array((55.7655, 37.6055)),
"time",
MapConfiguration(
DrawingMode.TIME,
zoom_level=16.5,
ignore_level_matching=True,
),
)
if id_ is None or id_ == "author":
draw_around_point(
np.array((55.7655, 37.6055)),
"author",
MapConfiguration(
DrawingMode.AUTHOR,
seed="a",
zoom_level=16.5,
ignore_level_matching=True,
),
)
if id_ is None or id_ == "colors":
draw_around_point(
np.array((48.87422, 2.377)),
"colors",
configuration=MapConfiguration(
zoom_level=17.6,
building_mode=BuildingMode.ISOMETRIC,
ignore_level_matching=True,
),
)
if id_ is None or id_ == "lanes":
draw_around_point(np.array((47.61224, -122.33866)), "lanes")
if __name__ == "__main__":
logging.basicConfig(format="%(levelname)s %(message)s", level=logging.DEBUG)
main(None if len(sys.argv) < 2 else sys.argv[1])

View file

@ -22,9 +22,7 @@ from map_machine.workspace import workspace
class TaginfoProjectFile:
"""
JSON structure with OpenStreetMap tag usage.
"""
"""JSON structure with OpenStreetMap tag usage."""
def __init__(self, path: Path, scheme: Scheme) -> None:
self.path: Path = path
@ -56,8 +54,8 @@ class TaginfoProjectFile:
key: str = list(matcher.tags.keys())[0]
value: str = matcher.tags[key]
ids: List[str] = [
(x if isinstance(x, str) else x["shape"])
for x in matcher.shapes
(shape if isinstance(shape, str) else shape["shape"])
for shape in matcher.shapes
]
icon_id: str = "___".join(ids)
if value == "*":

224
map_machine/doc/wiki.py Normal file
View file

@ -0,0 +1,224 @@
"""
Automate OpenStreetMap wiki editing.
"""
import re
from pathlib import Path
from typing import Optional
from map_machine.doc.collections import Collection
from map_machine.map_configuration import MapConfiguration
from map_machine.osm.osm_reader import Tags
from map_machine.pictogram.icon import Icon, ShapeExtractor
from map_machine.scheme import Scheme
from map_machine.workspace import Workspace
WORKSPACE: Workspace = Workspace(Path("temp"))
SCHEME: Scheme = Scheme.from_file(WORKSPACE.DEFAULT_SCHEME_PATH)
EXTRACTOR: ShapeExtractor = ShapeExtractor(
WORKSPACE.ICONS_PATH, WORKSPACE.ICONS_CONFIG_PATH
)
HEADER_PATTERN: re.Pattern = re.compile("==?=?.*==?=?")
HEADER_2_PATTERN: re.Pattern = re.compile("== .* ==")
HEADER_PATTERNS: list[re.Pattern] = [
re.compile("==\\s*Example.*=="),
re.compile("==\\s*See also\\s*=="),
]
RENDERING_HEADER_PATTERN: re.Pattern = re.compile("==\\s*Rendering.*==")
ROENTGEN_HEADER_PATTERN: re.Pattern = re.compile("===.*Röntgen.*===")
class WikiTable:
"""SVG table with icon combinations."""
def __init__(self, collection: Collection, page_name: str):
self.collection: Collection = collection
self.page_name: str = page_name
def generate_wiki_table(self) -> tuple[str, list[Icon]]:
"""
Generate Röntgen icon table for the OpenStreetMap wiki page.
"""
icons: list[Icon] = []
text: str = '{| class="wikitable"\n'
if self.collection.column_key is not None:
text += f"! {{{{Key|{self.collection.column_key}}}}}"
else:
text += "! Tag || Icon"
if self.collection.row_tags:
text += "\n"
for current_tags in self.collection.row_tags:
text += "|-\n"
text += "| "
if current_tags:
for key, value in current_tags.items():
if value == "*":
text += f"{{{{Key|{key}}}}}<br />"
else:
text += f"{{{{Tag|{key}|{value}}}}}<br />"
text = text[:-6]
text += "\n"
icon, _ = SCHEME.get_icon(
EXTRACTOR,
current_tags | self.collection.tags,
set(),
MapConfiguration(ignore_level_matching=True),
)
icons.append(icon.main_icon)
text += (
"| "
f"[[Image:Röntgen {icon.main_icon.get_name()}.svg|32px]]\n"
)
text += "|}\n"
return text, icons
if not self.collection.column_values:
self.collection.column_values = [""]
else:
make_vertical: bool = False
for column_value in self.collection.column_values:
if column_value and len(column_value) > 2:
make_vertical = True
for column_value in self.collection.column_values:
text += " ||"
if column_value:
tag: str = (
f"{{{{TagValue|"
f"{self.collection.column_key}|{column_value}}}}}"
)
text += " " + (
f"{{{{vert header|{tag}}}}}" if make_vertical else tag
)
text += "\n"
for row_value in self.collection.row_values:
text += "|-\n"
if row_value:
text += f"| {{{{Tag|{self.collection.row_key}|{row_value}}}}}\n"
else:
text += "|\n"
for column_value in self.collection.column_values:
current_tags: Tags = dict(self.collection.tags) | {
self.collection.row_key: row_value
}
if column_value:
current_tags |= {self.collection.column_key: column_value}
icon, _ = SCHEME.get_icon(EXTRACTOR, current_tags, set())
if not icon:
print("Icon was not constructed.")
text += (
"| "
f"[[Image:Röntgen {icon.main_icon.get_name()}.svg|32px]]\n"
)
icons.append(icon.main_icon)
text += "|}\n"
return text, icons
def generate_new_text(
old_text: str,
table: WikiTable,
) -> tuple[Optional[str], list[Icon]]:
"""
Generate Röntgen icon table for the OpenStreetMap wiki page.
:param old_text: previous wiki page text
:param table: wiki table generator
:return: new wiki page text
"""
wiki_text: str
icons = []
if table.collection.row_key or table.collection.row_tags:
wiki_text, icons = table.generate_wiki_table()
else:
processed = set()
icon, _ = SCHEME.get_icon(
EXTRACTOR, table.collection.tags, processed, MapConfiguration()
)
if not icon.main_icon.is_default():
wiki_text = (
f"[[Image:Röntgen {icon.main_icon.get_name()}.svg|32px]]\n"
)
icons.append(icon.main_icon)
elif icon.extra_icons:
wiki_text = (
f"Röntgen icon set has additional icon for the tag: "
f"[[Image:Röntgen {icon.extra_icons[0].get_name()}.svg|32px]]."
f"\n"
)
icons.append(icon.extra_icons[0])
else:
wiki_text = ""
lines: list[str] = old_text.split("\n")
# If rendering section already exists.
start: Optional[int] = None
end: int = -1
for index, line in enumerate(lines):
if HEADER_2_PATTERN.match(line):
if start is not None:
end = index
break
if RENDERING_HEADER_PATTERN.match(line):
start = index
if start is not None:
return (
"\n".join(lines[: start + 2])
+ "\n=== [[Röntgen]] icons in [[Map Machine]] ===\n"
+ f"\n{wiki_text}\n"
+ "\n".join(lines[end:])
), icons
# If Röntgen rendering section already exists.
start: Optional[int] = None
end: int = -1
for index, line in enumerate(lines):
if HEADER_PATTERN.match(line):
if start is not None:
end = index
break
if ROENTGEN_HEADER_PATTERN.match(line):
start = index
if start is not None:
return (
"\n".join(lines[: start + 2])
+ f"\n{wiki_text}\n"
+ "\n".join(lines[end:])
), icons
# Otherwise.
headers: list[Optional[int]] = [None, None]
for index, line in enumerate(lines):
for i, pattern in enumerate(HEADER_PATTERNS):
if pattern.match(line):
headers[i] = index
filtered = list(filter(lambda x: x is not None, headers))
header: int
if filtered:
header = filtered[0]
else:
lines += [""]
header = len(lines)
return (
"\n".join(lines[:header])
+ "\n== Rendering ==\n\n=== [[Röntgen]] icons in [[Map Machine]] "
"===\n\n" + wiki_text + "\n" + "\n".join(lines[header:])
), icons

View file

@ -20,16 +20,16 @@ __email__ = "me@enzet.ru"
PathCommands = List[Union[float, str, np.ndarray]]
DEFAULT_FONT: str = "Helvetica"
@dataclass
class Style:
"""
Drawing element style.
"""
"""Drawing element style."""
fill: Optional[Color] = None
stroke: Optional[Color] = None
width: float = 1
width: float = 1.0
def update_svg_element(self, element: BaseElement) -> None:
"""Set style for SVG element."""
@ -43,7 +43,7 @@ class Style:
def draw_png_fill(self, context: Context) -> None:
"""Set style for context and draw fill."""
context.set_source_rgba(
self.fill.get_red(), self.fill.get_green(), self.fill.get_blue(), 1
self.fill.get_red(), self.fill.get_green(), self.fill.get_blue()
)
context.fill()
@ -53,16 +53,13 @@ class Style:
self.stroke.get_red(),
self.stroke.get_green(),
self.stroke.get_blue(),
1,
)
context.set_line_width(self.width)
context.stroke()
class Drawing:
"""
Image.
"""
"""Image."""
def __init__(self, file_path: Path, width: int, height: int) -> None:
self.file_path: Path = file_path
@ -95,9 +92,7 @@ class Drawing:
class SVGDrawing(Drawing):
"""
SVG image.
"""
"""SVG image."""
def __init__(self, file_path: Path, width: int, height: int) -> None:
super().__init__(file_path, width, height)
@ -145,9 +140,7 @@ class SVGDrawing(Drawing):
class PNGDrawing(Drawing):
"""
PNG image.
"""
"""PNG image."""
def __init__(self, file_path: Path, width: int, height: int) -> None:
super().__init__(file_path, width, height)
@ -184,7 +177,7 @@ class PNGDrawing(Drawing):
def _do_path(self, commands: PathCommands) -> None:
"""Draw path."""
current: np.ndarray = np.array((0, 0))
current: np.ndarray = np.array((0.0, 0.0))
start_point: Optional[np.ndarray] = None
command: str = "M"
is_absolute: bool = True
@ -239,14 +232,14 @@ class PNGDrawing(Drawing):
point: np.ndarray
if is_absolute:
if command == "v":
point = np.array((0, commands[index]))
point = np.array((0.0, commands[index]))
else:
point = np.array((commands[index], 0))
point = np.array((commands[index], 0.0))
else:
if command == "v":
point = current + np.array((0, commands[index]))
point = current + np.array((0.0, commands[index]))
else:
point = current + np.array((commands[index], 0))
point = current + np.array((commands[index], 0.0))
current = point
self.context.line_to(point[0], point[1])
if start_point is None:
@ -304,3 +297,30 @@ def parse_path(path: str) -> PathCommands:
index += 1
return result
def draw_text(
svg: svgwrite.Drawing,
text: str,
point: np.ndarray,
size: float,
fill: Color,
anchor: str = "middle",
stroke_linejoin: str = "round",
stroke_width: float = 1.0,
stroke: Optional[Color] = None,
opacity: float = 1.0,
):
text_element = svg.text(
text,
point,
font_size=size,
text_anchor=anchor,
font_family=DEFAULT_FONT,
fill=fill.hex,
stroke_linejoin=stroke_linejoin,
stroke_width=stroke_width,
stroke=stroke.hex if stroke else "none",
opacity=opacity,
)
svg.add(text_element)

View file

@ -10,10 +10,11 @@ import numpy as np
import svgwrite
from svgwrite.path import Path as SVGPath
from map_machine.icon import ShapeExtractor
from map_machine.point import Point
from map_machine.map_configuration import LabelMode
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.pictogram.point import Point
from map_machine.scheme import LineStyle, Scheme
from map_machine.text import Label
from map_machine.text import Label, TextConstructor
from map_machine.workspace import workspace
__author__ = "Sergey Vartanov"
@ -34,29 +35,33 @@ def draw_element(options: argparse.Namespace) -> None:
target = "area"
tags_description = options.area
tags: Dict[str, str] = dict(
[x.split("=") for x in tags_description.split(",")]
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
tags: Dict[str, str] = {
tag.split("=")[0]: tag.split("=")[1]
for tag in tags_description.split(",")
}
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
processed: Set[str] = set()
icon, priority = scheme.get_icon(extractor, tags, processed)
icon, _ = scheme.get_icon(extractor, tags, processed)
is_for_node: bool = target == "node"
labels: List[Label] = scheme.construct_text(tags, "all", processed)
text_constructor: TextConstructor = TextConstructor(scheme)
labels: List[Label] = text_constructor.construct_text(
tags, processed, LabelMode.ALL
)
point: Point = Point(
icon,
labels,
tags,
processed,
np.array((32, 32)),
np.array((32.0, 32.0)),
is_for_node=is_for_node,
draw_outline=is_for_node,
)
border: np.ndarray = np.array((16, 16))
border: np.ndarray = np.array((16.0, 16.0))
size: np.ndarray = point.get_size() + border
point.point = np.array((size[0] / 2, 16 / 2 + border[1] / 2))
point.point = np.array((size[0] / 2.0, 16.0 / 2.0 + border[1] / 2.0))
output_file_path: Path = workspace.output_path / "element.svg"
svg: svgwrite.Drawing = svgwrite.Drawing(

View file

@ -0,0 +1,3 @@
"""
Specific map features: roads, directions, etc.
"""

View file

@ -0,0 +1,215 @@
"""
Buildings on the map.
"""
import numpy as np
from colour import Color
from svgwrite import Drawing
from svgwrite.container import Group
from svgwrite.path import Path
from typing import Dict, List
from map_machine.drawing import PathCommands
from map_machine.figure import Figure
from map_machine.geometry.flinger import Flinger
from map_machine.geometry.vector import Segment
from map_machine.osm.osm_reader import OSMNode
from map_machine.scheme import Scheme
BUILDING_MINIMAL_HEIGHT: float = 8.0
BUILDING_SCALE: float = 0.33
LEVEL_HEIGHT: float = 2.5
SHADE_SCALE: float = 0.4
class Building(Figure):
"""Building on the map."""
def __init__(
self,
tags: Dict[str, str],
inners: List[List[OSMNode]],
outers: List[List[OSMNode]],
flinger: Flinger,
scheme: Scheme,
) -> None:
super().__init__(tags, inners, outers)
self.is_construction: bool = (
tags.get("building") == "construction"
or tags.get("construction") == "yes"
)
self.has_walls: bool = tags.get("building") != "roof"
if self.is_construction:
self.fill: Color = scheme.get_color("building_construction_color")
self.stroke: Color = scheme.get_color(
"building_construction_border_color"
)
else:
if color := tags.get("roof:colour"):
self.fill = scheme.get_color(color)
self.stroke: Color = Color(self.fill)
self.stroke.set_luminance(self.fill.get_luminance() * 0.85)
else:
self.fill: Color = scheme.get_color("building_color")
self.stroke: Color = scheme.get_color("building_border_color")
self.parts: List[Segment] = []
for nodes in self.inners + self.outers:
for i in range(len(nodes) - 1):
flung_1: np.ndarray = flinger.fling(nodes[i].coordinates)
flung_2: np.ndarray = flinger.fling(nodes[i + 1].coordinates)
self.parts.append(Segment(flung_1, flung_2))
self.parts = sorted(self.parts)
self.height: float = BUILDING_MINIMAL_HEIGHT
self.min_height: float = 0.0
self.wall_color: Color
if self.is_construction:
self.wall_color = scheme.get_color("wall_construction_color")
else:
self.wall_color = scheme.get_color("wall_color")
if material := tags.get("building:material"):
if material in scheme.material_colors:
self.wall_color = Color(scheme.material_colors[material])
if color := tags.get("building:colour"):
self.wall_color = scheme.get_color(color)
if color := tags.get("colour"):
self.wall_color = scheme.get_color(color)
self.wall_bottom_color_1: Color = Color(self.wall_color)
self.wall_bottom_color_1.set_luminance(
self.wall_color.get_luminance() * 0.70
)
self.wall_bottom_color_2: Color = Color(self.wall_color)
self.wall_bottom_color_2.set_luminance(
self.wall_color.get_luminance() * 0.85
)
if levels := self.get_float("building:levels"):
self.height = BUILDING_MINIMAL_HEIGHT + levels * LEVEL_HEIGHT
if levels := self.get_float("building:min_level"):
self.min_height = BUILDING_MINIMAL_HEIGHT + levels * LEVEL_HEIGHT
if height := self.get_length("height"):
self.height = BUILDING_MINIMAL_HEIGHT + height
if height := self.get_length("min_height"):
self.min_height = BUILDING_MINIMAL_HEIGHT + height
def draw(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw simple building shape."""
path: Path = Path(
d=self.get_path(flinger),
stroke=self.stroke.hex,
fill=self.fill.hex,
stroke_linejoin="round",
)
svg.add(path)
def draw_shade(self, building_shade: Group, flinger: Flinger) -> None:
"""Draw shade casted by the building."""
scale: float = flinger.get_scale() * SHADE_SCALE
shift_1: np.ndarray = np.array((scale * self.min_height, 0.0))
shift_2: np.ndarray = np.array((scale * self.height, 0.0))
commands: str = self.get_path(flinger, shift_1)
path: Path = Path(
commands, fill="#000000", stroke="#000000", stroke_width=1.0
)
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: PathCommands = [
"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 = Path(
command, fill="#000000", stroke="#000000", stroke_width=1.0
)
building_shade.add(path)
def draw_walls(
self, svg: Drawing, height: float, previous_height: float, scale: float
) -> None:
"""Draw building walls."""
if not self.has_walls:
return
shift_1: np.ndarray = np.array(
(0.0, -previous_height * scale * BUILDING_SCALE)
)
shift_2: np.ndarray = np.array((0.0, -height * scale * BUILDING_SCALE))
for segment in self.parts:
draw_walls(svg, self, segment, height, shift_1, shift_2)
def draw_roof(self, svg: Drawing, flinger: Flinger, scale: float) -> None:
"""Draw building roof."""
path: Path = Path(
d=self.get_path(
flinger, np.array([0.0, -self.height * scale * BUILDING_SCALE])
),
stroke=self.stroke,
fill="none" if self.is_construction else self.fill.hex,
stroke_linejoin="round",
)
svg.add(path)
def draw_walls(svg, building: Building, segment, height, shift_1, shift_2):
fill: str
if building.is_construction:
color_part: float = segment.angle * 0.2
fill = Color(
rgb=(
building.wall_color.get_red() + color_part,
building.wall_color.get_green() + color_part,
building.wall_color.get_blue() + color_part,
)
).hex
elif height <= 0.25 / BUILDING_SCALE:
fill = building.wall_bottom_color_1.hex
elif height <= 0.5 / BUILDING_SCALE:
fill = building.wall_bottom_color_2.hex
else:
color_part: float = segment.angle * 0.2 - 0.1
fill = Color(
rgb=(
max(min(building.wall_color.get_red() + color_part, 1), 0),
max(min(building.wall_color.get_green() + color_part, 1), 0),
max(min(building.wall_color.get_blue() + color_part, 1), 0),
)
).hex
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: Path = Path(
d=command,
fill=fill,
stroke=fill,
stroke_width=1,
stroke_linejoin="round",
)
svg.add(path)

View file

@ -0,0 +1,47 @@
"""
Crater on the map.
"""
import numpy as np
from colour import Color
from svgwrite import Drawing
from typing import Dict
from map_machine.geometry.flinger import Flinger
from map_machine.osm.osm_reader import Tagged
class Crater(Tagged):
"""Volcano or impact crater on the map."""
def __init__(
self, tags: Dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw crater ridge."""
scale: float = flinger.get_scale(self.coordinates)
assert "diameter" in self.tags
radius: float = float(self.tags["diameter"]) / 2.0
radial_gradient = svg.radialGradient(
center=self.point + np.array((0.0, radius * scale / 7.0)),
r=radius * scale,
gradientUnits="userSpaceOnUse",
)
color: Color = Color("#000000")
gradient = svg.defs.add(radial_gradient)
(
gradient
.add_stop_color(0.0, color.hex, opacity=0.2)
.add_stop_color(0.7, color.hex, opacity=0.2)
.add_stop_color(1.0, color.hex, opacity=1.0)
) # fmt: skip
circle = svg.circle(
self.point,
radius * scale,
fill=gradient.get_funciri(),
opacity=0.2,
)
svg.add(circle)

View file

@ -1,19 +1,24 @@
"""
Direction tag support.
"""
from typing import Iterator, List, Optional
from typing import Iterator, List, Optional, Dict
import numpy as np
from colour import Color
from portolan import middle
from svgwrite import Drawing
from svgwrite.gradients import RadialGradient
from svgwrite.path import Path
from map_machine.drawing import PathCommands
from map_machine.osm.osm_reader import Tagged
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
SHIFT: float = -np.pi / 2
SMALLEST_ANGLE: float = np.pi / 15
DEFAULT_ANGLE: float = np.pi / 30
SHIFT: float = -np.pi / 2.0
SMALLEST_ANGLE: float = np.pi / 15.0
DEFAULT_ANGLE: float = np.pi / 30.0
def parse_vector(text: str) -> Optional[np.ndarray]:
@ -51,9 +56,7 @@ def rotation_matrix(angle: float) -> np.ndarray:
class Sector:
"""
Sector described by two vectors.
"""
"""Sector described by two vectors."""
def __init__(self, text: str, angle: Optional[float] = None) -> None:
"""
@ -64,17 +67,17 @@ class Sector:
self.end: Optional[np.ndarray] = None
self.main_direction: Optional[np.ndarray] = None
if "-" in text:
if "-" in text and not text.startswith("-"):
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
self.main_direction = (self.start + self.end) / 2.0
else:
result_angle: float
if angle is None:
result_angle = DEFAULT_ANGLE
else:
result_angle = max(SMALLEST_ANGLE, np.radians(angle) / 2)
result_angle = max(SMALLEST_ANGLE, np.radians(angle) / 2.0)
vector: Optional[np.ndarray] = parse_vector(text)
self.main_direction = vector
@ -107,21 +110,20 @@ class Sector:
None otherwise.
"""
if self.main_direction is not None:
if np.allclose(self.main_direction[0], 0):
if np.allclose(self.main_direction[0], 0.0):
return None
elif self.main_direction[0] > 0:
if self.main_direction[0] > 0.0:
return True
else:
return False
return None
def __str__(self) -> str:
return f"{self.start}-{self.end}"
class DirectionSet:
"""
Describes direction, set of directions.
"""
"""Describes direction, set of directions."""
def __init__(self, text: str) -> None:
"""
@ -152,9 +154,81 @@ 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] = [sector.is_right() for sector in self.sectors]
if result == [True] * len(result):
return True
if result == [False] * len(result):
return False
return None
class DirectionSector(Tagged):
"""Sector that represents direction."""
def __init__(self, tags: Dict[str, str], point: np.ndarray) -> None:
super().__init__(tags)
self.point: np.ndarray = point
def draw(self, svg: Drawing, scheme) -> None:
"""Draw gradient sector."""
angle: Optional[float] = None
is_revert_gradient: bool = False
direction: str
direction_radius: float
direction_color: Color
if self.get_tag("man_made") == "surveillance":
direction = self.get_tag("camera:direction")
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 = 50.0
direction_color = scheme.get_color("direction_camera_color")
elif self.get_tag("traffic_sign") == "stop":
direction = self.get_tag("direction")
direction_radius = 25.0
direction_color = Color("red")
else:
direction = self.get_tag("direction")
direction_radius = 50.0
direction_color = scheme.get_color("direction_view_color")
is_revert_gradient = True
if not direction:
return
point: np.ndarray = (self.point.astype(int)).astype(float)
paths: Iterator[PathCommands]
if angle is not None:
paths = [Sector(direction, angle).draw(point, direction_radius)]
else:
paths = DirectionSet(direction).draw(point, direction_radius)
for path in paths:
radial_gradient: RadialGradient = svg.radialGradient(
center=point,
r=direction_radius,
gradientUnits="userSpaceOnUse",
)
gradient: RadialGradient = svg.defs.add(radial_gradient)
if is_revert_gradient:
(
gradient
.add_stop_color(0.0, direction_color.hex, opacity=0.0)
.add_stop_color(1.0, direction_color.hex, opacity=0.7)
) # fmt: skip
else:
(
gradient
.add_stop_color(0.0, direction_color.hex, opacity=0.4)
.add_stop_color(1.0, direction_color.hex, opacity=0.0)
) # fmt: skip
path_element: Path = svg.path(
d=["M", point] + path + ["L", point, "Z"],
fill=gradient.get_funciri(),
)
svg.add(path_element)

View file

@ -1,38 +1,41 @@
"""
WIP: road shape drawing.
"""
import logging
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
import svgwrite
from colour import Color
from svgwrite import Drawing
from svgwrite.filters import Filter
from svgwrite.path import Path
from svgwrite.shapes import Circle
from map_machine.drawing import PathCommands
from map_machine.flinger import Flinger
from map_machine.osm_reader import OSMNode, Tagged
from map_machine.scheme import RoadMatcher
from map_machine.vector import (
from map_machine.geometry.flinger import Flinger
from map_machine.geometry.vector import (
Line,
Polyline,
compute_angle,
norm,
turn_by_angle,
)
from map_machine.osm.osm_reader import OSMNode, Tagged
from map_machine.scheme import RoadMatcher, Scheme
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
DEFAULT_LANE_WIDTH: float = 3.7
USE_BLUR: bool = False
@dataclass
class Lane:
"""
Road lane specification.
"""
"""Road lane specification."""
width: Optional[float] = None # Width in meters
is_forward: Optional[bool] = None # Whether lane is forward or backward
@ -54,9 +57,7 @@ class Lane:
class RoadPart:
"""
Line part of the road.
"""
"""Line part of the road."""
def __init__(
self,
@ -73,15 +74,18 @@ class RoadPart:
self.point_1: np.ndarray = point_1
self.point_2: np.ndarray = point_2
self.lanes: List[Lane] = lanes
self.width: float
if lanes:
self.width = sum(map(lambda x: x.get_width(scale), lanes))
else:
self.width = 1
self.left_offset: float = self.width / 2
self.right_offset: float = self.width / 2
self.width = 1.0
self.left_offset: float = self.width / 2.0
self.right_offset: float = self.width / 2.0
self.turned: np.ndarray = norm(
turn_by_angle(self.point_2 - self.point_1, np.pi / 2)
turn_by_angle(self.point_2 - self.point_1, np.pi / 2.0)
)
self.right_vector: np.ndarray = self.turned * self.right_offset
self.left_vector: np.ndarray = -self.turned * self.left_offset
@ -120,7 +124,7 @@ class RoadPart:
self.left_outer = self.left_connection
self.point_middle = self.right_outer - self.right_vector
max_: float = 100
max_: float = 100.0
if np.linalg.norm(self.point_middle - self.point_1) > max_:
self.point_a = self.point_1 + max_ * norm(
@ -287,9 +291,8 @@ class Intersection:
def __init__(self, parts: List[RoadPart]) -> None:
self.parts: List[RoadPart] = sorted(parts, key=lambda x: x.get_angle())
for index in range(len(self.parts)):
for index, part_1 in enumerate(self.parts):
next_index: int = 0 if index == len(self.parts) - 1 else index + 1
part_1: RoadPart = self.parts[index]
part_2: RoadPart = self.parts[next_index]
line_1: Line = Line(
part_1.point_1 + part_1.right_vector,
@ -306,9 +309,8 @@ class Intersection:
part_1.update()
part_2.update()
for index in range(len(self.parts)):
for index, part_1 in enumerate(self.parts):
next_index: int = 0 if index == len(self.parts) - 1 else index + 1
part_1: RoadPart = self.parts[index]
part_2: RoadPart = self.parts[next_index]
part_1.update()
part_2.update()
@ -362,9 +364,7 @@ class Intersection:
class Road(Tagged):
"""
Road or track on the map.
"""
"""Road or track on the map."""
def __init__(
self,
@ -372,39 +372,47 @@ class Road(Tagged):
nodes: List[OSMNode],
matcher: RoadMatcher,
flinger: Flinger,
scheme: Scheme,
) -> None:
super().__init__(tags)
self.nodes: List[OSMNode] = nodes
self.matcher: RoadMatcher = matcher
self.line: Polyline = Polyline(
[flinger.fling(x.coordinates) for x in self.nodes]
[flinger.fling(node.coordinates) for node in self.nodes]
)
self.width: Optional[float] = matcher.default_width
self.lanes: List[Lane] = []
self.scale: float = flinger.get_scale(self.nodes[0].coordinates)
self.is_area = scheme.is_area(tags) and nodes[0] == nodes[-1]
if "lanes" in tags:
try:
self.width = int(tags["lanes"]) * 3.7
self.width = int(tags["lanes"]) * DEFAULT_LANE_WIDTH
self.lanes = [Lane()] * int(tags["lanes"])
except ValueError:
pass
if "width:lanes" in tags:
try:
widths: List[float] = list(
map(float, tags["width:lanes"].split("|"))
)
if len(widths) == len(self.lanes):
for index, lane in enumerate(self.lanes):
lane.width = widths[index]
except ValueError:
pass
number: int
if "lanes:forward" in tags:
number = int(tags["lanes:forward"])
[x.set_forward(True) for x in self.lanes[-number:]]
map(lambda x: x.set_forward(True), self.lanes[-number:])
if "lanes:backward" in tags:
number = int(tags["lanes:backward"])
[x.set_forward(False) for x in self.lanes[:number]]
map(lambda x: x.set_forward(False), self.lanes[:number])
if "width" in tags:
try:
@ -412,106 +420,235 @@ class Road(Tagged):
except ValueError:
pass
self.layer: float = 0
self.layer: float = 0.0
if "layer" in tags:
self.layer = float(tags["layer"])
def draw(
self,
svg: Drawing,
flinger: Flinger,
color: Color,
extra_width: float = 0,
) -> None:
"""Draw road as simple SVG path."""
self.placement_offset: float = 0.0
self.is_transition: bool = False
if "placement" in tags:
value: str = tags["placement"]
if value == "transition":
self.is_transition = True
elif ":" in value and len(parts := value.split(":")) == 2:
place, lane_string = parts
lane_number: int = int(lane_string) - 1
self.placement_offset = -self.width * self.scale / 2.0
if lane_number > 0:
self.placement_offset += sum(
lane.get_width(self.scale)
for lane in self.lanes[:lane_number]
)
elif lane_number < 0:
self.placement_offset += (
DEFAULT_LANE_WIDTH * lane_number * self.scale
)
if place == "left_of":
pass
elif place == "middle_of":
self.placement_offset += (
self.lanes[lane_number].get_width(self.scale) * 0.5
)
elif place == "right_of":
self.placement_offset += self.lanes[lane_number].get_width(
self.scale
)
else:
logging.error(f"Unknown placement `{place}`.")
def get_style(
self, is_border: bool, is_for_stroke: bool = False
) -> Dict[str, Union[int, float, str]]:
"""Get road SVG style."""
width: float
if self.width is not None:
width = self.width
else:
width = self.matcher.default_width
if extra_width and self.tags.get("bridge") == "yes":
color = Color("#666666")
if extra_width and self.tags.get("ford") == "yes":
color = Color("#88BBFF")
width += 2
if extra_width and self.tags.get("embankment") == "yes":
color = Color("#666666")
width += 4
scale: float = flinger.get_scale(self.nodes[0].coordinates)
path_commands: str = self.line.get_path()
path: Path = Path(d=path_commands)
style: Dict[str, Any] = {
"fill": "none",
border_width: float
if is_border:
color = self.get_border_color()
border_width = 2.0
else:
color = self.get_color()
border_width = 0.0
extra_width: float = 0.0
if is_border:
if self.tags.get("bridge") == "yes":
extra_width = 0.5
if self.tags.get("ford") == "yes":
extra_width = 2.0
if self.tags.get("embankment") == "yes":
extra_width = 4.0
fill: str = "none"
if self.is_area:
fill = color.hex
style: Dict[str, Union[int, float, str]] = {
"fill": fill,
"stroke": color.hex,
"stroke-linecap": "butt",
"stroke-linejoin": "round",
"stroke-width": scale * width + extra_width,
"stroke-width": self.scale * width + extra_width + border_width,
}
if extra_width and self.tags.get("embankment") == "yes":
if is_for_stroke:
style["stroke-width"] = 2.0 + extra_width
if is_border and self.tags.get("embankment") == "yes":
style["stroke-dasharray"] = "1,3"
if extra_width and self.tags.get("tunnel") == "yes":
if self.tags.get("tunnel") == "yes":
if is_border:
style["stroke-dasharray"] = "3,3"
return style
def get_filter(self, svg: Drawing, is_border: bool) -> Optional[Filter]:
"""Get blurring filter."""
if not USE_BLUR:
return None
if is_border and self.tags.get("bridge") == "yes":
filter_ = svg.defs.add(svg.filter())
filter_.feGaussianBlur(in_="SourceGraphic", stdDeviation=2)
return filter_
return None
def draw(self, svg: Drawing, is_border: bool) -> None:
"""Draw road as simple SVG path."""
filter_: Filter = self.get_filter(svg, is_border)
style: dict[str, Union[int, float, str]] = self.get_style(is_border)
path_commands: str = self.line.get_path(self.placement_offset)
path: Path
if filter_:
path = Path(d=path_commands, filter=filter_.get_funciri())
else:
path = Path(d=path_commands)
path.update(style)
svg.add(path)
def draw_lanes(self, svg: Drawing, flinger: Flinger, color: Color) -> None:
def get_color(self) -> Color:
"""Get road main color."""
color: Color = self.matcher.color
if self.tags.get("tunnel") == "yes":
color = Color(color, luminance=min(1.0, color.luminance + 0.2))
return color
def get_border_color(self) -> Color:
"""Get road border color."""
color: Color = self.matcher.border_color
if self.tags.get("bridge") == "yes":
color = Color("#666666")
if self.tags.get("ford") == "yes":
color = Color("#88BBFF")
if self.tags.get("embankment") == "yes":
color = Color("#666666")
return color
def draw_lanes(self, svg: Drawing, color: Color) -> None:
"""Draw lane separators."""
scale: float = flinger.get_scale(self.nodes[0].coordinates)
if len(self.lanes) < 2:
return
for index in range(1, len(self.lanes)):
parallel_offset: float = scale * (
-self.width / 2 + index * self.width / len(self.lanes)
lane_offset: float = self.scale * (
-self.width / 2.0 + index * self.width / len(self.lanes)
)
path: Path = Path(
d=self.line.get_path(self.placement_offset + lane_offset)
)
path: Path = Path(d=self.line.get_path(parallel_offset))
style: Dict[str, Any] = {
"fill": "none",
"stroke": color.hex,
"stroke-linejoin": "round",
"stroke-width": 1,
"stroke-width": 1.0,
"opacity": 0.5,
}
path.update(style)
svg.add(path)
def draw_caption(self, svg: Drawing) -> None:
"""Draw road name along its path."""
name: Optional[str] = self.tags.get("name")
if not name:
return
path: Path = svg.path(
d=self.line.get_path(self.placement_offset + 3.0), fill="none"
)
svg.add(path)
text = svg.add(svg.text.Text(""))
text_path = svg.text.TextPath(
path=path,
text=name,
startOffset=None,
method="align",
spacing="exact",
font_family="Roboto",
font_size=10.0,
)
text.add(text_path)
def get_curve_points(
road: Road, scale: float, center: np.ndarray, road_end: np.ndarray
road: Road,
center: np.ndarray,
road_end: np.ndarray,
placement_offset: float,
is_end: bool,
) -> List[np.ndarray]:
"""
:param road: road segment
:param scale: current zoom scale
:param center: road intersection point
:param road_end: end point of the road segment
:param placement_offset: offset based on placement tag value
:param is_end: whether the point represents road end
"""
width: float = road.width / 2.0 * scale
width: float = road.width / 2.0 * road.scale
direction: np.ndarray = (road_end - center) / np.linalg.norm(
road_end - center
direction: np.ndarray = (center - road_end) / np.linalg.norm(
center - road_end
)
if is_end:
direction = -direction
left: np.ndarray = turn_by_angle(direction, np.pi / 2.0) * (
width + placement_offset
)
right: np.ndarray = turn_by_angle(direction, -np.pi / 2.0) * (
width - placement_offset
)
left: np.ndarray = turn_by_angle(direction, np.pi / 2.0) * width
right: np.ndarray = turn_by_angle(direction, -np.pi / 2.0) * width
return [road_end + left, center + left, center + right, road_end + right]
class Connector:
"""
Two roads connection.
"""
"""Two roads connection."""
def __init__(
self,
connections: List[Tuple[Road, int]],
flinger: Flinger,
scale: float,
) -> None:
self.connections: List[Tuple[Road, int]] = connections
self.road_1: Road = connections[0][0]
self.index_1: int = connections[0][1]
self.road_1: Road
self.index_1: int
self.road_1, self.index_1 = connections[0]
self.priority = self.road_1.matcher.priority
self.layer: float = min(x[0].layer for x in connections)
self.scale: float = scale
self.min_layer: float = min(
connection[0].layer for connection in connections
)
self.max_layer: float = max(
connection[0].layer for connection in connections
)
self.scale: float = self.road_1.scale
self.flinger: Flinger = flinger
def draw(self, svg: Drawing) -> None:
@ -524,17 +661,14 @@ class Connector:
class SimpleConnector(Connector):
"""
Simple connection between roads that don't change width.
"""
"""Simple connection between roads that don't change width."""
def __init__(
self,
connections: List[Tuple[Road, int]],
flinger: Flinger,
scale: float,
) -> None:
super().__init__(connections, flinger, scale)
super().__init__(connections, flinger)
self.road_2: Road = connections[1][0]
self.index_2: int = connections[1][1]
@ -546,8 +680,8 @@ class SimpleConnector(Connector):
"""Draw connection fill."""
circle: Circle = svg.circle(
self.point,
self.road_1.width * self.scale / 2,
fill=self.road_1.matcher.color.hex,
self.road_1.width * self.scale / 2.0,
fill=self.road_1.get_color().hex,
)
svg.add(circle)
@ -555,185 +689,213 @@ class SimpleConnector(Connector):
"""Draw connection outline."""
circle: Circle = svg.circle(
self.point,
self.road_1.width * self.scale / 2 + 1,
self.road_1.width * self.scale / 2.0 + 1.0,
fill=self.road_1.matcher.border_color.hex,
)
svg.add(circle)
class ComplexConnector(Connector):
"""
Connection between roads that change width.
"""
"""Connection between two roads that change width."""
def __init__(
self,
connections: List[Tuple[Road, int]],
flinger: Flinger,
scale: float,
) -> None:
super().__init__(connections, flinger, scale)
super().__init__(connections, flinger)
self.road_2: Road = connections[1][0]
self.index_2: int = connections[1][1]
length: float = abs(self.road_2.width - self.road_1.width) * scale
length: float = (
abs(self.road_2.width - self.road_1.width) * self.road_1.scale
)
self.road_1.line.shorten(self.index_1, length)
self.road_2.line.shorten(self.index_2, length)
node: OSMNode = self.road_1.nodes[self.index_1]
point: np.ndarray = flinger.fling(node.coordinates)
node_1: OSMNode = self.road_1.nodes[self.index_1]
point_1: np.ndarray = flinger.fling(node_1.coordinates)
node_2: OSMNode = self.road_2.nodes[self.index_2]
point_2: np.ndarray = flinger.fling(node_2.coordinates)
point = (point_1 + point_2) / 2.0
points_1: List[np.ndarray] = get_curve_points(
self.road_1, scale, point, self.road_1.line.points[self.index_1]
self.road_1,
point,
self.road_1.line.points[self.index_1],
self.road_1.placement_offset,
self.index_1 != 0,
)
points_2: List[np.ndarray] = get_curve_points(
self.road_2, scale, point, self.road_2.line.points[self.index_2]
self.road_2,
point,
self.road_2.line.points[self.index_2],
self.road_2.placement_offset,
self.index_2 != 0,
)
# fmt: off
self.curve_1: PathCommands = [
points_1[0], "C", points_1[1], points_2[2], points_2[3]
points_1[0], "C", points_1[1], points_2[1], points_2[0]
]
self.curve_2: PathCommands = [
points_2[0], "C", points_2[1], points_1[2], points_1[3]
points_2[3], "C", points_2[2], points_1[2], points_1[3]
]
# fmt: on
def draw(self, svg: Drawing) -> None:
"""Draw connection fill."""
for road, index in [
(self.road_1, self.index_1),
(self.road_2, self.index_2),
]:
circle: Circle = svg.circle(
road.line.points[index],
road.width * self.scale / 2,
fill=road.matcher.color.hex,
)
svg.add(circle)
path: Path = svg.path(
d=["M"] + self.curve_1 + ["L"] + self.curve_2 + ["Z"],
fill=self.road_1.matcher.color.hex,
fill=self.road_1.get_color(),
)
svg.add(path)
def draw_border(self, svg: Drawing) -> None:
"""Draw connection outline."""
filter_: Filter = self.road_1.get_filter(svg, True)
if filter_:
path: Path = svg.path(
d=["M"] + self.curve_1 + ["L"] + self.curve_2 + ["Z"],
fill="none",
stroke=self.road_1.matcher.border_color.hex,
stroke_width=2,
d=["M"] + self.curve_1 + ["M"] + self.curve_2,
filter=filter_.get_funciri(),
)
else:
path: Path = svg.path(d=["M"] + self.curve_1 + ["M"] + self.curve_2)
path.update(self.road_1.get_style(True, True))
svg.add(path)
class SimpleIntersection(Connector):
"""
Connection between more than two roads.
"""
def __init__(
self,
connections: List[Tuple[Road, int]],
flinger: Flinger,
scale: float,
) -> None:
super().__init__(connections, flinger, scale)
"""Connection between more than two roads."""
def draw(self, svg: Drawing) -> None:
"""Draw connection fill."""
for road, index in self.connections:
for road, _ in sorted(
self.connections, key=lambda x: x[0].matcher.priority
):
node: OSMNode = self.road_1.nodes[self.index_1]
point: np.ndarray = self.flinger.fling(node.coordinates)
circle: Circle = svg.circle(
point, road.width * self.scale / 2, fill=road.matcher.color.hex
point,
road.width * self.scale / 2.0,
fill=road.matcher.color.hex,
)
svg.add(circle)
def draw_border(self, svg: Drawing) -> None:
"""Draw connection outline."""
for road, index in self.connections:
for road, _ in self.connections:
node: OSMNode = self.road_1.nodes[self.index_1]
point: np.ndarray = self.flinger.fling(node.coordinates)
circle: Circle = svg.circle(
point,
road.width * self.scale / 2 + 1,
road.width * self.scale / 2.0 + 1.0,
fill=road.matcher.border_color.hex,
)
svg.add(circle)
class Roads:
"""
Whole road structure.
"""
"""Whole road structure."""
def __init__(self) -> None:
self.roads: List[Road] = []
self.connections: Dict[int, List[Tuple[Road, int]]] = {}
self.nodes: Dict[int, List[Tuple[Road, int]]] = {}
def append(self, road: Road) -> None:
"""Add road and update connections."""
self.roads.append(road)
for index in road.nodes[0].id_, road.nodes[-1].id_:
if index not in self.connections:
self.connections[index] = []
self.connections[road.nodes[0].id_].append((road, 0))
self.connections[road.nodes[-1].id_].append((road, -1))
for index, node in enumerate(road.nodes):
if node.id_ not in self.nodes:
self.nodes[node.id_] = []
self.nodes[node.id_].append((road, index))
def draw(self, svg: Drawing, flinger: Flinger) -> None:
def draw(
self, svg: Drawing, flinger: Flinger, draw_captions: bool = False
) -> None:
"""Draw whole road system."""
if not self.roads:
return
scale: float = flinger.get_scale(self.roads[0].nodes[0].coordinates)
layered_roads: Dict[float, List[Road]] = {}
layered_connectors: Dict[float, List[Connector]] = {}
layered_roads: Dict[float, List[Road]] = defaultdict(list)
layered_connectors: Dict[float, List[Connector]] = defaultdict(list)
for road in self.roads:
if road.layer not in layered_roads:
layered_roads[road.layer] = []
if not road.is_transition:
layered_roads[road.layer].append(road)
else:
connections = []
for end in 0, -1:
connections.append(
[
connection
for connection in self.nodes[road.nodes[end].id_]
if not connection[0].is_transition
]
)
if len(connections[0]) == 1 and len(connections[1]) == 1:
connector: Connector = ComplexConnector(
[connections[0][0], connections[1][0]], flinger
)
layered_connectors[road.layer].append(connector)
for id_ in self.connections:
connected: List[Tuple[Road, int]] = self.connections[id_]
for connected in self.nodes.values():
connector: Connector
if len(self.connections[id_]) == 2:
road_1, _ = connected[0]
road_2, _ = connected[1]
if road_1.width == road_2.width:
connector = SimpleConnector(connected, flinger, scale)
else:
connector = ComplexConnector(connected, flinger, scale)
else:
connector = SimpleIntersection(connected, flinger, scale)
if len(connected) <= 1:
continue
if connector.layer not in layered_connectors:
layered_connectors[connector.layer] = []
layered_connectors[connector.layer].append(connector)
if len(connected) == 2:
road_1, index_1 = connected[0]
road_2, index_2 = connected[1]
if (
road_1.width == road_2.width
or index_1 not in [0, len(road_1.nodes) - 1]
or index_2 not in [0, len(road_2.nodes) - 1]
):
connector = SimpleConnector(connected, flinger)
elif not road_1.is_transition and not road_2.is_transition:
connector = ComplexConnector(connected, flinger)
else:
continue
else:
# We can also use SimpleIntersection(connected, flinger, scale)
# here.
continue
layered_connectors[connector.min_layer].append(connector)
layered_connectors[connector.max_layer].append(connector)
for layer in sorted(layered_roads.keys()):
roads: List[Road] = sorted(
layered_roads[layer], key=lambda x: x.matcher.priority
)
connectors: List[Connector]
if layer in layered_connectors:
connectors = layered_connectors[layer]
else:
connectors = []
connectors: List[Connector] = layered_connectors.get(layer)
# Draw borders.
for road in roads:
road.draw(svg, flinger, road.matcher.border_color, 2)
road.draw(svg, True)
if connectors:
for connector in connectors:
if connector.min_layer == layer:
connector.draw_border(svg)
for connector in connectors:
connector.draw(svg)
for road in roads:
road.draw(svg, flinger, road.matcher.color)
# Draw inner parts.
for road in roads:
road.draw_lanes(svg, flinger, road.matcher.border_color)
road.draw(svg, False)
if connectors:
for connector in connectors:
if connector.max_layer == layer:
connector.draw(svg)
# Draw lane separators.
for road in roads:
road.draw_lanes(svg, road.matcher.border_color)
if draw_captions:
for road in self.roads:
road.draw_caption(svg)

View file

@ -0,0 +1,36 @@
import numpy as np
from colour import Color
from svgwrite import Drawing
from typing import Dict
from map_machine.geometry.flinger import Flinger
from map_machine.osm.osm_reader import Tagged
from map_machine.scheme import Scheme
class Tree(Tagged):
"""Tree on the map."""
def __init__(
self, tags: Dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme) -> None:
"""Draw crown and trunk."""
scale: float = flinger.get_scale(self.coordinates)
radius: float
if diameter_crown := self.get_float("diameter_crown") is not None:
radius = 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 := self.get_float("circumference")) is not None:
radius: float = circumference / 2.0 / np.pi
svg.add(svg.circle(self.point, radius * scale, fill="#B89A74"))

View file

@ -2,32 +2,22 @@
Figures displayed on the map.
"""
from typing import Any, Dict, Iterator, List, Optional
from svgwrite import Drawing
import numpy as np
from colour import Color
from svgwrite import Drawing
from svgwrite.container import Group
from svgwrite.path import Path
from map_machine.direction import DirectionSet, Sector
from map_machine.drawing import PathCommands
from map_machine.flinger import Flinger
from map_machine.osm_reader import OSMNode, Tagged
from map_machine.scheme import LineStyle, Scheme
from map_machine.geometry.flinger import Flinger
from map_machine.osm.osm_reader import OSMNode, Tagged
from map_machine.scheme import LineStyle
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
from map_machine.vector import Polyline
BUILDING_HEIGHT_SCALE: float = 2.5
BUILDING_MINIMAL_HEIGHT: float = 8.0
from map_machine.geometry.vector import Polyline
class Figure(Tagged):
"""
Some figure on the map: way or area.
"""
"""Some figure on the map: way or area."""
def __init__(
self,
@ -37,13 +27,17 @@ class Figure(Tagged):
) -> None:
super().__init__(tags)
if inners and outers:
self.inners: List[List[OSMNode]] = list(map(make_clockwise, inners))
self.outers: List[List[OSMNode]] = list(
map(make_counter_clockwise, outers)
)
else:
self.inners = inners
self.outers = outers
def get_path(
self, flinger: Flinger, offset: np.ndarray = np.array((0, 0))
self, flinger: Flinger, offset: np.ndarray = np.array((0.0, 0.0))
) -> str:
"""
Get SVG path commands.
@ -62,44 +56,6 @@ class Figure(Tagged):
return path
class Building(Figure):
"""
Building on the map.
"""
def __init__(
self,
tags: Dict[str, str],
inners: List[List[OSMNode]],
outers: List[List[OSMNode]],
flinger: Flinger,
scheme: Scheme,
) -> None:
super().__init__(tags, inners, outers)
style: Dict[str, Any] = {
"fill": scheme.get_color("building_color").hex,
"stroke": scheme.get_color("building_border_color").hex,
}
self.line_style: LineStyle = LineStyle(style)
self.parts: List[Segment] = []
for nodes in self.inners + self.outers:
for i in range(len(nodes) - 1):
flung_1: np.ndarray = flinger.fling(nodes[i].coordinates)
flung_2: np.ndarray = flinger.fling(nodes[i + 1].coordinates)
self.parts.append(Segment(flung_1, flung_2))
self.parts = sorted(self.parts)
self.height: float = BUILDING_MINIMAL_HEIGHT
self.min_height: float = 0.0
levels: Optional[str] = self.get_float("building:levels")
if levels:
self.height = float(levels) * BUILDING_HEIGHT_SCALE
levels: Optional[str] = self.get_float("building:min_level")
if levels:
self.min_height = float(levels) * BUILDING_HEIGHT_SCALE
@ -118,83 +74,9 @@ class Building(Figure):
path.update({"stroke-linejoin": "round"})
svg.add(path)
def draw_shade(self, building_shade: Group, flinger: Flinger) -> None:
"""Draw shade casted by the building."""
scale: float = flinger.get_scale() / 3.0
shift_1: np.ndarray = np.array((scale * self.min_height, 0))
shift_2: np.ndarray = np.array((scale * self.height, 0))
commands: str = self.get_path(flinger, shift_1)
path: Path = Path(
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: PathCommands = [
"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 = 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: np.ndarray = np.array((0, -previous_height * scale))
shift_2: np.ndarray = np.array((0, -height * scale))
for segment in self.parts:
fill: Color
if height == 2:
fill = Color("#AAAAAA")
elif height == 4:
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: 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) -> None:
"""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):
"""
Figure with stroke and fill style.
"""
"""Figure with stroke and fill style."""
def __init__(
self,
@ -206,164 +88,32 @@ class StyledFigure(Figure):
super().__init__(tags, inners, outers)
self.line_style: LineStyle = line_style
class Crater(Tagged):
def get_path(
self,
flinger: Flinger,
offset: np.ndarray = np.array((0.0, 0.0)),
) -> str:
"""
Volcano or impact crater on the map.
Get SVG path commands.
:param flinger: converter for geo coordinates
:param offset: offset vector
"""
path: str = ""
def __init__(
self, tags: Dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger) -> None:
"""Draw crater ridge."""
scale: float = flinger.get_scale(self.coordinates)
assert "diameter" in self.tags
radius: float = float(self.tags["diameter"]) / 2.0
radial_gradient = svg.radialGradient(
center=self.point + np.array((0, radius * scale / 7)),
r=radius * scale,
gradientUnits="userSpaceOnUse",
for outer_nodes in self.outers:
commands: str = get_path(
outer_nodes, offset, flinger, self.line_style.parallel_offset
)
color: Color = Color("#000000")
gradient = svg.defs.add(radial_gradient)
(
gradient
.add_stop_color(0, color.hex, opacity=0.2)
.add_stop_color(0.7, color.hex, opacity=0.2)
.add_stop_color(1, color.hex, opacity=1)
) # fmt: skip
circle = svg.circle(
self.point,
radius * scale,
fill=gradient.get_paint_server(),
opacity=0.2,
path += f"{commands} "
for inner_nodes in self.inners:
commands: str = get_path(
inner_nodes, offset, flinger, self.line_style.parallel_offset
)
svg.add(circle)
path += f"{commands} "
class Tree(Tagged):
"""
Tree on the map.
"""
def __init__(
self, tags: Dict[str, str], coordinates: np.ndarray, point: np.ndarray
) -> None:
super().__init__(tags)
self.coordinates: np.ndarray = coordinates
self.point: np.ndarray = point
def draw(self, svg: Drawing, flinger: Flinger, scheme: Scheme) -> None:
"""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: np.ndarray) -> None:
super().__init__(tags)
self.point: np.ndarray = point
def draw(self, svg: Drawing, scheme: Scheme) -> None:
"""Draw gradient sector."""
angle: Optional[float] = None
is_revert_gradient: bool = False
direction: str
direction_radius: float
direction_color: Color
if self.get_tag("man_made") == "surveillance":
direction = self.get_tag("camera:direction")
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 = 50
direction_color = scheme.get_color("direction_camera_color")
elif self.get_tag("traffic_sign") == "stop":
direction = self.get_tag("direction")
direction_radius = 25
direction_color = Color("red")
else:
direction = self.get_tag("direction")
direction_radius = 50
direction_color = scheme.get_color("direction_view_color")
is_revert_gradient = True
if not direction:
return
point: np.ndarray = (self.point.astype(int)).astype(float)
paths: Iterator[PathCommands]
if angle is not None:
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.ndarray, point_2: np.ndarray) -> None:
self.point_1: np.ndarray = point_1
self.point_2: np.ndarray = point_2
difference: np.ndarray = point_2 - point_1
vector: np.ndarray = difference / np.linalg.norm(difference)
self.angle: float = np.arccos(np.dot(vector, np.array((0, 1)))) / np.pi
def __lt__(self, other: "Segment") -> bool:
return (
((self.point_1 + self.point_2) / 2)[1]
< ((other.point_1 + other.point_2) / 2)[1]
) # fmt: skip
return path
def is_clockwise(polygon: List[OSMNode]) -> bool:
@ -372,13 +122,13 @@ def is_clockwise(polygon: List[OSMNode]) -> bool:
:param polygon: list of OpenStreetMap nodes
"""
count: float = 0
count: float = 0.0
for index, node in enumerate(polygon):
next_index: int = 0 if index == len(polygon) - 1 else index + 1
count += (polygon[next_index].coordinates[0] - node.coordinates[0]) * (
polygon[next_index].coordinates[1] + node.coordinates[1]
)
return count >= 0
return count >= 0.0
def make_clockwise(polygon: List[OSMNode]) -> List[OSMNode]:
@ -399,8 +149,13 @@ def make_counter_clockwise(polygon: List[OSMNode]) -> List[OSMNode]:
return polygon if not is_clockwise(polygon) else list(reversed(polygon))
def get_path(nodes: List[OSMNode], shift: np.ndarray, flinger: Flinger) -> str:
def get_path(
nodes: List[OSMNode],
shift: np.ndarray,
flinger: Flinger,
parallel_offset: float = 0.0,
) -> str:
"""Construct SVG path commands from nodes."""
return Polyline(
[flinger.fling(x.coordinates) + shift for x in nodes]
).get_path()
[flinger.fling(node.coordinates) + shift for node in nodes]
).get_path(parallel_offset)

View file

@ -0,0 +1,3 @@
"""
Map geometry: dealing with coordinates, projections.
"""

View file

@ -17,9 +17,7 @@ LONGITUDE_MAX_DIFFERENCE: float = 0.5
@dataclass
class BoundaryBox:
"""
Rectangle that limit space on the map.
"""
"""Rectangle that limit space on the map."""
left: float # Minimum longitude.
bottom: float # Minimum latitude.
@ -97,15 +95,15 @@ class BoundaryBox:
n: float = 2.0 ** (zoom_level + 8.0)
x: int = int((coordinates[1] + 180.0) / 360.0 * n)
left: float = (x - width / 2) / n * 360.0 - 180.0
right: float = (x + width / 2) / n * 360.0 - 180.0
left: float = (x - width / 2.0) / n * 360.0 - 180.0
right: float = (x + width / 2.0) / n * 360.0 - 180.0
y: int = (1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * n
bottom_radians = np.arctan(
np.sinh((1.0 - (y + height / 2) * 2.0 / n) * np.pi)
np.sinh((1.0 - (y + height / 2.0) * 2.0 / n) * np.pi)
)
top_radians = np.arctan(
np.sinh((1.0 - (y - height / 2) * 2.0 / n) * np.pi)
np.sinh((1.0 - (y - height / 2.0) * 2.0 / n) * np.pi)
)
return cls(
@ -123,27 +121,27 @@ class BoundaryBox:
"""Get maximum coordinates."""
return np.array((self.top, self.right))
def get_left_top(self) -> (np.ndarray, np.ndarray):
def get_left_top(self) -> np.ndarray:
"""Get left top corner of the boundary box."""
return self.top, self.left
return np.array((self.top, self.left))
def get_right_bottom(self) -> (np.ndarray, np.ndarray):
def get_right_bottom(self) -> np.ndarray:
"""Get right bottom corner of the boundary box."""
return self.bottom, self.right
return np.array((self.bottom, self.right))
def round(self) -> "BoundaryBox":
"""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
self.top = round(self.top * 1000) / 1000 + 0.001
self.left = round(self.left * 1000.0) / 1000.0 - 0.001
self.bottom = round(self.bottom * 1000.0) / 1000.0 - 0.001
self.right = round(self.right * 1000.0) / 1000.0 + 0.001
self.top = round(self.top * 1000.0) / 1000.0 + 0.001
return self
def center(self) -> np.ndarray:
"""Return center point of boundary box."""
return np.array(
((self.left + self.right) / 2, (self.top + self.bottom) / 2)
((self.top + self.bottom) / 2.0, (self.left + self.right) / 2.0)
)
def get_format(self) -> str:
@ -152,16 +150,23 @@ class BoundaryBox:
<longitude 1>,<latitude 1>,<longitude 2>,<latitude 2>. Coordinates are
rounded to three digits after comma.
"""
left: float = np.floor(self.left * 1000) / 1000
bottom: float = np.floor(self.bottom * 1000) / 1000
right: float = np.ceil(self.right * 1000) / 1000
top: float = np.ceil(self.top * 1000) / 1000
left: float = np.floor(self.left * 1000.0) / 1000.0
bottom: float = np.floor(self.bottom * 1000.0) / 1000.0
right: float = np.ceil(self.right * 1000.0) / 1000.0
top: float = np.ceil(self.top * 1000.0) / 1000.0
return f"{left:.3f},{bottom:.3f},{right:.3f},{top:.3f}"
def update(self, coordinates: np.ndarray) -> None:
"""Make the boundary box cover coordinates."""
self.left = min(self.left, coordinates[1])
self.bottom = min(self.bottom, coordinates[0])
self.right = max(self.right, coordinates[1])
self.top = max(self.top, coordinates[0])
def combine(self, other: "BoundaryBox") -> None:
"""Combine with another boundary box."""
self.left = min(self.left, other.left)
self.right = min(self.right, other.right)
self.bottom = min(self.bottom, other.bottom)
self.top = min(self.top, other.top)
self.right = max(self.right, other.right)
self.top = max(self.top, other.top)

View file

@ -5,7 +5,7 @@ from typing import Optional
import numpy as np
from map_machine.boundary_box import BoundaryBox
from map_machine.geometry.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -20,7 +20,9 @@ def pseudo_mercator(coordinates: np.ndarray) -> np.ndarray:
:return: position on the plane in the form of (x, y)
"""
y: float = (
180 / np.pi * np.log(np.tan(np.pi / 4 + coordinates[0] * np.pi / 360))
180.0
/ np.pi
* np.log(np.tan(np.pi / 4.0 + coordinates[0] * np.pi / 360.0))
)
return np.array((coordinates[1], y))
@ -36,13 +38,11 @@ def osm_zoom_level_to_pixels_per_meter(
function allows any non-negative float value
:param equator_length: celestial body equator length in meters
"""
return 2 ** zoom_level / equator_length * 256
return 2.0**zoom_level / equator_length * 256.0
class Flinger:
"""
Convert geo coordinates into SVG position points.
"""
"""Convert geo coordinates into SVG position points."""
def __init__(
self,
@ -56,7 +56,7 @@ class Flinger:
:param equator_length: celestial body equator length in meters
"""
self.geo_boundaries: BoundaryBox = geo_boundaries
self.ratio: float = 2 ** zoom_level * 256 / 360
self.ratio: float = 2.0**zoom_level * 256.0 / 360.0
self.size: np.ndarray = self.ratio * (
pseudo_mercator(self.geo_boundaries.max_())
- pseudo_mercator(self.geo_boundaries.min_())
@ -92,5 +92,5 @@ class Flinger:
# Get pixels per meter ratio for the center of the boundary box.
coordinates = self.geo_boundaries.center()
scale_factor: float = 1 / np.cos(coordinates[0] / 180 * np.pi)
scale_factor: float = abs(1.0 / np.cos(coordinates[0] / 180.0 * np.pi))
return self.pixels_per_meter * scale_factor

View file

@ -0,0 +1,174 @@
"""
Vector utility.
"""
import numpy as np
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
from typing import List
from shapely.geometry import LineString
def compute_angle(vector: np.ndarray) -> float:
"""
For the given vector compute an angle between it and (1, 0) vector. The
result is in [0, 2π].
"""
if vector[0] == 0.0:
if vector[1] > 0.0:
return np.pi / 2.0
return np.pi + np.pi / 2.0
if vector[0] < 0.0:
return np.arctan(vector[1] / vector[0]) + np.pi
if vector[1] < 0.0:
return np.arctan(vector[1] / vector[0]) + 2.0 * np.pi
return np.arctan(vector[1] / vector[0])
def turn_by_angle(vector: np.ndarray, angle: float) -> np.ndarray:
"""Turn vector by an angle."""
return np.array(
(
vector[0] * np.cos(angle) - vector[1] * np.sin(angle),
vector[0] * np.sin(angle) + vector[1] * np.cos(angle),
)
)
def norm(vector: np.ndarray) -> np.ndarray:
"""Compute vector with the same direction and length 1."""
return vector / np.linalg.norm(vector)
class Polyline:
"""List of connected points."""
def __init__(self, points: List[np.ndarray]) -> None:
self.points: List[np.ndarray] = points
def get_path(self, parallel_offset: float = 0.0) -> str:
"""Construct SVG path commands."""
points: List[np.ndarray]
if np.allclose(parallel_offset, 0.0):
points = self.points
else:
try:
points = (
LineString(self.points)
.parallel_offset(parallel_offset)
.coords
if parallel_offset
else self.points
)
except (ValueError, NotImplementedError):
points = self.points
return (
"M "
+ " L ".join(f"{point[0]},{point[1]}" for point in points)
+ (" Z" if np.allclose(points[0], points[-1]) else "")
)
def shorten(self, index: int, length: float) -> None:
"""Make shorten part specified with index."""
index_2: int = 1 if index == 0 else -2
diff: np.ndarray = self.points[index_2] - self.points[index]
self.points[index] = (
self.points[index] + diff / np.linalg.norm(diff) * length
)
class Line:
"""Infinity line: Ax + By + C = 0."""
def __init__(self, start: np.ndarray, end: np.ndarray) -> None:
# if start.near(end):
# util.error("cannot create line by one point")
self.a: float = start[1] - end[1]
self.b: float = end[0] - start[0]
self.c: float = start[0] * end[1] - end[0] * start[1]
def parallel_shift(self, shift: np.ndarray) -> None:
"""
Shift current vector according with shift.
:param shift: shift vector
"""
self.c -= self.a * shift[0] + self.b * shift[1]
def is_parallel(self, other: "Line") -> bool:
"""If lines are parallel or equal."""
return np.allclose(other.a * self.b - self.a * other.b, 0.0)
def get_intersection_point(self, other: "Line") -> np.ndarray:
"""Get point of intersection current line with other."""
if other.a * self.b - self.a * other.b == 0.0:
return np.array((0.0, 0.0))
x: float = -(self.b * other.c - other.b * self.c) / (
other.a * self.b - self.a * other.b
)
y: float = -(self.a * other.c - other.a * self.c) / (
other.b * self.a - self.b * other.a
)
return np.array((x, y))
def __repr__(self) -> str:
return f"{self.a} * x + {self.b} * y + {self.c} == 0"
class Segment:
"""Closed line segment."""
def __init__(self, point_1: np.ndarray, point_2: np.ndarray) -> None:
self.point_1: np.ndarray = point_1
self.point_2: np.ndarray = point_2
self.y = ((self.point_1 + self.point_2) / 2.0)[1]
difference: np.ndarray = point_2 - point_1
vector: np.ndarray = difference / np.linalg.norm(difference)
if vector[0] > 0:
vector = -vector
self.angle: float = (
np.arccos(np.dot(vector, np.array((0.0, 1.0)))) / np.pi
)
def __repr__(self):
return f"{self.point_1} -- {self.point_2}"
def __lt__(self, other: "Segment") -> bool:
return self.y < other.y
def intersection(self, other: "Segment"):
divisor = (self.point_1[0] - self.point_2[0]) * (
other.point_1[1] - other.point_2[1]
) - (self.point_1[1] - self.point_2[1]) * (
other.point_1[0] - other.point_2[0]
)
if not divisor:
return None
t: float = (
(self.point_1[0] - other.point_1[0])
* (other.point_1[1] - other.point_2[1])
- (self.point_1[1] - other.point_1[1])
* (other.point_1[0] - other.point_2[0])
) / divisor
u: float = (
(self.point_1[0] - other.point_1[0])
* (self.point_1[1] - self.point_2[1])
- (self.point_1[1] - other.point_1[1])
* (self.point_1[0] - self.point_2[0])
) / divisor
if 0 <= t <= 1 and 0 <= u <= 1:
print(t)
return [
self.point_1[0] + t * (self.point_2[0] - self.point_1[0]),
self.point_1[1] + t * (self.point_2[1] - self.point_1[1]),
]
else:
return None

View file

@ -1,6 +1,6 @@
Rontgen icons (c) by Sergey Vartanov
Röntgen icons (c) by Sergey Vartanov
Rontgen icons are licensed under a Creative Commons Attribution 4.0
Röntgen icons are licensed under a Creative Commons Attribution 4.0
International License.
You should have received a copy of the license along with this work. If not,

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before After
Before After

View file

@ -6,7 +6,7 @@ import logging
import sys
from pathlib import Path
from map_machine.ui import parse_arguments
from map_machine.ui.cli import parse_arguments
from map_machine.workspace import Workspace
__author__ = "Sergey Vartanov"
@ -29,22 +29,22 @@ def main() -> None:
elif arguments.command == "render":
from map_machine import mapper
mapper.ui(arguments)
mapper.render_map(arguments)
elif arguments.command == "tile":
from map_machine import tile
from map_machine.slippy import tile
tile.ui(arguments)
tile.generate_tiles(arguments)
elif arguments.command == "icons":
from map_machine.grid import draw_icons
from map_machine.pictogram.icon_collection import draw_icons
draw_icons()
elif arguments.command == "mapcss":
from map_machine import mapcss
mapcss.ui(arguments)
mapcss.generate_mapcss(arguments)
elif arguments.command == "element":
from map_machine.element import draw_element
@ -52,15 +52,17 @@ def main() -> None:
draw_element(arguments)
elif arguments.command == "server":
from map_machine import server
from map_machine.slippy import server
server.ui(arguments)
server.run_server(arguments)
elif arguments.command == "taginfo":
from map_machine.scheme import Scheme
from map_machine.taginfo import write_taginfo_project_file
from map_machine.doc.taginfo import write_taginfo_project_file
write_taginfo_project_file(Scheme(workspace.DEFAULT_SCHEME_PATH))
write_taginfo_project_file(
Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
)
if __name__ == "__main__":

View file

@ -4,51 +4,49 @@ Map drawing configuration.
import argparse
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from colour import Color
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
class DrawingMode(Enum):
"""
Map drawing mode.
"""
"""Map drawing mode."""
NORMAL: str = "normal"
AUTHOR: str = "author"
TIME: str = "time"
NORMAL = "normal"
AUTHOR = "author"
TIME = "time"
WHITE = "white"
BLACK = "black"
class LabelMode(Enum):
"""
Label drawing mode.
"""
"""Label drawing mode."""
NO: str = "no"
MAIN: str = "main"
ALL: str = "all"
NO = "no"
MAIN = "main"
ALL = "all"
ADDRESS = "address"
class BuildingMode(Enum):
"""
Building drawing mode.
"""
"""Building drawing mode."""
NO: str = "no"
FLAT: str = "flat"
ISOMETRIC: str = "isometric"
ISOMETRIC_NO_PARTS: str = "isometric-no-parts"
NO = "no"
FLAT = "flat"
ISOMETRIC = "isometric"
ISOMETRIC_NO_PARTS = "isometric-no-parts"
@dataclass
class MapConfiguration:
"""
Map drawing configuration.
"""
"""Map drawing configuration."""
drawing_mode: str = DrawingMode.NORMAL
building_mode: str = BuildingMode.FLAT
label_mode: str = LabelMode.MAIN
drawing_mode: DrawingMode = DrawingMode.NORMAL
building_mode: BuildingMode = BuildingMode.FLAT
label_mode: LabelMode = LabelMode.MAIN
zoom_level: float = 18.0
overlap: int = 12
level: str = "overground"
@ -57,6 +55,8 @@ class MapConfiguration:
country: str = "world"
ignore_level_matching: bool = False
draw_roofs: bool = True
use_building_colors: bool = False
show_overlapped: bool = False
@classmethod
def from_options(
@ -75,8 +75,16 @@ class MapConfiguration:
options.country,
options.ignore_level_matching,
options.roofs,
options.building_colors,
options.show_overlapped,
)
def is_wireframe(self) -> bool:
"""Whether drawing mode is special."""
return self.drawing_mode != DrawingMode.NORMAL
def background_color(self) -> Optional[Color]:
"""Get background map color based on drawing mode."""
if self.drawing_mode not in (DrawingMode.NORMAL, DrawingMode.BLACK):
return Color("#111111")
return None

View file

@ -9,9 +9,9 @@ from typing import Dict, List, Optional, TextIO
from colour import Color
from map_machine import __project__, __url__
from map_machine.grid import IconCollection
from map_machine.icon import ShapeExtractor
from map_machine.osm_reader import STAGES_OF_DECAY
from map_machine.osm.osm_reader import STAGES_OF_DECAY
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.pictogram.icon_collection import IconCollection
from map_machine.scheme import Matcher, Scheme
from map_machine.workspace import workspace
@ -67,9 +67,7 @@ meta {{
class MapCSSWriter:
"""
Writer that converts Map Machine scheme into MapCSS 0.2 format.
"""
"""Writer that converts Map Machine scheme into MapCSS 0.2 format."""
def __init__(
self,
@ -105,6 +103,10 @@ class MapCSSWriter:
"""
elements: Dict[str, str] = {}
for value in matcher.tags.values():
if value.startswith("^"):
return ""
clean_shapes = matcher.get_clean_shapes()
if clean_shapes:
elements["icon-image"] = (
@ -134,8 +136,8 @@ class MapCSSWriter:
return ""
selector: str = target + matcher.get_mapcss_selector(prefix) + " {\n"
for element in elements:
selector += f" {element}: {elements[element]};\n"
for key, value in elements.items():
selector += f" {key}: {value};\n"
selector += "}\n"
return selector
@ -164,7 +166,7 @@ class MapCSSWriter:
return
for index, stage_of_decay in enumerate(STAGES_OF_DECAY):
opacity: float = 0.6 - 0.4 * index / (len(STAGES_OF_DECAY) - 1)
opacity: float = 0.6 - 0.4 * index / (len(STAGES_OF_DECAY) - 1.0)
for matcher in self.point_matchers:
if len(matcher.tags) > 1:
continue
@ -176,18 +178,19 @@ class MapCSSWriter:
)
def ui(options: argparse.Namespace) -> None:
def generate_mapcss(options: argparse.Namespace) -> None:
"""Write MapCSS 0.2 scheme."""
directory: Path = workspace.get_mapcss_path()
icons_with_outline_path: Path = workspace.get_mapcss_icons_path()
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
collection: IconCollection = IconCollection.from_scheme(scheme, extractor)
collection.draw_icons(
icons_with_outline_path,
workspace.ICONS_LICENSE_PATH,
color=Color("black"),
outline=True,
outline_opacity=0.5,

View file

@ -3,6 +3,7 @@ Simple OpenStreetMap renderer.
"""
import argparse
import logging
import sys
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Set
@ -13,18 +14,21 @@ from svgwrite.container import Group
from svgwrite.path import Path as SVGPath
from svgwrite.shapes import Rect
from map_machine.boundary_box import BoundaryBox
from map_machine.constructor import Constructor
from map_machine.drawing import draw_text
from map_machine.feature.building import Building, draw_walls, BUILDING_SCALE
from map_machine.feature.road import Intersection, Road, RoadPart
from map_machine.figure import StyledFigure
from map_machine.flinger import Flinger
from map_machine.icon import ShapeExtractor
from map_machine.geometry.boundary_box import BoundaryBox
from map_machine.geometry.flinger import Flinger
from map_machine.geometry.vector import Segment
from map_machine.map_configuration import LabelMode, MapConfiguration
from map_machine.osm_getter import NetworkError, get_osm
from map_machine.osm_reader import OSMData, OSMNode
from map_machine.point import Occupied, Point
from map_machine.road import Intersection, Road, RoadPart
from map_machine.osm.osm_getter import NetworkError, get_osm
from map_machine.osm.osm_reader import OSMData, OSMNode
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.pictogram.point import Occupied, Point
from map_machine.scheme import Scheme
from map_machine.ui import BuildingMode, progress_bar
from map_machine.ui.cli import BuildingMode
from map_machine.workspace import workspace
__author__ = "Sergey Vartanov"
@ -32,9 +36,7 @@ __email__ = "me@enzet.ru"
class Map:
"""
Map drawing.
"""
"""Map drawing."""
def __init__(
self,
@ -49,33 +51,32 @@ class Map:
self.configuration = configuration
self.background_color: Color = self.scheme.get_color("background_color")
if self.configuration.is_wireframe():
self.background_color: Color = Color("#111111")
if color := self.configuration.background_color():
self.background_color = color
def draw(self, constructor: Constructor) -> None:
"""Draw map."""
self.svg.add(
Rect((0, 0), self.flinger.size, fill=self.background_color)
Rect((0.0, 0.0), self.flinger.size, fill=self.background_color)
)
ways: List[StyledFigure] = sorted(
constructor.figures, key=lambda x: x.line_style.priority
)
ways_length: int = len(ways)
for index, way in enumerate(ways):
progress_bar(index, ways_length, step=10, text="Drawing ways")
logging.info("Drawing ways...")
for way in ways:
path_commands: str = way.get_path(self.flinger)
if path_commands:
path: SVGPath = SVGPath(d=path_commands)
path.update(way.line_style.style)
self.svg.add(path)
progress_bar(-1, 0, text="Drawing ways")
constructor.roads.draw(self.svg, self.flinger)
for tree in constructor.trees:
tree.draw(self.svg, self.flinger, self.scheme)
for tree in constructor.craters:
tree.draw(self.svg, self.flinger)
for crater in constructor.craters:
crater.draw(self.svg, self.flinger)
self.draw_buildings(constructor)
@ -97,22 +98,16 @@ class Map:
nodes: List[Point] = sorted(
constructor.points, key=lambda x: -x.priority
)
steps: int = len(nodes)
for index, node in enumerate(nodes):
progress_bar(index, steps * 3, step=10, text="Drawing main icons")
logging.info("Drawing main icons...")
for node in nodes:
node.draw_main_shapes(self.svg, occupied)
for index, point in enumerate(nodes):
progress_bar(
steps + index, steps * 3, step=10, text="Drawing extra icons"
)
logging.info("Drawing extra icons...")
for point in nodes:
point.draw_extra_shapes(self.svg, occupied)
for index, point in enumerate(nodes):
progress_bar(
steps * 2 + index, steps * 3, step=10, text="Drawing texts"
)
logging.info("Drawing texts...")
for point in nodes:
if (
not self.configuration.is_wireframe()
and self.configuration.label_mode != LabelMode.NO
@ -121,7 +116,7 @@ class Map:
self.svg, occupied, self.configuration.label_mode
)
progress_bar(-1, len(nodes), step=10, text="Drawing nodes")
self.draw_credits(constructor.flinger.size)
def draw_buildings(self, constructor: Constructor) -> None:
"""Draw buildings: shade, walls, and roof."""
@ -132,21 +127,36 @@ class Map:
building.draw(self.svg, self.flinger)
return
scale: float = self.flinger.get_scale() / 3.0
logging.info("Drawing buildings...")
scale: float = self.flinger.get_scale()
building_shade: Group = Group(opacity=0.1)
for building in constructor.buildings:
building.draw_shade(building_shade, self.flinger)
self.svg.add(building_shade)
previous_height: float = 0
count: int = len(constructor.heights)
for index, height in enumerate(sorted(constructor.heights)):
progress_bar(index, count, step=1, text="Drawing buildings")
fill: Color()
walls: dict[Segment, Building] = {}
for building in constructor.buildings:
if building.height < height or building.min_height > height:
for part in building.parts:
walls[part] = building
sorted_walls = sorted(walls.keys())
previous_height: float = 0.0
for height in sorted(constructor.heights):
shift_1: np.ndarray = np.array(
(0.0, -previous_height * scale * BUILDING_SCALE)
)
shift_2: np.ndarray = np.array(
(0.0, -height * scale * BUILDING_SCALE)
)
for wall in sorted_walls:
building: Building = walls[wall]
if building.height < height or building.min_height >= height:
continue
building.draw_walls(self.svg, height, previous_height, scale)
draw_walls(self.svg, building, wall, height, shift_1, shift_2)
if self.configuration.draw_roofs:
for building in constructor.buildings:
@ -155,16 +165,14 @@ class Map:
previous_height = height
progress_bar(-1, count, step=1, text="Drawing buildings")
def draw_roads(self, roads: Iterator[Road]) -> None:
def draw_simple_roads(self, roads: Iterator[Road]) -> None:
"""Draw road as simple SVG path."""
nodes: Dict[OSMNode, Set[RoadPart]] = {}
for road in roads:
for index in range(len(road.outers[0]) - 1):
node_1: OSMNode = road.outers[0][index]
node_2: OSMNode = road.outers[0][index + 1]
for index in range(len(road.nodes) - 1):
node_1: OSMNode = road.nodes[index]
node_2: OSMNode = road.nodes[index + 1]
point_1: np.ndarray = self.flinger.fling(node_1.coordinates)
point_2: np.ndarray = self.flinger.fling(node_2.coordinates)
scale: float = self.flinger.get_scale(node_1.coordinates)
@ -179,17 +187,43 @@ class Map:
nodes[node_1].add(part_1)
nodes[node_2].add(part_2)
for node in nodes:
parts: Set[RoadPart] = nodes[node]
for node, parts in nodes.items():
if len(parts) < 4:
continue
intersection: Intersection = Intersection(list(parts))
intersection.draw(self.svg, True)
def draw_credits(self, size: np.ndarray):
def ui(arguments: argparse.Namespace) -> None:
for text, point in (
("Data: © OpenStreetMap contributors", np.array((15, 27))),
("Rendering: Map Machine", np.array((15, 15))),
):
for stroke_width, stroke, opacity in (
(3.0, Color("white"), 0.7),
(1.0, None, 1.0),
):
draw_text(
self.svg,
text,
size - point,
10,
Color("#888888"),
anchor="end",
stroke_width=stroke_width,
stroke=stroke,
opacity=opacity,
)
def fatal(message: str) -> None:
logging.fatal(message)
sys.exit(1)
def render_map(arguments: argparse.Namespace) -> None:
"""
Map Machine entry point.
Map rendering entry point.
:param arguments: command-line arguments
"""
@ -199,64 +233,65 @@ def ui(arguments: argparse.Namespace) -> None:
cache_path: Path = Path(arguments.cache)
cache_path.mkdir(parents=True, exist_ok=True)
boundary_box: Optional[BoundaryBox] = None
input_file_names: List[Path] = []
# Compute boundary box
boundary_box: Optional[BoundaryBox] = None
if arguments.input_file_names:
input_file_names = list(map(Path, arguments.input_file_names))
if arguments.boundary_box:
boundary_box = BoundaryBox.from_text(arguments.boundary_box)
else:
if arguments.boundary_box:
boundary_box = BoundaryBox.from_text(arguments.boundary_box)
elif arguments.coordinates and arguments.size:
coordinates: np.ndarray = np.array(
list(map(float, arguments.coordinates.split(",")))
)
width, height = np.array(
list(map(float, arguments.size.split(",")))
)
if len(coordinates) != 2:
fatal("Wrong number of coordinates.")
width, height = np.array(list(map(float, arguments.size.split(","))))
boundary_box = BoundaryBox.from_coordinates(
coordinates, configuration.zoom_level, width, height
)
else:
logging.fatal(
"Specify either --input, or --boundary-box, or --coordinates "
"and --size."
)
exit(1)
# Determine files
if arguments.input_file_names:
input_file_names = list(map(Path, arguments.input_file_names))
elif boundary_box:
try:
cache_file_path: Path = (
cache_path / f"{boundary_box.get_format()}.osm"
)
get_osm(boundary_box, cache_file_path)
input_file_names = [cache_file_path]
except NetworkError as e:
logging.fatal(e.message)
exit(1)
except NetworkError as error:
logging.fatal(error.message)
sys.exit(1)
else:
fatal(
"Specify either --input, or --boundary-box, or --coordinates and "
"--size."
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
min_: np.ndarray
max_: np.ndarray
osm_data: OSMData
# Get OpenStreetMap data
osm_data: OSMData = OSMData()
for input_file_name in input_file_names:
if not input_file_name.is_file():
logging.fatal(f"No such file: {input_file_name}.")
exit(1)
sys.exit(1)
if input_file_name.name.endswith(".json"):
osm_data.parse_overpass(input_file_name)
else:
osm_data.parse_osm_file(input_file_name)
view_box: BoundaryBox = boundary_box if boundary_box else osm_data.view_box
if not boundary_box:
boundary_box = osm_data.view_box
if not boundary_box:
boundary_box = osm_data.boundary_box
# Render
flinger: Flinger = Flinger(
view_box, arguments.zoom, osm_data.equator_length
boundary_box, arguments.zoom, osm_data.equator_length
)
size: np.ndarray = flinger.size
@ -265,6 +300,7 @@ def ui(arguments: argparse.Namespace) -> None:
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
constructor: Constructor = Constructor(
osm_data=osm_data,
flinger=flinger,
@ -274,10 +310,10 @@ def ui(arguments: argparse.Namespace) -> None:
)
constructor.construct()
painter: Map = Map(
map_: Map = Map(
flinger=flinger, svg=svg, scheme=scheme, configuration=configuration
)
painter.draw(constructor)
map_.draw(constructor)
logging.info(f"Writing output SVG to {arguments.output_file_name}...")
with open(arguments.output_file_name, "w", encoding="utf-8") as output_file:

View file

@ -0,0 +1,3 @@
"""
OpenStreetMap-specific things.
"""

View file

@ -9,7 +9,7 @@ from typing import Dict
import urllib3
from map_machine.boundary_box import BoundaryBox
from map_machine.geometry.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -52,7 +52,7 @@ def get_osm(
"Cannot download data: too many nodes (limit is 50000). Try "
"to request smaller area."
)
else:
raise NetworkError("Cannot download data.")
with cache_file_path.open("bw+") as output_file:

View file

@ -13,7 +13,7 @@ from xml.etree.ElementTree import Element
import numpy as np
from map_machine.boundary_box import BoundaryBox
from map_machine.geometry.boundary_box import BoundaryBox
from map_machine.util import MinMax
__author__ = "Sergey Vartanov"
@ -25,6 +25,9 @@ METERS_PATTERN: re.Pattern = re.compile("^(?P<value>\\d*\\.?\\d*)\\s*m$")
KILOMETERS_PATTERN: re.Pattern = re.compile("^(?P<value>\\d*\\.?\\d*)\\s*km$")
MILES_PATTERN: re.Pattern = re.compile("^(?P<value>\\d*\\.?\\d*)\\s*mi$")
EARTH_EQUATOR_LENGTH: float = 40_075_017.0
Tags = Dict[str, str]
# See https://wiki.openstreetmap.org/wiki/Lifecycle_prefix#Stages_of_decay
STAGES_OF_DECAY: List[str] = [
@ -59,11 +62,9 @@ def parse_levels(string: str) -> List[float]:
@dataclass
class Tagged:
"""
Something with tags (string to string mapping).
"""
"""Something with tags (string to string mapping)."""
tags: Dict[str, str]
tags: Tags
def get_tag(self, key: str) -> Optional[str]:
"""
@ -106,6 +107,20 @@ class Tagged:
return None
def verify(self) -> bool:
"""Check key and value types."""
is_well_formed: bool = True
for value, key in self.tags.items():
if not isinstance(key, str):
logging.warning(f"Not string key {key}.")
is_well_formed = False
if not isinstance(value, str):
logging.warning(f"Not string value {value}.")
is_well_formed = False
return is_well_formed
@dataclass
class OSMNode(Tagged):
@ -127,9 +142,9 @@ class OSMNode(Tagged):
def from_xml_structure(cls, element: Element) -> "OSMNode":
"""Parse node from OSM XML `<node>` element."""
attributes = element.attrib
tags: Dict[str, str] = dict(
[(x.attrib["k"], x.attrib["v"]) for x in element if x.tag == "tag"]
)
tags: Tags = {
x.attrib["k"]: x.attrib["v"] for x in element if x.tag == "tag"
}
return cls(
tags,
int(attributes["id"]),
@ -156,6 +171,14 @@ class OSMNode(Tagged):
coordinates=np.array((structure["lat"], structure["lon"])),
)
def get_boundary_box(self) -> BoundaryBox:
return BoundaryBox(
self.coordinates[1],
self.coordinates[0],
self.coordinates[1],
self.coordinates[0],
)
def __hash__(self) -> int:
return self.id_
@ -182,9 +205,9 @@ class OSMWay(Tagged):
) -> "OSMWay":
"""Parse way from OSM XML `<way>` element."""
attributes = element.attrib
tags: Dict[str, str] = dict(
[(x.attrib["k"], x.attrib["v"]) for x in element if x.tag == "tag"]
)
tags: Tags = {
x.attrib["k"]: x.attrib["v"] for x in element if x.tag == "tag"
}
return cls(
tags,
int(element.attrib["id"]),
@ -221,12 +244,13 @@ class OSMWay(Tagged):
def __repr__(self) -> str:
return f"Way <{self.id_}> {self.nodes}"
def __hash__(self) -> int:
return self.id_
@dataclass
class OSMMember:
"""
Member of OpenStreetMap relation.
"""
"""Member of OpenStreetMap relation."""
type_: str
ref: int
@ -254,7 +278,7 @@ class OSMRelation(Tagged):
"""Parse relation from OSM XML `<relation>` element."""
attributes = element.attrib
members: List[OSMMember] = []
tags: Dict[str, str] = {}
tags: Tags = {}
for subelement in element:
if subelement.tag == "member":
subattributes = subelement.attrib
@ -298,17 +322,11 @@ class OSMRelation(Tagged):
class NotWellFormedOSMDataException(Exception):
"""
OSM data structure is not well-formed.
"""
pass
"""OSM data structure is not well-formed."""
class OSMData:
"""
The whole OpenStreetMap information about nodes, ways, and relations.
"""
"""The whole OpenStreetMap information about nodes, ways, and relations."""
def __init__(self) -> None:
self.nodes: Dict[int, OSMNode] = {}
@ -319,7 +337,8 @@ class OSMData:
self.levels: Set[float] = set()
self.time: MinMax = MinMax()
self.view_box: Optional[BoundaryBox] = None
self.equator_length: float = 40_075_017.0
self.boundary_box: Optional[BoundaryBox] = None
self.equator_length: float = EARTH_EQUATOR_LENGTH
def add_node(self, node: OSMNode) -> None:
"""Add node and update map parameters."""
@ -334,6 +353,10 @@ class OSMData:
self.levels.union(parse_levels(node.tags["level"]))
self.time.update(node.timestamp)
if not self.boundary_box:
self.boundary_box = node.get_boundary_box()
self.boundary_box.update(node.coordinates)
def add_way(self, way: OSMWay) -> None:
"""Add way and update map parameters."""
if way.id_ in self.ways:
@ -345,6 +368,7 @@ class OSMData:
self.authors.add(way.user)
if way.tags.get("level"):
self.levels.union(parse_levels(way.tags["level"]))
if way.timestamp:
self.time.update(way.timestamp)
def add_relation(self, relation: OSMRelation) -> None:
@ -372,6 +396,14 @@ class OSMData:
node = OSMNode.parse_from_structure(element)
node_map[node.id_] = node
self.add_node(node)
if not self.view_box:
self.view_box = BoundaryBox(
node.coordinates[1],
node.coordinates[0],
node.coordinates[1],
node.coordinates[0],
)
self.view_box.update(node.coordinates)
for element in structure["elements"]:
if element["type"] == "way":

View file

@ -0,0 +1,3 @@
"""
Icons and points.
"""

View file

@ -23,7 +23,6 @@ from map_machine.color import is_bright
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
DEFAULT_COLOR: Color = Color("#444444")
DEFAULT_SHAPE_ID: str = "default"
DEFAULT_SMALL_SHAPE_ID: str = "default_small"
@ -38,18 +37,43 @@ GRID_STEP: int = 16
@dataclass
class Shape:
"""
SVG icon path description.
"""
"""SVG icon path description."""
path: str # SVG icon path
offset: np.ndarray # vector that should be used to shift the path
id_: str # shape identifier
name: Optional[str] = None # icon description
# String representation of SVG path commands.
path: str
# Vector that should be used to shift the path.
offset: np.ndarray
# Shape unique string identifier, e.g. `tree`.
id_: str
# Shape human-readable description.
name: Optional[str] = None
# If value is `None`, shape doesn't have distinct direction or its
# direction doesn't make sense. Shape is directed to the right if value is
# `True` and to the left if value is `False`.
#
# E.g. CCTV camera shape has direction and may be flipped horizontally to
# follow surveillance direction, whereas car shape has direction but
# flipping icon doesn't make any sense.
is_right_directed: Optional[bool] = None
# Set of emojis that represent the same entity. E.g. 🍐 (pear) for `pear`;
# 🍏 (green apple) and 🍎 (red apple) for `apple`.
emojis: Set[str] = field(default_factory=set)
# If shape is used only as a part of other icons.
is_part: bool = False
# Hierarchical icon group. Is used for icon sorting.
group: str = ""
# Icon categories that is used in OpenStreetMap wiki. E.g. `barrier` means
# https://wiki.openstreetmap.org/wiki/Category:Barrier_icons.
categories: Set[str] = field(default_factory=set)
@classmethod
def from_structure(
cls,
@ -70,6 +94,9 @@ class Shape:
"""
shape: "Shape" = cls(path, offset, id_, name)
if "name" in structure:
shape.name = structure["name"]
if "directed" in structure:
if structure["directed"] == "right":
shape.is_right_directed = True
@ -83,6 +110,12 @@ class Shape:
if "is_part" in structure:
shape.is_part = structure["is_part"]
if "group" in structure:
shape.group = structure["group"]
if "categories" in structure:
shape.categories = set(structure["categories"])
return shape
def is_default(self) -> bool:
@ -95,8 +128,8 @@ class Shape:
def get_path(
self,
point: np.ndarray,
offset: np.ndarray = np.array((0, 0)),
scale: np.ndarray = np.array((1, 1)),
offset: np.ndarray = np.array((0.0, 0.0)),
scale: np.ndarray = np.array((1.0, 1.0)),
) -> SVGPath:
"""
Draw icon into SVG file.
@ -110,7 +143,7 @@ class Shape:
transformations.append(f"translate({shift[0]},{shift[1]})")
if not np.allclose(scale, np.array((1, 1))):
if not np.allclose(scale, np.array((1.0, 1.0))):
transformations.append(f"scale({scale[0]},{scale[1]})")
transformations.append(f"translate({self.offset[0]},{self.offset[1]})")
@ -119,6 +152,10 @@ class Shape:
d=self.path, transform=" ".join(transformations)
)
def get_full_id(self) -> str:
"""Compute full shape identifier with group for sorting."""
return self.group + "_" + self.id_
def parse_length(text: str) -> float:
"""Parse length from SVG attribute."""
@ -139,8 +176,12 @@ def verify_sketch_element(element: Element, id_: str) -> bool:
return True
style: Dict[str, str] = dict(
[x.split(":") for x in element.attrib["style"].split(";")]
(x.split(":")[0], x.split(":")[1])
for x in element.attrib["style"].split(";")
)
# Sketch stroke element (black 0.1 px stroke, no fill).
if (
style["fill"] == "none"
and style["stroke"] == "#000000"
@ -149,6 +190,8 @@ def verify_sketch_element(element: Element, id_: str) -> bool:
):
return True
# Sketch fill element (black fill, no stroke, 20% opacity).
if (
style["fill"] == "none"
and style["stroke"] == "#000000"
@ -157,7 +200,13 @@ def verify_sketch_element(element: Element, id_: str) -> bool:
):
return True
if style["fill"] == "#0000ff" and style["stroke"] == "none":
# Experimental shape (blue fill, no stroke).
if (
style["fill"] == "#0000ff"
and "stroke" in style
and style["stroke"] == "none"
):
return True
if style and not id_.startswith("use"):
@ -166,6 +215,43 @@ def verify_sketch_element(element: Element, id_: str) -> bool:
return True
def parse_configuration(root: dict, configuration: dict, group: str) -> None:
"""
Shape description is a probably empty dictionary with optional fields
`name`, `emoji`, `is_part`, `directed`, and `categories`. Shape
configuration is a dictionary that contains shape descriptions. Shape
descriptions may be grouped and the nesting level may be arbitrary:
{
<shape id>: {<shape description>},
<shape id>: {<shape description>},
<group>: {
<shape id>: {<shape description>},
<shape id>: {<shape description>}
},
<group>: {
<subgroup>: {
<shape id>: {<shape description>},
<shape id>: {<shape description>}
}
}
}
"""
for key, value in root.items():
if (
not value
or "name" in value
or "emoji" in value
or "is_part" in value
or "directed" in value
or "categories" in value
):
configuration[key] = value
configuration[key]["group"] = group
else:
parse_configuration(value, configuration, f"{group}_{key}")
class ShapeExtractor:
"""
Extract shapes from SVG file.
@ -179,14 +265,26 @@ class ShapeExtractor:
"""
:param svg_file_name: input SVG file name with icons. File may contain
any other irrelevant graphics.
:param configuration_file_name: JSON file with grouped shape
descriptions
"""
self.shapes: Dict[str, Shape] = {}
self.configuration: Dict[str, Any] = json.load(
configuration_file_name.open(encoding="utf-8")
self.configuration: Dict[str, Any] = {}
parse_configuration(
json.load(configuration_file_name.open(encoding="utf-8")),
self.configuration,
"root",
)
root: Element = ElementTree.parse(svg_file_name).getroot()
self.parse(root)
for shape_id in self.configuration:
if shape_id not in self.shapes:
logging.warning(
f"Configuration for unknown shape `{shape_id}`."
)
def parse(self, node: Element) -> None:
"""
Extract icon paths into a map.
@ -218,7 +316,7 @@ class ShapeExtractor:
def get_offset(value: str) -> float:
"""Get negated icon offset from the origin."""
return (
-int(float(value) / GRID_STEP) * GRID_STEP - GRID_STEP / 2
-int(float(value) / GRID_STEP) * GRID_STEP - GRID_STEP / 2.0
)
point: np.ndarray = np.array(
@ -229,9 +327,15 @@ class ShapeExtractor:
name = child_node.text
break
configuration: Dict[str, Any] = (
self.configuration[id_] if id_ in self.configuration else {}
)
configuration: Dict[str, Any] = {}
if id_ in self.configuration:
configuration = self.configuration[id_]
if "name" not in configuration:
logging.warning(f"Shape `{id_}` doesn't have name.")
else:
logging.warning(f"Shape `{id_}` doesn't have configuration.")
self.shapes[id_] = Shape.from_structure(
configuration, path, point, id_, name
)
@ -252,13 +356,11 @@ class ShapeExtractor:
@dataclass
class ShapeSpecification:
"""
Specification for shape as a part of an icon.
"""
"""Specification for shape as a part of an icon."""
shape: Shape
color: Color = DEFAULT_COLOR
offset: np.ndarray = np.array((0, 0))
color: Color
offset: np.ndarray = np.array((0.0, 0.0))
flip_horizontally: bool = False
flip_vertically: bool = False
use_outline: bool = True
@ -274,6 +376,7 @@ class ShapeSpecification:
tags: Dict[str, Any] = None,
outline: bool = False,
outline_opacity: float = 1.0,
scale: float = 1.0,
) -> None:
"""
Draw icon shape into SVG file.
@ -284,15 +387,18 @@ class ShapeSpecification:
displayed, this argument should be None
:param outline: draw outline for the shape
:param outline_opacity: opacity of the outline
:param scale: scale icon by the magnitude
"""
scale: np.ndarray = np.array((1, 1))
scale_vector: np.ndarray = np.array((scale, scale))
if self.flip_vertically:
scale = np.array((1, -1))
scale_vector = np.array((scale, -scale))
if self.flip_horizontally:
scale = np.array((-1, 1))
scale_vector = np.array((-scale, scale))
point: np.ndarray = np.array(list(map(int, point)))
path: SVGPath = self.shape.get_path(point, self.offset, scale)
path: SVGPath = self.shape.get_path(
point, self.offset * scale, scale_vector
)
path.update({"fill": self.color.hex})
if outline and self.use_outline:
@ -326,9 +432,7 @@ class ShapeSpecification:
@dataclass
class Icon:
"""
Icon that consists of (probably) multiple shapes.
"""
"""Icon that consists of (probably) multiple shapes."""
shape_specifications: List[ShapeSpecification]
opacity: float = 1.0
@ -337,6 +441,14 @@ class Icon:
"""Get all shape identifiers in the icon."""
return [x.shape.id_ for x in self.shape_specifications]
def has_names(self) -> bool:
"""Check whether oll shape names are known."""
for specification in self.shape_specifications:
if not specification.shape.name:
return False
return True
def get_names(self) -> List[str]:
"""Get all shape names in the icon."""
return [
@ -344,12 +456,39 @@ class Icon:
for x in self.shape_specifications
]
def get_name(self) -> str:
"""Get combined human-readable icon name."""
names: List[str] = self.get_names()
if len(names) == 1:
return names[0]
return ", ".join(names[:-1]) + " and " + names[-1]
def has_categories(self) -> bool:
"""Check whether oll shape categories are known."""
for specification in self.shape_specifications:
if specification.shape.categories:
return True
return False
def get_categories(self) -> Set[str]:
"""Get all shape names in the icon."""
result: Set[str] = set()
for specification in self.shape_specifications:
result = result.union(specification.shape.categories)
return result
def draw(
self,
svg: svgwrite.Drawing,
point: np.ndarray,
tags: Dict[str, Any] = None,
outline: bool = False,
scale: float = 1.0,
) -> None:
"""
Draw icon to SVG.
@ -358,18 +497,21 @@ class Icon:
:param point: 2D position of the icon centre
:param tags: tags to be displayed as a tooltip
:param outline: draw outline for the icon
:param scale: scale icon by the magnitude
"""
if outline:
bright: bool = is_bright(self.shape_specifications[0].color)
opacity: float = 0.7 if bright else 0.5
outline_group: Group = Group(opacity=opacity)
for shape_specification in self.shape_specifications:
shape_specification.draw(outline_group, point, tags, True)
shape_specification.draw(
outline_group, point, tags, True, scale=scale
)
svg.add(outline_group)
else:
group: Group = Group(opacity=self.opacity)
for shape_specification in self.shape_specifications:
shape_specification.draw(group, point, tags)
shape_specification.draw(group, point, tags, scale=scale)
svg.add(group)
def draw_to_file(
@ -394,7 +536,7 @@ class Icon:
shape_specification.color = color
shape_specification.draw(
svg,
np.array((8, 8)),
np.array((8.0, 8.0)),
outline=outline,
outline_opacity=outline_opacity,
)
@ -402,14 +544,17 @@ class Icon:
for shape_specification in self.shape_specifications:
if color:
shape_specification.color = color
shape_specification.draw(svg, np.array((8, 8)))
shape_specification.draw(svg, np.array((8.0, 8.0)))
with file_name.open("w", encoding="utf-8") as output_file:
svg.write(output_file)
def is_default(self) -> bool:
"""Check whether first shape is default."""
return self.shape_specifications[0].is_default()
return (
len(self.shape_specifications) == 1
and self.shape_specifications[0].is_default()
)
def recolor(self, color: Color, white: Optional[Color] = None) -> None:
"""Paint all shapes in the color."""
@ -432,19 +577,21 @@ class Icon:
def __lt__(self, other: "Icon") -> bool:
return "".join(
[x.shape.id_ for x in self.shape_specifications]
) < "".join([x.shape.id_ for x in other.shape_specifications])
[x.shape.get_full_id() for x in self.shape_specifications]
) < "".join([x.shape.get_full_id() for x in other.shape_specifications])
@dataclass
class IconSet:
"""
Node representation: icons and color.
"""
"""Node representation: icons and color."""
main_icon: Icon
extra_icons: List[Icon]
# Icon to use if the point is hidden by overlapped icons but still need to
# be shown.
default_icon: Optional[Icon]
# Tag keys that were processed to create icon set (other tag keys should be
# displayed by text or ignored)
processed: Set[str]

View file

@ -2,6 +2,7 @@
Icon grid drawing.
"""
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Set, Union
@ -9,9 +10,13 @@ from typing import Dict, List, Optional, Set, Union
import numpy as np
from colour import Color
from svgwrite import Drawing
from svgwrite.shapes import Rect
from map_machine.icon import Icon, Shape, ShapeExtractor, ShapeSpecification
from map_machine.pictogram.icon import (
Icon,
Shape,
ShapeExtractor,
ShapeSpecification,
)
from map_machine.scheme import NodeMatcher, Scheme
from map_machine.workspace import workspace
@ -21,9 +26,7 @@ __email__ = "me@enzet.ru"
@dataclass
class IconCollection:
"""
Collection of icons.
"""
"""Collection of icons."""
icons: List[Icon]
@ -38,7 +41,10 @@ class IconCollection:
add_all: bool = False,
) -> "IconCollection":
"""
Collect all possible icon combinations in grid.
Collect all possible icon combinations.
This collection won't contain icons for tags matched with regular
expressions. E.g. traffic_sign=maxspeed; maxspeed=42.
:param scheme: tag specification
:param extractor: shape extractor for icon creation
@ -50,57 +56,51 @@ class IconCollection:
"""
icons: List[Icon] = []
def add() -> Icon:
def add(current_set: List[Dict[str, str]]) -> None:
"""Construct icon and add it to the list."""
specifications: List[ShapeSpecification] = [
scheme.get_shape_specification(x, extractor)
for x in current_set
]
specifications: List[ShapeSpecification] = []
for shape_specification in current_set:
if "#" in shape_specification["shape"]:
return
specifications.append(
scheme.get_shape_specification(
shape_specification, extractor
)
)
constructed_icon: Icon = Icon(specifications)
constructed_icon.recolor(color, white=background_color)
if constructed_icon not in icons:
icons.append(constructed_icon)
return constructed_icon
current_set: List[Union[str, Dict[str, str]]]
for matcher in scheme.node_matchers:
matcher: NodeMatcher
if matcher.shapes:
current_set = matcher.shapes
add()
add(matcher.shapes)
if matcher.add_shapes:
current_set = matcher.add_shapes
add()
add(matcher.add_shapes)
if not matcher.over_icon:
continue
if matcher.under_icon:
for icon_id in matcher.under_icon:
current_set = [icon_id] + matcher.over_icon
add()
add([icon_id] + matcher.over_icon)
if not (matcher.under_icon and matcher.with_icon):
continue
for icon_id in matcher.under_icon:
for icon_2_id in matcher.with_icon:
current_set: List[str] = (
[icon_id] + [icon_2_id] + matcher.over_icon
)
add()
add([icon_id] + [icon_2_id] + matcher.over_icon)
for icon_2_id in matcher.with_icon:
for icon_3_id in matcher.with_icon:
current_set = (
[icon_id]
+ [icon_2_id]
+ [icon_3_id]
+ matcher.over_icon
)
if (
icon_2_id != icon_3_id
and icon_2_id != icon_id
and icon_3_id != icon_id
):
add()
add(
[icon_id]
+ [icon_2_id]
+ [icon_3_id]
+ matcher.over_icon
)
specified_ids: Set[str] = set()
@ -112,15 +112,15 @@ class IconCollection:
shape: Shape = extractor.get_shape(shape_id)
if shape.is_part:
continue
icon: Icon = Icon([ShapeSpecification(shape)])
icon.recolor(color)
icon: Icon = Icon([ShapeSpecification(shape, color)])
icon.recolor(color, white=background_color)
icons.append(icon)
if add_all:
for shape_id in extractor.shapes.keys():
shape: Shape = extractor.get_shape(shape_id)
icon: Icon = Icon([ShapeSpecification(shape)])
icon.recolor(color)
icon: Icon = Icon([ShapeSpecification(shape, color)])
icon.recolor(color, white=background_color)
icons.append(icon)
return cls(icons)
@ -128,6 +128,7 @@ class IconCollection:
def draw_icons(
self,
output_directory: Path,
license_path: Path,
by_name: bool = False,
color: Optional[Color] = None,
outline: bool = False,
@ -136,6 +137,7 @@ class IconCollection:
"""
:param output_directory: path to the directory to store individual SVG
files for icons
:param license_path: path to the file with license
:param by_name: use names instead of identifiers
:param color: fill color
:param outline: if true, draw outline beneath the icon
@ -145,7 +147,7 @@ class IconCollection:
def get_file_name(x: Icon) -> str:
"""Generate human-readable file name."""
return f"Röntgen {' + '.join(x.get_names())}.svg"
return f"Röntgen {x.get_name()}.svg"
else:
@ -161,12 +163,15 @@ class IconCollection:
outline_opacity=outline_opacity,
)
shutil.copy(license_path, output_directory / "LICENSE")
def draw_grid(
self,
file_name: Path,
columns: int = 16,
step: float = 24,
background_color: Color = Color("white"),
step: float = 24.0,
background_color: Optional[Color] = Color("white"),
scale: float = 1.0,
) -> None:
"""
Draw icons in the form of table.
@ -175,26 +180,25 @@ class IconCollection:
:param columns: number of columns in grid
:param step: horizontal and vertical distance between icons in grid
:param background_color: background color
:param scale: scale icon by the magnitude
"""
point: np.ndarray = np.array((step / 2, step / 2))
width: float = step * columns
point: np.ndarray = np.array((step / 2.0 * scale, step / 2.0 * scale))
width: float = step * columns * scale
height: int = int(int(len(self.icons) / (width / step) + 1) * step)
height: int = int(int(len(self.icons) / columns + 1.0) * step * scale)
svg: Drawing = Drawing(str(file_name), (width, height))
svg.add(svg.rect((0, 0), (width, height), fill=background_color.hex))
if background_color is not None:
svg.add(
svg.rect((0, 0), (width, height), fill=background_color.hex)
)
for icon in self.icons:
icon: Icon
rectangle: Rect = svg.rect(
point - np.array((10, 10)), (20, 20), fill=background_color.hex
)
svg.add(rectangle)
icon.draw(svg, point)
point += np.array((step, 0))
if point[0] > width - 8:
point[0] = step / 2
point += np.array((0, step))
height += step
icon.draw(svg, point, scale=scale)
point += np.array((step * scale, 0.0))
if point[0] > width - 8.0:
point[0] = step / 2.0 * scale
point += np.array((0.0, step * scale))
height += step * scale
with file_name.open("w", encoding="utf-8") as output_file:
svg.write(output_file)
@ -212,21 +216,35 @@ def draw_icons() -> None:
Draw all possible icon shapes combinations as grid in one SVG file and as
individual SVG files.
"""
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
collection: IconCollection = IconCollection.from_scheme(
scheme, extractor, add_all=True
)
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}.")
collection: IconCollection = IconCollection.from_scheme(scheme, extractor)
collection.sort()
# Draw individual icons.
icons_by_id_path: Path = workspace.get_icons_by_id_path()
collection.draw_icons(icons_by_id_path, workspace.ICONS_LICENSE_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)
collection.draw_icons(
icons_by_name_path, workspace.ICONS_LICENSE_PATH, by_name=True
)
logging.info(
f"Icons are written to {icons_by_name_path} and {icons_by_id_path}."
)
# Draw grid.
for icon in collection.icons:
icon.recolor(Color("#444444"))
for path, scale in (
(workspace.get_icon_grid_path(), 1.0),
(workspace.GRID_PATH, 2.0),
):
collection.draw_grid(path, scale=scale)
logging.info(f"Icon grid is written to {path}.")

View file

@ -1,22 +1,22 @@
"""
Point: node representation on the map.
"""
import logging
from typing import Dict, List, Optional, Set
import numpy as np
import svgwrite
from colour import Color
from map_machine.icon import Icon, IconSet
from map_machine.drawing import draw_text
from map_machine.map_configuration import LabelMode
from map_machine.osm_reader import Tagged
from map_machine.osm.osm_reader import Tagged
from map_machine.pictogram.icon import Icon, IconSet
from map_machine.text import Label
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
DEFAULT_FONT: str = "Roboto"
class Occupied:
"""
@ -26,19 +26,28 @@ class Occupied:
def __init__(self, width: int, height: int, overlap: int) -> None:
self.matrix = np.full((int(width), int(height)), False, dtype=bool)
try:
self.matrix = np.full((int(width), int(height)), False, dtype=bool)
except Exception:
logging.fatal(
"Failed to allocate a matrix required by overlap algorithm. "
"Try to use smallest area or try --overlap=0 options."
)
exit(1)
self.width: float = width
self.height: float = height
self.overlap: int = overlap
def check(self, point: np.ndarray) -> bool:
"""Check whether point is already occupied by other elements."""
if 0 <= point[0] < self.width and 0 <= point[1] < self.height:
if 0.0 <= point[0] < self.width and 0.0 <= point[1] < self.height:
return self.matrix[point[0], point[1]]
return True
def register(self, point: np.ndarray) -> None:
"""Register that point is occupied by an element."""
if 0 <= point[0] < self.width and 0 <= point[1] < self.height:
if 0.0 <= point[0] < self.width and 0.0 <= point[1] < self.height:
self.matrix[point[0], point[1]] = True
assert self.matrix[point[0], point[1]]
@ -57,7 +66,7 @@ class Point(Tagged):
tags: Dict[str, str],
processed: Set[str],
point: np.ndarray,
priority: float = 0,
priority: float = 0.0,
is_for_node: bool = True,
draw_outline: bool = True,
add_tooltips: bool = False,
@ -71,12 +80,12 @@ class Point(Tagged):
self.processed: Set[str] = processed
self.point: np.ndarray = point
self.priority: float = priority
self.layer: float = 0
self.layer: float = 0.0
self.is_for_node: bool = is_for_node
self.draw_outline: bool = draw_outline
self.add_tooltips: bool = add_tooltips
self.y = 0
self.y: float = 0.0
self.main_icon_painted: bool = False
def draw_main_shapes(
@ -91,15 +100,20 @@ class Point(Tagged):
):
return
position: np.ndarray = self.point + np.array((0, self.y))
position: np.ndarray = self.point + np.array((0.0, self.y))
tags: Optional[Dict[str, str]] = (
self.tags if self.add_tooltips else None
)
self.main_icon_painted: bool = self.draw_point_shape(
svg, self.icon_set.main_icon, position, occupied, tags=tags
svg,
self.icon_set.main_icon,
self.icon_set.default_icon,
position,
occupied,
tags=tags,
)
if self.main_icon_painted:
self.y += 16
self.y += 16.0
def draw_extra_shapes(
self, svg: svgwrite.Drawing, occupied: Optional[Occupied] = None
@ -110,7 +124,7 @@ class Point(Tagged):
is_place_for_extra: bool = True
if occupied:
left: float = -(len(self.icon_set.extra_icons) - 1) * 8
left: float = -(len(self.icon_set.extra_icons) - 1.0) * 8.0
for _ in self.icon_set.extra_icons:
point: np.ndarray = np.array(
(int(self.point[0] + left), int(self.point[1] + self.y))
@ -118,38 +132,46 @@ class Point(Tagged):
if occupied.check(point):
is_place_for_extra = False
break
left += 16
left += 16.0
if is_place_for_extra:
left: float = -(len(self.icon_set.extra_icons) - 1) * 8
left: float = -(len(self.icon_set.extra_icons) - 1.0) * 8.0
for icon in self.icon_set.extra_icons:
point: np.ndarray = self.point + np.array((left, self.y))
self.draw_point_shape(svg, icon, point, occupied=occupied)
left += 16
self.draw_point_shape(svg, icon, None, point, occupied=occupied)
left += 16.0
if self.icon_set.extra_icons:
self.y += 16
self.y += 16.0
def draw_point_shape(
self,
svg: svgwrite.Drawing,
icon: Icon,
default_icon: Optional[Icon],
position: np.ndarray,
occupied: Occupied,
occupied: Optional[Occupied],
tags: Optional[Dict[str, str]] = None,
) -> bool:
"""Draw one combined icon and its outline."""
# Down-cast floats to integers to make icons pixel-perfect.
position: np.ndarray = np.array((int(position[0]), int(position[1])))
icon_to_draw: Icon = icon
is_painted: bool = True
if occupied and occupied.check(position):
if default_icon:
icon_to_draw = default_icon
is_painted = False
else:
return False
if self.draw_outline:
icon.draw(svg, position, outline=True)
icon_to_draw.draw(svg, position, outline=True)
icon.draw(svg, position, tags=tags)
icon_to_draw.draw(svg, position, tags=tags)
if occupied:
if occupied and is_painted:
overlap: int = occupied.overlap
for i in range(-overlap, overlap):
for j in range(-overlap, overlap):
@ -157,13 +179,13 @@ class Point(Tagged):
np.array((position[0] + i, position[1] + j))
)
return True
return is_painted
def draw_texts(
self,
svg: svgwrite.Drawing,
occupied: Optional[Occupied] = None,
label_mode: str = LabelMode.MAIN,
label_mode: LabelMode = LabelMode.MAIN,
) -> None:
"""Draw all labels."""
labels: List[Label]
@ -180,9 +202,15 @@ class Point(Tagged):
text = text.replace("&quot;", '"')
text = text.replace("&amp;", "&")
text = text[:26] + ("..." if len(text) > 26 else "")
point = self.point + np.array((0, self.y + 2))
point = self.point + np.array((0.0, self.y + 2.0))
self.draw_text(
svg, text, point, occupied, label.fill, size=label.size
svg,
text,
point,
occupied,
label.fill,
label.size,
label.out_fill,
)
def draw_text(
@ -192,8 +220,8 @@ class Point(Tagged):
point: np.ndarray,
occupied: Optional[Occupied],
fill: Color,
size: float = 10.0,
out_fill: Color = Color("white"),
size: float,
out_fill: Color,
out_opacity: float = 0.5,
out_fill_2: Optional[Color] = None,
out_opacity_2: float = 1.0,
@ -212,9 +240,9 @@ class Point(Tagged):
if occupied:
is_occupied: bool = False
for i in range(-int(length / 2), int(length / 2)):
for i in range(-int(length / 2.0), int(length / 2.0)):
text_position: np.ndarray = np.array(
(int(point[0] + i), int(point[1] - 4))
(int(point[0] + i), int(point[1] - 4.0))
)
if occupied.check(text_position):
is_occupied = True
@ -223,7 +251,7 @@ class Point(Tagged):
if is_occupied:
return
for i in range(-int(length / 2), int(length / 2)):
for i in range(-int(length / 2.0), int(length / 2.0)):
for j in range(-12, 5):
occupied.register(
np.array((int(point[0] + i), int(point[1] + j)))
@ -232,26 +260,28 @@ class Point(Tagged):
svg.add(svg.rect((point[0] + i, point[1] + j), (1, 1)))
if out_fill_2:
text_element = svg.text(
text, point, font_size=size, text_anchor="middle",
font_family=DEFAULT_FONT, fill=out_fill_2.hex,
stroke_linejoin="round", stroke_width=5, stroke=out_fill_2.hex,
opacity=out_opacity_2
) # fmt: skip
svg.add(text_element)
draw_text(
svg,
text,
point,
size,
fill=out_fill_2,
stroke_width=5.0,
stroke=out_fill_2,
opacity=out_opacity_2,
)
if out_fill:
text_element = svg.text(
text, point, font_size=size, text_anchor="middle",
font_family=DEFAULT_FONT, fill=out_fill.hex,
stroke_linejoin="round", stroke_width=3, stroke=out_fill.hex,
draw_text(
svg,
text,
point,
size,
fill,
stroke_width=3.0,
stroke=out_fill,
opacity=out_opacity,
) # fmt: skip
svg.add(text_element)
text_element = svg.text(
text, point, font_size=size, text_anchor="middle",
font_family=DEFAULT_FONT, fill=fill.hex,
) # fmt: skip
svg.add(text_element)
)
draw_text(svg, text, point, size, fill)
self.y += 11
@ -264,7 +294,9 @@ class Point(Tagged):
width: int = icon_size * (
1 + max(2, len(self.icon_set.extra_icons) - 1)
)
height: int = icon_size * (1 + int(len(self.icon_set.extra_icons) / 3))
height: int = icon_size * (
1 + np.ceil(len(self.icon_set.extra_icons) / 3.0)
)
if len(self.labels):
height += 4 + 11 * len(self.labels)
return np.array((width, height))

View file

@ -2,6 +2,7 @@
Map Machine drawing scheme.
"""
import logging
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@ -11,18 +12,18 @@ import numpy as np
import yaml
from colour import Color
from map_machine.direction import DirectionSet
from map_machine.icon import (
DEFAULT_COLOR,
from map_machine.feature.direction import DirectionSet
from map_machine.map_configuration import MapConfiguration
from map_machine.osm.osm_reader import Tagged, Tags
from map_machine.pictogram.icon import (
DEFAULT_SHAPE_ID,
Icon,
IconSet,
Shape,
ShapeExtractor,
ShapeSpecification,
DEFAULT_SMALL_SHAPE_ID,
)
from map_machine.map_configuration import MapConfiguration
from map_machine.text import Label, get_address, get_text
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -32,30 +33,28 @@ IconDescription = List[Union[str, Dict[str, str]]]
@dataclass
class LineStyle:
"""
SVG line style and its priority.
"""
"""SVG line style and its priority."""
style: Dict[str, Union[int, float, str]]
parallel_offset: float = 0.0
priority: float = 0.0
class MatchingType(Enum):
"""
Description on how tag was matched.
"""
"""Description on how tag was matched."""
NOT_MATCHED = 0
MATCHED_BY_SET = 1
MATCHED_BY_WILDCARD = 2
MATCHED = 3
MATCHED_BY_REGEX = 4
def is_matched_tag(
matcher_tag_key: str,
matcher_tag_value: Union[str, list],
tags: Dict[str, str],
) -> MatchingType:
tags: Tags,
) -> Tuple[MatchingType, List[str]]:
"""
Check whether element tags contradict tag matcher.
@ -63,20 +62,21 @@ def is_matched_tag(
:param matcher_tag_value: tag value, tag value list, or "*"
:param tags: element tags to check
"""
if matcher_tag_key in tags:
if matcher_tag_key not in tags:
return MatchingType.NOT_MATCHED, []
if matcher_tag_value == "*":
return MatchingType.MATCHED_BY_WILDCARD
if (
isinstance(matcher_tag_value, str)
and tags[matcher_tag_key] == matcher_tag_value
):
return MatchingType.MATCHED
if (
isinstance(matcher_tag_value, list)
and tags[matcher_tag_key] in matcher_tag_value
):
return MatchingType.MATCHED_BY_SET
return MatchingType.NOT_MATCHED
return MatchingType.MATCHED_BY_WILDCARD, []
if tags[matcher_tag_key] == matcher_tag_value:
return MatchingType.MATCHED, []
if matcher_tag_value.startswith("^"):
matcher: Optional[re.Match] = re.match(
matcher_tag_value, tags[matcher_tag_key]
)
if matcher:
return MatchingType.MATCHED_BY_REGEX, list(matcher.groups())
return MatchingType.NOT_MATCHED, []
def get_selector(key: str, value: str, prefix: str = "") -> str:
@ -103,15 +103,13 @@ def match_location(restrictions: Dict[str, str], country: str) -> bool:
return True
class Matcher:
"""
Tag matching.
"""
class Matcher(Tagged):
"""Tag matching."""
def __init__(
self, structure: Dict[str, Any], group: Optional[Dict[str, Any]] = None
) -> None:
self.tags: Dict[str, str] = structure["tags"]
super().__init__(structure["tags"])
self.exception: Dict[str, str] = {}
if "exception" in structure:
@ -129,6 +127,8 @@ class Matcher:
if "location_restrictions" in structure:
self.location_restrictions = structure["location_restrictions"]
self.verify()
def check_zoom_level(self, zoom_level: float) -> bool:
"""Check whether zoom level is matching."""
return (
@ -136,16 +136,16 @@ class Matcher:
)
def is_matched(
self,
tags: Dict[str, str],
configuration: Optional[MapConfiguration] = None,
) -> bool:
self, tags: Tags, configuration: Optional[MapConfiguration] = None
) -> Tuple[bool, Dict[str, str]]:
"""
Check whether element tags matches tag matcher.
:param tags: element tags to be matched
:param configuration: current map configuration to be matched
"""
groups: Dict[str, str] = {}
if (
configuration is not None
and self.location_restrictions
@ -153,28 +153,30 @@ class Matcher:
self.location_restrictions, configuration.country
)
):
return False
return False, {}
for config_tag_key in self.tags:
config_tag_key: str
tag_matcher = self.tags[config_tag_key]
if (
is_matched_tag(config_tag_key, tag_matcher, tags)
== MatchingType.NOT_MATCHED
):
return False
is_matched, matched_groups = is_matched_tag(
config_tag_key, self.tags[config_tag_key], tags
)
if is_matched == MatchingType.NOT_MATCHED:
return False, {}
if matched_groups:
for index, element in enumerate(matched_groups):
groups[f"#{config_tag_key}{index}"] = element
if self.exception:
for config_tag_key in self.exception:
config_tag_key: str
tag_matcher = self.exception[config_tag_key]
if (
is_matched_tag(config_tag_key, tag_matcher, tags)
!= MatchingType.NOT_MATCHED
):
return False
is_matched, matched_groups = is_matched_tag(
config_tag_key, self.exception[config_tag_key], tags
)
if is_matched != MatchingType.NOT_MATCHED:
return False, {}
return True
return True, groups
def get_mapcss_selector(self, prefix: str = "") -> str:
"""
@ -195,10 +197,21 @@ class Matcher:
return {}
def get_shape_specifications(
structure: List[Union[str, Dict[str, Any]]]
) -> List[dict]:
"""Parse shape specification from scheme."""
shapes: List[dict] = []
for shape_specification in structure:
if isinstance(shape_specification, str):
shapes.append({"shape": shape_specification})
else:
shapes.append(shape_specification)
return shapes
class NodeMatcher(Matcher):
"""
Tag specification matcher.
"""
"""Tag specification matcher."""
def __init__(
self, structure: Dict[str, Any], group: Dict[str, Any]
@ -212,15 +225,15 @@ class NodeMatcher(Matcher):
self.shapes: Optional[IconDescription] = None
if "shapes" in structure:
self.shapes = structure["shapes"]
self.shapes = get_shape_specifications(structure["shapes"])
self.over_icon: Optional[IconDescription] = None
if "over_icon" in structure:
self.over_icon = structure["over_icon"]
self.over_icon = get_shape_specifications(structure["over_icon"])
self.add_shapes: Optional[IconDescription] = None
if "add_shapes" in structure:
self.add_shapes = structure["add_shapes"]
self.add_shapes = get_shape_specifications(structure["add_shapes"])
self.set_main_color: Optional[str] = None
if "set_main_color" in structure:
@ -232,23 +245,21 @@ class NodeMatcher(Matcher):
self.under_icon: Optional[IconDescription] = None
if "under_icon" in structure:
self.under_icon = structure["under_icon"]
self.under_icon = get_shape_specifications(structure["under_icon"])
self.with_icon: Optional[IconDescription] = None
if "with_icon" in structure:
self.with_icon = structure["with_icon"]
self.with_icon = get_shape_specifications(structure["with_icon"])
def get_clean_shapes(self) -> Optional[List[str]]:
"""Get list of shape identifiers for shapes."""
if not self.shapes:
return None
return [(x if isinstance(x, str) else x["shape"]) for x in self.shapes]
return [x["shape"] for x in self.shapes]
class WayMatcher(Matcher):
"""
Special tag matcher for ways.
"""
"""Special tag matcher for ways."""
def __init__(self, structure: Dict[str, Any], scheme: "Scheme") -> None:
super().__init__(structure)
@ -260,38 +271,42 @@ class WayMatcher(Matcher):
self.style[key] = scheme.get_color(style[key]).hex.upper()
else:
self.style[key] = style[key]
self.priority: int = 0
self.priority: float = 0.0
if "priority" in structure:
self.priority = structure["priority"]
self.parallel_offset: float = 0.0
if parallel_offset := structure.get("parallel_offset"):
self.parallel_offset = parallel_offset
def get_style(self) -> Dict[str, Any]:
"""Return way SVG style."""
return self.style
class RoadMatcher(Matcher):
"""
Special tag matcher for highways.
"""
"""Special tag matcher for highways."""
def __init__(self, structure: Dict[str, Any], scheme: "Scheme") -> None:
super().__init__(structure)
self.border_color: Color = Color(
scheme.get_color(structure["border_color"])
)
self.color: Color = Color("white")
self.color: Color = scheme.get_color("road_color")
if "color" in structure:
self.color = Color(scheme.get_color(structure["color"]))
self.default_width: float = structure["default_width"]
self.priority: float = 0
self.priority: float = 0.0
if "priority" in structure:
self.priority = structure["priority"]
def get_priority(self, tags: Dict[str, str]) -> float:
layer: float = 0
def get_priority(self, tags: Tags) -> float:
"""Get priority for drawing order."""
layer: float = 0.0
if "layer" in tags:
layer = float(tags.get("layer"))
return 1000 * layer + self.priority
return 1000.0 * layer + self.priority
class Scheme:
@ -301,7 +316,56 @@ class Scheme:
Specifies map colors and rules to draw icons for OpenStreetMap tags.
"""
def __init__(self, file_name: Path) -> None:
def __init__(self, content: Dict[str, Any]) -> None:
self.node_matchers: List[NodeMatcher] = []
if "node_icons" in content:
for group in content["node_icons"]:
for element in group["tags"]:
self.node_matchers.append(NodeMatcher(element, group))
self.colors: Dict[str, str] = (
content["colors"] if "colors" in content else {}
)
self.material_colors: Dict[str, str] = (
content["material_colors"] if "material_colors" in content else {}
)
self.way_matchers: List[WayMatcher] = (
[WayMatcher(x, self) for x in content["ways"]]
if "ways" in content
else []
)
self.road_matchers: List[RoadMatcher] = (
[RoadMatcher(x, self) for x in content["roads"]]
if "roads" in content
else []
)
self.area_matchers: List[Matcher] = (
[Matcher(x) for x in content["area_tags"]]
if "area_tags" in content
else []
)
self.keys_to_write: List[str] = (
content["keys_to_write"] if "keys_to_write" in content else []
)
self.prefix_to_write: List[str] = (
content["prefix_to_write"] if "prefix_to_write" in content else []
)
self.keys_to_skip: List[str] = (
content["keys_to_skip"] if "keys_to_skip" in content else []
)
self.prefix_to_skip: List[str] = (
content["prefix_to_skip"] if "prefix_to_skip" in content else []
)
self.tags_to_skip: Dict[str, str] = (
content["tags_to_skip"] if "tags_to_skip" in content else {}
)
# Storage for created icon sets.
self.cache: Dict[str, Tuple[IconSet, int]] = {}
@classmethod
def from_file(cls, file_name: Path) -> "Scheme":
"""
:param file_name: name of the scheme file with tags, colors, and tag key
specification
@ -310,30 +374,7 @@ class Scheme:
content: Dict[str, Any] = yaml.load(
input_file.read(), Loader=yaml.FullLoader
)
self.node_matchers: List[NodeMatcher] = []
for group in content["node_icons"]:
for element in group["tags"]:
self.node_matchers.append(NodeMatcher(element, group))
self.colors: Dict[str, str] = content["colors"]
self.material_colors: Dict[str, str] = content["material_colors"]
self.way_matchers: List[WayMatcher] = [
WayMatcher(x, self) for x in content["ways"]
]
self.road_matchers: List[RoadMatcher] = [
RoadMatcher(x, self) for x in content["roads"]
]
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"]
# Storage for created icon sets.
self.cache: Dict[str, Tuple[IconSet, int]] = {}
return cls(content)
def get_color(self, color: str) -> Color:
"""
@ -343,42 +384,91 @@ class Scheme:
:return: color specification
"""
if color in self.colors:
specification: Union[str, dict] = self.colors[color]
if isinstance(specification, str):
return Color(self.colors[color])
color: Color = self.get_color(specification["color"])
if "darken" in specification:
percent: float = float(specification["darken"])
color.set_luminance(color.get_luminance() * (1 - percent))
return color
if color.lower() in self.colors:
return Color(self.colors[color.lower()])
try:
return Color(color)
except (ValueError, AttributeError):
return DEFAULT_COLOR
logging.debug(f"Unknown color `{color}`.")
return Color(self.colors["default"])
def is_no_drawable(self, key: str) -> bool:
def get_default_color(self) -> Color:
"""Get default color for a main icon."""
return self.get_color("default")
def get_extra_color(self) -> Color:
"""Get default color for an extra icon."""
return self.get_color("extra")
def get(self, variable_name: str):
"""
FIXME: colors should be variables.
"""
if variable_name in self.colors:
return self.colors[variable_name]
return 0.0
def is_no_drawable(self, key: str, value: str) -> bool:
"""
Return true if key is specified as no drawable (should not be
represented on the map as icon set or as text) by the scheme.
:param key: OpenStreetMap tag key
:param value: OpenStreetMap tag value
"""
if key in self.tags_to_write or key in self.tags_to_skip:
if (
key in self.keys_to_write + self.keys_to_skip
or key in self.tags_to_skip
and self.tags_to_skip[key] == value
):
return True
for prefix in self.prefix_to_write + self.prefix_to_skip:
if key[: len(prefix) + 1] == f"{prefix}:":
if ":" in key:
prefix: str = key.split(":")[0]
if prefix in self.prefix_to_write + self.prefix_to_skip:
return True
return False
def is_writable(self, key: str) -> bool:
def is_writable(self, key: str, value: str) -> bool:
"""
Return true if key is specified as writable (should be represented on
the map as text) by the scheme.
:param key: OpenStreetMap tag key
:param value: OpenStreetMap tag value
"""
if key in self.tags_to_skip:
if (
key in self.keys_to_skip
or key in self.tags_to_skip
and self.tags_to_skip[key] == value
):
return False
if key in self.tags_to_write:
if key in self.keys_to_write:
return True
for prefix in self.prefix_to_write:
if key[: len(prefix) + 1] == f"{prefix}:":
prefix: Optional[str] = None
if ":" in key:
prefix = key.split(":")[0]
if prefix in self.prefix_to_skip:
return False
if prefix in self.prefix_to_write:
return True
return False
def get_icon(
@ -406,11 +496,13 @@ class Scheme:
main_icon: Optional[Icon] = None
extra_icons: List[Icon] = []
priority: int = 0
color: Optional[Color] = None
for index, matcher in enumerate(self.node_matchers):
if not matcher.replace_shapes and main_icon:
continue
if not matcher.is_matched(tags, configuration):
matching, groups = matcher.is_matched(tags, configuration)
if not matching:
continue
if (
not configuration.ignore_level_matching
@ -423,7 +515,7 @@ class Scheme:
processed |= matcher_tags
if matcher.shapes:
specifications = [
self.get_shape_specification(x, extractor)
self.get_shape_specification(x, extractor, groups)
for x in matcher.shapes
]
main_icon = Icon(specifications)
@ -437,18 +529,18 @@ class Scheme:
processed |= matcher_tags
if matcher.add_shapes:
specifications = [
self.get_shape_specification(x, extractor, Color("#888888"))
self.get_shape_specification(
x, extractor, color=self.get_extra_color()
)
for x in matcher.add_shapes
]
extra_icons += [Icon(specifications)]
processed |= matcher_tags
if matcher.set_main_color and main_icon:
main_icon.recolor(self.get_color(matcher.set_main_color))
color = self.get_color(matcher.set_main_color)
if matcher.set_opacity and main_icon:
main_icon.opacity = matcher.set_opacity
color: Optional[Color] = None
if "material" in tags:
value: str = tags["material"]
if value in self.material_colors:
@ -465,24 +557,36 @@ class Scheme:
color = self.get_color(tags[color_tag_key])
processed.add(color_tag_key)
if not main_icon:
dot_spec: ShapeSpecification = ShapeSpecification(
extractor.get_shape(DEFAULT_SHAPE_ID), self.get_color("default")
)
main_icon: Icon = Icon([dot_spec])
if main_icon and color:
main_icon.recolor(color)
default_shape = extractor.get_shape(DEFAULT_SHAPE_ID)
if not main_icon:
main_icon = Icon([ShapeSpecification(default_shape)])
default_icon: Optional[Icon] = None
if configuration.show_overlapped:
small_dot_spec: ShapeSpecification = ShapeSpecification(
extractor.get_shape(DEFAULT_SMALL_SHAPE_ID),
color if color else self.get_color("default"),
)
default_icon = Icon([small_dot_spec])
returned: IconSet = IconSet(main_icon, extra_icons, processed)
returned: IconSet = IconSet(
main_icon, extra_icons, default_icon, processed
)
self.cache[tags_hash] = returned, priority
for key in ["direction", "camera:direction"]:
for key in "direction", "camera:direction":
if key in tags:
for specification in main_icon.shape_specifications:
if (
DirectionSet(tags[key]).is_right() is False
and specification.shape.is_right_directed is True
or specification.shape.is_right_directed is True
and specification.shape.is_right_directed is False
DirectionSet(tags[key]).is_right() is not None
and specification.shape.is_right_directed is not None
and DirectionSet(tags[key]).is_right()
!= specification.shape.is_right_directed
):
specification.flip_horizontally = True
@ -493,119 +597,51 @@ class Scheme:
line_styles = []
for matcher in self.way_matchers:
if not matcher.is_matched(tags):
matching, _ = matcher.is_matched(tags)
if not matching:
continue
line_styles.append(LineStyle(matcher.style, matcher.priority))
line_style: LineStyle = LineStyle(
matcher.style, matcher.parallel_offset, matcher.priority
)
line_styles.append(line_style)
return line_styles
def get_road(self, tags: Dict[str, Any]) -> Optional[RoadMatcher]:
"""Get road matcher if tags are matched."""
for matcher in self.road_matchers:
if not matcher.is_matched(tags):
matching, _ = matcher.is_matched(tags)
if not matching:
continue
return matcher
return None
def construct_text(
self, tags: Dict[str, str], draw_captions: str, processed: Set[str]
) -> List[Label]:
"""Construct labels for not processed tags."""
texts: List[Label] = []
name = None
alt_name = None
if "name" in tags:
name = tags["name"]
processed.add("name")
elif "name:en" in tags:
if not name:
name = tags["name:en"]
processed.add("name:en")
processed.add("name:en")
if "alt_name" in tags:
if alt_name:
alt_name += ", "
else:
alt_name = ""
alt_name += tags["alt_name"]
processed.add("alt_name")
if "old_name" in tags:
if alt_name:
alt_name += ", "
else:
alt_name = ""
alt_name += "ex " + tags["old_name"]
address: List[str] = get_address(tags, draw_captions, processed)
if name:
texts.append(Label(name, Color("black")))
if alt_name:
texts.append(Label(f"({alt_name})"))
if address:
texts.append(Label(", ".join(address)))
if draw_captions == "main":
return texts
texts += get_text(tags, processed)
if "route_ref" in tags:
texts.append(Label(tags["route_ref"].replace(";", " ")))
processed.add("route_ref")
if "cladr:code" in tags:
texts.append(Label(tags["cladr:code"], size=7))
processed.add("cladr:code")
if "website" in tags:
link = tags["website"]
if link[:7] == "http://":
link = link[7:]
if link[:8] == "https://":
link = link[8:]
if link[:4] == "www.":
link = link[4:]
if link[-1] == "/":
link = link[:-1]
link = link[:25] + ("..." if len(tags["website"]) > 25 else "")
texts.append(Label(link, Color("#000088")))
processed.add("website")
for key in ["phone"]:
if key in tags:
texts.append(Label(tags[key], Color("#444444")))
processed.add(key)
if "height" in tags:
texts.append(Label(f"{tags['height']} m"))
processed.add("height")
for tag in tags:
if self.is_writable(tag) and tag not in processed:
texts.append(Label(tags[tag]))
return texts
def is_area(self, tags: Dict[str, str]) -> bool:
def is_area(self, tags: Tags) -> bool:
"""Check whether way described by tags is area."""
for matcher in self.area_matchers:
if matcher.is_matched(tags):
matching, _ = matcher.is_matched(tags)
if matching:
return True
return False
def process_ignored(
self, tags: Dict[str, str], processed: Set[str]
) -> None:
def process_ignored(self, tags: Tags, processed: Set[str]) -> None:
"""
Mark all ignored tag as processed.
:param tags: input tag dictionary
:param processed: processed set
"""
[processed.add(tag) for tag in tags if self.is_no_drawable(tag)]
processed.update(
set(tag for tag in tags if self.is_no_drawable(tag, tags[tag]))
)
def get_shape_specification(
self,
structure: Union[str, Dict[str, Any]],
extractor: ShapeExtractor,
color: Color = DEFAULT_COLOR,
groups: Dict[str, str] = None,
color: Optional[Color] = None,
) -> ShapeSpecification:
"""
Parse shape specification from structure, that is just shape string
@ -613,21 +649,23 @@ class Scheme:
and offset (optional).
"""
shape: Shape = extractor.get_shape(DEFAULT_SHAPE_ID)
color: Color = color
offset: np.ndarray = np.array((0, 0))
color: Color = (
color if color is not None else Color(self.colors["default"])
)
offset: np.ndarray = np.array((0.0, 0.0))
flip_horizontally: bool = False
flip_vertically: bool = False
use_outline: bool = True
if isinstance(structure, str):
shape = extractor.get_shape(structure)
elif isinstance(structure, dict):
structure: Dict[str, Any]
if "shape" in structure:
shape = extractor.get_shape(structure["shape"])
shape_id: str = structure["shape"]
if groups:
for key in groups:
shape_id = shape_id.replace(key, groups[key])
shape = extractor.get_shape(shape_id)
else:
logging.error(
"Invalid shape specification: `shape` key expected."
)
logging.error("Invalid shape specification: `shape` key expected.")
if "color" in structure:
color = self.get_color(structure["color"])
if "offset" in structure:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
"""
Tiles generation for slippy maps.
"""

View file

@ -9,21 +9,20 @@ from typing import List, Optional, Tuple
import cairosvg
from map_machine.tile import Tile
from map_machine.map_configuration import MapConfiguration
from map_machine.slippy.tile import Tile
from map_machine.workspace import workspace
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
class _Handler(SimpleHTTPRequestHandler):
"""
HTTP request handler that process sloppy map tile requests.
"""
class TileServerHandler(SimpleHTTPRequestHandler):
"""HTTP request handler that process sloppy map tile requests."""
cache: Path = Path("cache")
update_cache: bool = False
options = None
options: Optional[argparse.Namespace] = None
def __init__(
self,
@ -31,6 +30,7 @@ class _Handler(SimpleHTTPRequestHandler):
client_address: Tuple[str, int],
server: HTTPServer,
) -> None:
# TODO: delete?
super().__init__(request, client_address, server)
def do_GET(self) -> None:
@ -50,7 +50,11 @@ class _Handler(SimpleHTTPRequestHandler):
if self.update_cache:
if not png_path.exists():
if not svg_path.exists():
tile.draw(tile_path, self.cache, self.options)
tile.draw(
tile_path,
self.cache,
MapConfiguration(zoom_level=zoom_level),
)
with svg_path.open(encoding="utf-8") as input_file:
cairosvg.svg2png(
file_obj=input_file, write_to=str(png_path)
@ -66,11 +70,11 @@ class _Handler(SimpleHTTPRequestHandler):
return
def ui(options: argparse.Namespace) -> None:
def run_server(options: argparse.Namespace) -> None:
"""Command-line interface for tile server."""
server: Optional[HTTPServer] = None
try:
handler = _Handler
handler = TileServerHandler
handler.cache = Path(options.cache)
handler.options = options
server: HTTPServer = HTTPServer(("", options.port), handler)

View file

@ -15,14 +15,14 @@ import numpy as np
import svgwrite
from PIL import Image
from map_machine.boundary_box import BoundaryBox
from map_machine.constructor import Constructor
from map_machine.flinger import Flinger
from map_machine.icon import ShapeExtractor
from map_machine.geometry.boundary_box import BoundaryBox
from map_machine.geometry.flinger import Flinger
from map_machine.map_configuration import MapConfiguration
from map_machine.mapper import Map
from map_machine.osm_getter import NetworkError, get_osm
from map_machine.osm_reader import OSMData
from map_machine.osm.osm_getter import NetworkError, get_osm
from map_machine.osm.osm_reader import OSMData
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.scheme import Scheme
from map_machine.workspace import workspace
@ -55,9 +55,9 @@ class Tile:
:param zoom_level: zoom level in OpenStreetMap terminology
"""
lat_rad: np.ndarray = np.radians(coordinates[0])
n: float = 2.0 ** zoom_level
x: int = int((coordinates[1] + 180.0) / 360.0 * n)
y: int = int((1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * n)
scale: float = 2.0**zoom_level
x: int = int((coordinates[1] + 180.0) / 360.0 * scale)
y: int = int((1.0 - np.arcsinh(np.tan(lat_rad)) / np.pi) / 2.0 * scale)
return cls(x, y, zoom_level)
def get_coordinates(self) -> np.ndarray:
@ -66,21 +66,23 @@ class Tile:
Code from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
"""
n: float = 2.0 ** self.zoom_level
lon_deg: float = self.x / n * 360.0 - 180.0
lat_rad: float = np.arctan(np.sinh(np.pi * (1 - 2 * self.y / n)))
scale: float = 2.0**self.zoom_level
lon_deg: float = self.x / scale * 360.0 - 180.0
lat_rad: float = np.arctan(np.sinh(np.pi * (1 - 2 * self.y / scale)))
lat_deg: np.ndarray = np.degrees(lat_rad)
return np.array((lat_deg, lon_deg))
def get_boundary_box(self) -> Tuple[np.ndarray, np.ndarray]:
def get_boundary_box(self) -> BoundaryBox:
"""
Get geographical boundary box of the tile: north-west and south-east
points.
"""
return (
self.get_coordinates(),
Tile(self.x + 1, self.y + 1, self.zoom_level).get_coordinates(),
)
point_1: np.ndarray = self.get_coordinates()
point_2: np.ndarray = Tile(
self.x + 1, self.y + 1, self.zoom_level
).get_coordinates()
return BoundaryBox(point_1[1], point_2[0], point_2[1], point_1[0])
def get_extended_boundary_box(self) -> BoundaryBox:
"""Same as get_boundary_box, but with extended boundaries."""
@ -139,8 +141,8 @@ class Tile:
"""
try:
osm_data: OSMData = self.load_osm_data(cache_path)
except NetworkError as e:
raise NetworkError(f"Map is not loaded. {e.message}")
except NetworkError as error:
raise NetworkError(f"Map is not loaded. {error.message}")
self.draw_with_osm_data(osm_data, directory_name, configuration)
@ -171,7 +173,7 @@ class Tile:
icon_extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
constructor: Constructor = Constructor(
osm_data, flinger, scheme, icon_extractor, configuration
)
@ -196,13 +198,11 @@ class Tile:
assert zoom_level >= self.zoom_level
tiles: List["Tile"] = []
n: int = 2 ** (zoom_level - self.zoom_level)
for i in range(n):
for j in range(n):
scale: int = 2 ** (zoom_level - self.zoom_level)
for i in range(scale):
for j in range(scale):
tile: Tile = Tile(
n * self.x + i,
n * self.y + j,
zoom_level,
scale * self.x + i, scale * self.y + j, zoom_level
)
tiles.append(tile)
return tiles
@ -210,9 +210,7 @@ class Tile:
@dataclass
class Tiles:
"""
Collection of tiles.
"""
"""Collection of tiles."""
tiles: List[Tile]
tile_1: Tile # Left top tile.
@ -278,6 +276,7 @@ class Tiles:
:param configuration: drawing configuration
"""
osm_data: OSMData = self.load_osm_data(cache_path)
for tile in self.tiles:
file_path: Path = tile.get_file_name(directory)
if not file_path.exists():
@ -286,6 +285,7 @@ class Tiles:
logging.debug(f"File {file_path} already exists.")
output_path: Path = file_path.with_suffix(".png")
if not output_path.exists():
with file_path.open(encoding="utf-8") as input_file:
cairosvg.svg2png(
@ -305,6 +305,7 @@ class Tiles:
cache_path: Path,
configuration: MapConfiguration,
osm_data: OSMData,
redraw: bool = False,
) -> None:
"""
Draw one PNG image with all tiles and split it into a set of separate
@ -314,13 +315,16 @@ class Tiles:
:param cache_path: directory for temporary OSM files
:param configuration: drawing configuration
:param osm_data: OpenStreetMap data
:param redraw: update cache
"""
if self.tiles_exist(directory):
if self.tiles_exist(directory) and not redraw:
return
self.draw_image_from_osm_data(cache_path, configuration, osm_data)
self.draw_image_from_osm_data(
cache_path, configuration, osm_data, redraw
)
input_path: Path = self.get_file_path(cache_path).with_suffix(".png")
with input_path.open("rb") as input_file:
image: Image = Image.open(input_file)
@ -365,11 +369,12 @@ class Tiles:
cache_path: Path,
configuration: MapConfiguration,
osm_data: OSMData,
redraw: bool = False,
) -> None:
"""Draw all tiles using OSM data."""
output_path: Path = self.get_file_path(cache_path)
if not output_path.exists():
if not output_path.exists() or redraw:
top, left = self.tile_1.get_coordinates()
bottom, right = Tile(
self.tile_2.x + 1,
@ -385,7 +390,7 @@ class Tiles:
extractor: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)
scheme: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
scheme: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
constructor: Constructor = Constructor(
osm_data, flinger, scheme, extractor, configuration
)
@ -404,7 +409,8 @@ class Tiles:
logging.debug(f"File {output_path} already exists.")
png_path: Path = self.get_file_path(cache_path).with_suffix(".png")
if not png_path.exists():
if not png_path.exists() or redraw:
with output_path.open(encoding="utf-8") as input_file:
cairosvg.svg2png(file_obj=input_file, write_to=str(png_path))
logging.info(f"SVG file is rasterized to {png_path}.")
@ -447,9 +453,9 @@ def parse_zoom_level(zoom_level_specification: str) -> List[int]:
result: List[int] = []
for part in parts:
if "-" in part:
from_, to = part.split("-")
from_zoom_level: int = parse(from_)
to_zoom_level: int = parse(to)
start, end = part.split("-")
from_zoom_level: int = parse(start)
to_zoom_level: int = parse(end)
if from_zoom_level > to_zoom_level:
raise ScaleConfigurationException("Wrong range.")
result += range(from_zoom_level, to_zoom_level + 1)
@ -459,7 +465,7 @@ def parse_zoom_level(zoom_level_specification: str) -> List[int]:
return result
def ui(options: argparse.Namespace) -> None:
def generate_tiles(options: argparse.Namespace) -> None:
"""Simple user interface for tile generation."""
directory: Path = workspace.get_tile_path()
@ -486,8 +492,8 @@ def ui(options: argparse.Namespace) -> None:
)
try:
osm_data: OSMData = min_tile.load_osm_data(Path(options.cache))
except NetworkError as e:
raise NetworkError(f"Map is not loaded. {e.message}")
except NetworkError as error:
raise NetworkError(f"Map is not loaded. {error.message}")
for zoom_level in zoom_levels:
tile: Tile = Tile.from_coordinates(
@ -498,8 +504,8 @@ def ui(options: argparse.Namespace) -> None:
options, zoom_level
)
tile.draw_with_osm_data(osm_data, directory, configuration)
except NetworkError as e:
logging.fatal(e.message)
except NetworkError as error:
logging.fatal(error.message)
elif options.tile:
zoom_level, x, y = map(int, options.tile.split("/"))
tile: Tile = Tile(x, y, zoom_level)
@ -516,8 +522,8 @@ def ui(options: argparse.Namespace) -> None:
min_tiles: Tiles = Tiles.from_boundary_box(boundary_box, min_zoom_level)
try:
osm_data: OSMData = min_tiles.load_osm_data(Path(options.cache))
except NetworkError as e:
raise NetworkError(f"Map is not loaded. {e.message}")
except NetworkError as error:
raise NetworkError(f"Map is not loaded. {error.message}")
for zoom_level in zoom_levels:
if EXTEND_TO_BIGGER_TILE:

View file

@ -2,60 +2,51 @@
OSM address tag processing.
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Set
from typing import Any, Dict, List, Optional, Set
from colour import Color
from map_machine.map_configuration import LabelMode
from map_machine.osm.osm_reader import Tags
from map_machine.scheme import Scheme
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
DEFAULT_FONT_SIZE: float = 10.0
DEFAULT_COLOR: Color = Color("#444444")
@dataclass
class Label:
"""
Text label.
"""
"""Text label."""
text: str
fill: Color = DEFAULT_COLOR
fill: Color
out_fill: Color
size: float = DEFAULT_FONT_SIZE
def get_address(
tags: Dict[str, Any], draw_captions_mode: str, processed: Set[str]
tags: Dict[str, Any], processed: Set[str], label_mode: LabelMode
) -> 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")
:param processed: set of processed tag keys
:param label_mode: captions mode
"""
address: List[str] = []
if draw_captions_mode == "address":
if "addr:postcode" in tags:
address.append(tags["addr:postcode"])
processed.add("addr:postcode")
if "addr:country" in tags:
address.append(tags["addr:country"])
processed.add("addr:country")
if "addr:city" in tags:
address.append(tags["addr:city"])
processed.add("addr:city")
if "addr:street" in tags:
street = tags["addr:street"]
if street.startswith("улица "):
street = "ул. " + street[len("улица ") :]
address.append(street)
processed.add("addr:street")
tag_names: List[str] = ["housenumber"]
if label_mode == LabelMode.ADDRESS:
tag_names += ["postcode", "country", "city", "street"]
if "addr:housenumber" in tags:
address.append(tags["addr:housenumber"])
processed.add("addr:housenumber")
for tag_name in tag_names:
key: str = f"addr:{tag_name}"
if key in tags:
address.append(tags[key])
processed.add(key)
return address
@ -80,7 +71,24 @@ def format_frequency(value: str) -> str:
return f"{value} "
def get_text(tags: Dict[str, Any], processed: Set[str]) -> List[Label]:
@dataclass
class TextConstructor:
def __init__(self, scheme: Scheme) -> None:
self.scheme: Scheme = scheme
self.default_color: Color = self.scheme.get_color("text_color")
self.main_color: Color = self.scheme.get_color("text_main_color")
self.default_out_color: Color = self.scheme.get_color(
"text_outline_color"
)
def label(self, text: str, size: float = DEFAULT_FONT_SIZE):
return Label(
text, self.default_color, self.default_out_color, size=size
)
def get_text(
self, tags: Dict[str, Any], processed: Set[str]
) -> List[Label]:
"""Get text representation of writable tags."""
texts: List[Label] = []
values: List[str] = []
@ -98,13 +106,106 @@ def get_text(tags: Dict[str, Any], processed: Set[str]) -> List[Label]:
processed.add("voltage")
if values:
texts.append(Label(", ".join(map(format_voltage, values))))
texts.append(self.label(", ".join(map(format_voltage, values))))
if "frequency" in tags:
text: str = ", ".join(
map(format_frequency, tags["frequency"].split(";"))
)
texts.append(Label(text))
texts.append(self.label(text))
processed.add("frequency")
return texts
def construct_text(
self,
tags: Tags,
processed: Set[str],
label_mode: LabelMode,
) -> List[Label]:
"""Construct list of labels from OSM tags."""
texts: List[Label] = []
name: Optional[str] = None
alternative_name: Optional[str] = None
if "name" in tags:
name = tags["name"]
processed.add("name")
elif "name:en" in tags:
if not name:
name = tags["name:en"]
processed.add("name:en")
processed.add("name:en")
if "alt_name" in tags:
if alternative_name:
alternative_name += ", "
else:
alternative_name = ""
alternative_name += tags["alt_name"]
processed.add("alt_name")
if "old_name" in tags:
if alternative_name:
alternative_name += ", "
else:
alternative_name = ""
alternative_name += "ex " + tags["old_name"]
address: List[str] = get_address(tags, processed, label_mode)
if name:
texts.append(Label(name, self.main_color, self.default_out_color))
if alternative_name:
texts.append(self.label(f"({alternative_name})"))
if address:
texts.append(self.label(", ".join(address)))
if label_mode == LabelMode.MAIN:
return texts
texts += self.get_text(tags, processed)
if "route_ref" in tags:
texts.append(self.label(tags["route_ref"].replace(";", " ")))
processed.add("route_ref")
if "cladr:code" in tags:
texts.append(self.label(tags["cladr:code"], size=7.0))
processed.add("cladr:code")
if "website" in tags:
link = tags["website"]
if link[:7] == "http://":
link = link[7:]
if link[:8] == "https://":
link = link[8:]
if link[:4] == "www.":
link = link[4:]
if link[-1] == "/":
link = link[:-1]
link = link[:25] + ("..." if len(tags["website"]) > 25 else "")
texts.append(Label(link, Color("#000088"), self.default_out_color))
processed.add("website")
for key in ["phone"]:
if key in tags:
texts.append(
Label(tags[key], Color("#444444"), self.default_out_color)
)
processed.add(key)
if "height" in tags:
texts.append(self.label(f"{tags['height']} m"))
processed.add("height")
for tag in tags:
if self.scheme.is_writable(tag, tags[tag]) and tag not in processed:
texts.append(
Label(
tags[tag],
self.default_color,
self.default_out_color,
)
)
return texts

View file

@ -0,0 +1,3 @@
"""
User interface.
"""

View file

@ -7,7 +7,7 @@ from typing import Dict, List
from map_machine import __version__
from map_machine.map_configuration import BuildingMode, DrawingMode, LabelMode
from map_machine.osm_reader import STAGES_OF_DECAY
from map_machine.osm.osm_reader import STAGES_OF_DECAY
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -15,7 +15,7 @@ __email__ = "me@enzet.ru"
BOXES: str = " ▏▎▍▌▋▊▉"
BOXES_LENGTH: int = len(BOXES)
COMMANDS: Dict[str, List[str]] = {
COMMAND_LINES: Dict[str, List[str]] = {
"render": ["render", "-b", "10.000,20.000,10.001,20.001"],
"render_with_tooltips": [
"render",
@ -28,6 +28,26 @@ COMMANDS: Dict[str, List[str]] = {
"element": ["element", "--node", "amenity=bench,material=wood"],
"tile": ["tile", "--coordinates", "50.000,40.000"],
}
COMMANDS: List[str] = [
"render",
"server",
"tile",
"element",
"mapcss",
"icons",
"taginfo",
]
BOUNDARY_BOX_WARNING: str = (
"if the first value is negative, use `=` sign or enclose the value with "
"quotes and use space before `-`, e.g. `-b=-84.752,39.504,-84.749,39.508` "
'or `-b " -84.752,39.504,-84.749,39.508"`'
)
COORDINATES_WARNING: str = (
"if the first value is negative, use `=` sign or enclose the value with "
"quotes and use space before `-`, e.g. `-c=-84.752,39.504` or `-c "
'" -84.752,39.504"`'
)
def parse_arguments(args: List[str]) -> argparse.Namespace:
@ -43,28 +63,62 @@ def parse_arguments(args: List[str]) -> argparse.Namespace:
)
subparser = parser.add_subparsers(dest="command")
render_parser = subparser.add_parser("render", help="draw SVG map")
render_parser = subparser.add_parser(
"render",
description="Render SVG map. Use --boundary-box to specify geo "
"boundaries, --input to specify OSM XML or JSON input file, or "
"--coordinates and --size to specify central point and resulting image "
"size.",
help="draw SVG map",
)
add_render_arguments(render_parser)
add_map_arguments(render_parser)
tile_parser = subparser.add_parser(
"tile", help="generate PNG tiles for slippy maps"
"tile",
description="Generate SVG and PNG 256 × 256 px tiles for slippy maps. "
"You can use server command to run server in order to display "
"generated tiles as a map (e.g. with Leaflet).",
help="generate SVG and PNG tiles for slippy maps",
)
add_tile_arguments(tile_parser)
add_map_arguments(tile_parser)
add_server_arguments(subparser.add_parser("server", help="run tile server"))
add_server_arguments(
subparser.add_parser(
"server",
description="Run in order to display generated tiles as a map "
"(e.g. with Leaflet).",
help="run tile server",
)
)
add_element_arguments(
subparser.add_parser(
"element", help="draw OSM element: node, way, relation"
"element",
description="Draw map element separately.",
help="draw OSM element: node, way, relation",
)
)
add_mapcss_arguments(
subparser.add_parser("mapcss", help="write MapCSS file")
subparser.add_parser(
"mapcss",
description="Write directory with MapCSS file and generated "
"Röntgen icons.",
help="write MapCSS file",
)
)
subparser.add_parser("icons", help="draw icons")
subparser.add_parser("taginfo", help="write Taginfo JSON file")
subparser.add_parser(
"icons",
description="Generate Röntgen icons as a grid and as separate SVG "
"icons",
help="draw Röntgen icons",
)
subparser.add_parser(
"taginfo",
description="Generate JSON file for Taginfo project.",
help="write Taginfo JSON file",
)
arguments: argparse.Namespace = parser.parse_args(args[1:])
@ -77,16 +131,17 @@ def add_map_arguments(parser: argparse.ArgumentParser) -> None:
"--buildings",
metavar="<mode>",
default="flat",
choices=(x.value for x in BuildingMode),
choices=(mode.value for mode in BuildingMode),
help="building drawing mode: "
+ ", ".join(x.value for x in BuildingMode),
+ ", ".join(mode.value for mode in BuildingMode),
)
parser.add_argument(
"--mode",
default="normal",
metavar="<string>",
choices=(x.value for x in DrawingMode),
help="map drawing mode: " + ", ".join(x.value for x in DrawingMode),
choices=(mode.value for mode in DrawingMode),
help="map drawing mode: "
+ ", ".join(mode.value for mode in DrawingMode),
)
parser.add_argument(
"--overlap",
@ -101,8 +156,9 @@ def add_map_arguments(parser: argparse.ArgumentParser) -> None:
dest="label_mode",
default="main",
metavar="<string>",
choices=(x.value for x in LabelMode),
help="label drawing mode: " + ", ".join(x.value for x in LabelMode),
choices=(mode.value for mode in LabelMode),
help="label drawing mode: "
+ ", ".join(mode.value for mode in LabelMode),
)
parser.add_argument(
"--level",
@ -152,9 +208,27 @@ def add_map_arguments(parser: argparse.ArgumentParser) -> None:
default=True,
)
parser.add_argument(
"--no-roofs",
dest="roofs",
help="don't draw building roofs",
"--building-colors",
help="paint walls (if isometric mode is enabled) and roofs with "
"specified colors",
action="store_true",
default=False,
)
parser.add_argument(
"--no-building-colors",
help="don't paint walls (if isometric mode is enabled) and roofs with "
"specified colors",
action="store_false",
)
parser.add_argument(
"--show-overlapped",
help="show hidden nodes with a dot",
action="store_true",
default=False,
)
parser.add_argument(
"--no-show-overlapped",
help="don't show hidden nodes with a dot",
action="store_false",
)
@ -165,7 +239,8 @@ def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
"-c",
"--coordinates",
metavar="<latitude>,<longitude>",
help="coordinates of any location inside the tile",
help="coordinates of any location inside the tile; "
+ COORDINATES_WARNING,
)
parser.add_argument(
"-t",
@ -182,8 +257,8 @@ def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-b",
"--boundary-box",
help="construct the minimum amount of tiles that cover requested "
"boundary box",
help="construct the minimum amount of tiles that cover the requested "
"boundary box; " + BOUNDARY_BOX_WARNING,
metavar="<lon1>,<lat1>,<lon2>,<lat2>",
)
parser.add_argument(
@ -200,7 +275,7 @@ def add_tile_arguments(parser: argparse.ArgumentParser) -> None:
"--input",
dest="input_file_name",
metavar="<path>",
help="input OSM XML file name (if not specified, file will be "
help="input OSM XML file name (if not specified, the file will be "
"downloaded using OpenStreetMap API)",
)
@ -252,8 +327,7 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
"-b",
"--boundary-box",
metavar="<lon1>,<lat1>,<lon2>,<lat2>",
help="geo boundary box; if first value is negative, enclose the value "
"with quotes and use space before `-`",
help="geo boundary box; " + BOUNDARY_BOX_WARNING,
)
parser.add_argument(
"--cache",
@ -267,13 +341,14 @@ def add_render_arguments(parser: argparse.ArgumentParser) -> None:
type=float,
metavar="<float>",
help="OSM zoom level",
default=18,
default=18.0,
)
parser.add_argument(
"-c",
"--coordinates",
metavar="<latitude>,<longitude>",
help="coordinates of any location inside the tile",
help="coordinates of any location inside the tile; "
+ COORDINATES_WARNING,
)
parser.add_argument(
"-s",
@ -338,9 +413,6 @@ def progress_bar(
subsequently)
:param text: short description
"""
if number == 0:
sys.stdout.write(text + "...\n")
return
if number == -1:
sys.stdout.write(f"100 % {length * ''}{text}\n")
elif number % step == 0:
@ -349,6 +421,7 @@ def progress_bar(
fill_length: int = int(parts / BOXES_LENGTH)
box: str = BOXES[int(parts - fill_length * BOXES_LENGTH)]
sys.stdout.write(
f"{str(int(int(ratio * 1000) / 10)):>3} % {fill_length * ''}{box}"
f"{str(int(int(ratio * 1000.0) / 10.0)):>3} % "
f"{fill_length * ''}{box}"
f"{int(length - fill_length - 1) * ' '}{text}\n\033[F"
)

View file

@ -0,0 +1,90 @@
"""
Creating fish shell autocompletion commands.
See https://fishshell.com/docs/current/completions.html
"""
import argparse
from pathlib import Path
from typing import Any
from map_machine.ui import cli
from map_machine.ui.cli import COMMANDS
class ArgumentParser(argparse.ArgumentParser):
"""Argument parser that generates fish shell autocompletion commands."""
def __init__(self, *args, **kwargs) -> None:
self.arguments: list[dict[str, Any]] = []
super().__init__(*args, **kwargs)
def add_argument(self, *args, **kwargs) -> None:
"""Just store argument with options."""
super().add_argument(*args, **kwargs)
argument: dict[str, Any] = {"arguments": args}
for key, value in kwargs.items():
argument[key] = value
self.arguments.append(argument)
def get_complete(self, command: str) -> str:
"""Return fish complete command."""
result: str = ""
for argument in self.arguments:
result += "complete -c map-machine"
result += f' -n "__fish_seen_subcommand_from {command}"'
if len(argument["arguments"]) == 2:
result += f" -s {argument['arguments'][0][1:]}"
result += f" -l {argument['arguments'][1][2:]}"
else:
result += f" -l {argument['arguments'][0][2:]}"
if "help" in argument:
result += f' -d "{argument["help"]}"'
result += "\n"
return result
def completion_commands() -> str:
"""Print fish completion commands."""
commands: str = " ".join(COMMANDS)
result: str = ""
result += f"set -l commands {commands}\n"
result += "complete -c map-machine -f\n"
result += (
f'complete -c map-machine -n "not __fish_seen_subcommand_from '
f'$commands" -a "{commands}"\n'
)
for command in COMMANDS:
if command in ["icons", "taginfo"]:
continue
parser: ArgumentParser = ArgumentParser()
if command == "render":
cli.add_render_arguments(parser)
cli.add_map_arguments(parser)
elif command == "server":
cli.add_server_arguments(parser)
elif command == "tile":
cli.add_tile_arguments(parser)
cli.add_map_arguments(parser)
elif command == "element":
cli.add_element_arguments(parser)
elif command == "mapcss":
cli.add_mapcss_arguments(parser)
else:
raise NotImplementedError(
f"no separate function for parser creation for {command}"
)
result += parser.get_complete(command) + "\n"
return result
if __name__ == "__main__":
completions_path: Path = (
Path.home() / ".config/fish/completions/map-machine.fish"
)
with completions_path.open("w+") as output_file:
output_file.write(completion_commands())

View file

@ -10,9 +10,7 @@ __email__ = "me@enzet.ru"
@dataclass
class MinMax:
"""
Minimum and maximum.
"""
"""Minimum and maximum."""
min_: Any = None
max_: Any = None
@ -28,7 +26,7 @@ class MinMax:
def center(self) -> Any:
"""Get middle point between minimum and maximum."""
return (self.min_ + self.max_) / 2
return (self.min_ + self.max_) / 2.0
def is_empty(self) -> bool:
"""Check if interval is empty."""

View file

@ -17,25 +17,25 @@ def check_and_create(directory: Path) -> Path:
class Workspace:
"""
Project file and directory paths and generated files and directories.
"""
"""Project file and directory paths and generated files and directories."""
# Project directories and files, that are the part of the repository.
SCHEME_PATH: Path = HERE / Path("scheme")
SCHEME_PATH: Path = HERE / "scheme"
DEFAULT_SCHEME_PATH: Path = SCHEME_PATH / "default.yml"
ICONS_PATH: Path = HERE / Path("icons/icons.svg")
ICONS_CONFIG_PATH: Path = HERE / Path("icons/config.json")
GITHUB_TEST_PATH: Path = Path(".github/workflows/test.yml")
ICONS_PATH: Path = HERE / "icons" / "icons.svg"
ICONS_CONFIG_PATH: Path = HERE / "icons" / "config.json"
ICONS_LICENSE_PATH: Path = HERE / "icons" / "LICENSE"
DOCUMENTATION_PATH: Path = Path("doc")
GRID_PATH: Path = DOCUMENTATION_PATH / "grid.svg"
# Generated directories and files.
MAPCSS_ICONS_DIRECTORY_NAME: str = "icons"
def __init__(self, output_path: Path) -> None:
self.output_path: Path = output_path
check_and_create(output_path)
self.output_path: Path = check_and_create(output_path)
self._icons_by_id_path: Path = output_path / "icons_by_id"
self._icons_by_name_path: Path = output_path / "icons_by_name"

View file

@ -3,7 +3,7 @@ colour>=0.1.5
numpy>=1.18.1
Pillow>=8.2.0
portolan>=1.0.1
pycairo
pycairo>=1.20.1
pytest>=6.2.2
PyYAML>=4.2b1
setuptools>=51.0.0

View file

@ -14,13 +14,21 @@ from map_machine import (
REQUIREMENTS,
)
with Path("README.md").open() as input_file:
with Path("README.md").open(encoding="utf-8") as input_file:
long_description: str = input_file.read()
setup(
name="map-machine",
version=__version__,
packages=["map_machine"],
packages=[
"map_machine",
"map_machine.feature",
"map_machine.geometry",
"map_machine.osm",
"map_machine.pictogram",
"map_machine.slippy",
"map_machine.ui",
],
url=__url__,
project_urls={
"Bug Tracker": f"{__url__}/issues",

View file

@ -3,7 +3,7 @@ Tests for Map Machine project.
"""
from pathlib import Path
from map_machine.icon import ShapeExtractor
from map_machine.pictogram.icon import ShapeExtractor
from map_machine.scheme import Scheme
from map_machine.workspace import Workspace
@ -12,7 +12,7 @@ __email__ = "me@enzet.ru"
workspace: Workspace = Workspace(Path("temp"))
SCHEME: Scheme = Scheme(workspace.DEFAULT_SCHEME_PATH)
SCHEME: Scheme = Scheme.from_file(workspace.DEFAULT_SCHEME_PATH)
SHAPE_EXTRACTOR: ShapeExtractor = ShapeExtractor(
workspace.ICONS_PATH, workspace.ICONS_CONFIG_PATH
)

10
tests/data/tag_tests.json Normal file
View file

@ -0,0 +1,10 @@
[
{"tags": {"addr:housenumber": "5"}, "labels": ["5"]},
{
"tags": {"amenity": "cafe", "name": "Nero"},
"icons": [["coffee_cup"]], "labels": ["Nero"]
},
{"tags": {"building": "yes"}, "icons": [["building"]]},
{"tags": {"natural": "tree"}, "icons": [["tree"]]},
{"tags": {"man_made": "surveillance"}, "icons": [["cctv"]]}
]

View file

@ -1,7 +1,7 @@
"""
Test boundary box.
"""
from map_machine.boundary_box import BoundaryBox
from map_machine.geometry.boundary_box import BoundaryBox
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -25,3 +25,23 @@ def test_round_coordinates() -> None:
).round()
assert box.get_format() == "10.067,46.093,10.070,46.096"
def test_boundary_box_parsing() -> None:
"""Test parsing boundary box from text."""
assert BoundaryBox.from_text("-0.1,-0.1,0.1,0.1") == BoundaryBox(
-0.1, -0.1, 0.1, 0.1
)
# Negative horizontal boundary.
assert BoundaryBox.from_text("0.1,-0.1,-0.1,0.1") is None
# Negative vertical boundary.
assert BoundaryBox.from_text("-0.1,0.1,0.1,-0.1") is None
# Wrong format.
assert BoundaryBox.from_text("wrong") is None
assert BoundaryBox.from_text("-O.1,-0.1,0.1,0.1") is None
# Too big boundary box.
assert BoundaryBox.from_text("-20,-20,20,20") is None

View file

@ -12,22 +12,31 @@ from typing import List
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from map_machine.ui import COMMANDS
from map_machine.ui.cli import COMMAND_LINES
LOG: bytes = (
b"INFO Constructing ways...\n"
b"INFO Constructing nodes...\n"
b"INFO Drawing ways...\n"
b"INFO Drawing main icons...\n"
b"INFO Drawing extra icons...\n"
b"INFO Drawing texts...\n"
)
def error_run(arguments: List[str], message: bytes) -> None:
"""Run command that should fail and check error message."""
p = Popen(["map-machine"] + arguments, stderr=PIPE)
_, error = p.communicate()
assert p.returncode != 0
with Popen(["map-machine"] + arguments, stderr=PIPE) as pipe:
_, error = pipe.communicate()
assert pipe.returncode != 0
assert error == message
def run(arguments: List[str], message: bytes) -> None:
"""Run command that should fail and check error message."""
p = Popen(["map-machine"] + arguments, stderr=PIPE)
_, error = p.communicate()
assert p.returncode == 0
with Popen(["map-machine"] + arguments, stderr=PIPE) as pipe:
_, error = pipe.communicate()
assert pipe.returncode == 0
assert error == message
@ -43,15 +52,15 @@ def test_wrong_render_arguments() -> None:
def test_render() -> None:
"""Test `render` command."""
run(
COMMANDS["render"] + ["--cache", "tests/data"],
b"INFO Writing output SVG to out/map.svg...\n",
COMMAND_LINES["render"] + ["--cache", "tests/data"],
LOG + b"INFO Writing output SVG to out/map.svg...\n",
)
with Path("out/map.svg").open() as output_file:
with Path("out/map.svg").open(encoding="utf-8") as output_file:
root: Element = ElementTree.parse(output_file).getroot()
# 4 expected elements: `defs`, `rect` (background), `g` (outline),
# `g` (icon).
assert len(root) == 4
# `g` (icon), 4 `text` elements (credits).
assert len(root) == 8
assert len(root[3][0]) == 0
assert root.get("width") == "186.0"
assert root.get("height") == "198.0"
@ -60,15 +69,15 @@ def test_render() -> None:
def test_render_with_tooltips() -> None:
"""Test `render` command."""
run(
COMMANDS["render_with_tooltips"] + ["--cache", "tests/data"],
b"INFO Writing output SVG to out/map.svg...\n",
COMMAND_LINES["render_with_tooltips"] + ["--cache", "tests/data"],
LOG + b"INFO Writing output SVG to out/map.svg...\n",
)
with Path("out/map.svg").open() as output_file:
with Path("out/map.svg").open(encoding="utf-8") as output_file:
root: Element = ElementTree.parse(output_file).getroot()
# 4 expected elements: `defs`, `rect` (background), `g` (outline),
# `g` (icon).
assert len(root) == 4
# `g` (icon), 4 `text` elements (credits).
assert len(root) == 8
assert len(root[3][0]) == 1
assert root[3][0][0].text == "natural: tree"
assert root.get("width") == "186.0"
@ -78,9 +87,10 @@ def test_render_with_tooltips() -> None:
def test_icons() -> None:
"""Test `icons` command."""
run(
COMMANDS["icons"],
COMMAND_LINES["icons"],
b"INFO Icons are written to out/icons_by_name and out/icons_by_id.\n"
b"INFO Icon grid is written to out/icon_grid.svg.\n"
b"INFO Icons are written to out/icons_by_name and out/icons_by_id.\n",
b"INFO Icon grid is written to doc/grid.svg.\n",
)
assert (Path("out") / "icon_grid.svg").is_file()
@ -93,30 +103,32 @@ def test_icons() -> None:
def test_mapcss() -> None:
"""Test `mapcss` command."""
run(
COMMANDS["mapcss"],
COMMAND_LINES["mapcss"],
b"INFO MapCSS 0.2 scheme is written to out/map_machine_mapcss.\n",
)
out_path: Path = Path("out") / "map_machine_mapcss"
assert (Path("out") / "map_machine_mapcss").is_dir()
assert (Path("out") / "map_machine_mapcss" / "icons").is_dir()
assert (
Path("out") / "map_machine_mapcss" / "icons" / "apple.svg"
).is_file()
assert (Path("out") / "map_machine_mapcss" / "map_machine.mapcss").is_file()
assert out_path.is_dir()
assert out_path.is_dir()
assert (out_path / "icons" / "apple.svg").is_file()
assert (out_path / "map_machine.mapcss").is_file()
assert (out_path / "icons" / "LICENSE").is_file()
def test_element() -> None:
"""Test `element` command."""
run(COMMANDS["element"], b"INFO Element is written to out/element.svg.\n")
run(
COMMAND_LINES["element"],
b"INFO Element is written to out/element.svg.\n",
)
assert (Path("out") / "element.svg").is_file()
def test_tile() -> None:
"""Test `tile` command."""
run(
COMMANDS["tile"] + ["--cache", "tests/data"],
b"INFO Tile is drawn to out/tiles/tile_18_160199_88904.svg.\n"
COMMAND_LINES["tile"] + ["--cache", "tests/data"],
LOG + b"INFO Tile is drawn to out/tiles/tile_18_160199_88904.svg.\n"
b"INFO SVG file is rasterized to out/tiles/tile_18_160199_88904.png.\n",
)

10
tests/test_completion.py Normal file
View file

@ -0,0 +1,10 @@
"""
Test Fish shell completion.
"""
from map_machine.ui.completion import completion_commands
def test_completion() -> None:
"""Test Fish shell completion generation."""
commands: str = completion_commands()
assert commands.startswith("set -l")

View file

@ -3,7 +3,7 @@ Test direction processing.
"""
import numpy as np
from map_machine.direction import DirectionSet, parse_vector
from map_machine.feature.direction import DirectionSet, parse_vector, Sector
__author__ = "Sergey Vartanov"
__email__ = "me@enzet.ru"
@ -41,3 +41,14 @@ def test_main_direction() -> None:
assert DirectionSet("70").is_right() is True
assert DirectionSet("270").is_right() is False
assert DirectionSet("180").is_right() is None
def test_sector_parsing() -> None:
"""Test constructing sector from the string representation."""
Sector("0", angle=0)
Sector("90", angle=0)
Sector("-90", angle=0)
sector: Sector = Sector("0-180")
assert np.allclose(sector.start, [0, -1])
assert np.allclose(sector.end, [0, 1])

Some files were not shown because too many files have changed in this diff Show more