Merge main.
4
.dockerignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
Dockerfile
|
||||
.git/
|
||||
tests/
|
||||
|
34
.github/CONTRIBUTING.md
vendored
|
@ -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.
|
||||
|
||||
|
|
2
.github/workflows/test.yml
vendored
|
@ -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
|
@ -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
|
@ -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
|
@ -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).
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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/).
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||

|
||||
|
||||
### Emergency ###
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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)).
|
||||
|
||||

|
||||
|
||||
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)).
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
#### 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.
|
||||
|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||

|
||||
|
||||
Wireframe view
|
||||
--------------
|
||||
|
@ -109,38 +128,40 @@ Wireframe view
|
|||
|
||||
Visualize element creation time with `--mode time`.
|
||||
|
||||

|
||||

|
||||
|
||||
### Author mode ###
|
||||
|
||||
Every way and node displayed with the random color picked for each author with `--mode author`.
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
|||
|
||||

|
||||
|
||||
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
|
@ -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
|
@ -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>
|
|
@ -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__":
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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
|
@ -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
|
@ -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
|
||||
```
|
BIN
doc/author.png
Before Width: | Height: | Size: 415 KiB |
2
doc/author.svg
Normal file
After Width: | Height: | Size: 610 KiB |
Before Width: | Height: | Size: 102 KiB |
2
doc/buildings.svg
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
doc/colors.png
Before Width: | Height: | Size: 213 KiB |
2
doc/colors.svg
Normal file
After Width: | Height: | Size: 7.7 MiB |
|
@ -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.
|
||||
|
|
Before Width: | Height: | Size: 6 KiB |
BIN
doc/grid.png
Before Width: | Height: | Size: 107 KiB |
2
doc/grid.svg
Normal file
After Width: | Height: | Size: 424 KiB |
2
doc/icons_emergency.svg
Normal 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
After Width: | Height: | Size: 5.8 KiB |
2
doc/icons_power.svg
Normal file
After Width: | Height: | Size: 31 KiB |
23
doc/install.moi
Normal 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}
|
BIN
doc/japanese.png
Before Width: | Height: | Size: 8.3 KiB |
BIN
doc/lanes.png
Before Width: | Height: | Size: 195 KiB |
2
doc/lanes.svg
Normal file
After Width: | Height: | Size: 342 KiB |
2
doc/mast.svg
Normal file
After Width: | Height: | Size: 37 KiB |
BIN
doc/power.png
Before Width: | Height: | Size: 135 KiB |
2
doc/power.svg
Normal file
After Width: | Height: | Size: 346 KiB |
Before Width: | Height: | Size: 16 KiB |
167
doc/readme.moi
|
@ -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}
|
||||
|
|
Before Width: | Height: | Size: 105 KiB |
2
doc/surveillance.svg
Normal file
After Width: | Height: | Size: 242 KiB |
BIN
doc/time.png
Before Width: | Height: | Size: 399 KiB |
2
doc/time.svg
Normal file
After Width: | Height: | Size: 610 KiB |
BIN
doc/trees.png
Before Width: | Height: | Size: 164 KiB |
2
doc/trees.svg
Normal file
After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 106 KiB |
2
doc/viewpoints.svg
Normal file
After Width: | Height: | Size: 207 KiB |
2
doc/volcano.svg
Normal file
After Width: | Height: | Size: 17 KiB |
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
3
map_machine/doc/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Documentation utilities.
|
||||
"""
|
335
map_machine/doc/collections.py
Normal 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
|
@ -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()
|
|
@ -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 key–value 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 key–value 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
|
@ -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])
|
|
@ -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
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
3
map_machine/feature/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Specific map features: roads, directions, etc.
|
||||
"""
|
215
map_machine/feature/building.py
Normal 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)
|
47
map_machine/feature/crater.py
Normal 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)
|
|
@ -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)
|
|
@ -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)
|
36
map_machine/feature/tree.py
Normal 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"))
|
|
@ -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)
|
||||
|
|
3
map_machine/geometry/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Map geometry: dealing with coordinates, projections.
|
||||
"""
|
|
@ -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)
|
|
@ -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
|
174
map_machine/geometry/vector.py
Normal 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
|
|
@ -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,
|
||||
|
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.9 MiB |
|
@ -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__":
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
3
map_machine/osm/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
OpenStreetMap-specific things.
|
||||
"""
|
|
@ -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:
|
|
@ -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":
|
3
map_machine/pictogram/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Icons and points.
|
||||
"""
|
|
@ -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]
|
|
@ -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}.")
|
|
@ -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(""", '"')
|
||||
text = text.replace("&", "&")
|
||||
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))
|
|
@ -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:
|
||||
|
|
3
map_machine/slippy/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Tiles generation for slippy maps.
|
||||
"""
|
|
@ -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)
|
|
@ -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:
|
|
@ -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
|
||||
|
|
3
map_machine/ui/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
User interface.
|
||||
"""
|
|
@ -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"
|
||||
)
|
90
map_machine/ui/completion.py
Normal 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())
|
|
@ -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."""
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
12
setup.py
|
@ -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",
|
||||
|
|
|
@ -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
|
@ -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"]]}
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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")
|
|
@ -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])
|
||||
|
|