├── .flake8 ├── .github └── workflows │ ├── codeql-analysis.yml │ └── main.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── examples ├── custom_objects.py ├── draw_gpx.py ├── frankfurt_newyork.py ├── freiburg_area.py ├── geodesic_circles.py ├── idl.py ├── running.gpx ├── start.png ├── tile_providers.py └── us_capitals.py ├── mypy.ini ├── requirements-cairo.txt ├── requirements-dev.txt ├── requirements-examples.txt ├── requirements.txt ├── setup.py ├── staticmaps ├── __init__.py ├── area.py ├── cairo_renderer.py ├── circle.py ├── cli.py ├── color.py ├── context.py ├── coordinates.py ├── image_marker.py ├── line.py ├── marker.py ├── meta.py ├── object.py ├── pillow_renderer.py ├── renderer.py ├── svg_renderer.py ├── tile_downloader.py ├── tile_provider.py └── transformer.py └── tests ├── __init__.py ├── mock_tile_downloader.py ├── test_color.py ├── test_context.py ├── test_coordinates.py ├── test_line.py ├── test_marker.py └── test_tile_provider.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '33 1 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint_and_test: 7 | runs-on: ${{matrix.os}} 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | python-version: [3.11] 13 | steps: 14 | - name: Git config 15 | run: git config --global core.autocrlf input 16 | - uses: actions/checkout@v2 17 | - name: Set up Python v${{matrix.python-version}} - ${{runner.os}} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{matrix.python-version}} 21 | cache: pip 22 | - name: Display Python version 23 | run: python --version 24 | - name: Install Python dependencies 25 | run: | 26 | python -m pip install --upgrade pip setuptools wheel 27 | pip install -r requirements.txt 28 | pip install -r requirements-dev.txt 29 | pip install -r requirements-examples.txt 30 | - name: Check formatting (black) 31 | run: black --line-length 120 --check --diff staticmaps examples tests 32 | - name: Lint (pylint) 33 | run: pylint staticmaps examples tests 34 | - name: Lint (flake8) 35 | run: flake8 staticmaps examples tests 36 | - name: Check types (mypy) 37 | run: mypy staticmaps examples tests 38 | - name: Run tests (pytest) 39 | run: python -m pytest tests 40 | build: 41 | runs-on: ${{matrix.os}} 42 | strategy: 43 | matrix: 44 | os: [ubuntu-latest] 45 | python-version: [3.7] 46 | needs: "lint_and_test" 47 | steps: 48 | - name: Git config 49 | run: git config --global core.autocrlf input 50 | - uses: actions/checkout@v2 51 | - name: Set up Python v${{matrix.python-version}} - ${{runner.os}} 52 | uses: actions/setup-python@v2 53 | with: 54 | python-version: ${{matrix.python-version}} 55 | cache: pip 56 | - name: Install Python dependencies 57 | run: | 58 | python -m pip install --upgrade pip setuptools wheel 59 | pip install -r requirements.txt 60 | pip install -r requirements-examples.txt 61 | - name: Build examples 62 | run: | 63 | cd examples 64 | mkdir build 65 | PYTHONPATH=.. python custom_objects.py 66 | PYTHONPATH=.. python draw_gpx.py running.gpx 67 | PYTHONPATH=.. python frankfurt_newyork.py 68 | PYTHONPATH=.. python freiburg_area.py 69 | PYTHONPATH=.. python geodesic_circles.py 70 | PYTHONPATH=.. python tile_providers.py 71 | PYTHONPATH=.. python us_capitals.py 72 | (ls *.svg && mv *.svg build/.) || echo "no svg files found!" 73 | (ls *pillow*.png && mv *pillow*.png build/.) || echo "no pillow png files found!" 74 | (ls *cairo*.png && mv *cairo*.png build/.) || echo "no cairo png files found!" 75 | cd - 76 | - name: Archive examples 77 | uses: actions/upload-artifact@v2 78 | with: 79 | name: build_examples 80 | path: examples/build 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | *.pyc 4 | # sqlite 5 | *.sql 6 | *.sqlite3 7 | *.sqlite 8 | # Sphinx documentation build 9 | _build/ 10 | # Testing 11 | .cache/ 12 | .eggs/ 13 | junit*.xml 14 | # virtualenv 15 | .env*/ 16 | env*/ 17 | .venv*/ 18 | venv*/ 19 | # setuptools 20 | *.egg-info 21 | dist/ 22 | # coverage.py 23 | .coverage 24 | htmlcov/ 25 | coverage*.xml 26 | # Temporary data directory 27 | tmp/* 28 | .pytest_cache/* 29 | # OSX 30 | .DS_Store 31 | # PyCharm 32 | .idea 33 | # VS Code 34 | .vscode 35 | pylint_usage_code.txt 36 | pylint_usage_tests.txt 37 | rackguru-api.iml 38 | # Jupyter 39 | .ipynb_checkpoints/ 40 | *.ipynb 41 | # Static files 42 | # 43 | # Build 44 | dist 45 | sdist 46 | .tox 47 | # mypy 48 | .mypy_cache 49 | 50 | # Project specific 51 | gpx_dir/* 52 | output/* 53 | data 54 | *.svg 55 | *.gif 56 | *.mov 57 | *.avi 58 | *.mpg 59 | *.mpeg 60 | *.mp4 61 | *.mkv 62 | *.wmv 63 | examples/build -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=.tox,.env,.venv,.eggs,build,migrations,south_migrations 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # Use multiple processes to speed up Pylint. 18 | jobs=0 19 | 20 | # Allow loading of arbitrary C extensions. Extensions are imported into the 21 | # active Python interpreter and may run arbitrary code. 22 | unsafe-load-any-extension=no 23 | 24 | # A comma-separated list of package or module names from where C extensions may 25 | # be loaded. Extensions are loading into the active Python interpreter and may 26 | # run arbitrary code 27 | extension-pkg-whitelist= 28 | 29 | 30 | [MESSAGES CONTROL] 31 | 32 | # Only show warnings with the listed confidence levels. Leave empty to show 33 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 34 | confidence= 35 | 36 | # Enable the message, report, category or checker with the given id(s). You can 37 | # either give multiple identifier separated by comma (,) or put this option 38 | # multiple time. See also the "--disable" option for examples. 39 | #enable= 40 | 41 | # Disable the message, report, category or checker with the given id(s). You 42 | # can either give multiple identifiers separated by comma (,) or put this 43 | # option multiple times (only on the command line, not in the configuration 44 | # file where it should appear only once).You can also use "--disable=all" to 45 | # disable everything first and then reenable specific checks. For example, if 46 | # you want to run only the similarities checker, you can use "--disable=all 47 | # --enable=similarities". If you want to run only the classes checker, but have 48 | # no Warning level messages displayed, use"--disable=all --enable=classes 49 | # --disable=W" 50 | disable= 51 | duplicate-code, 52 | missing-docstring, 53 | no-member, 54 | no-value-for-parameter, 55 | too-few-public-methods, 56 | too-many-arguments 57 | 58 | 59 | [REPORTS] 60 | 61 | # Set the output format. Available formats are text, parseable, colorized, msvs 62 | # (visual studio) and html. You can also give a reporter class, eg 63 | # mypackage.mymodule.MyReporterClass. 64 | output-format=colorized 65 | 66 | # Tells whether to display a full report or only the messages 67 | reports=no 68 | 69 | # Python expression which should return a note less than 10 (10 is the highest 70 | # note). You have access to the variables errors warning, statement which 71 | # respectively contain the number of errors / warnings messages and the total 72 | # number of statements analyzed. This is used by the global evaluation report 73 | # (RP0004). 74 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 75 | 76 | # Template used to display messages. This is a python new-style format string 77 | # used to format the message information. See doc for all details 78 | #msg-template= 79 | 80 | 81 | [LOGGING] 82 | 83 | # Logging modules to check that the string format arguments are in logging 84 | # function parameter format 85 | logging-modules=logging 86 | 87 | 88 | [MISCELLANEOUS] 89 | 90 | # List of note tags to take in consideration, separated by a comma. 91 | notes=FIXME,XXX,TODO 92 | 93 | 94 | [SIMILARITIES] 95 | 96 | # Minimum lines number of a similarity. 97 | min-similarity-lines=5 98 | 99 | # Ignore comments when computing similarities. 100 | ignore-comments=yes 101 | 102 | # Ignore docstrings when computing similarities. 103 | ignore-docstrings=no 104 | 105 | # Ignore imports when computing similarities. 106 | ignore-imports=yes 107 | 108 | 109 | [VARIABLES] 110 | 111 | # Tells whether we should check for unused import in __init__ files. 112 | init-import=no 113 | 114 | # A regular expression matching the name of dummy variables (i.e. expectedly 115 | # not used). 116 | dummy-variables-rgx=_$|dummy|tmp$ 117 | 118 | # List of additional names supposed to be defined in builtins. Remember that 119 | # you should avoid to define new builtins when possible. 120 | additional-builtins= 121 | 122 | # List of strings which can identify a callback function by name. A callback 123 | # name must start or end with one of those strings. 124 | callbacks=cb_,_cb 125 | 126 | 127 | [FORMAT] 128 | 129 | # Maximum number of characters on a single line. 130 | max-line-length=120 131 | 132 | # Regexp for a line that is allowed to be longer than the limit. 133 | ignore-long-lines=^\s*(# )??$ 134 | 135 | # Allow the body of an if to be on the same line as the test if there is no 136 | # else. 137 | single-line-if-stmt=no 138 | 139 | # Maximum number of lines in a module 140 | max-module-lines=500 141 | 142 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 143 | # tab). 144 | indent-string=' ' 145 | 146 | # Number of spaces of indent required inside a hanging or continued line. 147 | indent-after-paren=4 148 | 149 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 150 | expected-line-ending-format=LF 151 | 152 | 153 | [BASIC] 154 | 155 | # Good variable names which should always be accepted, separated by a comma 156 | good-names=i,_ 157 | 158 | # Bad variable names which should always be refused, separated by a comma 159 | bad-names=foo,bar,baz,toto,tutu,tata,wtf 160 | 161 | # Colon-delimited sets of names that determine each other's naming style when 162 | # the name regexes allow several styles. 163 | name-group= 164 | 165 | # Include a hint for the correct naming format with invalid-name 166 | include-naming-hint=yes 167 | 168 | # Regular expression matching correct function names 169 | function-rgx=([a-z_][a-z0-9_]{1,40}|test_[A-Za-z0-9_]{3,70})$ 170 | 171 | # Regular expression matching correct variable names 172 | variable-rgx=[a-z_][a-z0-9_]{0,40}$ 173 | 174 | # Regular expression matching correct constant names 175 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|(urls|urlpatterns|register))$ 176 | 177 | # Regular expression matching correct attribute names 178 | attr-rgx=[a-z_][a-z0-9_]{0,30}$ 179 | 180 | # Regular expression matching correct argument names 181 | argument-rgx=[a-z_][a-z0-9_]{0,30}$ 182 | 183 | # Regular expression matching correct class attribute names 184 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{1,40}|(__.*__))$ 185 | 186 | # Regular expression matching correct inline iteration names 187 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 188 | 189 | # Regular expression matching correct class names 190 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 191 | 192 | # Regular expression matching correct module names 193 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 194 | 195 | # Regular expression matching correct method names 196 | method-rgx=[a-z_][a-z0-9_]{1,30}$ 197 | 198 | # Regular expression which should only match function or class names that do 199 | # not require a docstring. 200 | no-docstring-rgx=^_ 201 | 202 | # Minimum line length for functions/classes that require docstrings, shorter 203 | # ones are exempt. 204 | docstring-min-length=-1 205 | 206 | 207 | [ELIF] 208 | 209 | # Maximum number of nested blocks for function / method body 210 | max-nested-blocks=5 211 | 212 | 213 | [TYPECHECK] 214 | 215 | # Tells whether missing members accessed in mixin class should be ignored. A 216 | # mixin class is detected if its name ends with "mixin" (case insensitive). 217 | ignore-mixin-members=yes 218 | 219 | # List of module names for which member attributes should not be checked 220 | # (useful for modules/projects where namespaces are manipulated during runtime 221 | # and thus existing member attributes cannot be deduced by static analysis. 222 | ignored-modules = 223 | 224 | # List of classes names for which member attributes should not be checked 225 | # (useful for classes with attributes dynamically set). 226 | ignored-classes= 227 | 228 | # List of members which are set dynamically and missed by pylint inference 229 | # system, and so shouldn't trigger E1101 when accessed. Python regular 230 | # expressions are accepted. 231 | generated-members= 232 | 233 | 234 | [SPELLING] 235 | 236 | # Spelling dictionary name. Available dictionaries: none. To make it working 237 | # install python-enchant package. 238 | spelling-dict= 239 | 240 | # List of comma separated words that should not be checked. 241 | spelling-ignore-words= 242 | 243 | # A path to a file that contains private dictionary; one word per line. 244 | spelling-private-dict-file= 245 | 246 | # Tells whether to store unknown words to indicated private dictionary in 247 | # --spelling-private-dict-file option instead of raising a message. 248 | spelling-store-unknown-words=no 249 | 250 | 251 | [DESIGN] 252 | 253 | # Maximum number of arguments for function / method 254 | max-args=5 255 | 256 | # Argument names that match this expression will be ignored. Default to name 257 | # with leading underscore 258 | ignored-argument-names=_.* 259 | 260 | # Maximum number of locals for function / method body 261 | max-locals=15 262 | 263 | # Maximum number of return / yield for function / method body 264 | max-returns=6 265 | 266 | # Maximum number of branch for function / method body 267 | max-branches=12 268 | 269 | # Maximum number of statements in function / method body 270 | max-statements=50 271 | 272 | # Maximum number of parents for a class (see R0901). 273 | max-parents=8 274 | 275 | # Maximum number of attributes for a class (see R0902). 276 | max-attributes=7 277 | 278 | # Minimum number of public methods for a class (see R0903). 279 | min-public-methods=1 280 | 281 | # Maximum number of public methods for a class (see R0904). 282 | max-public-methods=24 283 | 284 | # Maximum number of boolean expressions in a if statement 285 | max-bool-expr=5 286 | 287 | 288 | [CLASSES] 289 | 290 | # List of method names used to declare (i.e. assign) instance attributes. 291 | defining-attr-methods=__init__,__new__,setUp 292 | 293 | # List of valid names for the first argument in a class method. 294 | valid-classmethod-first-arg=cls 295 | 296 | # List of valid names for the first argument in a metaclass class method. 297 | valid-metaclass-classmethod-first-arg=mcs 298 | 299 | # List of member names, which should be excluded from the protected access 300 | # warning. 301 | exclude-protected=_meta 302 | 303 | 304 | [IMPORTS] 305 | 306 | # Deprecated modules which should not be used, separated by a comma 307 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 308 | 309 | # Create a graph of every (i.e. internal and external) dependencies in the 310 | # given file (report RP0402 must not be disabled) 311 | import-graph= 312 | 313 | # Create a graph of external dependencies in the given file (report RP0402 must 314 | # not be disabled) 315 | ext-import-graph= 316 | 317 | # Create a graph of internal dependencies in the given file (report RP0402 must 318 | # not be disabled) 319 | int-import-graph= 320 | 321 | 322 | [EXCEPTIONS] 323 | 324 | # Exceptions that will emit a warning when being caught. Defaults to 325 | # "Exception" 326 | overgeneral-exceptions=builtins.Exception 327 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Florian Pigorsch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements*.txt 4 | 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup 2 | setup: 3 | python3 -m venv .env 4 | .env/bin/pip install --upgrade pip 5 | .env/bin/pip install --upgrade --requirement requirements.txt 6 | .env/bin/pip install --upgrade --requirement requirements-dev.txt 7 | .env/bin/pip install --upgrade --requirement requirements-examples.txt 8 | 9 | .PHONY: install 10 | install: setup 11 | .env/bin/pip install . 12 | 13 | .PHONY: lint 14 | lint: 15 | .env/bin/pylint \ 16 | setup.py staticmaps examples tests 17 | .env/bin/flake8 \ 18 | setup.py staticmaps examples tests 19 | .env/bin/mypy \ 20 | setup.py staticmaps examples tests 21 | .env/bin/black \ 22 | --line-length 120 \ 23 | --check \ 24 | --diff \ 25 | setup.py staticmaps examples tests 26 | 27 | .PHONY: format 28 | format: 29 | .env/bin/black \ 30 | --line-length 120 \ 31 | setup.py staticmaps examples tests 32 | 33 | .PHONY: run-examples 34 | run-examples: 35 | (cd examples && PYTHONPATH=.. ../.env/bin/python custom_objects.py) 36 | (cd examples && PYTHONPATH=.. ../.env/bin/python draw_gpx.py running.gpx) 37 | (cd examples && PYTHONPATH=.. ../.env/bin/python frankfurt_newyork.py) 38 | (cd examples && PYTHONPATH=.. ../.env/bin/python freiburg_area.py) 39 | (cd examples && PYTHONPATH=.. ../.env/bin/python geodesic_circles.py) 40 | (cd examples && PYTHONPATH=.. ../.env/bin/python tile_providers.py) 41 | (cd examples && PYTHONPATH=.. ../.env/bin/python us_capitals.py) 42 | (cd examples && PYTHONPATH=.. ../.env/bin/python idl.py) 43 | (cd examples && mv *.svg build/.) 44 | (cd examples && mv *pillow*png build/.) 45 | (cd examples && mv *cairo*png build/.) 46 | (cd -) 47 | .PHONY: test 48 | test: 49 | PYTHONPATH=. .env/bin/python -m pytest tests 50 | 51 | .PHONY: build-package 52 | build-package: 53 | rm -rf dist 54 | PYTHONPATH=. .env/bin/python setup.py sdist 55 | PYTHONPATH=. .env/bin/twine check dist/* 56 | 57 | .PHONY: upload-package-test 58 | upload-package-test: 59 | PYTHONPATH=. .env/bin/twine upload --repository-url https://test.pypi.org/legacy/ dist/* 60 | 61 | .PHONY: upload-package 62 | upload-package: 63 | PYTHONPATH=. .env/bin/twine upload --repository py-staticmaps dist/* 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/flopp/py-staticmaps/workflows/CI/badge.svg)](https://github.com/flopp/py-staticmaps/actions?query=workflow%3ACI) 2 | [![PyPI Package](https://img.shields.io/pypi/v/py-staticmaps.svg)](https://pypi.org/project/py-staticmaps/) 3 | [![Format](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 4 | [![License MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=flat)](LICENSE) 5 | 6 | # py-staticmaps 7 | A python module to create static map images (PNG, SVG) with markers, geodesic lines, etc. 8 | 9 | 10 | ## Features 11 | 12 | - Map objects: pin-style markers, image (PNG) markers, polylines, polygons, (geodesic) circles 13 | - Automatic computation of best center + zoom from the added map objects 14 | - Several pre-configured map tile providers 15 | - Proper tile provider attributions display 16 | - On-disc caching of map tile images for faster drawing and reduced load on the tile servers 17 | - Non-anti-aliased drawing via `PILLOW` 18 | - Anti-aliased drawing via `pycairo` (optional; only if `pycairo` is installed properly) 19 | - SVG creation via `svgwrite` 20 | 21 | 22 | ## Installation 23 | 24 | ### SVG + non-anti-aliased PNG version 25 | 26 | ```shell 27 | pip install py-staticmaps 28 | ``` 29 | 30 | ### SVG + anti-aliased PNG version (via Cairo) 31 | 32 | ```shell 33 | pip install py-staticmaps[cairo] 34 | ``` 35 | 36 | `py-staticmaps` uses `pycairo` for creating anti-aliased raster-graphics, so make sure `libcairo2` is installed on your system (on Ubuntu just install the `libcairo2-dev` package, i.e. `sudo apt install libcairo2-dev`). 37 | 38 | 39 | ## Examples 40 | 41 | Note: PNG support (e.g. `context.render_cairo(...)`) is only available if the `pycairo` module is installed. 42 | 43 | ### Markers and Geodesic Lines 44 | 45 | ```python 46 | import staticmaps 47 | 48 | context = staticmaps.Context() 49 | context.set_tile_provider(staticmaps.tile_provider_StamenToner) 50 | 51 | frankfurt = staticmaps.create_latlng(50.110644, 8.682092) 52 | newyork = staticmaps.create_latlng(40.712728, -74.006015) 53 | 54 | context.add_object(staticmaps.Line([frankfurt, newyork], color=staticmaps.BLUE, width=4)) 55 | context.add_object(staticmaps.Marker(frankfurt, color=staticmaps.GREEN, size=12)) 56 | context.add_object(staticmaps.Marker(newyork, color=staticmaps.RED, size=12)) 57 | 58 | # render non-anti-aliased png 59 | image = context.render_pillow(800, 500) 60 | image.save("frankfurt_newyork.pillow.png") 61 | 62 | # render anti-aliased png (this only works if pycairo is installed) 63 | image = context.render_cairo(800, 500) 64 | image.write_to_png("frankfurt_newyork.cairo.png") 65 | 66 | # render svg 67 | svg_image = context.render_svg(800, 500) 68 | with open("frankfurt_newyork.svg", "w", encoding="utf-8") as f: 69 | svg_image.write(f, pretty=True) 70 | ``` 71 | 72 | ![franfurt_newyork](../assets/frankfurt_newyork.png?raw=true) 73 | 74 | 75 | ### Transparent Polygons 76 | 77 | ```python 78 | import staticmaps 79 | 80 | context = staticmaps.Context() 81 | context.set_tile_provider(staticmaps.tile_provider_OSM) 82 | 83 | freiburg_polygon = [ 84 | (47.96881, 7.79045), 85 | (47.96866, 7.78610), 86 | (47.97134, 7.77874), 87 | ... 88 | ] 89 | 90 | context.add_object( 91 | staticmaps.Area( 92 | [staticmaps.create_latlng(lat, lng) for lat, lng in freiburg_polygon], 93 | fill_color=staticmaps.parse_color("#00FF003F"), 94 | width=2, 95 | color=staticmaps.BLUE, 96 | ) 97 | ) 98 | 99 | # render non-anti-aliased png 100 | image = context.render_pillow(800, 500) 101 | image.save("freiburg_area.pillow.png") 102 | 103 | # render anti-aliased png (this only works if pycairo is installed) 104 | image = context.render_cairo(800, 500) 105 | image.write_to_png("freiburg_area.cairo.png") 106 | 107 | # render svg 108 | svg_image = context.render_svg(800, 500) 109 | with open("freiburg_area.svg", "w", encoding="utf-8") as f: 110 | svg_image.write(f, pretty=True) 111 | ``` 112 | 113 | ![draw_gpx](../assets/freiburg_area.png?raw=true) 114 | 115 | 116 | ### Drawing a GPX Track + Image Marker (PNG) 117 | 118 | ```python 119 | import sys 120 | 121 | import gpxpy 122 | import staticmaps 123 | 124 | context = staticmaps.Context() 125 | context.set_tile_provider(staticmaps.tile_provider_ArcGISWorldImagery) 126 | 127 | with open(sys.argv[1], "r") as file: 128 | gpx = gpxpy.parse(file) 129 | 130 | for track in gpx.tracks: 131 | for segment in track.segments: 132 | line = [staticmaps.create_latlng(p.latitude, p.longitude) for p in segment.points] 133 | context.add_object(staticmaps.Line(line)) 134 | 135 | for p in gpx.walk(only_points=True): 136 | pos = staticmaps.create_latlng(p.latitude, p.longitude) 137 | marker = staticmaps.ImageMarker(pos, "start.png", origin_x=27, origin_y=35) 138 | context.add_object(marker) 139 | break 140 | 141 | # render non-anti-aliased png 142 | image = context.render_pillow(800, 500) 143 | image.save("draw_gpx.pillow.png") 144 | 145 | # render anti-aliased png (this only works if pycairo is installed) 146 | image = context.render_cairo(800, 500) 147 | image.write_to_png("draw_gpx.cairo.png") 148 | ``` 149 | 150 | ![draw_gpx](../assets/draw_gpx.png?raw=true) 151 | 152 | 153 | ### US State Capitals 154 | 155 | ```python 156 | import json 157 | import requests 158 | import staticmaps 159 | 160 | context = staticmaps.Context() 161 | context.set_tile_provider(staticmaps.tile_provider_OSM) 162 | 163 | URL = ( 164 | "https://gist.githubusercontent.com/jpriebe/d62a45e29f24e843c974/" 165 | "raw/b1d3066d245e742018bce56e41788ac7afa60e29/us_state_capitals.json" 166 | ) 167 | response = requests.get(URL) 168 | for _, data in json.loads(response.text).items(): 169 | capital = staticmaps.create_latlng(float(data["lat"]), float(data["long"])) 170 | context.add_object(staticmaps.Marker(capital, size=5)) 171 | 172 | # render non-anti-aliased png 173 | image = context.render_pillow(800, 500) 174 | image.save("us_capitals.pillow.png") 175 | 176 | # render anti-aliased png (this only works if pycairo is installed) 177 | image = context.render_cairo(800, 500) 178 | image.write_to_png("us_capitals.cairo.png") 179 | ``` 180 | 181 | ![us_capitals](../assets/us_capitals.png?raw=true) 182 | 183 | 184 | ### Geodesic Circles 185 | 186 | ```python 187 | import staticmaps 188 | 189 | context = staticmaps.Context() 190 | context.set_tile_provider(staticmaps.tile_provider_StamenToner) 191 | 192 | center1 = staticmaps.create_latlng(66, 0) 193 | center2 = staticmaps.create_latlng(0, 0) 194 | 195 | context.add_object(staticmaps.Circle(center1, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.RED, width=2)) 196 | context.add_object(staticmaps.Circle(center2, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.GREEN, width=2)) 197 | context.add_object(staticmaps.Marker(center1, color=staticmaps.RED)) 198 | context.add_object(staticmaps.Marker(center2, color=staticmaps.GREEN)) 199 | 200 | # render non-anti-aliased png 201 | image = context.render_pillow(800, 500) 202 | image.save("geodesic_circles.pillow.png") 203 | 204 | # render anti-aliased png (this only works if pycairo is installed) 205 | image = context.render_cairo(800, 600) 206 | image.write_to_png("geodesic_circles.cairo.png") 207 | ``` 208 | 209 | ![geodesic_circles](../assets/geodesic_circles.png?raw=true) 210 | 211 | 212 | ### Other Examples 213 | 214 | Please take a look at the command line program which uses the `staticmaps` package: `staticmaps/cli.py` 215 | 216 | 217 | ### Dependencies 218 | 219 | `py-staticmaps` uses 220 | 221 | - `PILLOW` for rendering raster-graphics 222 | - `pycairo` for rendering antialiased raster-graphics (optional!) 223 | - `svgwrite` for writing SVG files 224 | - `s2sphere` for geo coordinates handling 225 | - `geographiclib` for geodesic computations 226 | - `appdirs` for finding the user's default cache directory 227 | - `requests` for downloading tile files 228 | 229 | 230 | ## License 231 | 232 | [MIT](LICENSE) © 2020-2021 Florian Pigorsch 233 | -------------------------------------------------------------------------------- /examples/custom_objects.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2021 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | try: 7 | import cairo # type: ignore 8 | except ImportError: 9 | pass 10 | import s2sphere # type: ignore 11 | import staticmaps 12 | 13 | 14 | class TextLabel(staticmaps.Object): 15 | def __init__(self, latlng: s2sphere.LatLng, text: str) -> None: 16 | staticmaps.Object.__init__(self) 17 | self._latlng = latlng 18 | self._text = text 19 | self._margin = 4 20 | self._arrow = 16 21 | self._font_size = 12 22 | 23 | def latlng(self) -> s2sphere.LatLng: 24 | return self._latlng 25 | 26 | def bounds(self) -> s2sphere.LatLngRect: 27 | return s2sphere.LatLngRect.from_point(self._latlng) 28 | 29 | def extra_pixel_bounds(self) -> staticmaps.PixelBoundsT: 30 | # Guess text extents. 31 | tw = len(self._text) * self._font_size * 0.5 32 | th = self._font_size * 1.2 33 | w = max(self._arrow, tw + 2.0 * self._margin) 34 | return (int(w / 2.0), int(th + 2.0 * self._margin + self._arrow), int(w / 2), 0) 35 | 36 | def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: 37 | x, y = renderer.transformer().ll2pixel(self.latlng()) 38 | x = x + renderer.offset_x() 39 | 40 | left, top, right, bottom = renderer.draw().textbbox((0, 0), self._text) 41 | th = bottom - top 42 | tw = right - left 43 | w = max(self._arrow, tw + 2 * self._margin) 44 | h = th + 2 * self._margin 45 | 46 | path = [ 47 | (x, y), 48 | (x + self._arrow / 2, y - self._arrow), 49 | (x + w / 2, y - self._arrow), 50 | (x + w / 2, y - self._arrow - h), 51 | (x - w / 2, y - self._arrow - h), 52 | (x - w / 2, y - self._arrow), 53 | (x - self._arrow / 2, y - self._arrow), 54 | ] 55 | 56 | renderer.draw().polygon(path, fill=(255, 255, 255, 255)) 57 | renderer.draw().line(path, fill=(255, 0, 0, 255)) 58 | renderer.draw().text((x - tw / 2, y - self._arrow - h / 2 - th / 2), self._text, fill=(0, 0, 0, 255)) 59 | 60 | def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: 61 | x, y = renderer.transformer().ll2pixel(self.latlng()) 62 | 63 | ctx = renderer.context() 64 | ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 65 | 66 | ctx.set_font_size(self._font_size) 67 | x_bearing, y_bearing, tw, th, _, _ = ctx.text_extents(self._text) 68 | 69 | w = max(self._arrow, tw + 2 * self._margin) 70 | h = th + 2 * self._margin 71 | 72 | path = [ 73 | (x, y), 74 | (x + self._arrow / 2, y - self._arrow), 75 | (x + w / 2, y - self._arrow), 76 | (x + w / 2, y - self._arrow - h), 77 | (x - w / 2, y - self._arrow - h), 78 | (x - w / 2, y - self._arrow), 79 | (x - self._arrow / 2, y - self._arrow), 80 | ] 81 | 82 | ctx.set_source_rgb(1, 1, 1) 83 | ctx.new_path() 84 | for p in path: 85 | ctx.line_to(*p) 86 | ctx.close_path() 87 | ctx.fill() 88 | 89 | ctx.set_source_rgb(1, 0, 0) 90 | ctx.set_line_width(1) 91 | ctx.new_path() 92 | for p in path: 93 | ctx.line_to(*p) 94 | ctx.close_path() 95 | ctx.stroke() 96 | 97 | ctx.set_source_rgb(0, 0, 0) 98 | ctx.set_line_width(1) 99 | ctx.move_to(x - tw / 2 - x_bearing, y - self._arrow - h / 2 - y_bearing - th / 2) 100 | ctx.show_text(self._text) 101 | ctx.stroke() 102 | 103 | def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: 104 | x, y = renderer.transformer().ll2pixel(self.latlng()) 105 | 106 | # guess text extents 107 | tw = len(self._text) * self._font_size * 0.5 108 | th = self._font_size * 1.2 109 | 110 | w = max(self._arrow, tw + 2 * self._margin) 111 | h = th + 2 * self._margin 112 | 113 | path = renderer.drawing().path( 114 | fill="#ffffff", 115 | stroke="#ff0000", 116 | stroke_width=1, 117 | opacity=1.0, 118 | ) 119 | path.push(f"M {x} {y}") 120 | path.push(f" l {self._arrow / 2} {-self._arrow}") 121 | path.push(f" l {w / 2 - self._arrow / 2} 0") 122 | path.push(f" l 0 {-h}") 123 | path.push(f" l {-w} 0") 124 | path.push(f" l 0 {h}") 125 | path.push(f" l {w / 2 - self._arrow / 2} 0") 126 | path.push("Z") 127 | renderer.group().add(path) 128 | 129 | renderer.group().add( 130 | renderer.drawing().text( 131 | self._text, 132 | text_anchor="middle", 133 | dominant_baseline="central", 134 | insert=(x, y - self._arrow - h / 2), 135 | font_family="sans-serif", 136 | font_size=f"{self._font_size}px", 137 | fill="#000000", 138 | ) 139 | ) 140 | 141 | 142 | context = staticmaps.Context() 143 | 144 | p1 = staticmaps.create_latlng(48.005774, 7.834042) 145 | p2 = staticmaps.create_latlng(47.988716, 7.868804) 146 | p3 = staticmaps.create_latlng(47.985958, 7.824601) 147 | 148 | context.add_object(TextLabel(p1, "X")) 149 | context.add_object(TextLabel(p2, "Label")) 150 | context.add_object(TextLabel(p3, "This is a very long text label")) 151 | 152 | context.set_tile_provider(staticmaps.tile_provider_CartoDarkNoLabels) 153 | 154 | # render png via pillow 155 | image = context.render_pillow(800, 500) 156 | image.save("custom_objects.pillow.png") 157 | 158 | # render png via cairo 159 | if staticmaps.cairo_is_supported(): 160 | cairo_image = context.render_cairo(800, 500) 161 | cairo_image.write_to_png("custom_objects.cairo.png") 162 | 163 | # render svg 164 | svg_image = context.render_svg(800, 500) 165 | with open("custom_objects.svg", "w", encoding="utf-8") as f: 166 | svg_image.write(f, pretty=True) 167 | -------------------------------------------------------------------------------- /examples/draw_gpx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import sys 7 | 8 | import gpxpy # type: ignore 9 | import staticmaps 10 | 11 | context = staticmaps.Context() 12 | context.set_tile_provider(staticmaps.tile_provider_ArcGISWorldImagery) 13 | 14 | with open(sys.argv[1], "r", encoding="utf-8") as file: 15 | gpx = gpxpy.parse(file) 16 | 17 | for track in gpx.tracks: 18 | for segment in track.segments: 19 | line = [staticmaps.create_latlng(p.latitude, p.longitude) for p in segment.points] 20 | context.add_object(staticmaps.Line(line)) 21 | 22 | # add an image marker to the first track point 23 | for p in gpx.walk(only_points=True): 24 | pos = staticmaps.create_latlng(p.latitude, p.longitude) 25 | marker = staticmaps.ImageMarker(pos, "start.png", origin_x=27, origin_y=35) 26 | context.add_object(marker) 27 | break 28 | 29 | # render png via pillow 30 | image = context.render_pillow(800, 500) 31 | image.save("running.pillow.png") 32 | 33 | # render png via cairo 34 | if staticmaps.cairo_is_supported(): 35 | cairo_image = context.render_cairo(800, 500) 36 | cairo_image.write_to_png("running.cairo.png") 37 | 38 | # render svg 39 | svg_image = context.render_svg(800, 500) 40 | with open("running.svg", "w", encoding="utf-8") as f: 41 | svg_image.write(f, pretty=True) 42 | -------------------------------------------------------------------------------- /examples/frankfurt_newyork.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import staticmaps 7 | 8 | context = staticmaps.Context() 9 | context.set_tile_provider(staticmaps.tile_provider_ArcGISWorldImagery) 10 | 11 | frankfurt = staticmaps.create_latlng(50.110644, 8.682092) 12 | newyork = staticmaps.create_latlng(40.712728, -74.006015) 13 | 14 | context.add_object(staticmaps.Line([frankfurt, newyork], color=staticmaps.BLUE, width=4)) 15 | context.add_object(staticmaps.Marker(frankfurt, color=staticmaps.GREEN, size=12)) 16 | context.add_object(staticmaps.Marker(newyork, color=staticmaps.RED, size=12)) 17 | 18 | # render png via pillow 19 | image = context.render_pillow(800, 500) 20 | image.save("frankfurt_newyork.pillow.png") 21 | 22 | # render png via cairo 23 | if staticmaps.cairo_is_supported(): 24 | cairo_image = context.render_cairo(800, 500) 25 | cairo_image.write_to_png("frankfurt_newyork.cairo.png") 26 | 27 | # render svg 28 | svg_image = context.render_svg(800, 500) 29 | with open("frankfurt_newyork.svg", "w", encoding="utf-8") as f: 30 | svg_image.write(f, pretty=True) 31 | -------------------------------------------------------------------------------- /examples/freiburg_area.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import staticmaps 7 | 8 | context = staticmaps.Context() 9 | context.set_tile_provider(staticmaps.tile_provider_OSM) 10 | 11 | freiburg_polygon = [ 12 | (47.96881, 7.79045), 13 | (47.96866, 7.78610), 14 | (47.97134, 7.77874), 15 | (47.97305, 7.77517), 16 | (47.97464, 7.77492), 17 | (47.97631, 7.77099), 18 | (47.97746, 7.77029), 19 | (47.97840, 7.76794), 20 | (47.97700, 7.76551), 21 | (47.97683, 7.76318), 22 | (47.97852, 7.75531), 23 | (47.97940, 7.75589), 24 | (47.97791, 7.74972), 25 | (47.97764, 7.74706), 26 | (47.97817, 7.74235), 27 | (47.97844, 7.73899), 28 | (47.97696, 7.73655), 29 | (47.97688, 7.73311), 30 | (47.97549, 7.73054), 31 | (47.97563, 7.73048), 32 | (47.97575, 7.73039), 33 | (47.97585, 7.73029), 34 | (47.97596, 7.73016), 35 | (47.97606, 7.73000), 36 | (47.97626, 7.72969), 37 | (47.97653, 7.72913), 38 | (47.97697, 7.72815), 39 | (47.97741, 7.72731), 40 | (47.97766, 7.72677), 41 | (47.97781, 7.72642), 42 | (47.97588, 7.72338), 43 | (47.97530, 7.72410), 44 | (47.97337, 7.72093), 45 | (47.97084, 7.72396), 46 | (47.96778, 7.71903), 47 | (47.96279, 7.70870), 48 | (47.95923, 7.70065), 49 | (47.95921, 7.70010), 50 | (47.95930, 7.69948), 51 | (47.95943, 7.69903), 52 | (47.95972, 7.69828), 53 | (47.96011, 7.69759), 54 | (47.96057, 7.69705), 55 | (47.96120, 7.69628), 56 | (47.96173, 7.69550), 57 | (47.96212, 7.69469), 58 | (47.96246, 7.69364), 59 | (47.96291, 7.69218), 60 | (47.96324, 7.69083), 61 | (47.96336, 7.68970), 62 | (47.96339, 7.68916), 63 | (47.96336, 7.68863), 64 | (47.96323, 7.68810), 65 | (47.96298, 7.68821), 66 | (47.96125, 7.68781), 67 | (47.96241, 7.68398), 68 | (47.96057, 7.67924), 69 | (47.95939, 7.67770), 70 | (47.96083, 7.67572), 71 | (47.96408, 7.67154), 72 | (47.96421, 7.66967), 73 | (47.96260, 7.66806), 74 | (47.96508, 7.66479), 75 | (47.96557, 7.66246), 76 | (47.96650, 7.66314), 77 | (47.97034, 7.66592), 78 | (47.97291, 7.66665), 79 | (47.97433, 7.67031), 80 | (47.97454, 7.67594), 81 | (47.97609, 7.67900), 82 | (47.97857, 7.67843), 83 | (47.98179, 7.68004), 84 | (47.98349, 7.68058), 85 | (47.98331, 7.68246), 86 | (47.98606, 7.68325), 87 | (47.98911, 7.68388), 88 | (47.98991, 7.68081), 89 | (47.99061, 7.68118), 90 | (47.99158, 7.67885), 91 | (47.99274, 7.67947), 92 | (47.99404, 7.67805), 93 | (47.99583, 7.67903), 94 | (47.99684, 7.67958), 95 | (48.00043, 7.68554), 96 | (48.00340, 7.69160), 97 | (48.00585, 7.69506), 98 | (48.00859, 7.70018), 99 | (48.01007, 7.70648), 100 | (48.01238, 7.70833), 101 | (48.01281, 7.70504), 102 | (48.01410, 7.70576), 103 | (48.01627, 7.70602), 104 | (48.01674, 7.70434), 105 | (48.01869, 7.70359), 106 | (48.02023, 7.70452), 107 | (48.02153, 7.70300), 108 | (48.02313, 7.70393), 109 | (48.02343, 7.70323), 110 | (48.02467, 7.70376), 111 | (48.02598, 7.70348), 112 | (48.02608, 7.70183), 113 | (48.02781, 7.70221), 114 | (48.02867, 7.70376), 115 | (48.03147, 7.70512), 116 | (48.03325, 7.70672), 117 | (48.03527, 7.70826), 118 | (48.03415, 7.71157), 119 | (48.03383, 7.71384), 120 | (48.03303, 7.71367), 121 | (48.03347, 7.71659), 122 | (48.03442, 7.71670), 123 | (48.03468, 7.71945), 124 | (48.03391, 7.72156), 125 | (48.03425, 7.72344), 126 | (48.03515, 7.72399), 127 | (48.03508, 7.72531), 128 | (48.03531, 7.72853), 129 | (48.03835, 7.73153), 130 | (48.03804, 7.73211), 131 | (48.03655, 7.73446), 132 | (48.03300, 7.73353), 133 | (48.03107, 7.73673), 134 | (48.02993, 7.73999), 135 | (48.02882, 7.74051), 136 | (48.02674, 7.74396), 137 | (48.02632, 7.74637), 138 | (48.02538, 7.74663), 139 | (48.02276, 7.75155), 140 | (48.02133, 7.75100), 141 | (48.01890, 7.75728), 142 | (48.01859, 7.75816), 143 | (48.02012, 7.75889), 144 | (48.02221, 7.75889), 145 | (48.02092, 7.76242), 146 | (48.02049, 7.76223), 147 | (48.01943, 7.76461), 148 | (48.01928, 7.76621), 149 | (48.01856, 7.76749), 150 | (48.01794, 7.76782), 151 | (48.01752, 7.76889), 152 | (48.01470, 7.76774), 153 | (48.02290, 7.77924), 154 | (48.02566, 7.77755), 155 | (48.02719, 7.77952), 156 | (48.02645, 7.78374), 157 | (48.02857, 7.78927), 158 | (48.03012, 7.78820), 159 | (48.03288, 7.78634), 160 | (48.03379, 7.78850), 161 | (48.03666, 7.79030), 162 | (48.04103, 7.79139), 163 | (48.04240, 7.79077), 164 | (48.04386, 7.79240), 165 | (48.04531, 7.79296), 166 | (48.05310, 7.79636), 167 | (48.05344, 7.79289), 168 | (48.05528, 7.79529), 169 | (48.05906, 7.79708), 170 | (48.05911, 7.79559), 171 | (48.06106, 7.79606), 172 | (48.06116, 7.80123), 173 | (48.06112, 7.80387), 174 | (48.06187, 7.80571), 175 | (48.06248, 7.80614), 176 | (48.06389, 7.80521), 177 | (48.07111, 7.81502), 178 | (48.06953, 7.81751), 179 | (48.07021, 7.82013), 180 | (48.07003, 7.82195), 181 | (48.06903, 7.82471), 182 | (48.06797, 7.82523), 183 | (48.06656, 7.82650), 184 | (48.06540, 7.82765), 185 | (48.06197, 7.83217), 186 | (48.06142, 7.83274), 187 | (48.06055, 7.83347), 188 | (48.05957, 7.83399), 189 | (48.05857, 7.83469), 190 | (48.05600, 7.83857), 191 | (48.05565, 7.83957), 192 | (48.05406, 7.84395), 193 | (48.05385, 7.84448), 194 | (48.05354, 7.84540), 195 | (48.05341, 7.84582), 196 | (48.05318, 7.84656), 197 | (48.05291, 7.84751), 198 | (48.05231, 7.84965), 199 | (48.05204, 7.85060), 200 | (48.05108, 7.85095), 201 | (48.04891, 7.85175), 202 | (48.04795, 7.85210), 203 | (48.04686, 7.85245), 204 | (48.04589, 7.85272), 205 | (48.04506, 7.85291), 206 | (48.04406, 7.85308), 207 | (48.04306, 7.85322), 208 | (48.04062, 7.85350), 209 | (48.03991, 7.85360), 210 | (48.03922, 7.85372), 211 | (48.03840, 7.85390), 212 | (48.03774, 7.85408), 213 | (48.03709, 7.85432), 214 | (48.03689, 7.85439), 215 | (48.03671, 7.85447), 216 | (48.03659, 7.85454), 217 | (48.03640, 7.85477), 218 | (48.03460, 7.85836), 219 | (48.03507, 7.86373), 220 | (48.03414, 7.86673), 221 | (48.03064, 7.87091), 222 | (48.02987, 7.87069), 223 | (48.02946, 7.87253), 224 | (48.03145, 7.87524), 225 | (48.02806, 7.87812), 226 | (48.02685, 7.87847), 227 | (48.02505, 7.88080), 228 | (48.02301, 7.88182), 229 | (48.02215, 7.88373), 230 | (48.02283, 7.88523), 231 | (48.01932, 7.89069), 232 | (48.01914, 7.89351), 233 | (48.01586, 7.89826), 234 | (48.01527, 7.89734), 235 | (48.01673, 7.89289), 236 | (48.01635, 7.88893), 237 | (48.02102, 7.88394), 238 | (48.01896, 7.87925), 239 | (48.01926, 7.87875), 240 | (48.01953, 7.87825), 241 | (48.01996, 7.87741), 242 | (48.02019, 7.87690), 243 | (48.02034, 7.87654), 244 | (48.02061, 7.87585), 245 | (48.02068, 7.87566), 246 | (48.02079, 7.87529), 247 | (48.01961, 7.87299), 248 | (48.01678, 7.87206), 249 | (48.01368, 7.87752), 250 | (48.01155, 7.88520), 251 | (48.01015, 7.88816), 252 | (48.01025, 7.90032), 253 | (48.01205, 7.90414), 254 | (48.01328, 7.90722), 255 | (48.01186, 7.91342), 256 | (48.01558, 7.92057), 257 | (48.01525, 7.92178), 258 | (48.01401, 7.92648), 259 | (48.01397, 7.92664), 260 | (48.00607, 7.92835), 261 | (48.00344, 7.92822), 262 | (48.00087, 7.92718), 263 | (47.99595, 7.92991), 264 | (47.99085, 7.93031), 265 | (47.98881, 7.92967), 266 | (47.98933, 7.92781), 267 | (47.98751, 7.92625), 268 | (47.98607, 7.92768), 269 | (47.98531, 7.92483), 270 | (47.98072, 7.92489), 271 | (47.98072, 7.92476), 272 | (47.98071, 7.92455), 273 | (47.98069, 7.92433), 274 | (47.98056, 7.92348), 275 | (47.98042, 7.92224), 276 | (47.98010, 7.92000), 277 | (47.97992, 7.91856), 278 | (47.97984, 7.91792), 279 | (47.97980, 7.91783), 280 | (47.97946, 7.91582), 281 | (47.97938, 7.91520), 282 | (47.97934, 7.91479), 283 | (47.97930, 7.91437), 284 | (47.97928, 7.91397), 285 | (47.97929, 7.91360), 286 | (47.97811, 7.91462), 287 | (47.97787, 7.91485), 288 | (47.97765, 7.91507), 289 | (47.97742, 7.91533), 290 | (47.97721, 7.91561), 291 | (47.97661, 7.91655), 292 | (47.97641, 7.91685), 293 | (47.97631, 7.91698), 294 | (47.97619, 7.91709), 295 | (47.97608, 7.91721), 296 | (47.97596, 7.91729), 297 | (47.97570, 7.91745), 298 | (47.97556, 7.91753), 299 | (47.97526, 7.91766), 300 | (47.97510, 7.91771), 301 | (47.97480, 7.91778), 302 | (47.97465, 7.91780), 303 | (47.97453, 7.91817), 304 | (47.97424, 7.91915), 305 | (47.97412, 7.91953), 306 | (47.97399, 7.91989), 307 | (47.97392, 7.92008), 308 | (47.97384, 7.92024), 309 | (47.97375, 7.92040), 310 | (47.97366, 7.92053), 311 | (47.97355, 7.92067), 312 | (47.97333, 7.92091), 313 | (47.97308, 7.92113), 314 | (47.97246, 7.92160), 315 | (47.97220, 7.92182), 316 | (47.97197, 7.92204), 317 | (47.97174, 7.92231), 318 | (47.97144, 7.92273), 319 | (47.97106, 7.92332), 320 | (47.97057, 7.92412), 321 | (47.96968, 7.92270), 322 | (47.96751, 7.92175), 323 | (47.96746, 7.91814), 324 | (47.96484, 7.91532), 325 | (47.96283, 7.91509), 326 | (47.95817, 7.91742), 327 | (47.95709, 7.91868), 328 | (47.95522, 7.91937), 329 | (47.95314, 7.92086), 330 | (47.95094, 7.92471), 331 | (47.94997, 7.92434), 332 | (47.94623, 7.92482), 333 | (47.94349, 7.92455), 334 | (47.94159, 7.92406), 335 | (47.93999, 7.92473), 336 | (47.93738, 7.92204), 337 | (47.93578, 7.92008), 338 | (47.93465, 7.91698), 339 | (47.93008, 7.91304), 340 | (47.92912, 7.90950), 341 | (47.92778, 7.90751), 342 | (47.92457, 7.90663), 343 | (47.91760, 7.90998), 344 | (47.91444, 7.90916), 345 | (47.91374, 7.90520), 346 | (47.91198, 7.90137), 347 | (47.91216, 7.89916), 348 | (47.91048, 7.89628), 349 | (47.91013, 7.89369), 350 | (47.90662, 7.89295), 351 | (47.90421, 7.88497), 352 | (47.90426, 7.88285), 353 | (47.90411, 7.88047), 354 | (47.90339, 7.87350), 355 | (47.90419, 7.87058), 356 | (47.90827, 7.86947), 357 | (47.90928, 7.86828), 358 | (47.91253, 7.86548), 359 | (47.91300, 7.86290), 360 | (47.91537, 7.86246), 361 | (47.91644, 7.86409), 362 | (47.91694, 7.86343), 363 | (47.91759, 7.86292), 364 | (47.91827, 7.86259), 365 | (47.91906, 7.86235), 366 | (47.91996, 7.86219), 367 | (47.92077, 7.86215), 368 | (47.92157, 7.86222), 369 | (47.92242, 7.86230), 370 | (47.92326, 7.86239), 371 | (47.92360, 7.86241), 372 | (47.92342, 7.86750), 373 | (47.92368, 7.86687), 374 | (47.92392, 7.86637), 375 | (47.92410, 7.86605), 376 | (47.92437, 7.86561), 377 | (47.92458, 7.86535), 378 | (47.92481, 7.86509), 379 | (47.92505, 7.86487), 380 | (47.92531, 7.86466), 381 | (47.92545, 7.86458), 382 | (47.92571, 7.86442), 383 | (47.92599, 7.86428), 384 | (47.92627, 7.86417), 385 | (47.92656, 7.86409), 386 | (47.92695, 7.86403), 387 | (47.92722, 7.86401), 388 | (47.92764, 7.86403), 389 | (47.92792, 7.86406), 390 | (47.92834, 7.86413), 391 | (47.92871, 7.86422), 392 | (47.92874, 7.86700), 393 | (47.93240, 7.86742), 394 | (47.93406, 7.86919), 395 | (47.93100, 7.87444), 396 | (47.93480, 7.87711), 397 | (47.94120, 7.87677), 398 | (47.94350, 7.87871), 399 | (47.94859, 7.87902), 400 | (47.94921, 7.87558), 401 | (47.95148, 7.87101), 402 | (47.95115, 7.86835), 403 | (47.94939, 7.87002), 404 | (47.94951, 7.86193), 405 | (47.94883, 7.85817), 406 | (47.95021, 7.85558), 407 | (47.95214, 7.85401), 408 | (47.95530, 7.85329), 409 | (47.95673, 7.84896), 410 | (47.96079, 7.84887), 411 | (47.96227, 7.84587), 412 | (47.96352, 7.84337), 413 | (47.96320, 7.84009), 414 | (47.96505, 7.83786), 415 | (47.96602, 7.83423), 416 | (47.96973, 7.83683), 417 | (47.97127, 7.83730), 418 | (47.97264, 7.83662), 419 | (47.97247, 7.83296), 420 | (47.97240, 7.82634), 421 | (47.97242, 7.82278), 422 | (47.97116, 7.82100), 423 | (47.97054, 7.82011), 424 | (47.97021, 7.81859), 425 | (47.96796, 7.81701), 426 | (47.96768, 7.81300), 427 | (47.96724, 7.81219), 428 | (47.96592, 7.80894), 429 | (47.96425, 7.80478), 430 | (47.96369, 7.80338), 431 | (47.96430, 7.80115), 432 | (47.96484, 7.79920), 433 | (47.96712, 7.79471), 434 | (47.96883, 7.79090), 435 | (47.96881, 7.79045), 436 | ] 437 | 438 | context.add_object( 439 | staticmaps.Area( 440 | [staticmaps.create_latlng(lat, lng) for lat, lng in freiburg_polygon], 441 | fill_color=staticmaps.parse_color("#00FF007F"), 442 | width=2, 443 | color=staticmaps.BLUE, 444 | ) 445 | ) 446 | 447 | # render png via pillow 448 | image = context.render_pillow(800, 500) 449 | image.save("freiburg_area.pillow.png") 450 | 451 | # render png via cairo 452 | if staticmaps.cairo_is_supported(): 453 | cairo_image = context.render_cairo(800, 500) 454 | cairo_image.write_to_png("freiburg_area.cairo.png") 455 | 456 | # render svg 457 | svg_image = context.render_svg(800, 500) 458 | with open("freiburg_area.svg", "w", encoding="utf-8") as f: 459 | svg_image.write(f, pretty=True) 460 | -------------------------------------------------------------------------------- /examples/geodesic_circles.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import staticmaps 7 | 8 | context = staticmaps.Context() 9 | context.set_tile_provider(staticmaps.tile_provider_OSM) 10 | 11 | center1 = staticmaps.create_latlng(66, 0) 12 | center2 = staticmaps.create_latlng(0, 0) 13 | 14 | context.add_object(staticmaps.Circle(center1, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.RED, width=2)) 15 | context.add_object(staticmaps.Circle(center2, 2000, fill_color=staticmaps.TRANSPARENT, color=staticmaps.GREEN, width=2)) 16 | context.add_object(staticmaps.Marker(center1, color=staticmaps.RED)) 17 | context.add_object(staticmaps.Marker(center2, color=staticmaps.GREEN)) 18 | 19 | # render png via pillow 20 | image = context.render_pillow(800, 600) 21 | image.save("geodesic_circles.pillow.png") 22 | 23 | # render png via cairo 24 | if staticmaps.cairo_is_supported(): 25 | cairo_image = context.render_cairo(800, 600) 26 | cairo_image.write_to_png("geodesic_circles.cairo.png") 27 | 28 | # render svg 29 | svg_image = context.render_svg(800, 600) 30 | with open("geodesic_circles.svg", "w", encoding="utf-8") as f: 31 | svg_image.write(f, pretty=True) 32 | -------------------------------------------------------------------------------- /examples/idl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import staticmaps 7 | 8 | context = staticmaps.Context() 9 | context.set_tile_provider(staticmaps.tile_provider_ArcGISWorldImagery) 10 | 11 | hongkong = staticmaps.create_latlng(22.308046, 113.918480) 12 | newyork = staticmaps.create_latlng(40.641766, -73.780968) 13 | 14 | context.add_object(staticmaps.Line([hongkong, newyork], color=staticmaps.BLUE)) 15 | context.add_object(staticmaps.Marker(hongkong, color=staticmaps.GREEN)) 16 | context.add_object(staticmaps.Marker(newyork, color=staticmaps.RED)) 17 | 18 | # render png via pillow 19 | image = context.render_pillow(1920, 1080) 20 | image.save("idl.pillow.png") 21 | 22 | # render png via cairo 23 | if staticmaps.cairo_is_supported(): 24 | cairo_image = context.render_cairo(1920, 1080) 25 | cairo_image.write_to_png("idl.cairo.png") 26 | 27 | # render svg 28 | svg_image = context.render_svg(1920, 1080) 29 | with open("idl.svg", "w", encoding="utf-8") as f: 30 | svg_image.write(f, pretty=True) 31 | -------------------------------------------------------------------------------- /examples/running.gpx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Running 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | -------------------------------------------------------------------------------- /examples/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flopp/py-staticmaps/e0266dc40163e87ce42a0ea5d8836a9a4bd92208/examples/start.png -------------------------------------------------------------------------------- /examples/tile_providers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import staticmaps 7 | 8 | context = staticmaps.Context() 9 | 10 | p1 = staticmaps.create_latlng(48.005774, 7.834042) 11 | p2 = staticmaps.create_latlng(47.988716, 7.868804) 12 | p3 = staticmaps.create_latlng(47.985958, 7.824601) 13 | 14 | context.add_object(staticmaps.Area([p1, p2, p3, p1], color=staticmaps.RED, fill_color=staticmaps.TRANSPARENT, width=2)) 15 | context.add_object(staticmaps.Marker(p1, color=staticmaps.BLUE)) 16 | context.add_object(staticmaps.Marker(p2, color=staticmaps.GREEN)) 17 | context.add_object(staticmaps.Marker(p3, color=staticmaps.YELLOW)) 18 | 19 | for name, provider in staticmaps.default_tile_providers.items(): 20 | context.set_tile_provider(provider) 21 | 22 | # render png via pillow 23 | image = context.render_pillow(800, 500) 24 | image.save(f"provider_{name}.pillow.png") 25 | 26 | # render png via cairo 27 | if staticmaps.cairo_is_supported(): 28 | cairo_image = context.render_cairo(800, 500) 29 | cairo_image.write_to_png(f"provider_{name}.cairo.png") 30 | 31 | # render svg 32 | svg_image = context.render_svg(800, 500) 33 | with open(f"provider_{name}.svg", "w", encoding="utf-8") as f: 34 | svg_image.write(f, pretty=True) 35 | -------------------------------------------------------------------------------- /examples/us_capitals.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import json 7 | import requests 8 | import staticmaps 9 | 10 | context = staticmaps.Context() 11 | context.set_tile_provider(staticmaps.tile_provider_OSM) 12 | 13 | URL = ( 14 | "https://gist.githubusercontent.com/jpriebe/d62a45e29f24e843c974/" 15 | "raw/b1d3066d245e742018bce56e41788ac7afa60e29/us_state_capitals.json" 16 | ) 17 | response = requests.get(URL, timeout=10) 18 | for _, data in json.loads(response.text).items(): 19 | capital = staticmaps.create_latlng(float(data["lat"]), float(data["long"])) 20 | context.add_object(staticmaps.Marker(capital, size=5)) 21 | 22 | # render png via pillow 23 | image = context.render_pillow(800, 500) 24 | image.save("us_capitals.pillow.png") 25 | 26 | # render png via cairo 27 | if staticmaps.cairo_is_supported(): 28 | cairo_image = context.render_cairo(800, 500) 29 | cairo_image.write_to_png("us_capitals.cairo.png") 30 | 31 | # render svg 32 | svg_image = context.render_svg(800, 500) 33 | with open("us_capitals.svg", "w", encoding="utf-8") as f: 34 | svg_image.write(f, pretty=True) 35 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | disallow_incomplete_defs = True 4 | disallow_untyped_defs = True -------------------------------------------------------------------------------- /requirements-cairo.txt: -------------------------------------------------------------------------------- 1 | pycairo 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | mypy 4 | pylint 5 | pytest 6 | twine 7 | -------------------------------------------------------------------------------- /requirements-examples.txt: -------------------------------------------------------------------------------- 1 | gpxpy 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs 2 | geographiclib 3 | PILLOW 4 | python-slugify 5 | requests 6 | s2sphere 7 | slugify 8 | svgwrite 9 | types-requests 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import os 5 | import re 6 | import typing 7 | 8 | import setuptools # type: ignore 9 | 10 | 11 | def _read_meta(rel_path: str, identifier: str) -> str: 12 | abs_path = os.path.join(os.path.dirname(__file__), rel_path) 13 | with open(abs_path, encoding="utf-8") as f: 14 | content = f.read() 15 | r = r"^" + identifier + r"\s*=\s*['\"]([^'\"]*)['\"]$" 16 | m = re.search(r, content, re.M) 17 | if not m: 18 | raise RuntimeError(f"Unable to find {identifier} string in {rel_path}.") 19 | return m.group(1) 20 | 21 | 22 | def _read_descr(rel_path: str) -> str: 23 | abs_path = os.path.join(os.path.dirname(__file__), rel_path) 24 | re_image = re.compile(r"^.*!\[.*\]\(.*\).*$") 25 | lines: typing.List[str] = [] 26 | with open(abs_path, encoding="utf-8") as f: 27 | for line in f: 28 | if re_image.match(line): 29 | continue 30 | lines.append(line) 31 | print("".join(lines)) 32 | return "".join(lines) 33 | 34 | 35 | def _read_reqs(rel_path: str) -> typing.List[str]: 36 | abs_path = os.path.join(os.path.dirname(__file__), rel_path) 37 | with open(abs_path, encoding="utf-8") as f: 38 | return [s.strip() for s in f.readlines() if s.strip() and not s.strip().startswith("#")] 39 | 40 | 41 | PACKAGE = "staticmaps" 42 | 43 | setuptools.setup( 44 | name=_read_meta(f"{PACKAGE}/meta.py", "LIB_NAME"), 45 | version=_read_meta(f"{PACKAGE}/meta.py", "VERSION"), 46 | description="Create static map images (SVG, PNG) with markers, geodesic lines, ...", 47 | long_description=_read_descr("README.md"), 48 | long_description_content_type="text/markdown", 49 | url=_read_meta(f"{PACKAGE}/meta.py", "GITHUB_URL"), 50 | author="Florian Pigorsch", 51 | author_email="mail@florian-pigorsch.de", 52 | classifiers=[ 53 | "Development Status :: 3 - Alpha", 54 | "Intended Audience :: Developers", 55 | "Topic :: Scientific/Engineering :: GIS", 56 | "License :: OSI Approved :: MIT License", 57 | "Programming Language :: Python :: 3", 58 | "Programming Language :: Python :: 3.6", 59 | "Programming Language :: Python :: 3.7", 60 | "Programming Language :: Python :: 3.8", 61 | "Programming Language :: Python :: 3.9", 62 | ], 63 | keywords="map staticmap osm markers", 64 | packages=[PACKAGE], 65 | install_requires=_read_reqs("requirements.txt"), 66 | extras_require={ 67 | "cairo": _read_reqs("requirements-cairo.txt"), 68 | "dev": _read_reqs("requirements-dev.txt"), 69 | "examples": _read_reqs("requirements-examples.txt"), 70 | }, 71 | entry_points={ 72 | "console_scripts": [ 73 | f"createstaticmap={PACKAGE}.cli:main", 74 | ], 75 | }, 76 | ) 77 | -------------------------------------------------------------------------------- /staticmaps/__init__.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | # flake8: noqa 5 | from .area import Area 6 | from .cairo_renderer import CairoRenderer, cairo_is_supported 7 | from .circle import Circle 8 | from .color import ( 9 | parse_color, 10 | random_color, 11 | Color, 12 | BLACK, 13 | BLUE, 14 | BROWN, 15 | GREEN, 16 | ORANGE, 17 | PURPLE, 18 | RED, 19 | YELLOW, 20 | WHITE, 21 | TRANSPARENT, 22 | ) 23 | from .context import Context 24 | from .coordinates import create_latlng, parse_latlng, parse_latlngs, parse_latlngs2rect 25 | from .image_marker import ImageMarker 26 | from .line import Line 27 | from .marker import Marker 28 | from .meta import GITHUB_URL, LIB_NAME, VERSION 29 | from .object import Object, PixelBoundsT 30 | from .pillow_renderer import PillowRenderer 31 | from .svg_renderer import SvgRenderer 32 | from .tile_downloader import TileDownloader 33 | from .tile_provider import ( 34 | TileProvider, 35 | default_tile_providers, 36 | tile_provider_OSM, 37 | tile_provider_StamenTerrain, 38 | tile_provider_StamenToner, 39 | tile_provider_StamenTonerLite, 40 | tile_provider_ArcGISWorldImagery, 41 | tile_provider_CartoNoLabels, 42 | tile_provider_CartoDarkNoLabels, 43 | tile_provider_None, 44 | ) 45 | from .transformer import Transformer 46 | -------------------------------------------------------------------------------- /staticmaps/area.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import typing 5 | 6 | from PIL import Image as PIL_Image # type: ignore 7 | from PIL import ImageDraw as PIL_ImageDraw # type: ignore 8 | import s2sphere # type: ignore 9 | 10 | from .cairo_renderer import CairoRenderer 11 | from .color import Color, RED, TRANSPARENT 12 | from .line import Line 13 | from .pillow_renderer import PillowRenderer 14 | from .svg_renderer import SvgRenderer 15 | 16 | 17 | class Area(Line): 18 | """Render an area using different renderers 19 | 20 | :param master: A line object 21 | """ 22 | 23 | def __init__( 24 | self, latlngs: typing.List[s2sphere.LatLng], fill_color: Color = RED, color: Color = TRANSPARENT, width: int = 0 25 | ) -> None: 26 | Line.__init__(self, latlngs, color, width) 27 | if latlngs is None or len(latlngs) < 3: 28 | raise ValueError("Trying to create area with less than 3 coordinates") 29 | 30 | self._fill_color = fill_color 31 | 32 | def fill_color(self) -> Color: 33 | """Return fill color of the area 34 | 35 | :return: color object 36 | :rtype: Color 37 | """ 38 | return self._fill_color 39 | 40 | def render_pillow(self, renderer: PillowRenderer) -> None: 41 | """Render area using PILLOW 42 | 43 | :param renderer: pillow renderer 44 | :type renderer: PillowRenderer 45 | """ 46 | xys = [ 47 | (x + renderer.offset_x(), y) 48 | for (x, y) in [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 49 | ] 50 | overlay = PIL_Image.new("RGBA", renderer.image().size, (255, 255, 255, 0)) 51 | draw = PIL_ImageDraw.Draw(overlay) 52 | draw.polygon(xys, fill=self.fill_color().int_rgba()) 53 | renderer.alpha_compose(overlay) 54 | if self.width() > 0: 55 | renderer.draw().line(xys, fill=self.color().int_rgba(), width=self.width()) 56 | 57 | def render_svg(self, renderer: SvgRenderer) -> None: 58 | """Render area using svgwrite 59 | 60 | :param renderer: svg renderer 61 | :type renderer: SvgRenderer 62 | """ 63 | xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 64 | 65 | polygon = renderer.drawing().polygon( 66 | xys, 67 | fill=self.fill_color().hex_rgb(), 68 | opacity=self.fill_color().float_a(), 69 | ) 70 | renderer.group().add(polygon) 71 | 72 | if self.width() > 0: 73 | polyline = renderer.drawing().polyline( 74 | xys, 75 | fill="none", 76 | stroke=self.color().hex_rgb(), 77 | stroke_width=self.width(), 78 | opacity=self.color().float_a(), 79 | ) 80 | renderer.group().add(polyline) 81 | 82 | def render_cairo(self, renderer: CairoRenderer) -> None: 83 | """Render area using cairo 84 | 85 | :param renderer: cairo renderer 86 | :type renderer: CairoRenderer 87 | """ 88 | xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 89 | 90 | renderer.context().set_source_rgba(*self.fill_color().float_rgba()) 91 | renderer.context().new_path() 92 | for x, y in xys: 93 | renderer.context().line_to(x, y) 94 | renderer.context().fill() 95 | 96 | if self.width() > 0: 97 | renderer.context().set_source_rgba(*self.color().float_rgba()) 98 | renderer.context().set_line_width(self.width()) 99 | renderer.context().new_path() 100 | for x, y in xys: 101 | renderer.context().line_to(x, y) 102 | renderer.context().stroke() 103 | -------------------------------------------------------------------------------- /staticmaps/cairo_renderer.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import io 5 | import math 6 | import sys 7 | import typing 8 | 9 | try: 10 | import cairo # type: ignore 11 | except ImportError: 12 | pass 13 | 14 | from PIL import Image as PIL_Image # type: ignore 15 | 16 | from .color import Color, BLACK, WHITE 17 | from .renderer import Renderer 18 | from .transformer import Transformer 19 | 20 | if typing.TYPE_CHECKING: 21 | # avoid circlic import 22 | from .object import Object # pylint: disable=cyclic-import 23 | 24 | 25 | def cairo_is_supported() -> bool: 26 | """Check whether cairo is supported 27 | 28 | :return: Is cairo supported 29 | :rtype: bool 30 | """ 31 | return "cairo" in sys.modules 32 | 33 | 34 | # Dummy types, so that type annotation works if cairo is missing. 35 | cairo_Context = typing.Any # pylint: disable=invalid-name 36 | cairo_ImageSurface = typing.Any # pylint: disable=invalid-name 37 | 38 | 39 | class CairoRenderer(Renderer): 40 | """An image renderer using cairo that extends a generic renderer class""" 41 | 42 | def __init__(self, transformer: Transformer) -> None: 43 | Renderer.__init__(self, transformer) 44 | 45 | if not cairo_is_supported(): 46 | raise RuntimeError("Cannot render to Cairo since the 'cairo' module could not be imported.") 47 | 48 | self._surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *self._trans.image_size()) 49 | self._context = cairo.Context(self._surface) 50 | 51 | def image_surface(self) -> cairo_ImageSurface: 52 | """ 53 | 54 | :return: cairo image surface 55 | :rtype: cairo.ImageSurface 56 | """ 57 | return self._surface 58 | 59 | def context(self) -> cairo_Context: 60 | """ 61 | 62 | :return: cairo context 63 | :rtype: cairo.Context 64 | """ 65 | return self._context 66 | 67 | @staticmethod 68 | def create_image(image_data: bytes) -> cairo_ImageSurface: 69 | """Create a cairo image 70 | 71 | :param image_data: Image data 72 | :type image_data: bytes 73 | 74 | :return: cairo image surface 75 | :rtype: cairo.ImageSurface 76 | """ 77 | image = PIL_Image.open(io.BytesIO(image_data)) 78 | if image.format == "PNG": 79 | return cairo.ImageSurface.create_from_png(io.BytesIO(image_data)) 80 | png_bytes = io.BytesIO() 81 | image.save(png_bytes, format="PNG") 82 | png_bytes.flush() 83 | png_bytes.seek(0) 84 | return cairo.ImageSurface.create_from_png(png_bytes) 85 | 86 | def render_objects(self, objects: typing.List["Object"]) -> None: 87 | """Render all objects of static map 88 | 89 | :param objects: objects of static map 90 | :type objects: typing.List["Object"] 91 | """ 92 | x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) 93 | for obj in objects: 94 | for p in range(-x_count, x_count + 1): 95 | self._context.save() 96 | self._context.translate(p * self._trans.world_width(), 0) 97 | obj.render_cairo(self) 98 | self._context.restore() 99 | 100 | def render_background(self, color: typing.Optional[Color]) -> None: 101 | """Render background of static map 102 | 103 | :param color: background color 104 | :type color: typing.Optional[Color] 105 | """ 106 | if color is None: 107 | return 108 | self._context.set_source_rgb(*color.float_rgb()) 109 | self._context.rectangle(0, 0, *self._trans.image_size()) 110 | self._context.fill() 111 | 112 | def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: 113 | """Render background of static map 114 | 115 | :param download: url of tiles provider 116 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 117 | """ 118 | for yy in range(0, self._trans.tiles_y()): 119 | y = self._trans.first_tile_y() + yy 120 | if y < 0 or y >= self._trans.number_of_tiles(): 121 | continue 122 | for xx in range(0, self._trans.tiles_x()): 123 | x = (self._trans.first_tile_x() + xx) % self._trans.number_of_tiles() 124 | try: 125 | tile_img = self.fetch_tile(download, x, y) 126 | if tile_img is None: 127 | continue 128 | self._context.save() 129 | self._context.translate( 130 | xx * self._trans.tile_size() + self._trans.tile_offset_x(), 131 | yy * self._trans.tile_size() + self._trans.tile_offset_y(), 132 | ) 133 | self._context.set_source_surface(tile_img) 134 | self._context.paint() 135 | self._context.restore() 136 | except RuntimeError: 137 | pass 138 | 139 | def render_attribution(self, attribution: typing.Optional[str]) -> None: 140 | """Render attribution from given tiles provider 141 | 142 | :param attribution: Attribution for the given tiles provider 143 | :type attribution: typing.Optional[str]: 144 | """ 145 | if (attribution is None) or (attribution == ""): 146 | return 147 | width, height = self._trans.image_size() 148 | self._context.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) 149 | font_size = 9.0 150 | while True: 151 | self._context.set_font_size(font_size) 152 | _, f_descent, f_height, _, _ = self._context.font_extents() 153 | t_width = self._context.text_extents(attribution)[3] 154 | if t_width < width - 4: 155 | break 156 | font_size = font_size - 0.25 157 | self._context.set_source_rgba(*WHITE.float_rgb(), 0.8) 158 | self._context.rectangle(0, height - f_height - f_descent - 2, width, height) 159 | self._context.fill() 160 | 161 | self._context.set_source_rgb(*BLACK.float_rgb()) 162 | self._context.move_to(4, height - f_descent - 2) 163 | self._context.show_text(attribution) 164 | self._context.stroke() 165 | 166 | def fetch_tile( 167 | self, download: typing.Callable[[int, int, int], typing.Optional[bytes]], x: int, y: int 168 | ) -> typing.Optional[cairo_ImageSurface]: 169 | """Fetch tiles from given tiles provider 170 | 171 | :param download: callable 172 | :param x: width 173 | :param y: height 174 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 175 | :type x: int 176 | :type y: int 177 | 178 | :return: cairo image surface 179 | :rtype: typing.Optional[cairo_ImageSurface] 180 | """ 181 | image_data = download(self._trans.zoom(), x, y) 182 | if image_data is None: 183 | return None 184 | return self.create_image(image_data) 185 | -------------------------------------------------------------------------------- /staticmaps/circle.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import typing 5 | 6 | from geographiclib.geodesic import Geodesic # type: ignore 7 | import s2sphere # type: ignore 8 | 9 | from .area import Area 10 | from .color import Color, RED, TRANSPARENT 11 | from .coordinates import create_latlng 12 | 13 | 14 | class Circle(Area): 15 | """Render a circle using different renderers 16 | 17 | :param master: an area object 18 | """ 19 | 20 | def __init__( 21 | self, 22 | center: s2sphere.LatLng, 23 | radius_km: float, 24 | fill_color: Color = RED, 25 | color: Color = TRANSPARENT, 26 | width: int = 0, 27 | ) -> None: 28 | Area.__init__(self, list(Circle.compute_circle(center, radius_km)), fill_color, color, width) 29 | 30 | @staticmethod 31 | def compute_circle(center: s2sphere.LatLng, radius_km: float) -> typing.Iterator[s2sphere.LatLng]: 32 | """Compute a circle with given center and radius 33 | 34 | :param center: Center of the circle 35 | :param radius_km: Radius of the circle 36 | :type center: s2sphere.LatLng 37 | :type radius_km: float 38 | 39 | :return: circle 40 | :rtype: typing.Iterator[s2sphere.LatLng] 41 | """ 42 | first = None 43 | delta_angle = 0.1 44 | angle = 0.0 45 | geod = Geodesic.WGS84 46 | while angle < 360.0: 47 | d = geod.Direct( 48 | center.lat().degrees, 49 | center.lng().degrees, 50 | angle, 51 | radius_km * 1000.0, 52 | Geodesic.LONGITUDE | Geodesic.LATITUDE | Geodesic.LONG_UNROLL, 53 | ) 54 | latlng = create_latlng(d["lat2"], d["lon2"]) 55 | if first is None: 56 | first = latlng 57 | yield latlng 58 | angle = angle + delta_angle 59 | if first: 60 | yield first 61 | -------------------------------------------------------------------------------- /staticmaps/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # py-staticmaps 4 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 5 | 6 | import argparse 7 | import enum 8 | import os 9 | 10 | import staticmaps 11 | 12 | 13 | class FileFormat(enum.Enum): 14 | GUESS = "guess" 15 | PNG = "png" 16 | SVG = "svg" 17 | 18 | 19 | def determine_file_format(file_format: FileFormat, file_name: str) -> FileFormat: 20 | if file_format != FileFormat.GUESS: 21 | return file_format 22 | extension = os.path.splitext(file_name)[1] 23 | if extension == ".png": 24 | return FileFormat.PNG 25 | if extension == ".svg": 26 | return FileFormat.SVG 27 | raise RuntimeError("Cannot guess the image type from the given file name: {file_name}") 28 | 29 | 30 | def main() -> None: 31 | args_parser = argparse.ArgumentParser(prog="createstaticmap") 32 | args_parser.add_argument( 33 | "--center", 34 | metavar="LAT,LNG", 35 | type=str, 36 | ) 37 | args_parser.add_argument( 38 | "--zoom", 39 | metavar="ZOOM", 40 | type=int, 41 | ) 42 | args_parser.add_argument( 43 | "--width", 44 | metavar="WIDTH", 45 | type=int, 46 | required=True, 47 | ) 48 | args_parser.add_argument( 49 | "--height", 50 | metavar="HEIGHT", 51 | type=int, 52 | required=True, 53 | ) 54 | args_parser.add_argument( 55 | "--background", 56 | metavar="COLOR", 57 | type=str, 58 | ) 59 | args_parser.add_argument( 60 | "--marker", 61 | metavar="LAT,LNG", 62 | type=str, 63 | action="append", 64 | ) 65 | args_parser.add_argument( 66 | "--line", 67 | metavar="LAT,LNG LAT,LNG ...", 68 | type=str, 69 | action="append", 70 | ) 71 | args_parser.add_argument( 72 | "--area", 73 | metavar="LAT,LNG LAT,LNG ...", 74 | type=str, 75 | action="append", 76 | ) 77 | args_parser.add_argument( 78 | "--bounds", 79 | metavar="LAT,LNG LAT,LNG", 80 | type=str, 81 | ) 82 | args_parser.add_argument( 83 | "--tiles", 84 | metavar="TILEPROVIDER", 85 | type=str, 86 | choices=staticmaps.default_tile_providers.keys(), 87 | default=staticmaps.tile_provider_OSM.name(), 88 | ) 89 | args_parser.add_argument( 90 | "--tiles-api-key", 91 | dest="tiles_api_key", 92 | metavar="API_KEY", 93 | type=str, 94 | default=None, 95 | ) 96 | args_parser.add_argument( 97 | "--file-format", 98 | metavar="FORMAT", 99 | type=FileFormat, 100 | choices=FileFormat, 101 | default=FileFormat.GUESS, 102 | ) 103 | args_parser.add_argument( 104 | "filename", 105 | metavar="FILE", 106 | type=str, 107 | nargs=1, 108 | ) 109 | 110 | args = args_parser.parse_args() 111 | 112 | context = staticmaps.Context() 113 | 114 | context.set_tile_provider(staticmaps.default_tile_providers[args.tiles], args.tiles_api_key) 115 | 116 | if args.center is not None: 117 | context.set_center(staticmaps.parse_latlng(args.center)) 118 | if args.zoom is not None: 119 | context.set_zoom(args.zoom) 120 | if args.background is not None: 121 | context.set_background_color(staticmaps.parse_color(args.background)) 122 | if args.area: 123 | for coords in args.area: 124 | context.add_object(staticmaps.Area(staticmaps.parse_latlngs(coords))) 125 | if args.line: 126 | for coords in args.line: 127 | context.add_object(staticmaps.Line(staticmaps.parse_latlngs(coords))) 128 | if args.marker: 129 | for coords in args.marker: 130 | context.add_object(staticmaps.Marker(staticmaps.parse_latlng(coords))) 131 | if args.bounds is not None: 132 | context.add_bounds(staticmaps.parse_latlngs2rect(args.bounds)) 133 | 134 | file_name = args.filename[0] 135 | if determine_file_format(args.file_format, file_name) == FileFormat.PNG: 136 | image = context.render_cairo(args.width, args.height) 137 | image.write_to_png(file_name) 138 | else: 139 | svg_image = context.render_svg(args.width, args.height) 140 | with open(file_name, "w", encoding="utf-8") as f: 141 | svg_image.write(f, pretty=True) 142 | print(f"wrote result image to {file_name}") 143 | 144 | 145 | if __name__ == "__main__": 146 | main() 147 | -------------------------------------------------------------------------------- /staticmaps/color.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import random 5 | import re 6 | import typing 7 | 8 | 9 | class Color: 10 | """A generic color class""" 11 | 12 | def __init__(self, r: int, g: int, b: int, a: int = 255): 13 | if not 0x00 <= r <= 0xFF: 14 | raise ValueError(f"'r' component out of range (must be 0-255): {r}") 15 | if not 0x00 <= g <= 0xFF: 16 | raise ValueError(f"'g' component out of range (must be 0-255): {g}") 17 | if not 0x00 <= b <= 0xFF: 18 | raise ValueError(f"'b' component out of range (must be 0-255): {b}") 19 | if not 0x00 <= a <= 0xFF: 20 | raise ValueError(f"'a' component out of range (must be 0-255): {a}") 21 | self._r = r 22 | self._g = g 23 | self._b = b 24 | self._a = a 25 | 26 | def text_color(self) -> "Color": 27 | """Return text color depending on luminance 28 | 29 | :return: a color depending on luminance 30 | :rtype: Color 31 | """ 32 | luminance = 0.299 * self._r + 0.587 * self._g + 0.114 * self._b 33 | return BLACK if luminance >= 0x7F else WHITE 34 | 35 | def hex_rgb(self) -> str: 36 | """Return color in rgb hex values 37 | 38 | :return: color in rgb hex values 39 | :rtype:str 40 | """ 41 | return f"#{self._r:02x}{self._g:02x}{self._b:02x}" 42 | 43 | def int_rgb(self) -> typing.Tuple[int, int, int]: 44 | """Return color in int values 45 | 46 | :return: color in int values 47 | :rtype:tuple 48 | """ 49 | return self._r, self._g, self._b 50 | 51 | def int_rgba(self) -> typing.Tuple[int, int, int, int]: 52 | """Return color in rgba int values with transparency 53 | 54 | :return: color in rgba int values 55 | :rtype:tuple 56 | """ 57 | return self._r, self._g, self._b, self._a 58 | 59 | def float_rgb(self) -> typing.Tuple[float, float, float]: 60 | return self._r / 255.0, self._g / 255.0, self._b / 255.0 61 | 62 | def float_rgba(self) -> typing.Tuple[float, float, float, float]: 63 | return self._r / 255.0, self._g / 255.0, self._b / 255.0, self._a / 255.0 64 | 65 | def float_a(self) -> float: 66 | return self._a / 255.0 67 | 68 | 69 | TRANSPARENT = Color(0x00, 0x00, 0x00, 0x00) 70 | BLACK = Color(0x00, 0x00, 0x00) 71 | WHITE = Color(0xFF, 0xFF, 0xFF) 72 | BLUE = Color(0x00, 0x00, 0xFF) 73 | BROWN = Color(0x96, 0x4B, 0x00) 74 | GREEN = Color(0x00, 0xFF, 0x00) 75 | ORANGE = Color(0xFF, 0x7F, 0x00) 76 | PURPLE = Color(0x7F, 0x00, 0x7F) 77 | RED = Color(0xFF, 0x00, 0x00) 78 | YELLOW = Color(0xFF, 0xFF, 0x00) 79 | 80 | 81 | def parse_color(s: str) -> Color: 82 | re_rgb = re.compile(r"^(0x|#)([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$") 83 | re_rgba = re.compile(r"^(0x|#)([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$") 84 | 85 | s = s.strip().lower() 86 | 87 | m = re_rgb.match(s) 88 | if m: 89 | return Color( 90 | int(f"0x{m[2]}", 16), 91 | int(f"0x{m[3]}", 16), 92 | int(f"0x{m[4]}", 16), 93 | ) 94 | 95 | m = re_rgba.match(s) 96 | if m: 97 | return Color( 98 | int(f"0x{m[2]}", 16), 99 | int(f"0x{m[3]}", 16), 100 | int(f"0x{m[4]}", 16), 101 | int(f"0x{m[5]}", 16), 102 | ) 103 | 104 | color_map = { 105 | "black": BLACK, 106 | "blue": BLUE, 107 | "brown": BROWN, 108 | "green": GREEN, 109 | "orange": ORANGE, 110 | "purple": PURPLE, 111 | "red": RED, 112 | "yellow": YELLOW, 113 | "white": WHITE, 114 | "transparent": TRANSPARENT, 115 | } 116 | if s in color_map: 117 | return color_map[s] 118 | 119 | raise ValueError(f"Cannot parse color string: {s}") 120 | 121 | 122 | def random_color() -> Color: 123 | return random.choice( 124 | [ 125 | BLACK, 126 | BLUE, 127 | BROWN, 128 | GREEN, 129 | ORANGE, 130 | PURPLE, 131 | RED, 132 | YELLOW, 133 | WHITE, 134 | ] 135 | ) 136 | -------------------------------------------------------------------------------- /staticmaps/context.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import math 5 | import os 6 | import typing 7 | 8 | import appdirs # type: ignore 9 | import s2sphere # type: ignore 10 | import svgwrite # type: ignore 11 | from PIL import Image as PIL_Image # type: ignore 12 | 13 | from .cairo_renderer import CairoRenderer, cairo_is_supported 14 | from .color import Color 15 | from .meta import LIB_NAME 16 | from .object import Object, PixelBoundsT 17 | from .pillow_renderer import PillowRenderer 18 | from .svg_renderer import SvgRenderer 19 | from .tile_downloader import TileDownloader 20 | from .tile_provider import TileProvider, tile_provider_OSM 21 | from .transformer import Transformer 22 | 23 | 24 | class Context: 25 | # pylint: disable=too-many-instance-attributes 26 | def __init__(self) -> None: 27 | self._background_color: typing.Optional[Color] = None 28 | self._objects: typing.List[Object] = [] 29 | self._center: typing.Optional[s2sphere.LatLng] = None 30 | self._bounds: typing.Optional[s2sphere.LatLngRect] = None 31 | self._extra_pixel_bounds: typing.Tuple[int, int, int, int] = (0, 0, 0, 0) 32 | self._zoom: typing.Optional[int] = None 33 | self._tile_provider = tile_provider_OSM 34 | self._tile_downloader = TileDownloader() 35 | self._cache_dir = os.path.join(appdirs.user_cache_dir(LIB_NAME), "tiles") 36 | 37 | def set_zoom(self, zoom: int) -> None: 38 | """Set zoom for static map 39 | 40 | :param zoom: zoom for static map 41 | :type zoom: int 42 | :raises ValueError: raises value error for invalid zoom factors 43 | """ 44 | if zoom < 0 or zoom > 30: 45 | raise ValueError(f"Bad zoom value: {zoom}") 46 | self._zoom = zoom 47 | 48 | def set_center(self, latlng: s2sphere.LatLng) -> None: 49 | """Set center for static map 50 | 51 | :param latlng: zoom for static map 52 | :type latlng: s2sphere.LatLng 53 | """ 54 | self._center = latlng 55 | 56 | def set_background_color(self, color: Color) -> None: 57 | """Set background color for static map 58 | 59 | :param color: background color for static map 60 | :type color: s2sphere.LatLng 61 | """ 62 | self._background_color = color 63 | 64 | def set_cache_dir(self, directory: str) -> None: 65 | """Set cache dir 66 | 67 | :param directory: cache directory 68 | :type directory: str 69 | """ 70 | self._cache_dir = directory 71 | 72 | def set_tile_downloader(self, downloader: TileDownloader) -> None: 73 | """Set tile downloader 74 | 75 | :param downloader: tile downloader 76 | :type downloader: TileDownloader 77 | """ 78 | self._tile_downloader = downloader 79 | 80 | def set_tile_provider(self, provider: TileProvider, api_key: typing.Optional[str] = None) -> None: 81 | """Set tile provider 82 | 83 | :param provider: tile provider 84 | :type provider: TileProvider 85 | :param api_key: api key (if needed) 86 | :type api_key: str 87 | """ 88 | self._tile_provider = provider 89 | if api_key: 90 | self._tile_provider.set_api_key(api_key) 91 | 92 | def add_object(self, obj: Object) -> None: 93 | """Add object for the static map (e.g. line, area, marker) 94 | 95 | :param obj: map object 96 | :type obj: Object 97 | """ 98 | self._objects.append(obj) 99 | 100 | def add_bounds( 101 | self, 102 | latlngrect: s2sphere.LatLngRect, 103 | extra_pixel_bounds: typing.Optional[typing.Union[int, typing.Tuple[int, int, int, int]]] = None, 104 | ) -> None: 105 | """Add boundaries that shall be respected by the static map 106 | 107 | :param latlngrect: boundaries to be respected 108 | :type latlngrect: s2sphere.LatLngRect 109 | :param extra_pixel_bounds: extra pixel bounds to be respected 110 | :type extra_pixel_bounds: int, tuple 111 | """ 112 | self._bounds = latlngrect 113 | if extra_pixel_bounds: 114 | if isinstance(extra_pixel_bounds, tuple): 115 | self._extra_pixel_bounds = extra_pixel_bounds 116 | else: 117 | self._extra_pixel_bounds = ( 118 | extra_pixel_bounds, 119 | extra_pixel_bounds, 120 | extra_pixel_bounds, 121 | extra_pixel_bounds, 122 | ) 123 | 124 | def render_cairo(self, width: int, height: int) -> typing.Any: 125 | """Render area using cairo 126 | 127 | :param width: width of static map 128 | :type width: int 129 | :param height: height of static map 130 | :type height: int 131 | :return: cairo image 132 | :rtype: cairo.ImageSurface 133 | :raises RuntimeError: raises runtime error if cairo is not available 134 | :raises RuntimeError: raises runtime error if map has no center and zoom 135 | """ 136 | if not cairo_is_supported(): 137 | raise RuntimeError('You need to install the "cairo" module to enable "render_cairo".') 138 | 139 | center, zoom = self.determine_center_zoom(width, height) 140 | if center is None or zoom is None: 141 | raise RuntimeError("Cannot render map without center/zoom.") 142 | 143 | trans = Transformer(width, height, zoom, center, self._tile_provider.tile_size()) 144 | 145 | renderer = CairoRenderer(trans) 146 | renderer.render_background(self._background_color) 147 | renderer.render_tiles(self._fetch_tile) 148 | renderer.render_objects(self._objects) 149 | renderer.render_attribution(self._tile_provider.attribution()) 150 | 151 | return renderer.image_surface() 152 | 153 | def render_pillow(self, width: int, height: int) -> PIL_Image.Image: 154 | """Render context using PILLOW 155 | 156 | :param width: width of static map 157 | :type width: int 158 | :param height: height of static map 159 | :type height: int 160 | :return: pillow image 161 | :rtype: PIL_Image 162 | :raises RuntimeError: raises runtime error if map has no center and zoom 163 | """ 164 | center, zoom = self.determine_center_zoom(width, height) 165 | if center is None or zoom is None: 166 | raise RuntimeError("Cannot render map without center/zoom.") 167 | 168 | trans = Transformer(width, height, zoom, center, self._tile_provider.tile_size()) 169 | 170 | renderer = PillowRenderer(trans) 171 | renderer.render_background(self._background_color) 172 | renderer.render_tiles(self._fetch_tile) 173 | renderer.render_objects(self._objects) 174 | renderer.render_attribution(self._tile_provider.attribution()) 175 | 176 | return renderer.image() 177 | 178 | def render_svg(self, width: int, height: int) -> svgwrite.Drawing: 179 | """Render context using svgwrite 180 | 181 | :param width: width of static map 182 | :type width: int 183 | :param height: height of static map 184 | :type height: int 185 | :return: svg drawing 186 | :rtype: svgwrite.Drawing 187 | :raises RuntimeError: raises runtime error if map has no center and zoom 188 | """ 189 | center, zoom = self.determine_center_zoom(width, height) 190 | if center is None or zoom is None: 191 | raise RuntimeError("Cannot render map without center/zoom.") 192 | 193 | trans = Transformer(width, height, zoom, center, self._tile_provider.tile_size()) 194 | 195 | renderer = SvgRenderer(trans) 196 | renderer.render_background(self._background_color) 197 | renderer.render_tiles(self._fetch_tile) 198 | renderer.render_objects(self._objects) 199 | renderer.render_attribution(self._tile_provider.attribution()) 200 | 201 | return renderer.drawing() 202 | 203 | def object_bounds(self) -> typing.Optional[s2sphere.LatLngRect]: 204 | """return maximum bounds of all objects 205 | 206 | :return: maximum of all object bounds 207 | :rtype: s2sphere.LatLngRect 208 | """ 209 | bounds = None 210 | if len(self._objects) != 0: 211 | bounds = s2sphere.LatLngRect() 212 | for obj in self._objects: 213 | assert bounds 214 | bounds = bounds.union(obj.bounds()) 215 | return self._custom_bounds(bounds) 216 | 217 | def _custom_bounds(self, bounds: typing.Optional[s2sphere.LatLngRect]) -> typing.Optional[s2sphere.LatLngRect]: 218 | """check for additional bounds and return the union with object bounds 219 | 220 | :param bounds: boundaries from objects 221 | :type bounds: s2sphere.LatLngRect 222 | :return: maximum of additional and object bounds 223 | :rtype: s2sphere.LatLngRect 224 | """ 225 | if not self._bounds: 226 | return bounds 227 | if not bounds: 228 | return self._bounds 229 | return bounds.union(self._bounds) 230 | 231 | def extra_pixel_bounds(self) -> PixelBoundsT: 232 | """return extra pixel bounds from all objects 233 | 234 | :return: extra pixel object bounds 235 | :rtype: PixelBoundsT 236 | """ 237 | max_l, max_t, max_r, max_b = self._extra_pixel_bounds 238 | attribution = self._tile_provider.attribution() 239 | if (attribution is None) or (attribution == ""): 240 | max_b = 12 241 | for obj in self._objects: 242 | (l, t, r, b) = obj.extra_pixel_bounds() 243 | max_l = max(max_l, l) 244 | max_t = max(max_t, t) 245 | max_r = max(max_r, r) 246 | max_b = max(max_b, b) 247 | return max_l, max_t, max_r, max_b 248 | 249 | def determine_center_zoom( 250 | self, width: int, height: int 251 | ) -> typing.Tuple[typing.Optional[s2sphere.LatLng], typing.Optional[int]]: 252 | """return center and zoom of static map 253 | 254 | :param width: width of static map 255 | :param height: height of static map 256 | :type width: int 257 | :type height: int 258 | :return: center, zoom 259 | :rtype: tuple 260 | """ 261 | if self._center is not None: 262 | if self._zoom is not None: 263 | return self._center, self._clamp_zoom(self._zoom) 264 | b = self.object_bounds() 265 | return self._center, self._determine_zoom(width, height, b, self._center) 266 | 267 | b = self.object_bounds() 268 | if b is None: 269 | return None, None 270 | 271 | c = self._determine_center(b) 272 | z = self._zoom 273 | if z is None: 274 | z = self._determine_zoom(width, height, b, c) 275 | if z is None: 276 | return None, None 277 | return self._adjust_center(width, height, c, z), z 278 | 279 | def _determine_zoom( 280 | self, width: int, height: int, b: typing.Optional[s2sphere.LatLngRect], c: s2sphere.LatLng 281 | ) -> typing.Optional[int]: 282 | if b is None: 283 | b = s2sphere.LatLngRect(c, c) 284 | else: 285 | b = b.union(s2sphere.LatLngRect(c, c)) 286 | assert b 287 | if b.is_point(): 288 | return self._clamp_zoom(15) 289 | 290 | pixel_margin = self.extra_pixel_bounds() 291 | 292 | w = (width - pixel_margin[0] - pixel_margin[2]) / self._tile_provider.tile_size() 293 | h = (height - pixel_margin[1] - pixel_margin[3]) / self._tile_provider.tile_size() 294 | # margins are bigger than target image size => ignore them 295 | if w <= 0 or h <= 0: 296 | w = width / self._tile_provider.tile_size() 297 | h = height / self._tile_provider.tile_size() 298 | 299 | min_y = (1.0 - math.log(math.tan(b.lat_lo().radians) + (1.0 / math.cos(b.lat_lo().radians)))) / (2 * math.pi) 300 | max_y = (1.0 - math.log(math.tan(b.lat_hi().radians) + (1.0 / math.cos(b.lat_hi().radians)))) / (2 * math.pi) 301 | dx = (b.lng_hi().degrees - b.lng_lo().degrees) / 360.0 302 | if dx < 0: 303 | dx += math.ceil(math.fabs(dx)) 304 | if dx > 1: 305 | dx -= math.floor(dx) 306 | dy = math.fabs(max_y - min_y) 307 | 308 | for zoom in range(1, self._tile_provider.max_zoom()): 309 | tiles = 2**zoom 310 | if (dx * tiles > w) or (dy * tiles > h): 311 | return self._clamp_zoom(zoom - 1) 312 | return self._clamp_zoom(15) 313 | 314 | @staticmethod 315 | def _determine_center(b: s2sphere.LatLngRect) -> s2sphere.LatLng: 316 | y1 = math.log((1 + math.sin(b.lat_lo().radians)) / (1 - math.sin(b.lat_lo().radians))) / 2 317 | y2 = math.log((1 + math.sin(b.lat_hi().radians)) / (1 - math.sin(b.lat_hi().radians))) / 2 318 | lat = math.atan(math.sinh((y1 + y2) / 2)) * 180 / math.pi 319 | lng = b.get_center().lng().degrees 320 | return s2sphere.LatLng.from_degrees(lat, lng) 321 | 322 | def _adjust_center(self, width: int, height: int, center: s2sphere.LatLng, zoom: int) -> s2sphere.LatLng: 323 | if len(self._objects) == 0: 324 | return center 325 | 326 | trans = Transformer(width, height, zoom, center, self._tile_provider.tile_size()) 327 | 328 | min_x = None 329 | max_x = None 330 | min_y = None 331 | max_y = None 332 | for obj in self._objects: 333 | l, t, r, b = obj.pixel_rect(trans) 334 | if min_x is None: 335 | min_x = l 336 | max_x = r 337 | min_y = t 338 | max_y = b 339 | else: 340 | min_x = min(min_x, l) 341 | max_x = max(max_x, r) 342 | min_y = min(min_y, t) 343 | max_y = max(max_y, b) 344 | assert min_x is not None 345 | assert max_x is not None 346 | assert min_y is not None 347 | assert max_y is not None 348 | 349 | # margins are bigger than the image => ignore 350 | if (max_x - min_x) > width or (max_y - min_y) > height: 351 | return center 352 | 353 | return trans.pixel2ll((max_x + min_x) * 0.5, (max_y + min_y) * 0.5) 354 | 355 | def _fetch_tile(self, z: int, x: int, y: int) -> typing.Optional[bytes]: 356 | return self._tile_downloader.get(self._tile_provider, self._cache_dir, z, x, y) 357 | 358 | def _clamp_zoom(self, zoom: typing.Optional[int]) -> typing.Optional[int]: 359 | if zoom is None: 360 | return None 361 | if zoom < 0: 362 | return 0 363 | if zoom > self._tile_provider.max_zoom(): 364 | return self._tile_provider.max_zoom() 365 | return zoom 366 | -------------------------------------------------------------------------------- /staticmaps/coordinates.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import typing 5 | 6 | import s2sphere # type: ignore 7 | 8 | 9 | def create_latlng(lat: float, lng: float) -> s2sphere.LatLng: 10 | """Create a LatLng object from float values 11 | 12 | :param lat: latitude 13 | :type lat: float 14 | :param lng: longitude 15 | :type lng: float 16 | :return: LatLng object 17 | :rtype: s2sphere.LatLng 18 | """ 19 | return s2sphere.LatLng.from_degrees(lat, lng) 20 | 21 | 22 | def parse_latlng(s: str) -> s2sphere.LatLng: 23 | """Parse a string with comma separated latitude,longitude values and create a LatLng object from float values 24 | 25 | :param s: string with latitude,longitude values 26 | :type s: str 27 | :return: LatLng object 28 | :rtype: s2sphere.LatLng 29 | :raises ValueError: raises a value error if the format is wrong 30 | """ 31 | a = s.split(",") 32 | if len(a) != 2: 33 | raise ValueError(f'Cannot parse coordinates string "{s}" (not a comma-separated lat/lng pair)') 34 | 35 | try: 36 | lat = float(a[0].strip()) 37 | lng = float(a[1].strip()) 38 | except ValueError as e: 39 | raise ValueError(f'Cannot parse coordinates string "{s}" (non-numeric lat/lng values)') from e 40 | 41 | if lat < -90 or lat > 90 or lng < -180 or lng > 180: 42 | raise ValueError(f'Cannot parse coordinates string "{s}" (out of bounds lat/lng values)') 43 | 44 | return create_latlng(lat, lng) 45 | 46 | 47 | def parse_latlngs(s: str) -> typing.List[s2sphere.LatLng]: 48 | """Parse a string with multiple comma separated latitude,longitude values and create a list of LatLng objects 49 | 50 | :param s: string with multiple latitude,longitude values separated with empty space 51 | :type s: str 52 | :return: list of LatLng objects 53 | :rtype: typing.List[s2sphere.LatLng] 54 | """ 55 | res = [] 56 | for c in s.split(): 57 | c = c.strip() 58 | if len(c) > 0: 59 | res.append(parse_latlng(c)) 60 | return res 61 | 62 | 63 | def parse_latlngs2rect(s: str) -> s2sphere.LatLngRect: 64 | """Parse a string with two comma separated latitude,longitude values and 65 | create a LatLngRect object 66 | 67 | :param s: string with two latitude,longitude values separated with empty space 68 | :type s: str 69 | :return: LatLngRect from LatLng pair 70 | :rtype: s2sphere.LatLngRect 71 | :raises ValueError: exactly two lat/lng pairs must be given as argument 72 | """ 73 | latlngs = parse_latlngs(s) 74 | if len(latlngs) != 2: 75 | raise ValueError(f'Cannot parse coordinates string "{s}" (requires exactly two lat/lng pairs)') 76 | 77 | return s2sphere.LatLngRect.from_point_pair(latlngs[0], latlngs[1]) 78 | -------------------------------------------------------------------------------- /staticmaps/image_marker.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import io 5 | import typing 6 | 7 | import s2sphere # type: ignore 8 | from PIL import Image as PIL_Image # type: ignore 9 | 10 | from .cairo_renderer import CairoRenderer 11 | from .object import Object, PixelBoundsT 12 | from .pillow_renderer import PillowRenderer 13 | from .svg_renderer import SvgRenderer 14 | 15 | 16 | class ImageMarker(Object): 17 | def __init__(self, latlng: s2sphere.LatLng, png_file: str, origin_x: int, origin_y: int) -> None: 18 | Object.__init__(self) 19 | self._latlng = latlng 20 | self._png_file = png_file 21 | self._origin_x = origin_x 22 | self._origin_y = origin_y 23 | self._width = 0 24 | self._height = 0 25 | self._image_data: typing.Optional[bytes] = None 26 | 27 | def origin_x(self) -> int: 28 | """Return x origin of the image marker 29 | 30 | :return: x origin of the image marker 31 | :rtype: int 32 | """ 33 | return self._origin_x 34 | 35 | def origin_y(self) -> int: 36 | """Return y origin of the image marker 37 | 38 | :return: y origin of the image marker 39 | :rtype: int 40 | """ 41 | return self._origin_y 42 | 43 | def width(self) -> int: 44 | """Return width of the image marker 45 | 46 | :return: width of the image marker 47 | :rtype: int 48 | """ 49 | if self._image_data is None: 50 | self.load_image_data() 51 | return self._width 52 | 53 | def height(self) -> int: 54 | """Return height of the image marker 55 | 56 | :return: height of the image marker 57 | :rtype: int 58 | """ 59 | if self._image_data is None: 60 | self.load_image_data() 61 | return self._height 62 | 63 | def image_data(self) -> bytes: 64 | """Return image data of the image marker 65 | 66 | :return: image data of the image marker 67 | :rtype: bytes 68 | """ 69 | if self._image_data is None: 70 | self.load_image_data() 71 | assert self._image_data 72 | return self._image_data 73 | 74 | def latlng(self) -> s2sphere.LatLng: 75 | """Return LatLng of the image marker 76 | 77 | :return: LatLng of the image marker 78 | :rtype: s2sphere.LatLng 79 | """ 80 | return self._latlng 81 | 82 | def bounds(self) -> s2sphere.LatLngRect: 83 | """Return bounds of the image marker 84 | 85 | :return: bounds of the image marker 86 | :rtype: s2sphere.LatLngRect 87 | """ 88 | return s2sphere.LatLngRect.from_point(self._latlng) 89 | 90 | def extra_pixel_bounds(self) -> PixelBoundsT: 91 | """Return extra pixel bounds of the image marker 92 | 93 | :return: extra pixel bounds of the image marker 94 | :rtype: PixelBoundsT 95 | """ 96 | return ( 97 | max(0, self._origin_x), 98 | max(0, self._origin_y), 99 | max(0, self.width() - self._origin_x), 100 | max(0, self.height() - self._origin_y), 101 | ) 102 | 103 | def render_pillow(self, renderer: PillowRenderer) -> None: 104 | """Render marker using PILLOW 105 | 106 | :param renderer: pillow renderer 107 | :type renderer: PillowRenderer 108 | """ 109 | x, y = renderer.transformer().ll2pixel(self.latlng()) 110 | image = renderer.create_image(self.image_data()) 111 | overlay = PIL_Image.new("RGBA", renderer.image().size, (255, 255, 255, 0)) 112 | overlay.paste( 113 | image, 114 | ( 115 | int(x - self.origin_x() + renderer.offset_x()), 116 | int(y - self.origin_y()), 117 | ), 118 | mask=image, 119 | ) 120 | renderer.alpha_compose(overlay) 121 | 122 | def render_svg(self, renderer: SvgRenderer) -> None: 123 | """Render marker using svgwrite 124 | 125 | :param renderer: svg renderer 126 | :type renderer: SvgRenderer 127 | """ 128 | x, y = renderer.transformer().ll2pixel(self.latlng()) 129 | image = renderer.create_inline_image(self.image_data()) 130 | 131 | renderer.group().add( 132 | renderer.drawing().image( 133 | image, 134 | insert=(x - self.origin_x(), y - self.origin_y()), 135 | size=(self.width(), self.height()), 136 | ) 137 | ) 138 | 139 | def render_cairo(self, renderer: CairoRenderer) -> None: 140 | """Render marker using cairo 141 | 142 | :param renderer: cairo renderer 143 | :type renderer: CairoRenderer 144 | """ 145 | x, y = renderer.transformer().ll2pixel(self.latlng()) 146 | image = renderer.create_image(self.image_data()) 147 | 148 | renderer.context().translate(x - self.origin_x(), y - self.origin_y()) 149 | renderer.context().set_source_surface(image) 150 | renderer.context().paint() 151 | 152 | def load_image_data(self) -> None: 153 | """Load image data for the image marker""" 154 | with open(self._png_file, "rb") as f: 155 | self._image_data = f.read() 156 | image = PIL_Image.open(io.BytesIO(self._image_data)) 157 | self._width, self._height = image.size 158 | -------------------------------------------------------------------------------- /staticmaps/line.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import math 5 | import typing 6 | 7 | from geographiclib.geodesic import Geodesic # type: ignore 8 | import s2sphere # type: ignore 9 | 10 | from .color import Color, RED 11 | from .coordinates import create_latlng 12 | from .object import Object, PixelBoundsT 13 | from .cairo_renderer import CairoRenderer 14 | from .pillow_renderer import PillowRenderer 15 | from .svg_renderer import SvgRenderer 16 | 17 | 18 | class Line(Object): 19 | def __init__(self, latlngs: typing.List[s2sphere.LatLng], color: Color = RED, width: int = 2) -> None: 20 | Object.__init__(self) 21 | if latlngs is None or len(latlngs) < 2: 22 | raise ValueError("Trying to create line with less than 2 coordinates") 23 | if width < 0: 24 | raise ValueError(f"'width' must be >= 0: {width}") 25 | 26 | self._latlngs = latlngs 27 | self._color = color 28 | self._width = width 29 | self._interpolation_cache: typing.Optional[typing.List[s2sphere.LatLng]] = None 30 | 31 | def color(self) -> Color: 32 | """Return color of the line 33 | 34 | :return: color object 35 | :rtype: Color 36 | """ 37 | return self._color 38 | 39 | def width(self) -> int: 40 | """Return width of line 41 | 42 | :return: width 43 | :rtype: int 44 | """ 45 | return self._width 46 | 47 | def bounds(self) -> s2sphere.LatLngRect: 48 | """Return bounds of line 49 | 50 | :return: bounds of line 51 | :rtype: s2sphere.LatLngRect 52 | """ 53 | b = s2sphere.LatLngRect() 54 | for latlng in self.interpolate(): 55 | b = b.union(s2sphere.LatLngRect.from_point(latlng.normalized())) 56 | return b 57 | 58 | def extra_pixel_bounds(self) -> PixelBoundsT: 59 | """Return extra pixel bounds from line 60 | 61 | :return: extra pixel bounds 62 | :rtype: PixelBoundsT 63 | """ 64 | return self._width, self._width, self._width, self._width 65 | 66 | def interpolate(self) -> typing.List[s2sphere.LatLng]: 67 | """Interpolate bounds 68 | 69 | :return: list of LatLng 70 | :rtype: typing.List[s2sphere.LatLng] 71 | """ 72 | if self._interpolation_cache is not None: 73 | return self._interpolation_cache 74 | assert len(self._latlngs) >= 2 75 | self._interpolation_cache = [] 76 | threshold = 2 * math.pi / 360 77 | last = self._latlngs[0] 78 | self._interpolation_cache.append(last) 79 | geod = Geodesic.WGS84 80 | for current in self._latlngs[1:]: 81 | # don't perform geodesic interpolation if the longitudinal distance is < threshold = 1° 82 | dlng = current.lng().radians - last.lng().radians 83 | while dlng < 0: 84 | dlng += 2 * math.pi 85 | while dlng >= math.pi: 86 | dlng -= 2 * math.pi 87 | if abs(dlng) < threshold: 88 | self._interpolation_cache.append(current) 89 | last = current 90 | continue 91 | # geodesic interpolation 92 | line = geod.InverseLine( 93 | last.lat().degrees, 94 | last.lng().degrees, 95 | current.lat().degrees, 96 | current.lng().degrees, 97 | ) 98 | n = 2 + math.ceil(line.a13) 99 | for i in range(1, n + 1): 100 | a = (i * line.a13) / n 101 | g = line.ArcPosition(a, Geodesic.LATITUDE | Geodesic.LONGITUDE | Geodesic.LONG_UNROLL) 102 | self._interpolation_cache.append(create_latlng(g["lat2"], g["lon2"])) 103 | last = current 104 | return self._interpolation_cache 105 | 106 | def render_pillow(self, renderer: PillowRenderer) -> None: 107 | """Render line using PILLOW 108 | 109 | :param renderer: pillow renderer 110 | :type renderer: PillowRenderer 111 | """ 112 | if self.width() == 0: 113 | return 114 | xys = [ 115 | (x + renderer.offset_x(), y) 116 | for (x, y) in [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 117 | ] 118 | renderer.draw().line(xys, self.color().int_rgba(), self.width()) 119 | 120 | def render_svg(self, renderer: SvgRenderer) -> None: 121 | """Render line using svgwrite 122 | 123 | :param renderer: svg renderer 124 | :type renderer: SvgRenderer 125 | """ 126 | if self.width() == 0: 127 | return 128 | xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 129 | polyline = renderer.drawing().polyline( 130 | xys, 131 | fill="none", 132 | stroke=self.color().hex_rgb(), 133 | stroke_width=self.width(), 134 | opacity=self.color().float_a(), 135 | ) 136 | renderer.group().add(polyline) 137 | 138 | def render_cairo(self, renderer: CairoRenderer) -> None: 139 | """Render line using cairo 140 | 141 | :param renderer: cairo renderer 142 | :type renderer: CairoRenderer 143 | """ 144 | if self.width() == 0: 145 | return 146 | xys = [renderer.transformer().ll2pixel(latlng) for latlng in self.interpolate()] 147 | renderer.context().set_source_rgba(*self.color().float_rgba()) 148 | renderer.context().set_line_width(self.width()) 149 | renderer.context().new_path() 150 | for x, y in xys: 151 | renderer.context().line_to(x, y) 152 | renderer.context().stroke() 153 | -------------------------------------------------------------------------------- /staticmaps/marker.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import math 5 | 6 | import s2sphere # type: ignore 7 | 8 | from .color import Color, RED 9 | from .object import Object, PixelBoundsT 10 | from .cairo_renderer import CairoRenderer 11 | from .pillow_renderer import PillowRenderer 12 | from .svg_renderer import SvgRenderer 13 | 14 | 15 | class Marker(Object): 16 | def __init__(self, latlng: s2sphere.LatLng, color: Color = RED, size: int = 10) -> None: 17 | Object.__init__(self) 18 | self._latlng = latlng 19 | self._color = color 20 | self._size = size 21 | 22 | def latlng(self) -> s2sphere.LatLng: 23 | """Return LatLng of the marker 24 | 25 | :return: LatLng of the marker 26 | :rtype: s2sphere.LatLng 27 | """ 28 | return self._latlng 29 | 30 | def color(self) -> Color: 31 | """Return color of the marker 32 | 33 | :return: color object 34 | :rtype: Color 35 | """ 36 | return self._color 37 | 38 | def size(self) -> int: 39 | """Return size of the marker 40 | 41 | :return: size of the marker 42 | :rtype: int 43 | """ 44 | return self._size 45 | 46 | def bounds(self) -> s2sphere.LatLngRect: 47 | """Return bounds of the marker 48 | 49 | :return: bounds of the marker 50 | :rtype: s2sphere.LatLngRect 51 | """ 52 | return s2sphere.LatLngRect.from_point(self._latlng) 53 | 54 | def extra_pixel_bounds(self) -> PixelBoundsT: 55 | """Return extra pixel bounds of the marker 56 | 57 | :return: extra pixel bounds of the marker 58 | :rtype: PixelBoundsT 59 | """ 60 | return self._size, self._size, self._size, 0 61 | 62 | def render_pillow(self, renderer: PillowRenderer) -> None: 63 | """Render marker using PILLOW 64 | 65 | :param renderer: pillow renderer 66 | :type renderer: PillowRenderer 67 | """ 68 | x, y = renderer.transformer().ll2pixel(self.latlng()) 69 | x = x + renderer.offset_x() 70 | 71 | r = self.size() 72 | dx = math.sin(math.pi / 3.0) 73 | dy = math.cos(math.pi / 3.0) 74 | cy = y - 2 * r 75 | 76 | renderer.draw().chord([(x - r, cy - r), (x + r, cy + r)], 150, 30, fill=self.color().text_color().int_rgba()) 77 | renderer.draw().polygon( 78 | [(x, y), (x - dx * r, cy + dy * r), (x + dx * r, cy + dy * r)], fill=self.color().text_color().int_rgba() 79 | ) 80 | 81 | renderer.draw().polygon( 82 | [(x, y - 1), (x - dx * (r - 1), cy + dy * (r - 1)), (x + dx * (r - 1), cy + dy * (r - 1))], 83 | fill=self.color().int_rgba(), 84 | ) 85 | renderer.draw().chord( 86 | [(x - (r - 1), cy - (r - 1)), (x + (r - 1), cy + (r - 1))], 150, 30, fill=self.color().int_rgba() 87 | ) 88 | 89 | def render_svg(self, renderer: SvgRenderer) -> None: 90 | """Render marker using svgwrite 91 | 92 | :param renderer: svg renderer 93 | :type renderer: SvgRenderer 94 | """ 95 | x, y = renderer.transformer().ll2pixel(self.latlng()) 96 | r = self.size() 97 | dx = math.sin(math.pi / 3.0) 98 | dy = math.cos(math.pi / 3.0) 99 | path = renderer.drawing().path( 100 | fill=self.color().hex_rgb(), 101 | stroke=self.color().text_color().hex_rgb(), 102 | stroke_width=1, 103 | opacity=self.color().float_a(), 104 | ) 105 | path.push(f"M {x} {y}") 106 | path.push(f" l {- dx * r} {- 2 * r + dy * r}") 107 | path.push(f" a {r} {r} 0 1 1 {2 * r * dx} 0") 108 | path.push("Z") 109 | renderer.group().add(path) 110 | 111 | def render_cairo(self, renderer: CairoRenderer) -> None: 112 | """Render marker using cairo 113 | 114 | :param renderer: cairo renderer 115 | :type renderer: CairoRenderer 116 | """ 117 | x, y = renderer.transformer().ll2pixel(self.latlng()) 118 | r = self.size() 119 | dx = math.sin(math.pi / 3.0) 120 | dy = math.cos(math.pi / 3.0) 121 | 122 | renderer.context().set_source_rgb(*self.color().text_color().float_rgb()) 123 | renderer.context().arc(x, y - 2 * r, r, 0, 2 * math.pi) 124 | renderer.context().fill() 125 | renderer.context().new_path() 126 | renderer.context().line_to(x, y) 127 | renderer.context().line_to(x - dx * r, y - 2 * r + dy * r) 128 | renderer.context().line_to(x + dx * r, y - 2 * r + dy * r) 129 | renderer.context().close_path() 130 | renderer.context().fill() 131 | 132 | renderer.context().set_source_rgb(*self.color().float_rgb()) 133 | renderer.context().arc(x, y - 2 * r, r - 1, 0, 2 * math.pi) 134 | renderer.context().fill() 135 | renderer.context().new_path() 136 | renderer.context().line_to(x, y - 1) 137 | renderer.context().line_to(x - dx * (r - 1), y - 2 * r + dy * (r - 1)) 138 | renderer.context().line_to(x + dx * (r - 1), y - 2 * r + dy * (r - 1)) 139 | renderer.context().close_path() 140 | renderer.context().fill() 141 | -------------------------------------------------------------------------------- /staticmaps/meta.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | GITHUB_URL = "https://github.com/flopp/py-staticmaps" 5 | LIB_NAME = "py-staticmaps" 6 | VERSION = "0.5.0" 7 | -------------------------------------------------------------------------------- /staticmaps/object.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | from abc import ABC, abstractmethod 5 | import typing 6 | 7 | import s2sphere # type: ignore 8 | 9 | from .cairo_renderer import CairoRenderer 10 | from .pillow_renderer import PillowRenderer 11 | from .svg_renderer import SvgRenderer 12 | from .transformer import Transformer 13 | 14 | 15 | PixelBoundsT = typing.Tuple[int, int, int, int] 16 | 17 | 18 | class Object(ABC): 19 | def __init__(self) -> None: 20 | pass 21 | 22 | @abstractmethod 23 | def extra_pixel_bounds(self) -> PixelBoundsT: 24 | """Return extra pixel bounds from object 25 | 26 | :return: extra pixel bounds 27 | :rtype: PixelBoundsT 28 | """ 29 | return 0, 0, 0, 0 30 | 31 | @abstractmethod 32 | def bounds(self) -> s2sphere.LatLngRect: 33 | """Return bounds of object 34 | 35 | :return: bounds of object 36 | :rtype: s2sphere.LatLngRect 37 | """ 38 | return s2sphere.LatLngRect() 39 | 40 | def render_pillow(self, renderer: PillowRenderer) -> None: 41 | """Render object using PILLOW 42 | 43 | :param renderer: pillow renderer 44 | :type renderer: PillowRenderer 45 | :raises RuntimeError: raises runtime error if a not implemented method is called 46 | """ 47 | # pylint: disable=unused-argument 48 | t = "Pillow" 49 | c = type(self).__name__ 50 | m = "render_pillow" 51 | raise RuntimeError(f"Cannot render to {t} since the class '{c}' doesn't implement the '{m}' method.") 52 | 53 | def render_svg(self, renderer: SvgRenderer) -> None: 54 | """Render object using svgwrite 55 | 56 | :param renderer: svg renderer 57 | :type renderer: SvgRenderer 58 | :raises RuntimeError: raises runtime error if a not implemented method is called 59 | """ 60 | # pylint: disable=unused-argument 61 | t = "SVG" 62 | c = type(self).__name__ 63 | m = "render_svg" 64 | raise RuntimeError(f"Cannot render to {t} since the class '{c}' doesn't implement the '{m}' method.") 65 | 66 | def render_cairo(self, renderer: CairoRenderer) -> None: 67 | """Render object using cairo 68 | 69 | :param renderer: cairo renderer 70 | :type renderer: CairoRenderer 71 | :raises RuntimeError: raises runtime error if a not implemented method is called 72 | """ 73 | # pylint: disable=unused-argument 74 | t = "Cairo" 75 | c = type(self).__name__ 76 | m = "render_cairo" 77 | raise RuntimeError(f"Cannot render to {t} since the class '{c}' doesn't implement the '{m}' method.") 78 | 79 | def pixel_rect(self, trans: Transformer) -> typing.Tuple[float, float, float, float]: 80 | """Return the pixel rect (left, top, right, bottom) of the object when using the supplied Transformer. 81 | 82 | :param trans: 83 | :type trans: Transformer 84 | :return: pixel rectangle of object 85 | :rtype: typing.Tuple[float, float, float, float] 86 | """ 87 | bounds = self.bounds() 88 | se_x, se_y = trans.ll2pixel(bounds.get_vertex(1)) 89 | nw_x, nw_y = trans.ll2pixel(bounds.get_vertex(3)) 90 | l, t, r, b = self.extra_pixel_bounds() 91 | return nw_x - l, nw_y - t, se_x + r, se_y + b 92 | -------------------------------------------------------------------------------- /staticmaps/pillow_renderer.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2021 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import io 5 | import math 6 | import typing 7 | 8 | from PIL import Image as PIL_Image # type: ignore 9 | from PIL import ImageDraw as PIL_ImageDraw # type: ignore 10 | 11 | from .color import Color 12 | from .renderer import Renderer 13 | from .transformer import Transformer 14 | 15 | if typing.TYPE_CHECKING: 16 | # avoid circlic import 17 | from .object import Object # pylint: disable=cyclic-import 18 | 19 | 20 | class PillowRenderer(Renderer): 21 | """An image renderer using pillow that extends a generic renderer class""" 22 | 23 | def __init__(self, transformer: Transformer) -> None: 24 | Renderer.__init__(self, transformer) 25 | self._image = PIL_Image.new("RGBA", (self._trans.image_width(), self._trans.image_height())) 26 | self._draw = PIL_ImageDraw.Draw(self._image) 27 | self._offset_x = 0 28 | 29 | def draw(self) -> PIL_ImageDraw.ImageDraw: 30 | return self._draw 31 | 32 | def image(self) -> PIL_Image.Image: 33 | return self._image 34 | 35 | def offset_x(self) -> int: 36 | return self._offset_x 37 | 38 | def alpha_compose(self, image: PIL_Image.Image) -> None: 39 | assert image.size == self._image.size 40 | self._image = PIL_Image.alpha_composite(self._image, image) 41 | self._draw = PIL_ImageDraw.Draw(self._image) 42 | 43 | def render_objects(self, objects: typing.List["Object"]) -> None: 44 | """Render all objects of static map 45 | 46 | :param objects: objects of static map 47 | :type objects: typing.List["Object"] 48 | """ 49 | x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) 50 | for obj in objects: 51 | for p in range(-x_count, x_count + 1): 52 | self._offset_x = p * self._trans.world_width() 53 | obj.render_pillow(self) 54 | 55 | def render_background(self, color: typing.Optional[Color]) -> None: 56 | """Render background of static map 57 | 58 | :param color: background color 59 | :type color: typing.Optional[Color] 60 | """ 61 | if color is None: 62 | return 63 | self.draw().rectangle([(0, 0), self.image().size], fill=color.int_rgba()) 64 | 65 | def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: 66 | """Render background of static map 67 | 68 | :param download: url of tiles provider 69 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 70 | """ 71 | for yy in range(0, self._trans.tiles_y()): 72 | y = self._trans.first_tile_y() + yy 73 | if y < 0 or y >= self._trans.number_of_tiles(): 74 | continue 75 | for xx in range(0, self._trans.tiles_x()): 76 | x = (self._trans.first_tile_x() + xx) % self._trans.number_of_tiles() 77 | try: 78 | tile_img = self.fetch_tile(download, x, y) 79 | if tile_img is None: 80 | continue 81 | self._image.paste( 82 | tile_img, 83 | ( 84 | int(xx * self._trans.tile_size() + self._trans.tile_offset_x()), 85 | int(yy * self._trans.tile_size() + self._trans.tile_offset_y()), 86 | ), 87 | ) 88 | except RuntimeError: 89 | pass 90 | 91 | def render_attribution(self, attribution: typing.Optional[str]) -> None: 92 | """Render attribution from given tiles provider 93 | 94 | :param attribution: Attribution for the given tiles provider 95 | :type attribution: typing.Optional[str]: 96 | """ 97 | if (attribution is None) or (attribution == ""): 98 | return 99 | margin = 2 100 | w = self._trans.image_width() 101 | h = self._trans.image_height() 102 | _, top, _, bottom = self.draw().textbbox((margin, h - margin), attribution) 103 | th = bottom - top 104 | overlay = PIL_Image.new("RGBA", self._image.size, (255, 255, 255, 0)) 105 | draw = PIL_ImageDraw.Draw(overlay) 106 | draw.rectangle([(0, h - th - 2 * margin), (w, h)], fill=(255, 255, 255, 204)) 107 | self.alpha_compose(overlay) 108 | self.draw().text((margin, h - th - margin), attribution, fill=(0, 0, 0, 255)) 109 | 110 | def fetch_tile( 111 | self, download: typing.Callable[[int, int, int], typing.Optional[bytes]], x: int, y: int 112 | ) -> typing.Optional[PIL_Image.Image]: 113 | """Fetch tiles from given tiles provider 114 | 115 | :param download: callable 116 | :param x: width 117 | :param y: height 118 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 119 | :type x: int 120 | :type y: int 121 | 122 | :return: pillow image 123 | :rtype: typing.Optional[PIL_Image.Image] 124 | """ 125 | image_data = download(self._trans.zoom(), x, y) 126 | if image_data is None: 127 | return None 128 | return PIL_Image.open(io.BytesIO(image_data)) 129 | 130 | @staticmethod 131 | def create_image(image_data: bytes) -> PIL_Image.Image: 132 | """Create a pillow image 133 | 134 | :param image_data: Image data 135 | :type image_data: bytes 136 | 137 | :return: pillow image 138 | :rtype: PIL.Image 139 | """ 140 | return PIL_Image.open(io.BytesIO(image_data)).convert("RGBA") 141 | -------------------------------------------------------------------------------- /staticmaps/renderer.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | from abc import ABC, abstractmethod 5 | import typing 6 | 7 | from .color import Color 8 | from .transformer import Transformer 9 | 10 | 11 | if typing.TYPE_CHECKING: 12 | # avoid circlic import 13 | from .area import Area # pylint: disable=cyclic-import 14 | from .image_marker import ImageMarker # pylint: disable=cyclic-import 15 | from .line import Line # pylint: disable=cyclic-import 16 | from .marker import Marker # pylint: disable=cyclic-import 17 | from .object import Object # pylint: disable=cyclic-import 18 | 19 | 20 | class Renderer(ABC): 21 | """A generic renderer class""" 22 | 23 | def __init__(self, transformer: Transformer) -> None: 24 | self._trans = transformer 25 | 26 | def transformer(self) -> Transformer: 27 | """Return transformer object 28 | 29 | :return: transformer 30 | :rtype: Transformer 31 | """ 32 | return self._trans 33 | 34 | @abstractmethod 35 | def render_objects(self, objects: typing.List["Object"]) -> None: 36 | """Render all objects of static map 37 | 38 | :param objects: objects of static map 39 | :type objects: typing.List["Object"] 40 | """ 41 | 42 | @abstractmethod 43 | def render_background(self, color: typing.Optional[Color]) -> None: 44 | """Render background of static map 45 | 46 | :param color: background color 47 | :type color: typing.Optional[Color] 48 | """ 49 | 50 | @abstractmethod 51 | def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: 52 | """Render background of static map 53 | 54 | :param download: url of tiles provider 55 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 56 | """ 57 | 58 | def render_marker_object(self, marker: "Marker") -> None: 59 | """Render marker object of static map 60 | 61 | :param marker: marker object 62 | :type marker: Marker 63 | """ 64 | 65 | def render_image_marker_object(self, marker: "ImageMarker") -> None: 66 | """Render image marker object of static map 67 | 68 | :param marker: image marker object 69 | :type marker: ImageMarker 70 | """ 71 | 72 | def render_line_object(self, line: "Line") -> None: 73 | """Render line object of static map 74 | 75 | :param line: line object 76 | :type line: Line 77 | """ 78 | 79 | def render_area_object(self, area: "Area") -> None: 80 | """Render area object of static map 81 | 82 | :param area: area object 83 | :type area: Area 84 | """ 85 | 86 | @abstractmethod 87 | def render_attribution(self, attribution: typing.Optional[str]) -> None: 88 | """Render attribution from given tiles provider 89 | 90 | :param attribution: Attribution for the given tiles provider 91 | :type attribution: typing.Optional[str]: 92 | """ 93 | -------------------------------------------------------------------------------- /staticmaps/svg_renderer.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import base64 5 | import math 6 | import typing 7 | 8 | import svgwrite # type: ignore 9 | 10 | from .color import Color, BLACK, WHITE 11 | from .renderer import Renderer 12 | from .transformer import Transformer 13 | 14 | if typing.TYPE_CHECKING: 15 | # avoid circlic import 16 | from .object import Object # pylint: disable=cyclic-import 17 | 18 | 19 | class SvgRenderer(Renderer): 20 | """An svg image renderer class that extends a generic renderer class""" 21 | 22 | def __init__(self, transformer: Transformer) -> None: 23 | Renderer.__init__(self, transformer) 24 | self._draw = svgwrite.Drawing( 25 | size=(f"{self._trans.image_width()}px", f"{self._trans.image_height()}px"), 26 | viewBox=f"0 0 {self._trans.image_width()} {self._trans.image_height()}", 27 | ) 28 | clip = self._draw.defs.add(self._draw.clipPath(id="page")) 29 | clip.add(self._draw.rect(insert=(0, 0), size=(self._trans.image_width(), self._trans.image_height()))) 30 | self._group: typing.Optional[svgwrite.container.Group] = None 31 | 32 | def drawing(self) -> svgwrite.Drawing: 33 | """Return the svg drawing for the image 34 | 35 | :return: svg drawing 36 | :rtype: svgwrite.Drawing 37 | """ 38 | return self._draw 39 | 40 | def group(self) -> svgwrite.container.Group: 41 | """Return the svg group for the image 42 | 43 | :return: svg group 44 | :rtype: svgwrite.container.Group 45 | """ 46 | assert self._group is not None 47 | return self._group 48 | 49 | def render_objects(self, objects: typing.List["Object"]) -> None: 50 | """Render all objects of static map 51 | 52 | :param objects: objects of static map 53 | :type objects: typing.List["Object"] 54 | """ 55 | x_count = math.ceil(self._trans.image_width() / (2 * self._trans.world_width())) 56 | for obj in objects: 57 | for p in range(-x_count, x_count + 1): 58 | self._group = self._draw.g( 59 | clip_path="url(#page)", transform=f"translate({p * self._trans.world_width()}, 0)" 60 | ) 61 | obj.render_svg(self) 62 | self._draw.add(self._group) 63 | self._group = None 64 | 65 | def render_background(self, color: typing.Optional[Color]) -> None: 66 | """Render background of static map 67 | 68 | :param color: background color 69 | :type color: typing.Optional[Color] 70 | """ 71 | if color is None: 72 | return 73 | group = self._draw.g(clip_path="url(#page)") 74 | group.add(self._draw.rect(insert=(0, 0), size=self._trans.image_size(), rx=None, ry=None, fill=color.hex_rgb())) 75 | self._draw.add(group) 76 | 77 | def render_tiles(self, download: typing.Callable[[int, int, int], typing.Optional[bytes]]) -> None: 78 | """Render background of static map 79 | 80 | :param download: url of tiles provider 81 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 82 | """ 83 | group = self._draw.g(clip_path="url(#page)") 84 | for yy in range(0, self._trans.tiles_y()): 85 | y = self._trans.first_tile_y() + yy 86 | if y < 0 or y >= self._trans.number_of_tiles(): 87 | continue 88 | for xx in range(0, self._trans.tiles_x()): 89 | x = (self._trans.first_tile_x() + xx) % self._trans.number_of_tiles() 90 | try: 91 | tile_img = self.fetch_tile(download, x, y) 92 | if tile_img is None: 93 | continue 94 | group.add( 95 | self._draw.image( 96 | tile_img, 97 | insert=( 98 | xx * self._trans.tile_size() + self._trans.tile_offset_x(), 99 | yy * self._trans.tile_size() + self._trans.tile_offset_y(), 100 | ), 101 | size=(self._trans.tile_size(), self._trans.tile_size()), 102 | ) 103 | ) 104 | except RuntimeError: 105 | pass 106 | self._draw.add(group) 107 | 108 | def render_attribution(self, attribution: typing.Optional[str]) -> None: 109 | """Render attribution from given tiles provider 110 | 111 | :param attribution: Attribution for the given tiles provider 112 | :type attribution: typing.Optional[str]: 113 | """ 114 | if (attribution is None) or (attribution == ""): 115 | return 116 | group = self._draw.g(clip_path="url(#page)") 117 | group.add( 118 | self._draw.rect( 119 | insert=(0, self._trans.image_height() - 12), 120 | size=(self._trans.image_width(), 12), 121 | rx=None, 122 | ry=None, 123 | fill=WHITE.hex_rgb(), 124 | fill_opacity="0.8", 125 | ) 126 | ) 127 | group.add( 128 | self._draw.text( 129 | attribution, 130 | insert=(2, self._trans.image_height() - 3), 131 | font_family="Arial, Helvetica, sans-serif", 132 | font_size="9px", 133 | fill=BLACK.hex_rgb(), 134 | ) 135 | ) 136 | self._draw.add(group) 137 | 138 | def fetch_tile( 139 | self, download: typing.Callable[[int, int, int], typing.Optional[bytes]], x: int, y: int 140 | ) -> typing.Optional[str]: 141 | """Fetch tiles from given tiles provider 142 | 143 | :param download: callable 144 | :param x: width 145 | :param y: height 146 | :type download: typing.Callable[[int, int, int], typing.Optional[bytes]] 147 | :type x: int 148 | :type y: int 149 | 150 | :return: svg drawing 151 | :rtype: typing.Optional[str] 152 | """ 153 | image_data = download(self._trans.zoom(), x, y) 154 | if image_data is None: 155 | return None 156 | return SvgRenderer.create_inline_image(image_data) 157 | 158 | @staticmethod 159 | def guess_image_mime_type(data: bytes) -> str: 160 | """Guess mime type from image data 161 | 162 | :param data: image data 163 | :type data: bytes 164 | :return: mime type 165 | :rtype: str 166 | """ 167 | if data[:4] == b"\xff\xd8\xff\xe0" and data[6:11] == b"JFIF\0": 168 | return "image/jpeg" 169 | if data[1:4] == b"PNG": 170 | return "image/png" 171 | return "image/png" 172 | 173 | @staticmethod 174 | def create_inline_image(image_data: bytes) -> str: 175 | """Create an svg inline image 176 | 177 | :param image_data: Image data 178 | :type image_data: bytes 179 | 180 | :return: svg inline image 181 | :rtype: str 182 | """ 183 | image_type = SvgRenderer.guess_image_mime_type(image_data) 184 | return f"data:{image_type};base64,{base64.b64encode(image_data).decode('utf-8')}" 185 | -------------------------------------------------------------------------------- /staticmaps/tile_downloader.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import os 5 | import pathlib 6 | import typing 7 | 8 | import requests 9 | import slugify # type: ignore 10 | 11 | from .meta import GITHUB_URL, LIB_NAME, VERSION 12 | from .tile_provider import TileProvider 13 | 14 | 15 | class TileDownloader: 16 | """A tile downloader class""" 17 | 18 | def __init__(self) -> None: 19 | self._user_agent = f"Mozilla/5.0+(compatible; {LIB_NAME}/{VERSION}; {GITHUB_URL})" 20 | self._sanitized_name_cache: typing.Dict[str, str] = {} 21 | 22 | def set_user_agent(self, user_agent: str) -> None: 23 | """Set the user agent for the downloader 24 | 25 | :param user_agent: user agent 26 | :type user_agent: str 27 | """ 28 | self._user_agent = user_agent 29 | 30 | def get(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) -> typing.Optional[bytes]: 31 | """Get tiles 32 | 33 | :param provider: tile provider 34 | :type provider: TileProvider 35 | :param cache_dir: cache directory for tiles 36 | :type cache_dir: str 37 | :param zoom: zoom for static map 38 | :type zoom: int 39 | :param x: x value of center for the static map 40 | :type x: int 41 | :param y: y value of center for the static map 42 | :type y: int 43 | :return: tiles 44 | :rtype: typing.Optional[bytes] 45 | :raises RuntimeError: raises a runtime error if the the server response status is not 200 46 | """ 47 | file_name = None 48 | if cache_dir is not None: 49 | file_name = self.cache_file_name(provider, cache_dir, zoom, x, y) 50 | if os.path.isfile(file_name): 51 | with open(file_name, "rb") as f: 52 | return f.read() 53 | 54 | url = provider.url(zoom, x, y) 55 | if url is None: 56 | return None 57 | res = requests.get(url, headers={"user-agent": self._user_agent}, timeout=10) 58 | if res.status_code == 200: 59 | data = res.content 60 | else: 61 | raise RuntimeError(f"fetch {url} yields {res.status_code}") 62 | 63 | if file_name is not None: 64 | pathlib.Path(os.path.dirname(file_name)).mkdir(parents=True, exist_ok=True) 65 | with open(file_name, "wb") as f: 66 | f.write(data) 67 | return data 68 | 69 | def sanitized_name(self, name: str) -> str: 70 | """Return sanitized name 71 | 72 | :param name: name to sanitize 73 | :type name: str 74 | :return: sanitized name 75 | :rtype: str 76 | """ 77 | if name in self._sanitized_name_cache: 78 | return self._sanitized_name_cache[name] 79 | sanitized = slugify.slugify(name) 80 | if sanitized is None: 81 | sanitized = "_" 82 | self._sanitized_name_cache[name] = sanitized 83 | return sanitized 84 | 85 | def cache_file_name(self, provider: TileProvider, cache_dir: str, zoom: int, x: int, y: int) -> str: 86 | """Return a cache file name 87 | 88 | :param provider: tile provider 89 | :type provider: TileProvider 90 | :param cache_dir: cache directory for tiles 91 | :type cache_dir: str 92 | :param zoom: zoom for static map 93 | :type zoom: int 94 | :param x: x value of center for the static map 95 | :type x: int 96 | :param y: y value of center for the static map 97 | :type y: int 98 | :return: cache file name 99 | :rtype: str 100 | """ 101 | return os.path.join(cache_dir, self.sanitized_name(provider.name()), str(zoom), str(x), f"{y}.png") 102 | -------------------------------------------------------------------------------- /staticmaps/tile_provider.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import string 5 | import typing 6 | 7 | 8 | class TileProvider: 9 | """A tile provider class with several pre-defined tile providers""" 10 | 11 | def __init__( 12 | self, 13 | name: str, 14 | url_pattern: str, 15 | shards: typing.Optional[typing.List[str]] = None, 16 | api_key: typing.Optional[str] = None, 17 | attribution: typing.Optional[str] = None, 18 | max_zoom: int = 24, 19 | ) -> None: 20 | self._name = name 21 | self._url_pattern = string.Template(url_pattern) 22 | self._shards = shards 23 | self._api_key = api_key 24 | self._attribution = attribution 25 | self._max_zoom = max_zoom if ((max_zoom is not None) and (max_zoom <= 20)) else 20 26 | 27 | def set_api_key(self, key: str) -> None: 28 | """Set an api key 29 | 30 | :param key: api key 31 | :type key: str 32 | """ 33 | self._api_key = key 34 | 35 | def name(self) -> str: 36 | """Return the name of the tile provider 37 | 38 | :return: name of tile provider 39 | :rtype: str 40 | """ 41 | return self._name 42 | 43 | def attribution(self) -> typing.Optional[str]: 44 | """Return the attribution of the tile provider 45 | 46 | :return: attribution of tile provider if available 47 | :rtype: typing.Optional[str] 48 | """ 49 | return self._attribution 50 | 51 | @staticmethod 52 | def tile_size() -> int: 53 | """Return the tile size 54 | 55 | :return: tile size 56 | :rtype: int 57 | """ 58 | return 256 59 | 60 | def max_zoom(self) -> int: 61 | """Return the maximum zoom of the tile provider 62 | 63 | :return: maximum zoom 64 | :rtype: int 65 | """ 66 | return self._max_zoom 67 | 68 | def url(self, zoom: int, x: int, y: int) -> typing.Optional[str]: 69 | """Return the url of the tile provider 70 | 71 | :param zoom: zoom for static map 72 | :type zoom: int 73 | :param x: x value of center for the static map 74 | :type x: int 75 | :param y: y value of center for the static map 76 | :type y: int 77 | :return: url with zoom, x and y values 78 | :rtype: typing.Optional[str] 79 | """ 80 | if len(self._url_pattern.template) == 0: 81 | return None 82 | if (zoom < 0) or (zoom > self._max_zoom): 83 | return None 84 | shard = None 85 | if self._shards is not None and len(self._shards) > 0: 86 | shard = self._shards[(x + y) % len(self._shards)] 87 | return self._url_pattern.substitute(s=shard, z=zoom, x=x, y=y, k=self._api_key) 88 | 89 | 90 | tile_provider_OSM = TileProvider( 91 | "osm", 92 | url_pattern="https://$s.tile.openstreetmap.org/$z/$x/$y.png", 93 | shards=["a", "b", "c"], 94 | attribution="Maps & Data (C) OpenStreetMap.org contributors", 95 | max_zoom=19, 96 | ) 97 | 98 | tile_provider_StamenTerrain = TileProvider( 99 | "stamen-terrain", 100 | url_pattern="http://$s.tile.stamen.com/terrain/$z/$x/$y.png", 101 | shards=["a", "b", "c", "d"], 102 | attribution="Maps (C) Stamen, Data (C) OpenStreetMap.org contributors", 103 | max_zoom=18, 104 | ) 105 | 106 | tile_provider_StamenToner = TileProvider( 107 | "stamen-toner", 108 | url_pattern="http://$s.tile.stamen.com/toner/$z/$x/$y.png", 109 | shards=["a", "b", "c", "d"], 110 | attribution="Maps (C) Stamen, Data (C) OpenStreetMap.org contributors", 111 | max_zoom=20, 112 | ) 113 | 114 | tile_provider_StamenTonerLite = TileProvider( 115 | "stamen-toner-lite", 116 | url_pattern="http://$s.tile.stamen.com/toner-lite/$z/$x/$y.png", 117 | shards=["a", "b", "c", "d"], 118 | attribution="Maps (C) Stamen, Data (C) OpenStreetMap.org contributors", 119 | max_zoom=20, 120 | ) 121 | 122 | tile_provider_ArcGISWorldImagery = TileProvider( 123 | "arcgis-worldimagery", 124 | url_pattern="https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/$z/$y/$x", 125 | attribution="Source: Esri, Maxar, GeoEye, Earthstar Geographics, " 126 | "CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community", 127 | max_zoom=24, 128 | ) 129 | 130 | tile_provider_Carto = TileProvider( 131 | "carto", 132 | url_pattern="http://$s.basemaps.cartocdn.com/rastertiles/light_all/$z/$x/$y.png", 133 | shards=["a", "b", "c", "d"], 134 | attribution="Maps (C) CARTO (C) OpenStreetMap.org contributors", 135 | max_zoom=20, 136 | ) 137 | 138 | tile_provider_CartoNoLabels = TileProvider( 139 | "carto-nolabels", 140 | url_pattern="http://$s.basemaps.cartocdn.com/rastertiles/light_nolabels/$z/$x/$y.png", 141 | shards=["a", "b", "c", "d"], 142 | attribution="Maps (C) CARTO (C) OpenStreetMap.org contributors", 143 | max_zoom=20, 144 | ) 145 | 146 | tile_provider_CartoDark = TileProvider( 147 | "carto-dark", 148 | url_pattern="http://$s.basemaps.cartocdn.com/rastertiles/dark_all/$z/$x/$y.png", 149 | shards=["a", "b", "c", "d"], 150 | attribution="Maps (C) CARTO (C) OpenStreetMap.org contributors", 151 | max_zoom=20, 152 | ) 153 | 154 | tile_provider_CartoDarkNoLabels = TileProvider( 155 | "carto-darknolabels", 156 | url_pattern="http://$s.basemaps.cartocdn.com/rastertiles/dark_nolabels/$z/$x/$y.png", 157 | shards=["a", "b", "c", "d"], 158 | attribution="Maps (C) CARTO (C) OpenStreetMap.org contributors", 159 | max_zoom=20, 160 | ) 161 | 162 | tile_provider_StadiaAlidadeSmooth = TileProvider( 163 | "stadia-alidade-smooth", 164 | url_pattern="https://tiles.stadiamaps.com/tiles/alidade_smooth/$z/$x/$y.png?api_key=$k", 165 | shards=["a", "b", "c", "d"], 166 | attribution="Maps (C) Stadia Maps (C) OpenMapTiles (C) OpenStreetMap.org contributors", 167 | max_zoom=20, 168 | api_key="", 169 | ) 170 | 171 | tile_provider_StadiaAlidadeSmoothDark = TileProvider( 172 | "stadia-alidade-smooth", 173 | url_pattern="https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/$z/$x/$y.png?api_key=$k", 174 | shards=["a", "b", "c", "d"], 175 | attribution="Maps (C) Stadia Maps (C) OpenMapTiles (C) OpenStreetMap.org contributors", 176 | max_zoom=20, 177 | ) 178 | 179 | tile_provider_JawgLight = TileProvider( 180 | "jawg-light", 181 | url_pattern="https://$s.tile.jawg.io/jawg-light/$z/$x/$y.png?access-token=$k", 182 | shards=["a", "b", "c", "d"], 183 | attribution="Maps (C) Jawg Maps (C) OpenStreetMap.org contributors", 184 | max_zoom=22, 185 | ) 186 | 187 | tile_provider_JawgDark = TileProvider( 188 | "jawg-dark", 189 | url_pattern="https://$s.tile.jawg.io/jawg-dark/$z/$x/$y.png?access-token=$k", 190 | shards=["a", "b", "c", "d"], 191 | attribution="Maps (C) Jawg Maps (C) OpenStreetMap.org contributors", 192 | max_zoom=22, 193 | ) 194 | 195 | tile_provider_None = TileProvider( 196 | "none", 197 | url_pattern="", 198 | attribution=None, 199 | max_zoom=99, 200 | ) 201 | 202 | default_tile_providers = { 203 | tile_provider_ArcGISWorldImagery.name(): tile_provider_ArcGISWorldImagery, 204 | tile_provider_Carto.name(): tile_provider_Carto, 205 | tile_provider_CartoNoLabels.name(): tile_provider_CartoNoLabels, 206 | tile_provider_CartoDark.name(): tile_provider_CartoDark, 207 | tile_provider_CartoDarkNoLabels.name(): tile_provider_CartoDarkNoLabels, 208 | tile_provider_OSM.name(): tile_provider_OSM, 209 | tile_provider_StadiaAlidadeSmooth.name(): tile_provider_StadiaAlidadeSmooth, 210 | tile_provider_StadiaAlidadeSmoothDark.name(): tile_provider_StadiaAlidadeSmoothDark, 211 | tile_provider_JawgLight.name(): tile_provider_JawgLight, 212 | tile_provider_JawgDark.name(): tile_provider_JawgDark, 213 | tile_provider_None.name(): tile_provider_None, 214 | } 215 | -------------------------------------------------------------------------------- /staticmaps/transformer.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import math 5 | import typing 6 | 7 | import s2sphere # type: ignore 8 | 9 | 10 | # pylint: disable=too-many-instance-attributes 11 | class Transformer: 12 | """Base class for transforming values""" 13 | 14 | def __init__(self, width: int, height: int, zoom: int, center: s2sphere.LatLng, tile_size: int) -> None: 15 | self._zoom = zoom 16 | self._number_of_tiles = 2**zoom 17 | self._tile_size = tile_size 18 | self._width = width 19 | self._height = height 20 | 21 | # Fractional tile index to center of requested area. 22 | self._tile_center_x, self._tile_center_y = self.ll2t(center) 23 | 24 | ww = width / tile_size 25 | hh = height / tile_size 26 | 27 | # Top-left tile in requested area 28 | self._first_tile_x = int(math.floor(self._tile_center_x - ww / 2)) 29 | self._first_tile_y = int(math.floor(self._tile_center_y - hh / 2)) 30 | 31 | # Number of tiles (horizontal, vertical) covering requested area 32 | self._tiles_x = 1 + int(math.floor(self._tile_center_x + ww / 2)) - self._first_tile_x 33 | self._tiles_y = 1 + int(math.floor(self._tile_center_y + hh / 2)) - self._first_tile_y 34 | 35 | # Pixel-offset of the top-left tile relative to the requested area 36 | self._tile_offset_x = width / 2 - int((self._tile_center_x - self._first_tile_x) * tile_size) 37 | self._tile_offset_y = height / 2 - int((self._tile_center_y - self._first_tile_y) * tile_size) 38 | 39 | def world_width(self) -> int: 40 | """Return the width of the world in pixels depending on tiles provider 41 | 42 | :return: width of the world in pixels 43 | :rtype: int 44 | """ 45 | return self._number_of_tiles * self._tile_size 46 | 47 | def image_width(self) -> int: 48 | """Return the width of the image in pixels 49 | 50 | :return: width of the image in pixels 51 | :rtype: int 52 | """ 53 | return self._width 54 | 55 | def image_height(self) -> int: 56 | """Return the height of the image in pixels 57 | 58 | :return: height of the image in pixels 59 | :rtype: int 60 | """ 61 | return self._height 62 | 63 | def zoom(self) -> int: 64 | """Return the zoom of the static map 65 | 66 | :return: zoom of the static map 67 | :rtype: int 68 | """ 69 | 70 | return self._zoom 71 | 72 | def image_size(self) -> typing.Tuple[int, int]: 73 | """Return the size of the image as tuple of width and height 74 | 75 | :return: width and height of the image in pixels 76 | :rtype: tuple 77 | """ 78 | 79 | return self._width, self._height 80 | 81 | def number_of_tiles(self) -> int: 82 | """Return number of tiles of static map 83 | 84 | :return: number of tiles 85 | :rtype: int 86 | """ 87 | return self._number_of_tiles 88 | 89 | def first_tile_x(self) -> int: 90 | """Return number of first tile in x 91 | 92 | :return: number of first tile 93 | :rtype: int 94 | """ 95 | return self._first_tile_x 96 | 97 | def first_tile_y(self) -> int: 98 | """Return number of first tile in y 99 | 100 | :return: number of first tile 101 | :rtype: int 102 | """ 103 | return self._first_tile_y 104 | 105 | def tiles_x(self) -> int: 106 | """Return number of tiles in x 107 | 108 | :return: number of tiles 109 | :rtype: int 110 | """ 111 | return self._tiles_x 112 | 113 | def tiles_y(self) -> int: 114 | """Return number of tiles in y 115 | 116 | :return: number of tiles 117 | :rtype: int 118 | """ 119 | return self._tiles_y 120 | 121 | def tile_offset_x(self) -> float: 122 | """Return tile offset in x 123 | 124 | :return: tile offset 125 | :rtype: int 126 | """ 127 | return self._tile_offset_x 128 | 129 | def tile_offset_y(self) -> float: 130 | """Return tile offset in y 131 | 132 | :return: tile offset 133 | :rtype: int 134 | """ 135 | return self._tile_offset_y 136 | 137 | def tile_size(self) -> int: 138 | """Return tile size 139 | 140 | :return: tile size 141 | :rtype: int 142 | """ 143 | return self._tile_size 144 | 145 | @staticmethod 146 | def mercator(latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: 147 | """Mercator projection 148 | 149 | :param latlng: LatLng object 150 | :type latlng: s2sphere.LatLng 151 | :return: tile values of given LatLng 152 | :rtype: tuple 153 | """ 154 | lat = latlng.lat().radians 155 | lng = latlng.lng().radians 156 | return lng / (2 * math.pi) + 0.5, (1 - math.log(math.tan(lat) + (1 / math.cos(lat))) / math.pi) / 2 157 | 158 | @staticmethod 159 | def mercator_inv(x: float, y: float) -> s2sphere.LatLng: 160 | """Inverse Mercator projection 161 | 162 | :param x: x value 163 | :type x: float 164 | :param y: x value 165 | :type y: float 166 | :return: LatLng values of given values 167 | :rtype: s2sphere.LatLng 168 | """ 169 | x = 2 * math.pi * (x - 0.5) 170 | k = math.exp(4 * math.pi * (0.5 - y)) 171 | y = math.asin((k - 1) / (k + 1)) 172 | return s2sphere.LatLng(y, x) 173 | 174 | def ll2t(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: 175 | """Transform LatLng values into tiles 176 | 177 | :param latlng: LatLng object 178 | :type latlng: s2sphere.LatLng 179 | :return: tile values of given LatLng 180 | :rtype: tuple 181 | """ 182 | x, y = self.mercator(latlng) 183 | return self._number_of_tiles * x, self._number_of_tiles * y 184 | 185 | def t2ll(self, x: float, y: float) -> s2sphere.LatLng: 186 | """Transform tile values into LatLng values 187 | 188 | :param x: x tile 189 | :type x: float 190 | :param y: x tile 191 | :type y: float 192 | :return: LatLng values of given tile values 193 | :rtype: s2sphere.LatLng 194 | """ 195 | return self.mercator_inv(x / self._number_of_tiles, y / self._number_of_tiles) 196 | 197 | def ll2pixel(self, latlng: s2sphere.LatLng) -> typing.Tuple[float, float]: 198 | """Transform LatLng values into pixel values 199 | 200 | :param latlng: LatLng object 201 | :type latlng: s2sphere.LatLng 202 | :return: pixel values of given LatLng 203 | :rtype: tuple 204 | """ 205 | x, y = self.ll2t(latlng) 206 | s = self._tile_size 207 | x = self._width / 2 + (x - self._tile_center_x) * s 208 | y = self._height / 2 + (y - self._tile_center_y) * s 209 | return x, y 210 | 211 | def pixel2ll(self, x: float, y: float) -> s2sphere.LatLng: 212 | """Transform pixel values into LatLng values 213 | 214 | :param x: x pixel 215 | :type x: float 216 | :param y: x pixel 217 | :type y: float 218 | :return: LatLng values of given pixel values 219 | :rtype: s2sphere.LatLng 220 | """ 221 | s = self._tile_size 222 | x = (x - self._width / 2) / s + self._tile_center_x 223 | y = (y - self._height / 2) / s + self._tile_center_y 224 | return self.t2ll(x, y) 225 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | -------------------------------------------------------------------------------- /tests/mock_tile_downloader.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import typing 5 | import staticmaps 6 | 7 | 8 | class MockTileDownloader(staticmaps.TileDownloader): 9 | def __init__(self) -> None: 10 | super().__init__() 11 | self._dummy_image_data: typing.Optional[bytes] = None 12 | 13 | def set_user_agent(self, user_agent: str) -> None: 14 | pass 15 | 16 | def get( 17 | self, provider: staticmaps.TileProvider, cache_dir: str, zoom: int, x: int, y: int 18 | ) -> typing.Optional[bytes]: 19 | return self._dummy_image_data 20 | -------------------------------------------------------------------------------- /tests/test_color.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import pytest # type: ignore 5 | import staticmaps 6 | 7 | 8 | def test_parse_color() -> None: 9 | bad = [ 10 | "", 11 | "aaa", 12 | "midnightblack", 13 | "#123", 14 | "#12345", 15 | "#1234567", 16 | ] 17 | for s in bad: 18 | with pytest.raises(ValueError): 19 | staticmaps.parse_color(s) 20 | 21 | good = ["0x1a2b3c", "0x1A2B3C", "#1a2b3c", "0x1A2B3C", "0x1A2B3C4D", "black", "RED", "Green", "transparent"] 22 | for s in good: 23 | staticmaps.parse_color(s) 24 | 25 | 26 | def test_create() -> None: 27 | bad = [ 28 | (-1, 0, 0), 29 | (256, 0, 0), 30 | (0, -1, 0), 31 | (0, 256, 0), 32 | (0, 0, -1), 33 | (0, 0, 256), 34 | (0, 0, 0, -1), 35 | (0, 0, 0, 256), 36 | ] 37 | for rgb in bad: 38 | with pytest.raises(ValueError): 39 | staticmaps.Color(*rgb) 40 | 41 | staticmaps.Color(1, 2, 3) 42 | staticmaps.Color(1, 2, 3, 4) 43 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import pytest # type: ignore 5 | import s2sphere # type: ignore 6 | 7 | import staticmaps 8 | 9 | from .mock_tile_downloader import MockTileDownloader 10 | 11 | 12 | def test_bounds() -> None: 13 | context = staticmaps.Context() 14 | assert context.object_bounds() is None 15 | 16 | context.add_object(staticmaps.Marker(staticmaps.create_latlng(48, 8))) 17 | bounds = context.object_bounds() 18 | assert bounds is not None 19 | assert bounds.is_point() 20 | 21 | context.add_object(staticmaps.Marker(staticmaps.create_latlng(47, 7))) 22 | assert context.object_bounds() is not None 23 | assert context.object_bounds() == s2sphere.LatLngRect( 24 | staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) 25 | ) 26 | 27 | context.add_object(staticmaps.Marker(staticmaps.create_latlng(47.5, 7.5))) 28 | assert context.object_bounds() is not None 29 | assert context.object_bounds() == s2sphere.LatLngRect( 30 | staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) 31 | ) 32 | 33 | context.add_bounds(s2sphere.LatLngRect(staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9))) 34 | assert context.object_bounds() is not None 35 | assert context.object_bounds() == s2sphere.LatLngRect( 36 | staticmaps.create_latlng(46, 6), staticmaps.create_latlng(49, 9) 37 | ) 38 | 39 | context.add_bounds(s2sphere.LatLngRect(staticmaps.create_latlng(47.5, 7.5), staticmaps.create_latlng(48, 8))) 40 | assert context.object_bounds() is not None 41 | assert context.object_bounds() == s2sphere.LatLngRect( 42 | staticmaps.create_latlng(47, 7), staticmaps.create_latlng(48, 8) 43 | ) 44 | 45 | 46 | def test_render_empty() -> None: 47 | context = staticmaps.Context() 48 | with pytest.raises(RuntimeError): 49 | context.render_svg(200, 100) 50 | 51 | 52 | def test_render_center_zoom() -> None: 53 | context = staticmaps.Context() 54 | context.set_tile_downloader(MockTileDownloader()) 55 | context.set_center(staticmaps.create_latlng(48, 8)) 56 | context.set_zoom(15) 57 | context.render_svg(200, 100) 58 | -------------------------------------------------------------------------------- /tests/test_coordinates.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import pytest # type: ignore 5 | 6 | import staticmaps 7 | 8 | 9 | def test_parse_latlng() -> None: 10 | bad = [ 11 | "", 12 | "aaa", 13 | "12", 14 | "1,2,3", 15 | "48,", 16 | "48,x", 17 | "4 8,8", 18 | "91,8", 19 | "-91,8", 20 | "48,-181", 21 | "48,181", 22 | ] 23 | for s in bad: 24 | with pytest.raises(ValueError): 25 | staticmaps.parse_latlng(s) 26 | 27 | good = ["48,8", " 48 , 8 ", "-48,8", "+48,8", "48,-8", "48,+8", "48.123,8.456"] 28 | for s in good: 29 | c = staticmaps.parse_latlng(s) 30 | assert c.is_valid() 31 | 32 | 33 | def test_parse_latlngs() -> None: 34 | good = [("", 0), ("48,8", 1), ("48,8 47,7", 2), (" 48,8 47,7 ", 2), ("48,7 48,8 47,7", 3)] 35 | for s, expected_len in good: 36 | a = staticmaps.parse_latlngs(s) 37 | assert len(a) == expected_len 38 | 39 | bad = ["xyz", "48,8 xyz", "48,8 48,181"] 40 | for s in bad: 41 | with pytest.raises(ValueError): 42 | staticmaps.parse_latlngs(s) 43 | 44 | 45 | def test_parse_latlngs2rect() -> None: 46 | good = ["48,8 47,7", " 48,8 47,7 "] 47 | for s in good: 48 | r = staticmaps.parse_latlngs2rect(s) 49 | assert r.is_valid() 50 | 51 | bad = ["xyz", "48,8 xyz", "48,8 48,181", "48,7", "48,7 48,8 47,7"] 52 | for s in bad: 53 | with pytest.raises(ValueError): 54 | staticmaps.parse_latlngs2rect(s) 55 | -------------------------------------------------------------------------------- /tests/test_line.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import pytest 5 | 6 | import staticmaps 7 | 8 | 9 | def test_bad_creation() -> None: 10 | with pytest.raises(ValueError): 11 | staticmaps.Line([]) 12 | 13 | with pytest.raises(ValueError): 14 | staticmaps.Line([staticmaps.create_latlng(48, 8)]) 15 | 16 | with pytest.raises(ValueError): 17 | staticmaps.Line([staticmaps.create_latlng(48, 8), staticmaps.create_latlng(49, 9)], width=-123) 18 | 19 | 20 | def test_creation() -> None: 21 | staticmaps.Line( 22 | [staticmaps.create_latlng(48, 8), staticmaps.create_latlng(49, 9), staticmaps.create_latlng(50, 8)], 23 | color=staticmaps.YELLOW, 24 | ) 25 | 26 | 27 | def test_bounds() -> None: 28 | line = staticmaps.Line( 29 | [staticmaps.create_latlng(48, 8), staticmaps.create_latlng(49, 9), staticmaps.create_latlng(50, 8)], 30 | color=staticmaps.YELLOW, 31 | ) 32 | assert not line.bounds().is_point() 33 | -------------------------------------------------------------------------------- /tests/test_marker.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import staticmaps 5 | 6 | 7 | def test_creation() -> None: 8 | staticmaps.Marker(staticmaps.create_latlng(48, 8), color=staticmaps.YELLOW, size=8) 9 | 10 | 11 | def test_bounds() -> None: 12 | marker = staticmaps.Marker(staticmaps.create_latlng(48, 8)) 13 | assert marker.bounds().is_point() 14 | -------------------------------------------------------------------------------- /tests/test_tile_provider.py: -------------------------------------------------------------------------------- 1 | # py-staticmaps 2 | # Copyright (c) 2020 Florian Pigorsch; see /LICENSE for licensing information 3 | 4 | import staticmaps 5 | 6 | 7 | def test_sharding() -> None: 8 | t = staticmaps.TileProvider(name="test", url_pattern="$s/$z/$x/$y", shards=["0", "1", "2"]) 9 | shard_counts = [0, 0, 0] 10 | for x in range(0, 100): 11 | for y in range(0, 100): 12 | u = t.url(0, x, y) 13 | for s in [0, 1, 2]: 14 | if u == f"{s}/0/{x}/{y}": 15 | shard_counts[s] += 1 16 | assert shard_counts[0] + shard_counts[1] + shard_counts[2] == 100 * 100 17 | third = (100 * 100) // 3 18 | for s in shard_counts: 19 | assert (third * 0.9) < s 20 | assert s < (third * 1.1) 21 | --------------------------------------------------------------------------------