├── .github └── workflows │ ├── build.yml │ ├── release.yml │ └── static.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── examples ├── README.md ├── data │ ├── dortmund-cluster.json │ ├── dortmund-point.json │ ├── dortmund-route.json │ ├── fleet-cloud-all-0.7.3.json │ ├── fleet-cloud-all-0.8.json │ ├── fleet-cloud.json │ ├── hamburg-route.json │ ├── kyoto-route.json │ ├── paris-route.json │ ├── rome-cluster.json │ ├── rome-point.json │ └── rome-route.json └── gallery │ └── README.md ├── nextplot ├── __about__.py ├── __init__.py ├── cluster.py ├── common.py ├── geojson.py ├── main.py ├── osrm.py ├── point.py ├── progression.py ├── route.py ├── routingkit.py ├── test.py └── types.py ├── plot.py ├── pyproject.toml ├── requirements-dev.txt └── tests ├── test_cli.py └── testdata ├── fleet-cloud-all-0.7.3.json ├── fleet-cloud-all-0.8.json ├── fleet-cloud-comparison.golden ├── fleet-cloud-comparison.html.golden ├── fleet-cloud-comparison.png.golden ├── geojson-data.json ├── geojson-data.json.golden ├── geojson-data.json.map.html.golden ├── geojson-nested-data.json ├── geojson-nested-data.json.golden ├── geojson-nested-data.json.map.html.golden ├── paris-cluster.json ├── paris-cluster.json.golden ├── paris-cluster.map.html.golden ├── paris-cluster.plot.html.golden ├── paris-cluster.plot.png.golden ├── paris-point.json ├── paris-point.json.golden ├── paris-point.map.html.golden ├── paris-point.plot.html.golden ├── paris-point.plot.png.golden ├── paris-pos.json ├── paris-route-indexed.json ├── paris-route-indexed.json.golden ├── paris-route-indexed.map.html.golden ├── paris-route-indexed.plot.html.golden ├── paris-route-indexed.plot.png.golden ├── paris-route.json ├── paris-route.json.golden ├── paris-route.map.html.golden ├── paris-route.plot.html.golden └── paris-route.plot.png.golden /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | name: "nextplot build, lint & test" 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # Tests are currently stable only for python 3.11 12 | # due to the way we are testing 13 | python-version: ["3.11"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | # Install all development dependencies 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements-dev.txt 27 | 28 | # Lint with ruff 29 | - name: lint with ruff 30 | run: ruff check --output-format=github . 31 | 32 | # Run the tests with pytest 33 | - name: Test with pytest 34 | run: python -m pytest -v -s 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | run-name: Release ${{ inputs.VERSION }} (pre-release - ${{ inputs.IS_PRE_RELEASE }}) by @${{ github.actor }} from ${{ github.ref_name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | VERSION: 8 | description: "The version to release" 9 | required: true 10 | IS_PRE_RELEASE: 11 | description: "It IS a pre-release" 12 | required: true 13 | default: false 14 | type: boolean 15 | 16 | jobs: 17 | bump: # This job is used to bump the version and create a release 18 | runs-on: ubuntu-latest 19 | env: 20 | VERSION: ${{ inputs.VERSION }} 21 | GH_TOKEN: ${{ github.token }} 22 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 23 | permissions: 24 | contents: write 25 | steps: 26 | - name: set up Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: install dependencies 32 | run: | 33 | pip install --upgrade pip 34 | pip install build hatch 35 | 36 | - name: configure git with the bot credentials 37 | run: | 38 | mkdir -p ~/.ssh 39 | ssh-keyscan github.com >> ~/.ssh/known_hosts 40 | ssh-agent -a $SSH_AUTH_SOCK > /dev/null 41 | ssh-add - <<< "${{ secrets.NEXTMVBOT_SSH_KEY }}" 42 | 43 | echo "${{ secrets.NEXTMVBOT_SIGNING_KEY }}" > ~/.ssh/signing.key 44 | chmod 600 ~/.ssh/signing.key 45 | 46 | git config --global user.name "nextmv-bot" 47 | git config --global user.email "tech+gh-nextmv-bot@nextmv.io" 48 | git config --global gpg.format ssh 49 | git config --global user.signingkey ~/.ssh/signing.key 50 | 51 | git clone git@github.com:nextmv-io/nextplot.git 52 | 53 | - name: upgrade version with hatch 54 | run: hatch version ${{ env.VERSION }} 55 | working-directory: ./nextplot 56 | 57 | - name: commit new version 58 | run: | 59 | git add nextplot/__about__.py 60 | git commit -S -m "Bump version to $VERSION" 61 | git push 62 | git tag $VERSION 63 | git push origin $VERSION 64 | working-directory: ./nextplot 65 | 66 | - name: create release 67 | run: | 68 | PRERELEASE_FLAG="" 69 | if [ ${{ inputs.IS_PRE_RELEASE }} = true ]; then 70 | PRERELEASE_FLAG="--prerelease" 71 | fi 72 | 73 | gh release create $VERSION \ 74 | --verify-tag \ 75 | --generate-notes \ 76 | --title $VERSION $PRERELEASE_FLAG 77 | working-directory: ./nextplot 78 | 79 | - name: ensure passing build 80 | run: python -m build 81 | working-directory: ./nextplot 82 | 83 | release: # This job is used to publish the release to PyPI/TestPyPI 84 | runs-on: ubuntu-latest 85 | needs: bump 86 | strategy: 87 | matrix: 88 | include: 89 | - target-env: pypi 90 | target-url: https://pypi.org/p/nextplot 91 | - target-env: testpypi 92 | target-url: https://test.pypi.org/p/nextplot 93 | environment: 94 | name: ${{ matrix.target-env }} 95 | url: ${{ matrix.target-url }} 96 | permissions: 97 | contents: read 98 | id-token: write # This is required for trusted publishing to PyPI 99 | steps: 100 | - name: git clone develop 101 | uses: actions/checkout@v4 102 | with: 103 | ref: develop 104 | 105 | - name: set up Python 106 | uses: actions/setup-python@v5 107 | with: 108 | python-version: "3.12" 109 | 110 | - name: install dependencies 111 | run: | 112 | pip install --upgrade pip 113 | pip install build hatch 114 | 115 | - name: build binary wheel and source tarball 116 | run: python -m build 117 | 118 | - name: Publish package distributions to PyPI 119 | if: ${{ matrix.target-env == 'pypi' }} 120 | uses: pypa/gh-action-pypi-publish@release/v1 121 | with: 122 | packages-dir: ./dist 123 | 124 | - name: Publish package distributions to TestPyPI 125 | if: ${{ matrix.target-env == 'testpypi' }} 126 | uses: pypa/gh-action-pypi-publish@release/v1 127 | with: 128 | repository-url: https://test.pypi.org/legacy/ 129 | packages-dir: ./dist 130 | 131 | notify: 132 | runs-on: ubuntu-latest 133 | needs: release 134 | if: ${{ needs.release.result == 'success' && inputs.IS_PRE_RELEASE == false }} 135 | steps: 136 | - name: notify slack 137 | run: | 138 | export DATA="{\"text\":\"Release notification - nextplot ${{ inputs.VERSION }} (see / )\"}" 139 | curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_URL_MISSION_CONTROL }} 140 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: pages 3 | 4 | on: 5 | # Runs on pushes to the content branch 6 | push: 7 | branches: ["content"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v4 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload content directory 40 | path: "content/" 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Ruff 132 | .ruff_cache/ 133 | 134 | # Project related 135 | *.html 136 | *.png 137 | *.gif 138 | !/material/**/* 139 | !/examples/plots/**/* 140 | tests/output/ 141 | tmp/ 142 | *.pbf 143 | *.ch 144 | build/ 145 | *.json 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022-2023 nextmv.io inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextplot 2 | 3 | [![pypi_version](https://img.shields.io/pypi/v/nextplot?label=pypi)](https://pypi.org/project/nextplot)[![build](https://github.com/nextmv-io/nextplot/actions/workflows/build.yml/badge.svg)](https://github.com/nextmv-io/nextplot/actions/workflows/build.yml) 4 | 5 | Tools for plotting routes and clusters from JSON 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pip install nextplot 11 | ``` 12 | 13 | ## Usage 14 | 15 | If the installation succeeded, you should be able to invoke the following: 16 | 17 | ```bash 18 | nextplot --help 19 | ``` 20 | 21 | Furthermore, use the __route__ and __cluster__ commands for the respective 22 | plotting type. Find an overview of the arguments for the specific mode by 23 | invoking `nextplot route --help` and `nextplot cluster --help`. 24 | 25 | Above shows some information about how to use the script. Below presents some 26 | further information for running the script using route plotting as an example. 27 | 28 | There are basically two options of running the script. Either feed the JSON to 29 | STDIN of the script like so: 30 | 31 | ```bash 32 | cat examples/data/kyoto-route.json | nextplot route \ 33 | --jpath_route "vehicles[*].route" \ 34 | --jpath_x "position.lon" \ 35 | --jpath_y "position.lat" 36 | ``` 37 | 38 | Or supply a path to the `.json` file like so: 39 | 40 | ```bash 41 | nextplot route \ 42 | --input_route examples/data/kyoto-route.json \ 43 | --jpath_route "vehicles[*].route" \ 44 | --jpath_x "position.lon" \ 45 | --jpath_y "position.lat" 46 | ``` 47 | 48 | Both approaches will create a `.png` plot image and if possible (valid lon/lat 49 | coordinates given) an interactive `.html` plot file. The filenames will be 50 | `plot.[png,html]` for the first option, based on the input filename for the 51 | second option and customized ones if `--output_image` and/or `--output_map` are 52 | specified. 53 | 54 | The input file is expected to have some JSON array of positions which can be 55 | addressed using the notation described here: 56 | [https://goessner.net/articles/JsonPath/](https://goessner.net/articles/JsonPath/). 57 | The path to the positions can be modified via the `--jpath_route` parameter. 58 | For example, if your input file stores the locations like shown below, the path 59 | `"vehicles[*].route"` will extract them. As you can see, the 60 | path first goes through the `vehicles`. Next, `[*]` denotes that a list of 61 | vehicles is expected, which in turn results in a list of routes. Finally, 62 | `.route` points to the exact location of the route object per vehicle. The 63 | `--jpath_x` and `--jpath_y` parameters are used to extract the longitude and 64 | latitude values from the stop objects at `position.lon` and `position.lat`. 65 | 66 | ```jsonc 67 | { 68 | "vehicles": [ 69 | // ... 70 | { 71 | "id": "v1", 72 | "route": [ 73 | { 74 | "id": "v1-start", 75 | "position": { "lon": 135.73723, "lat": 35.04381 } 76 | }, 77 | { 78 | "id": "Kinkaku-ji", 79 | "position": { "lon": 135.728898, "lat": 35.039705 } 80 | }, 81 | { 82 | "id": "Nijō Castle", 83 | "position": { "lon": 135.748134, "lat": 35.014239 } 84 | }, 85 | { 86 | "id": "Arashiyama Bamboo Forest", 87 | "position": { "lon": 135.672009, "lat": 35.017209 } 88 | } 89 | ] 90 | } 91 | // ... 92 | ] 93 | } 94 | ``` 95 | 96 | ### Further examples 97 | 98 | A more detailed introduction and a plot gallery can be found [here](examples/README.md). 99 | 100 | ## Preview 101 | 102 | Preview _route_ plot (screenshot of .html-file, cartodbdark_matter selected): 103 | 104 | ![sample-popup](https://nextmv-io.github.io/nextplot/plots/sneak/example-popup.png) 105 | 106 | Another preview _route_ plot (screenshot of .html-file, cartodbdark_matter selected): 107 | 108 | ![sample-plot-html](https://nextmv-io.github.io/nextplot/plots/sneak/example-route-html.png) 109 | 110 | Preview _cluster_ plot (screenshot of .html-file, cartodbdark_matter selected): 111 | 112 | ![sample-plot-html](https://nextmv-io.github.io/nextplot/plots/sneak/example-cluster-html.png) 113 | 114 | ## Auto-completion 115 | 116 | Auto-completion (using _tab_) is supported via `argcomplete`. To enable it, install the package: 117 | 118 | ```bash 119 | pip install argcomplete 120 | ``` 121 | 122 | Then, add the following line to your `~/.bashrc`: 123 | 124 | ```bash 125 | eval "$(register-python-argcomplete nextplot)" 126 | ``` 127 | 128 | ## Tests 129 | 130 | Tests are located in `tests/` and can be run via `python -m pytest`. Update test 131 | expectations (golden files) by running `UPDATE=1 python test_cli.py` from the 132 | test directory. Tests require specific versions of the dependencies to be 133 | installed. These can be installed via `pip install -r requirements-dev.txt`. 134 | It is recommended to update the expectations in a docker container to avoid 135 | messing up local dependencies. This can be done by running the following 136 | commands: 137 | 138 | ```bash 139 | docker run --rm -it -v $(pwd):/app -w /app python:3.11 bash -c "pip install -r requirements-dev.txt && cd tests && UPDATE=1 python test_cli.py" 140 | ``` 141 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextmv-io/nextplot/c2356734796cce0c95d8d006fabeb7f1ade33502/__init__.py -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | More detailed examples and descriptions are given below. Further examples can be 4 | found in the [gallery](gallery/README.md) section. 5 | 6 | ## General steps 7 | 8 | When plotting clusters or routes from JSON files we need to first identify where 9 | the necessary information is stored within the JSON structure. For this, we can 10 | use the `test` mode of `nextplot`. 11 | 12 | ```bash 13 | nextplot test --help 14 | ``` 15 | 16 | Let's look at `data/hamburg-route.json`. The relevant data (routes as list of 17 | points) is stored as follows. 18 | 19 | ```json 20 | { 21 | "state": { 22 | "drivers": [ 23 | { 24 | "geometry": { 25 | "coordinates": [ 26 | [7.445303531392447, 51.49135302624266], 27 | [7.447852883113915, 51.48998800466363], 28 | [7.447056459334395, 51.48748598974504], 29 | [7.446920673259998, 51.48822589325628], 30 | [7.446382930568115, 51.48952134441981], 31 | [7.445632420473728, 51.48962184633942] 32 | ] 33 | } 34 | } 35 | ] 36 | } 37 | } 38 | ``` 39 | 40 | Hence, we should be able to extract the routes with the following JSON path: 41 | `state.drivers[*].geometry.coordinates[*]`. Let's test it by invoking the `test` 42 | mode. Adding the `--stats` flag will give us the number of routes found. 43 | 44 | ```bash 45 | nextplot test \ 46 | --input data/hamburg-route.json \ 47 | --jpath "state.drivers[*].geometry.coordinates" \ 48 | --stats 49 | ``` 50 | 51 | The result should be all the expected lists of routes. For the given example 52 | `271 matches` should be returned. 53 | 54 | ## Route plotting 55 | 56 | Now let's plot some routes. We will be using `data/dortmund-route.json` as 57 | sample input data. The file contains the same structure as the Hamburg example 58 | above. Hence, we can use the same `jpath`. We can get our plot with this: 59 | 60 | ```bash 61 | nextplot route \ 62 | --input_route data/dortmund-route.json \ 63 | --jpath_route "state.drivers[*].geometry.coordinates" 64 | ``` 65 | 66 | The output at stdout should be: 67 | 68 | ```txt 69 | Route stats 70 | Route count: 271 71 | Route stops (max): 29 72 | Route stops (min): 1 73 | Route stops (avg): 21.365313653136532 74 | Route length (max): 8.344633870323412 75 | Route length (min): 0 76 | Route length (avg): 2.1533391639934014 77 | Plotting image to data/dortmund-route.json.png 78 | Plotting map to data/dortmund-route.json.html 79 | ``` 80 | 81 | The output contains some statistics and shows that the plot files have been 82 | written to `data/.png` & `data/.html`. At default, 83 | the plots are written to the same directory as the input file using its name. 84 | This can be changed via the `--output_image` & `--output_map` parameters. 85 | 86 | The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/dortmund-route): 87 | ![dortmund-route.json.html.png](https://nextmv-io.github.io/nextplot/plots/dortmund-route/dortmund-route.json.html.png) 88 | 89 | ## Route plotting with OSRM support 90 | 91 | Next, we will plot routes using the road network. We do this with the support of 92 | [OSRM][osrm]. Make sure a server with a suitable region and profile is running. 93 | 94 | ### Pre-requisites for OSRM 95 | 96 | 1. Spin up an OSRM server with a suitable region and profile. Follow the 97 | [steps][osrm-install] provided by OSRM to get started. 98 | 99 | ### Plot route paths via OSRM 100 | 101 | The command is similar to the one above, but specifies some extra options (refer 102 | to the full list [below](#additional-information)). The `osrm_host` option 103 | activates OSRM driven plotting. 104 | 105 | ```bash 106 | nextplot route \ 107 | --input_route data/kyoto-route.json \ 108 | --jpath_route "vehicles[*].route" \ 109 | --jpath_x "position.lon" \ 110 | --jpath_y "position.lat" \ 111 | --output_map kyoto-route.html \ 112 | --output_image kyoto-route.png \ 113 | --osrm_host http://localhost:5000 114 | ``` 115 | 116 | ## Route plotting with RoutingKit support 117 | 118 | Another option to plot routes is to use the [go-routingkit][go-rk] library which 119 | comes with a standalone binary. This approach does not need a running server, 120 | but takes longer to compute the routes (as it needs to preprocess the osm file 121 | on each run). 122 | 123 | ### Pre-requisites for RoutingKit 124 | 125 | 1. Install [go-routingkit][go-rk] standalone: 126 | 127 | ```bash 128 | go install github.com/nextmv-io/go-routingkit/cmd/routingkit@latest 129 | ``` 130 | 131 | 2. Download suitable osm file of containing all locations (e.g. Kansai region 132 | for Kyōto example): 133 | 134 | ```bash 135 | wget -N http://download.geofabrik.de/asia/japan/kansai-latest.osm.pbf 136 | ``` 137 | 138 | ### Plot route paths via RoutingKit 139 | 140 | The command is similar to the one above, but specifies some extra options (refer 141 | to the full list [below](#additional-information)). The `rk_osm` option 142 | activates routingkit driven plotting. 143 | 144 | ```bash 145 | nextplot route \ 146 | --input_route data/kyoto-route.json \ 147 | --jpath_route "vehicles[*].route" \ 148 | --jpath_x "position.lon" \ 149 | --jpath_y "position.lat" \ 150 | --output_map kyoto-route.html \ 151 | --output_image kyoto-route.png \ 152 | --rk_osm kansai-latest.osm.pbf 153 | ``` 154 | 155 | The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/kyoto-route): 156 | ![kyoto-route.json.html.png](https://nextmv-io.github.io/nextplot/plots/kyoto-route/kyoto-route.json.html.png) 157 | 158 | ## Cluster plotting 159 | 160 | Now let's move on to plotting some clusters. We will use 161 | `data/rome-cluster.json` as an example. Unfortunately, the file does not contain 162 | the positions for the points itself. It does contain the indices of the points 163 | to plot though. Looking at the file we know we can extract them via 164 | `state.clusters[*].points`. (note: some data was removed from the preview below) 165 | 166 | ```json 167 | { 168 | "state": { 169 | "clusters": [ 170 | { 171 | "centroid": [-88.13468293225804, 41.81753548064515], 172 | "points": [ 173 | 51, 174 | 65, 175 | 148 176 | ] 177 | } 178 | ] 179 | } 180 | } 181 | ``` 182 | 183 | Now we need the actual positions of the points at these indices. We can get 184 | them from the `data/rome-point.json` file. The JSON structure is very simple. We 185 | can find the list of points at `points`. The assumption here is that the indices 186 | of the file above are in line with the order of points in this file. 187 | 188 | ```json 189 | { 190 | "depot": 0, 191 | "neighbors": 500, 192 | "points": [ 193 | [12.450535369539924, 41.82296871624285], 194 | [12.244249981200547, 41.81779915932505] 195 | ] 196 | } 197 | ``` 198 | 199 | Since we have two files, we also have separate _inputs_ and _jpaths_. There are 200 | `--input_pos` & `--jpath_pos` available for the position information and 201 | `--input_cluster` & `--jpath_cluster` for the cluster information. 202 | 203 | This is all information we need. The following command will give us the plots: 204 | 205 | ```bash 206 | nextplot cluster \ 207 | --input_cluster data/rome-cluster.json \ 208 | --jpath_cluster "state.clusters[*].points" \ 209 | --input_pos data/rome-point.json \ 210 | --jpath_pos "points" 211 | ``` 212 | 213 | Command output: 214 | 215 | ```txt 216 | Cluster stats 217 | Total points: 1622 218 | Cluster count: 57 219 | Cluster size (max): 35 220 | Cluster size (min): 11 221 | Cluster size (avg): 28.45614035087719 222 | Cluster size (variance): 26.28316405047707 223 | Cluster diameter (max): 16.17086445602463 224 | Cluster diameter (min): 1.0249801072810207 225 | Cluster diameter (avg): 6.762504023482336 226 | Sum of max distances from centroid: 226.9416857406218 227 | Max distance from centroid: 11.637893312799743 228 | Sum of distances from centroid: 2807.765322949338 229 | Sum of squares from centroid: 7723.523174962022 230 | Bad assignments: 139 231 | Plotting image to data/rome-cluster.json.png 232 | Plotting map to data/rome-cluster.json.html 233 | ``` 234 | 235 | We again get some statistics about our clusters and the plots are also available 236 | at the data file location. 237 | 238 | The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/rome-cluster): 239 | ![rome-cluster.json.html.png](https://nextmv-io.github.io/nextplot/plots/rome-cluster/rome-cluster.json.html.png) 240 | 241 | ## Additional information 242 | 243 | Above descriptions should cover most route/cluster plotting needs. However, 244 | there are more options for steering the resulting plots towards your needs or 245 | handle certain data formats. Find an outline of these options here: 246 | 247 | - `--coords [{euclidean,haversine,auto}]`: 248 | There are different modes (`euclidean`, `haversine` & `auto`) how coordinates 249 | are processed. These can be selected via `--coords`. 250 | - `euclidean` uses euclidean distance for measuring route characteristics and 251 | deactivates html-map plotting. 252 | - `haversine` uses haversine distance for measuring route characteristics and 253 | activates html-map plotting. In this mode coordinates are required to be 254 | valid lon/lat. I.e., x, y need to be from the ranges [-180, 180], [-90, 90] 255 | - `auto` results in haversine, if coordinates are valid lon/lat. Otherwise, 256 | euclidean will be used. 257 | - `--omit_start` & `--omit_end`: 258 | If the input file contains routes starting and/or ending at a depot, many 259 | routes will overlap and long lines will be shown. To avoid these confusing 260 | plots `--omit_start` & `--omit_end` can be activated. These will simply cause 261 | the first and/or last stop of a route to be omitted. 262 | - `--swap`: 263 | `nextplot` expects positions to be given in (x,y) / (lon,lat) manner. If the 264 | positions are in the opposite order, they can be reversed by using the 265 | `--swap` flag. 266 | - `--sort_color`: 267 | Routes and clusters are all colored using the same saturation & brightness. 268 | The hue value is uniformly distributed among them and set in the same order as 269 | the routes/clusters appear. For a rainbow like effect colors may be sorted 270 | clockwise by the route/cluster centroids using the `--sort_color` flag. 271 | - `--colors`: 272 | Specifies the color profile. Can simply be a preset like `cloud` and 273 | `rainbow`, but there are also customization modes: 274 | - `gradient`: performs a color gradient from one color to another, e.g., 275 | `gradient,419AA8,092940` (it is possible to define multiple colors like 276 | this: `gradient,FFFFFF,419AA8,092940,333333`) 277 | - `rainbow`: performs a rainbow from first hue-value to second using the 278 | saturation and value settings, e.g., `rainbow,140,180,0.6,0.7` 279 | - `--custom_map_tile`: 280 | When plotting interactive maps `nextplot` uses the default OSM map tiles 281 | (more details) and CartoDB Dark Matter (black to focus colored 282 | routes/cluster). Additional custom map tiles can be added via 283 | `--custom_map_tile [CUSTOM_MAP_TILE]`. An overview can be found here: 284 | [folium-tiles][folium-tiles]. 285 | - Custom tile providers can even be used by supplying them in the format 286 | `",,"`. 287 | Example: 288 | 289 | ```bash 290 | --custom_map_tile "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png,DarkMatter no labels,OpenStreetMap authors" 291 | ``` 292 | 293 | (an overview of custom tile providers can be found [here][custom-layers]) 294 | - `--jpath_x` & `--jpath_y`: 295 | `nextplot` expects to find arrays of points or indices, if a path like 296 | `state.drivers[*].geometry.coordinates` is given. I.e., either 297 | `[[7.44, 51.49], [7.44, 51.48], ...]` or `[[24, 400, ...]]` are expected. 298 | However, sometimes route/cluster objects are more complex. For example, a list 299 | of named lon/lat like the following, may be given at the path above: 300 | `{ "lon": 7.44, "lat": 51.49 }` (instead of `[7.44, 51.49]`). In order to 301 | extract the positions from the nested structure we use `--jpath_x lon` & 302 | `--jpath_y lat`. 303 | - `--jpath_unassigned` (route only): 304 | Path to the array of unassigned points. If provided and points are found, they 305 | will be plot separately. 306 | - `--jpath_unassigned_x` & `--jpath_unassigned_y` (route only): 307 | Same as `--jpath_x` & `--jpath_y`, but for the unassigned points. 308 | - `--route_direction` (route only): 309 | Specifies how to indicate route direction. Can be one of `none` (no route 310 | direction indication), `arrow` (arrows drawn onto the route) and `animation` 311 | (animated route flow). By default, route directions are not annotated. 312 | - `--route_animation_color` (route only): 313 | Specifies the background color for the two color route direction animation. 314 | Colors can be provided as hex strings without the leading `#`, e.g., `000000`. 315 | The default animation background color is white (`FFFFFF`). 316 | - `--weight_route ` (route only): 317 | The thickness of the routes can be controlled by the `weight_route` factor. 318 | For example, a factor of 2 doubles the thickness. 319 | - `--no_points` (cluster & route): 320 | The `--no_points` flag may be used to skip plotting the actual points in 321 | addition to the clusters / routes. 322 | - `--weight_points` (cluster & route): 323 | The size of the individual points is controlled via the `weight_points` 324 | factor. For example, a factor of 2 doubles the point diameter. 325 | - `--start_end_markers` (route only): 326 | The `--start_end_markers` flag may be used to mark the first and last stops of 327 | the routes. 328 | - `--stats_file `: 329 | If provided, statistics will be written to the given file in addition to 330 | stdout. 331 | - `osrm_host` (route only): 332 | Host of the OSRM server to be used for routing. If provided, routes will be 333 | generated via OSRM. Example: `http://localhost:5000`. 334 | - `rk_bin` (route only): 335 | Path to the [go-routingkit][go-rk] standalone binary. Alternatively, 336 | `routingkit` command will be used at default (requires go-routingkit 337 | [installation][go-rk-install]). 338 | - `rk_osm` (route only): 339 | Path to the OpenStreetMap data file to be used for routing. All points must be 340 | contained within the region of the file. This file is mandatory when using 341 | routingkit. Furthermore, this switch activates road level routes, if provided. 342 | - `rk_profile` (route only): 343 | Profile used to generate paths via routingkit. Can be one of _car_, _bike_ & 344 | _pedestrian_. 345 | - `rk_distance` (route only): 346 | If given routingkit costs will be returned in distance instead of duration. 347 | 348 | [go-rk]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit 349 | [go-rk-install]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit#install 350 | [osrm]: https://project-osrm.org/ 351 | [osrm-install]: https://github.com/Project-OSRM/osrm-backend?tab=readme-ov-file#quick-start 352 | [custom-layers]: http://leaflet-extras.github.io/leaflet-providers/preview/ 353 | [folium-tiles]: https://deparkes.co.uk/2016/06/10/folium-map-tiles/ 354 | -------------------------------------------------------------------------------- /examples/data/kyoto-route.json: -------------------------------------------------------------------------------- 1 | { 2 | "unassigned": [], 3 | "vehicles": [ 4 | { 5 | "id": "v1", 6 | "route": [ 7 | { 8 | "id": "v1-start", 9 | "position": { 10 | "lon": 135.73723, 11 | "lat": 35.04381 12 | } 13 | }, 14 | { 15 | "id": "Kinkaku-ji", 16 | "position": { 17 | "lon": 135.728898, 18 | "lat": 35.039705 19 | } 20 | }, 21 | { 22 | "id": "Nijō Castle", 23 | "position": { 24 | "lon": 135.748134, 25 | "lat": 35.014239 26 | } 27 | }, 28 | { 29 | "id": "Arashiyama Bamboo Forest", 30 | "position": { 31 | "lon": 135.672009, 32 | "lat": 35.017209 33 | } 34 | } 35 | ] 36 | }, 37 | { 38 | "id": "v2", 39 | "route": [ 40 | { 41 | "id": "v2-start", 42 | "position": { 43 | "lon": 135.771716, 44 | "lat": 34.951317 45 | } 46 | }, 47 | { 48 | "id": "Fushimi Inari Taisha", 49 | "position": { 50 | "lon": 135.772695, 51 | "lat": 34.967146 52 | } 53 | }, 54 | { 55 | "id": "Kiyomizu-dera", 56 | "position": { 57 | "lon": 135.78506, 58 | "lat": 34.994857 59 | } 60 | }, 61 | { 62 | "id": "Gionmachi", 63 | "position": { 64 | "lon": 135.775682, 65 | "lat": 35.002457 66 | } 67 | }, 68 | { 69 | "id": "Kyoto Imperial Palace", 70 | "position": { 71 | "lon": 135.762057, 72 | "lat": 35.025431 73 | } 74 | } 75 | ] 76 | } 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /examples/data/paris-route.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "tours": [ 4 | { 5 | "id": "tour1", 6 | "route": [ 7 | { 8 | "id": "Gare du Nord", 9 | "location": [48.880769277577656, 2.3552878933106447], 10 | "quantity": 0 11 | }, 12 | { 13 | "id": "Louvre", 14 | "location": [48.86064983816991, 2.3373478124467524], 15 | "quantity": -1 16 | }, 17 | { 18 | "id": "Place de la Concorde", 19 | "location": [48.86548659130954, 2.3211691026573686], 20 | "quantity": -1 21 | }, 22 | { 23 | "id": "Arc de Triomphe", 24 | "location": [48.873730646108235, 2.2950561481174456], 25 | "quantity": 1 26 | }, 27 | { 28 | "id": "Tour Eiffel", 29 | "location": [48.85814487640506, 2.2945793548805833], 30 | "quantity": -1 31 | }, 32 | { 33 | "id": "Tour Montparnasse", 34 | "location": [48.842085594729355, 2.321845363923588], 35 | "quantity": -1 36 | }, 37 | { 38 | "id": "Gare du Nord", 39 | "location": [48.880769277577656, 2.3552878933106447], 40 | "quantity": 0 41 | } 42 | ] 43 | }, 44 | { 45 | "id": "tour2", 46 | "route": [ 47 | { 48 | "id": "Gare du Nord", 49 | "location": [48.880769277577656, 2.3552878933106447], 50 | "quantity": 0 51 | }, 52 | { 53 | "id": "Panthéon", 54 | "location": [48.84616060048901, 2.346233405549605], 55 | "quantity": 0 56 | }, 57 | { 58 | "id": "Notre-Dame", 59 | "location": [48.853070514317345, 2.349489020192572], 60 | "quantity": 0 61 | }, 62 | { 63 | "id": "Sacré-Cœur", 64 | "location": [48.88634368898782, 2.343046834223321], 65 | "quantity": 1 66 | }, 67 | { 68 | "id": "Gare du Nord", 69 | "location": [48.880769277577656, 2.3552878933106447], 70 | "quantity": 0 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/gallery/README.md: -------------------------------------------------------------------------------- 1 | # Gallery 2 | 3 | The gallery contains a list of plots using the provided sample files. Try to 4 | execute the listed calls from the [example](example) directory. 5 | 6 | --- 7 | 8 | This route plot uses sorting to get a rainbow like result. Since the coordinates 9 | are not nested but a list of points, the `--jpath_x` and `--jpath_y` flags are 10 | reset to `""`. 11 | 12 | ```bash 13 | nextplot route \ 14 | --input_route ../data/dortmund-route.json \ 15 | --jpath_route "state.drivers[*].geometry.coordinates" \ 16 | --jpath_x "" \ 17 | --jpath_y "" \ 18 | --output_map dortmund-route.map.html \ 19 | --output_plot dortmund-route.plot.html \ 20 | --output_image dortmund-route.plot.png \ 21 | --sort_color 22 | ``` 23 | 24 | Image result: 25 | 26 | ![dortmund-route.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-route/dortmund-route.png) 27 | 28 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/dortmund-route)): 29 | 30 | ![dortmund-route.html.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-route/dortmund-route.html.png) 31 | 32 | --- 33 | 34 | This cluster plot has a separate file containing the coordinates while the main 35 | file contains sets of indices referring to them as clusters. Furthermore, it 36 | uses sorting to get a rainbow like result. Since the coordinates are not nested 37 | but a list of points, the `--jpath_x` and `--jpath_y` flags are reset to `""`. 38 | 39 | ```bash 40 | nextplot cluster \ 41 | --input_cluster ../data/dortmund-cluster.json \ 42 | --jpath_cluster "state.clusters[*].points" \ 43 | --input_pos ../data/dortmund-point.json \ 44 | --jpath_pos "points" \ 45 | --jpath_x "" \ 46 | --jpath_y "" \ 47 | --output_map dortmund-cluster.map.html \ 48 | --output_plot dortmund-cluster.plot.html \ 49 | --output_image dortmund-cluster.plot.png \ 50 | --sort_color 51 | ``` 52 | 53 | Image result: 54 | 55 | ![dortmund-cluster.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-cluster/dortmund-cluster.png) 56 | 57 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/dortmund-cluster)): 58 | 59 | ![dortmund-cluster.html.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-cluster/dortmund-cluster.html.png) 60 | 61 | --- 62 | 63 | This point plot uses sorting to get a rainbow like result. 64 | 65 | ```bash 66 | nextplot point \ 67 | --input_point ../data/dortmund-route.json \ 68 | --jpath_point "state.drivers[*].geometry.coordinates" \ 69 | --jpath_x "" \ 70 | --jpath_y "" \ 71 | --output_map dortmund-point.map.html \ 72 | --output_plot dortmund-point.plot.html \ 73 | --output_image dortmund-point.plot.png \ 74 | --no_points \ 75 | --sort_color 76 | ``` 77 | 78 | Image result: 79 | 80 | ![dortmund-point.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-point/dortmund-point.png) 81 | 82 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/dortmund-point)): 83 | 84 | ![dortmund-point.html.png](https://nextmv-io.github.io/nextplot/gallery/dortmund-point/dortmund-point.html.png) 85 | 86 | --- 87 | 88 | This route plot uses custom x, y paths, omits the depot location at start & end, 89 | quadruples the width of the route lines and adds a custom tile layer. 90 | 91 | ```bash 92 | nextplot route \ 93 | --input_route ../data/paris-route.json \ 94 | --jpath_route "state.tours[*].route" \ 95 | --jpath_x "location[1]" \ 96 | --jpath_y "location[0]" \ 97 | --output_map paris-route.map.html \ 98 | --output_plot paris-route.plot.html \ 99 | --output_image paris-route.plot.png \ 100 | --omit_start \ 101 | --omit_end \ 102 | --custom_map_tile 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png,DarkMatter no labels,OpenStreetMap' \ 103 | --weight_route 2.5 \ 104 | --weight_points 4 105 | ``` 106 | 107 | Image result: 108 | 109 | ![paris-route.png](https://nextmv-io.github.io/nextplot/gallery/paris-route/paris-route.png) 110 | 111 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/paris-route)): 112 | 113 | ![paris-route.html.png](https://nextmv-io.github.io/nextplot/gallery/paris-route/paris-route.html.png) 114 | 115 | --- 116 | 117 | This plot uses routingkit and therefore needs OSM information. Download a 118 | suitable region file via: 119 | 120 | ```bash 121 | wget -N http://download.geofabrik.de/north-america/us/texas-latest.osm.pbf 122 | ``` 123 | 124 | This route plot uses routingkit for plotting road paths. Alternatively, spin up 125 | a local OSRM server and use the `--osrm_host` flag to use it (see 126 | [osrm-steps][osrm-steps]). Furthermore, unassigned points are plotted in 127 | addition to the route stops. 128 | 129 | ```bash 130 | nextplot route \ 131 | --input_route ../data/fleet-cloud.json \ 132 | --jpath_route "state.vehicles[*].route" \ 133 | --jpath_unassigned "state.unassigned" \ 134 | --output_map fleet-cloud.map.html \ 135 | --output_plot fleet-cloud.plot.html \ 136 | --output_image fleet-cloud.plot.png \ 137 | --rk_osm "texas-latest.osm.pbf" 138 | ``` 139 | 140 | Image result: 141 | 142 | ![fleet-cloud.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud/fleet-cloud.png) 143 | 144 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud)): 145 | 146 | ![fleet-cloud.html.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud/fleet-cloud.html.png) 147 | 148 | --- 149 | 150 | This plot uses routingkit like the one above. Download a suitable region file 151 | via: 152 | 153 | ```bash 154 | wget -N https://download.geofabrik.de/europe/france/ile-de-france-latest.osm.pbf 155 | ``` 156 | 157 | In addition to what the plots above introduced, this one adds start and end 158 | markers for the routes. Since the routes in the `paris-route.json` file start at 159 | a mutual "depot", we are omitting the start and end to get individual markers. 160 | 161 | ```bash 162 | nextplot route \ 163 | --input_route ../data/paris-route.json \ 164 | --jpath_route "state.tours[*].route" \ 165 | --jpath_x "location[1]" \ 166 | --jpath_y "location[0]" \ 167 | --omit_start \ 168 | --omit_end \ 169 | --output_map paris-markers.map.html \ 170 | --output_plot paris-markers.plot.html \ 171 | --output_image paris-markers.plot.png \ 172 | --start_end_markers \ 173 | --rk_osm "ile-de-france-latest.osm.pbf" 174 | ``` 175 | 176 | Image result: 177 | 178 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/paris-markers)): 179 | 180 | ![fleet-cloud.html.png](https://nextmv-io.github.io/nextplot/gallery/paris-markers/paris-markers.html.png) 181 | 182 | --- 183 | 184 | This plot adds animations as an indication for route direction to previous plot. 185 | Hence, don't forget to download a suitable region file (see above). 186 | 187 | ```bash 188 | nextplot route \ 189 | --input_route ../data/fleet-cloud.json \ 190 | --jpath_route "state.vehicles[*].route" \ 191 | --jpath_unassigned "state.unassigned" \ 192 | --output_map fleet-cloud.map.html \ 193 | --output_plot fleet-cloud.plot.html \ 194 | --output_image fleet-cloud.plot.png \ 195 | --rk_osm "texas-latest.osm.pbf" \ 196 | --route_direction animation 197 | ``` 198 | 199 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud/fleet-cloud-animation.html)): 200 | 201 | ![fleet-cloud-animation.html.gif](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud/fleet-cloud-animation.gif) 202 | 203 | --- 204 | 205 | Following command plots the points of the Paris file while maintaining their 206 | grouping (as indicated by the jpath - grouped by route). 207 | 208 | ```bash 209 | nextplot point \ 210 | --input_point ../data/paris-route.json \ 211 | --jpath_point state[*].tours[*].route \ 212 | --jpath_x location[1] \ 213 | --jpath_y location[0] \ 214 | --output_map paris-point-grouped.map.html \ 215 | --output_plot paris-point-grouped.plot.html \ 216 | --output_image paris-point-grouped.plot.png \ 217 | --no_points \ 218 | --weight_points 4 219 | ``` 220 | 221 | Image result: 222 | 223 | ![paris-point-grouped.png](https://nextmv-io.github.io/nextplot/gallery/paris-point-grouped/paris-point-grouped.png) 224 | 225 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/paris-point-grouped)): 226 | 227 | ![paris-point-grouped.html.png](https://nextmv-io.github.io/nextplot/gallery/paris-point-grouped/paris-point-grouped.html.png) 228 | 229 | --- 230 | 231 | Following command plots the same points ignoring their grouping. 232 | 233 | ```bash 234 | nextplot point \ 235 | --input_point ../data/paris-route.json \ 236 | --jpath_point state[*].tours[*].route[*].location \ 237 | --output_map paris-point-ungrouped.map.html \ 238 | --output_plot paris-point-ungrouped.plot.html \ 239 | --output_image paris-point-ungrouped.plot.png \ 240 | --weight_points 4 \ 241 | --swap \ 242 | --no_points \ 243 | --sort_color 244 | ``` 245 | 246 | Image result: 247 | 248 | ![paris-point-ungrouped.png](https://nextmv-io.github.io/nextplot/gallery/paris-point-ungrouped/paris-point-ungrouped.png) 249 | 250 | Map result ([link](https://nextmv-io.github.io/nextplot/gallery/paris-point-ungrouped)): 251 | 252 | ![paris-point-ungrouped.html.png](https://nextmv-io.github.io/nextplot/gallery/paris-point-ungrouped/paris-point-ungrouped.html.png) 253 | 254 | --- 255 | 256 | Following command plots the solution value progression of two hop runs with all 257 | solutions returned. The default jpaths adhere to hop's all solutions structure. 258 | However, if necessary `jpath_solution`, `jpath_value` & `jpath_elapsed` can be 259 | used to customize the path to the relevant information. For additional 260 | information please refer to `nextplot progression --help`. 261 | 262 | ```bash 263 | nextplot progression \ 264 | --input_progression 0.7.3,../data/fleet-cloud-all-0.7.3.json 0.8,../data/fleet-cloud-all-0.8.json \ 265 | --output_png fleet-cloud-comparison.png \ 266 | --output_html fleet-cloud-comparison.html \ 267 | --title "Fleet cloud comparison" 268 | ``` 269 | 270 | Interactive result: [link](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud-comparison) 271 | 272 | Image result: 273 | 274 | ![fleet-cloud-comparison.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud-comparison/fleet-cloud-comparison.png) 275 | 276 | [osrm-steps]: ../README.md#route-plotting-with-osrm-support 277 | -------------------------------------------------------------------------------- /nextplot/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "v0.1.8" 2 | -------------------------------------------------------------------------------- /nextplot/__init__.py: -------------------------------------------------------------------------------- 1 | """Nextplot plot functions.""" 2 | -------------------------------------------------------------------------------- /nextplot/cluster.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Callable 3 | 4 | import folium 5 | import numpy as np 6 | import plotly.graph_objects as go 7 | import scipy.spatial 8 | from folium import plugins 9 | 10 | from . import common, types 11 | 12 | # ==================== This file contains cluster plotting code (mode: 'cluster') 13 | 14 | 15 | # ==================== Cluster mode argument definition 16 | 17 | 18 | def arguments(parser): 19 | """ 20 | Defines arguments specific to cluster plotting. 21 | """ 22 | parser.add_argument( 23 | "--input_cluster", 24 | type=str, 25 | nargs="?", 26 | default="", 27 | help="path to the cluster file to plot", 28 | ) 29 | parser.add_argument( 30 | "--jpath_cluster", 31 | type=str, 32 | nargs="?", 33 | default="state.clusters[*].points", 34 | help="JSON path to the cluster elements (XPATH like," 35 | + " see https://goessner.net/articles/JsonPath/," 36 | + ' example: "state.clusters[*].points")', 37 | ) 38 | parser.add_argument( 39 | "--input_pos", 40 | type=str, 41 | nargs="?", 42 | default="", 43 | help="path to file containing the positions, if not supplied by cluster file", 44 | ) 45 | parser.add_argument( 46 | "--jpath_pos", 47 | type=str, 48 | nargs="?", 49 | default="", 50 | help="JSON path to the positions, if cluster elements are stored as indices", 51 | ) 52 | parser.add_argument( 53 | "--no_points", 54 | action="store_true", 55 | help="indicates whether to omit plotting the actual points in addition to the convex hull", 56 | ) 57 | parser.add_argument( 58 | "--weight_points", 59 | type=float, 60 | nargs="?", 61 | default=1, 62 | help="point size (<1 decreases, >1 increases)", 63 | ) 64 | 65 | 66 | # ==================== Cluster plotting specific functionality 67 | 68 | 69 | def convex_hull(points): 70 | """ 71 | Calculates the convex hull for the given points and 72 | returns it as a sorted list of points. 73 | """ 74 | 75 | if len(points) <= 2: 76 | return [(p.lon, p.lat) for p in points] 77 | 78 | np_points = np.array([(p.lon, p.lat) for p in points]) 79 | hull = scipy.spatial.ConvexHull(np_points) 80 | simplices = hull.vertices.tolist() 81 | 82 | return [(points[s].lon, points[s].lat) for s in simplices] 83 | 84 | 85 | def parse( 86 | input_cluster: str, 87 | jpath_cluster: str, 88 | input_pos: str, 89 | jpath_pos: str, 90 | jpath_x: str, 91 | jpath_y: str, 92 | ) -> tuple[list[list[types.Position]], list[list[types.Position]]]: 93 | """ 94 | Parses the cluster data from the file(s). 95 | """ 96 | # Load json data 97 | content_cluster, content_points = common.load_data(input_cluster, input_pos) 98 | 99 | # Extract clusters 100 | points = common.extract_position_groups( 101 | content_cluster, 102 | jpath_cluster, 103 | content_points, 104 | jpath_pos, 105 | jpath_x, 106 | jpath_y, 107 | ) 108 | 109 | return points 110 | 111 | 112 | def plot( 113 | input_cluster: str, 114 | jpath_cluster: str, 115 | input_pos: str, 116 | jpath_pos: str, 117 | jpath_x: str, 118 | jpath_y: str, 119 | swap: bool, 120 | coords: str, 121 | output_image: str, 122 | output_plot: str, 123 | output_map: str, 124 | stats_file: str, 125 | colors: str, 126 | sort_color: bool, 127 | no_points: bool, 128 | weight_points: float, 129 | custom_map_tile: list[str], 130 | plotly_theme: str, 131 | ): 132 | """ 133 | Plots clusters based on the given arguments. 134 | Interprets args, reads .json, collects some stats, 135 | plots a .png and plots an interactive .html map. 136 | """ 137 | 138 | # Determine base filename 139 | base_name = "plot" # Default for STDIN 140 | if input_cluster: 141 | base_name = input_cluster 142 | 143 | # Parse data 144 | points = parse( 145 | input_cluster, 146 | jpath_cluster, 147 | input_pos, 148 | jpath_pos, 149 | jpath_x, 150 | jpath_y, 151 | ) 152 | 153 | # Quit on no points 154 | if len(points) <= 0: 155 | print("no points found in given file(s) using given filter(s)") 156 | return 157 | 158 | # Conduct some checks 159 | points, world_coords, dataerror = common.preprocess_coordinates(points, swap, coords) 160 | if dataerror: 161 | print(dataerror) 162 | return 163 | 164 | # Determine bbox 165 | bbox = common.bounding_box(points) 166 | 167 | # Wrap in clusters 168 | clusters = [types.Cluster(p) for p in points] # Wrap it 169 | if len(clusters) <= 0: 170 | print(f"no clusters could be extracted at the given path: {jpath_cluster}") 171 | return 172 | 173 | measure = common.haversine if world_coords else common.euclidean 174 | 175 | # Process clusters 176 | for cluster in clusters: 177 | # Collect some statistics of the cluster 178 | cluster.size = len(cluster.points) 179 | cluster.diameter = 0 180 | if len(cluster.points) > 0: 181 | centroid_x = sum([p.lon for p in cluster.points]) / len(cluster.points) 182 | centroid_y = sum([p.lat for p in cluster.points]) / len(cluster.points) 183 | cluster.centroid = (centroid_x, centroid_y) 184 | 185 | distances_from_centroid = [measure(p, cluster.centroid) for p in cluster.points] 186 | cluster.sum_of_distances_from_centroid = sum(distances_from_centroid) 187 | cluster.max_distance_from_centroid = max(distances_from_centroid, default=0) 188 | cluster.wcss = sum([measure(p, cluster.centroid) ** 2 for p in cluster.points]) 189 | 190 | for i in range(len(cluster.points)): 191 | for j in range(len(cluster.points)): 192 | if i == j: 193 | continue 194 | distance = measure( 195 | cluster.points[i], 196 | cluster.points[j], 197 | ) 198 | if distance > cluster.diameter: 199 | cluster.diameter = distance 200 | 201 | # Determine convex hulls 202 | for cluster in clusters: 203 | cluster.hull = convex_hull(cluster.points) 204 | 205 | # Dump some stats 206 | statistics(clusters, measure, stats_file) 207 | 208 | # Prepares colors for the groups 209 | common.prepare_colors(clusters, colors, sort_color) 210 | 211 | # Make simple plot of clusters 212 | aspect_ratio = (bbox.height) / (bbox.width) if bbox.width > 0 else 1 213 | 214 | # Init plot 215 | fig = go.Figure( 216 | layout=go.Layout( 217 | xaxis_title="lon" if world_coords else "x", 218 | yaxis_title="lat" if world_coords else "y", 219 | template=plotly_theme, 220 | margin={"l": 20, "r": 20, "b": 20, "t": 20, "pad": 4}, 221 | font={"size": 18}, 222 | showlegend=False, 223 | ) 224 | ) 225 | 226 | # Plot clusters 227 | for i, cluster in enumerate(clusters): 228 | if len(cluster.points) <= 0: 229 | continue 230 | # Calculate hull of cluster 231 | hull_points = np.array(cluster.hull) 232 | # Repeat the first point at the end to close the polygon 233 | hull_points = np.append(hull_points, hull_points[0, :].reshape(1, 2), axis=0) 234 | # Plot hull 235 | fig.add_trace( 236 | go.Scatter( 237 | x=hull_points[:, 0], 238 | y=hull_points[:, 1], 239 | mode="lines", 240 | line={"color": cluster.color.hex, "width": 2}, 241 | name=f"Cluster {i+1}", 242 | fill="toself", 243 | ) 244 | ) 245 | 246 | # Plot points 247 | if not no_points: 248 | for i, cluster in enumerate(clusters): 249 | if len(cluster.points) <= 0: 250 | continue 251 | # Plot points 252 | fig.add_trace( 253 | go.Scatter( 254 | x=[p.lon for p in cluster.points], 255 | y=[p.lat for p in cluster.points], 256 | mode="markers", 257 | marker={ 258 | "size": weight_points * 5, 259 | "color": cluster.color.hex, 260 | }, 261 | name=f"Cluster {i+1}", 262 | ) 263 | ) 264 | 265 | # Save interactive plot 266 | plot_file = output_plot 267 | if not plot_file: 268 | plot_file = base_name + ".plot.html" 269 | print(f"Plotting interactive plot to {plot_file}") 270 | fig.write_html(plot_file) 271 | 272 | # Save plot image 273 | image_file = output_image 274 | if not image_file: 275 | image_file = base_name + ".plot.png" 276 | print(f"Plotting image to {image_file}") 277 | fig.write_image( 278 | image_file, 279 | width=min(common.IMAGE_SIZE, common.IMAGE_SIZE / aspect_ratio), 280 | height=min(common.IMAGE_SIZE, common.IMAGE_SIZE * aspect_ratio), 281 | ) 282 | 283 | # Skip plotting on map, if no geo-coordinates 284 | if not world_coords: 285 | print("No world coordinates, skipping map plotting") 286 | quit() 287 | 288 | # Make map plot of routes 289 | map_file = output_map 290 | if not map_file: 291 | map_file = base_name + ".map.html" 292 | print(f"Plotting map to {map_file}") 293 | m, base_tree = common.create_map( 294 | (bbox.max_x + bbox.min_x) / 2.0, 295 | (bbox.max_y + bbox.min_y) / 2.0, 296 | custom_map_tile, 297 | ) 298 | plot_groups = {} 299 | group_names = {} 300 | 301 | # Plot the clusters themselves 302 | for i, cluster in enumerate(clusters): 303 | if len(cluster.points) <= 0: 304 | continue 305 | layer_name = f"Cluster {i+1}" 306 | plot_groups[i] = folium.FeatureGroup(name=layer_name) 307 | group_names[plot_groups[i]] = layer_name 308 | text = ( 309 | "

" 310 | + f"Cluster: {i} / {len(clusters)}
" 311 | + f"Cluster points: {cluster.size}
" 312 | + f"Cluster diameter: {cluster.diameter:.2f} km " 313 | + f"({common.km_to_miles(cluster.diameter):.2f} miles)
" 314 | + "

" 315 | ) 316 | plot_map_cluster(plot_groups[i], cluster, text) 317 | 318 | # Plot the individual points 319 | if not no_points: 320 | for i, cluster in enumerate(clusters): 321 | for point in cluster.points: 322 | d = point.desc.replace("\n", "
").replace(r"`", r"\`") 323 | text = ( 324 | f"

Location (lon/lat): {point[0]}, {point[1]}

{d}

" 325 | ) 326 | plot_map_point( 327 | plot_groups[i], 328 | point, 329 | text, 330 | weight_points, 331 | cluster.color.hex, 332 | ) 333 | 334 | # Add all grouped parts to the map 335 | for k in plot_groups: 336 | plot_groups[k].add_to(m) 337 | 338 | # Add button to expand the map to fullscreen 339 | plugins.Fullscreen( 340 | position="topright", 341 | title="Expand me", 342 | title_cancel="Exit me", 343 | ).add_to(m) 344 | 345 | # Create overlay tree for advanced control of route/unassigned layers 346 | overlay_tree = { 347 | "label": "Overlays", 348 | "select_all_checkbox": "Un/select all", 349 | "children": [ 350 | { 351 | "label": "Clusters", 352 | "select_all_checkbox": True, 353 | "collapsed": True, 354 | "children": [{"label": group_names[v], "layer": v} for v in plot_groups.values()], 355 | } 356 | ], 357 | } 358 | 359 | # Add control for all layers and write file 360 | plugins.TreeLayerControl(base_tree=base_tree, overlay_tree=overlay_tree).add_to(m) 361 | 362 | # Fit map to bounds 363 | m.fit_bounds([[bbox.min_y, bbox.min_x], [bbox.max_y, bbox.max_x]]) 364 | 365 | # Save map 366 | m.save(map_file) 367 | 368 | 369 | def plot_map_point(map, point, text, weight, color): 370 | """ 371 | Plots a point on the given map. 372 | """ 373 | popup_text = folium.Html(text, script=True) 374 | popup = folium.Popup(popup_text, max_width=450, sticky=True) 375 | marker = folium.Circle( 376 | (point[1], point[0]), # folium operates on lat/lon 377 | color=color, 378 | popup=popup, 379 | radius=15 * weight, 380 | fill=True, 381 | fillOpacity=1.0, 382 | ) 383 | marker.options["fillOpacity"] = 1.0 384 | marker.add_to(map) 385 | 386 | 387 | def plot_map_cluster( 388 | map: object, 389 | cluster: object, 390 | text: str, 391 | ): 392 | """ 393 | Plots a cluster on the given map. 394 | """ 395 | popup_text = folium.Html(text, script=True) 396 | popup = folium.Popup(popup_text, max_width=450, sticky=True) 397 | mod_hull = [(y, x) for (x, y) in cluster.hull] # folium operates on lat/lon 398 | polygon = folium.Polygon( 399 | mod_hull, 400 | color=cluster.color.hex, 401 | fill=True, 402 | popup=popup, 403 | ) 404 | polygon.add_to(map) 405 | 406 | 407 | def statistics( 408 | clusters: list[types.Cluster], 409 | measure: Callable[[types.Position, types.Position], float], 410 | stats_file: str, 411 | ): 412 | """ 413 | Outlines some route statistics. Statistics are written to file, if provided. 414 | """ 415 | # Collect statistics 416 | sizes, diameters = [r.size for r in clusters], [r.diameter for r in clusters] 417 | sum_of_max_distances = sum([c.max_distance_from_centroid for c in clusters]) 418 | max_distance = max([c.max_distance_from_centroid for c in clusters]) 419 | sum_of_distances = sum([c.sum_of_distances_from_centroid for c in clusters]) 420 | wcss = sum([c.wcss for c in clusters]) 421 | bad_assignments = 0 422 | for c in clusters: 423 | for p in c.points: 424 | distance_to_centroid = measure(c.centroid, p) 425 | distances_to_other_centroids = np.array( 426 | [[measure(c2.centroid, p) for c2 in clusters if hasattr(c2, "centroid")]] 427 | ) 428 | if len(distances_to_other_centroids[distance_to_centroid > distances_to_other_centroids]): 429 | bad_assignments += 1 430 | 431 | stats = [ 432 | types.Stat("npoints", "Total points", sum([len(c.points) for c in clusters])), 433 | types.Stat("nclusters", "Cluster count", len(clusters)), 434 | types.Stat("clust_size_max", "Cluster size (max)", max(sizes)), 435 | types.Stat("clust_size_min", "Cluster size (min)", min(sizes)), 436 | types.Stat("clust_size_avg", "Cluster size (avg)", sum(sizes) / float(len(clusters))), 437 | types.Stat( 438 | "cluster_size_var", 439 | "Cluster size (variance)", 440 | np.var(np.array([len(c.points) for c in clusters])), 441 | ), 442 | types.Stat("clust_diam_max", "Cluster diameter (max)", max(diameters)), 443 | types.Stat("clust_diam_min", "Cluster diameter (min)", min(diameters)), 444 | types.Stat( 445 | "clust_diam_avg", 446 | "Cluster diameter (avg)", 447 | sum(diameters) / float(len(clusters)), 448 | ), 449 | types.Stat( 450 | "sum_max_distances", 451 | "Sum of max distances from centroid", 452 | sum_of_max_distances, 453 | ), 454 | types.Stat("distance_from_centroid_max", "Max distance from centroid", max_distance), 455 | types.Stat( 456 | "distance_from_centroid_sum", 457 | "Sum of distances from centroid", 458 | sum_of_distances, 459 | ), 460 | types.Stat("wcss", "Sum of squares from centroid", wcss), 461 | types.Stat("bad_assignments", "Bad assignments", bad_assignments), 462 | ] 463 | 464 | # Log statistics 465 | print("Cluster stats") 466 | for stat in stats: 467 | print(f"{stat.desc}: {stat.val:.2f}") 468 | 469 | # Write statistics to file 470 | if stats_file: 471 | stats_table = {} 472 | for stat in stats: 473 | stats_table[stat.name] = stat.val 474 | with open(stats_file, "w+") as f: 475 | json.dump(stats_table, f) 476 | -------------------------------------------------------------------------------- /nextplot/geojson.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import folium 4 | import jsonpath_ng 5 | from folium import plugins 6 | from folium.elements import JSCSSMixin 7 | from folium.map import Layer 8 | from jinja2 import Template 9 | 10 | from . import common 11 | 12 | # ==================== This file contains plain geojson plotting code (mode: 'geojson') 13 | 14 | 15 | # ==================== geojson mode argument definition 16 | 17 | 18 | def arguments(parser): 19 | """ 20 | Defines arguments specific to geojson plotting. 21 | """ 22 | parser.add_argument( 23 | "--input_geojson", 24 | type=str, 25 | nargs="?", 26 | default="", 27 | help="path to the GeoJSON file to plot", 28 | ) 29 | parser.add_argument( 30 | "--jpath_geojson", 31 | type=str, 32 | nargs="?", 33 | default="", 34 | help="JSON path to the GeoJSON elements (XPATH like," 35 | + " see https://goessner.net/articles/JsonPath/," 36 | + ' example: "state.routes[*].geojson")', 37 | ) 38 | parser.add_argument( 39 | "--output_map", 40 | type=str, 41 | nargs="?", 42 | default=None, 43 | help="Interactive map file path", 44 | ) 45 | parser.add_argument( 46 | "--custom_map_tile", 47 | nargs="+", 48 | default=[], 49 | help="add further folium custom map tiles " 50 | + "(either by name " 51 | + '[e.g.: "stamenwatercolor"] or ' 52 | + 'by ",," ' 53 | + '[e.g.: "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' 54 | + ',DarkMatter no labels,OpenStreetMap authors"])', 55 | ) 56 | parser.add_argument( 57 | "--style", 58 | dest="style", 59 | action="store_true", 60 | default=False, 61 | help="indicates whether to attempt to apply any style info found", 62 | ) 63 | 64 | 65 | # ==================== geojson plotting specific functionality 66 | 67 | 68 | class StyledGeoJson(JSCSSMixin, Layer): 69 | """ 70 | Creates a GeoJson which supports simplestyle and maki markers. 71 | 72 | source: https://stackoverflow.com/questions/66813862/loosing-geojsons-feature-property-information-when-visualising-by-using-folium 73 | """ 74 | 75 | _template = Template( 76 | """ 77 | {% macro script(this, kwargs) %} 78 | 79 | var {{ this.get_name() }} = L.geoJson({{ this.data }}, 80 | { 81 | useSimpleStyle: true, 82 | useMakiMarkers: false 83 | } 84 | ).addTo({{ this._parent.get_name() }}); 85 | {% endmacro %} 86 | """ 87 | ) 88 | 89 | default_js = [ 90 | ("leaflet-simplestyle", "https://unpkg.com/leaflet-simplestyle"), 91 | ] 92 | 93 | def __init__(self, data, name=None, overlay=True, control=True, show=True): 94 | super().__init__(name=name, overlay=overlay, control=control, show=show) 95 | self._name = "StyledGeoJson" 96 | self.data = data 97 | 98 | 99 | def parse( 100 | input_geojson: str, 101 | jpath_geojson: str, 102 | ) -> list[dict]: 103 | """ 104 | Parses the geojson data object(s) from the file(s). 105 | """ 106 | # Load json data 107 | content, _ = common.load_data(input_geojson, "") 108 | json_content = json.loads(content) 109 | 110 | # Extract geojsons 111 | if jpath_geojson: 112 | expression = jsonpath_ng.parse(jpath_geojson) 113 | geojsons = [match.value for match in expression.find(json_content)] 114 | else: 115 | geojsons = [json_content] 116 | 117 | return geojsons 118 | 119 | 120 | def plot( 121 | input_geojson: str, 122 | jpath_geojson: str, 123 | output_map: str, 124 | style: bool, 125 | custom_map_tile: list[str], 126 | ): 127 | """ 128 | Plots geojson objects from the given file(s) onto a map. 129 | """ 130 | 131 | # Determine base filename 132 | base_name = "plot" # Default for STDIN 133 | if input_geojson: 134 | base_name = input_geojson 135 | 136 | # Parse data 137 | geojsons = parse( 138 | input_geojson, 139 | jpath_geojson, 140 | ) 141 | 142 | # Quit on no points 143 | if len(geojsons) <= 0: 144 | print("no geojson found in given file") 145 | return 146 | 147 | # Determine bbox for zooming 148 | bbox_sw, bbox_ne = [90, 180], [-90, -180] 149 | for gj in geojsons: 150 | sw, ne = determine_geojson_bbox(gj) 151 | bbox_sw[0], bbox_sw[1] = min(bbox_sw[0], sw[0]), min(bbox_sw[1], sw[1]) 152 | bbox_ne[0], bbox_ne[1] = max(bbox_ne[0], ne[0]), max(bbox_ne[1], ne[1]) 153 | 154 | # Make map plot of geojson data 155 | map_file = output_map 156 | if not map_file: 157 | map_file = base_name + ".map.html" 158 | print(f"Plotting map to {map_file}") 159 | m, base_tree = common.create_map( 160 | (bbox_sw[1] + bbox_ne[1]) / 2.0, 161 | (bbox_sw[0] + bbox_ne[0]) / 2.0, 162 | custom_map_tile, 163 | ) 164 | plot_groups = {} 165 | group_names = {} 166 | for i, gj in enumerate(geojsons): 167 | group_name = f"GeoJSON {i}" 168 | plot_groups[i] = folium.FeatureGroup(name=group_name) 169 | group_names[plot_groups[i]] = group_name 170 | if style: 171 | StyledGeoJson(gj).add_to(plot_groups[i]) 172 | else: 173 | folium.GeoJson(gj).add_to(plot_groups[i]) 174 | plot_groups[i].add_to(m) 175 | 176 | # Add button to expand the map to fullscreen 177 | plugins.Fullscreen( 178 | position="topright", 179 | title="Expand me", 180 | title_cancel="Exit me", 181 | ).add_to(m) 182 | 183 | # Create overlay tree for advanced control of route/unassigned layers 184 | overlay_tree = { 185 | "label": "Overlays", 186 | "select_all_checkbox": "Un/select all", 187 | "children": [ 188 | { 189 | "label": "GeoJSONs", 190 | "select_all_checkbox": True, 191 | "collapsed": True, 192 | "children": [{"label": group_names[v], "layer": v} for v in plot_groups.values()], 193 | } 194 | ], 195 | } 196 | 197 | # Add control for all layers and write file 198 | plugins.TreeLayerControl(base_tree=base_tree, overlay_tree=overlay_tree).add_to(m) 199 | 200 | # Fit bounds 201 | m.fit_bounds([sw, ne]) 202 | 203 | # Save map 204 | m.save(map_file) 205 | 206 | 207 | def determine_geojson_bbox(d: dict) -> tuple[tuple[float, float], tuple[float, float]]: 208 | """ 209 | Determine the bounding box of a geojson object. 210 | """ 211 | sw, ne = [90, 180], [-90, -180] 212 | 213 | def traverse(d): 214 | if isinstance(d, list) and len(d) == 2 and isinstance(d[0], int | float): 215 | sw[0], sw[1] = min(sw[0], d[1]), min(sw[1], d[0]) 216 | ne[0], ne[1] = max(ne[0], d[1]), max(ne[1], d[0]) 217 | return 218 | elif isinstance(d, dict): 219 | for value in d.values(): 220 | traverse(value) 221 | elif isinstance(d, list): 222 | for value in d: 223 | traverse(value) 224 | 225 | traverse(d) 226 | return sw, ne 227 | -------------------------------------------------------------------------------- /nextplot/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # PYTHON_ARGCOMPLETE_OK 3 | import argparse 4 | 5 | import argcomplete 6 | 7 | from . import __about__, cluster, common, geojson, point, progression, route, test 8 | 9 | # ==================== Argument parsing 10 | 11 | 12 | MODE_CLUSTER = "cluster" 13 | MODE_GEOJSON = "geojson" 14 | MODE_POINT = "point" 15 | MODE_PROGRESSION = "progression" 16 | MODE_ROUTE = "route" 17 | MODE_TEST = "test" 18 | 19 | 20 | def argparse_generate(): 21 | """ 22 | Creates the CLI argument parser. 23 | """ 24 | parser = argparse.ArgumentParser(description=f"nextmv.io plotting tools (version: {__about__.__version__})") 25 | parser.add_argument( 26 | "--version", 27 | action="version", 28 | version=f"%(prog)s {__about__.__version__}", 29 | ) 30 | subparsers = parser.add_subparsers(dest="command") 31 | routeparser = subparsers.add_parser(MODE_ROUTE) 32 | route.arguments(routeparser) 33 | common.generic_arguments(routeparser) 34 | clusterparser = subparsers.add_parser(MODE_CLUSTER) 35 | cluster.arguments(clusterparser) 36 | common.generic_arguments(clusterparser) 37 | pointparser = subparsers.add_parser(MODE_POINT) 38 | point.arguments(pointparser) 39 | common.generic_arguments(pointparser) 40 | progressionparser = subparsers.add_parser(MODE_PROGRESSION) 41 | progression.arguments(progressionparser) 42 | geojsonparser = subparsers.add_parser(MODE_GEOJSON) 43 | geojson.arguments(geojsonparser) 44 | testparser = subparsers.add_parser(MODE_TEST) 45 | test.arguments(testparser) 46 | return parser 47 | 48 | 49 | def entry_point(): 50 | """ 51 | Main entry point. 52 | """ 53 | # Read arguments 54 | parser = argparse_generate() 55 | argcomplete.autocomplete(parser) 56 | args = parser.parse_args() 57 | 58 | # Guide flow 59 | if args.command == MODE_ROUTE: 60 | route.plot( 61 | input_route=args.input_route, 62 | jpath_route=args.jpath_route, 63 | input_pos=args.input_pos, 64 | jpath_pos=args.jpath_pos, 65 | jpath_x=args.jpath_x, 66 | jpath_y=args.jpath_y, 67 | jpath_unassigned=args.jpath_unassigned, 68 | jpath_unassigned_x=args.jpath_unassigned_x, 69 | jpath_unassigned_y=args.jpath_unassigned_y, 70 | swap=args.swap, 71 | coords=args.coords, 72 | omit_start=args.omit_start, 73 | omit_end=args.omit_end, 74 | omit_short=args.omit_short, 75 | route_direction=args.route_direction, 76 | output_image=args.output_image, 77 | output_plot=args.output_plot, 78 | output_map=args.output_map, 79 | stats_file=args.stats_file, 80 | colors=args.colors, 81 | sort_color=args.sort_color, 82 | weight_route=args.weight_route, 83 | weight_points=args.weight_points, 84 | no_points=args.no_points, 85 | start_end_markers=args.start_end_markers, 86 | osrm_host=args.osrm_host, 87 | rk_osm=args.rk_osm, 88 | rk_bin=args.rk_bin, 89 | rk_profile=args.rk_profile, 90 | rk_distance=args.rk_distance, 91 | route_animation_color=args.route_animation_color, 92 | custom_map_tile=args.custom_map_tile, 93 | plotly_theme=args.plotly_theme, 94 | nextroute=args.nextroute, 95 | ) 96 | elif args.command == MODE_CLUSTER: 97 | cluster.plot( 98 | input_cluster=args.input_cluster, 99 | jpath_cluster=args.jpath_cluster, 100 | input_pos=args.input_pos, 101 | jpath_pos=args.jpath_pos, 102 | jpath_x=args.jpath_x, 103 | jpath_y=args.jpath_y, 104 | swap=args.swap, 105 | coords=args.coords, 106 | output_image=args.output_image, 107 | output_plot=args.output_plot, 108 | output_map=args.output_map, 109 | stats_file=args.stats_file, 110 | colors=args.colors, 111 | sort_color=args.sort_color, 112 | no_points=args.no_points, 113 | weight_points=args.weight_points, 114 | custom_map_tile=args.custom_map_tile, 115 | plotly_theme=args.plotly_theme, 116 | ) 117 | elif args.command == MODE_POINT: 118 | point.plot( 119 | input_point=args.input_point, 120 | jpath_point=args.jpath_point, 121 | input_pos=args.input_pos, 122 | jpath_pos=args.jpath_pos, 123 | jpath_x=args.jpath_x, 124 | jpath_y=args.jpath_y, 125 | swap=args.swap, 126 | coords=args.coords, 127 | output_image=args.output_image, 128 | output_plot=args.output_plot, 129 | output_map=args.output_map, 130 | stats_file=args.stats_file, 131 | colors=args.colors, 132 | sort_color=args.sort_color, 133 | weight_points=args.weight_points, 134 | custom_map_tile=args.custom_map_tile, 135 | plotly_theme=args.plotly_theme, 136 | ) 137 | elif args.command == MODE_PROGRESSION: 138 | progression.plot( 139 | input_progression=args.input_progression, 140 | jpath_solution=args.jpath_solution, 141 | jpath_value=args.jpath_value, 142 | jpath_elapsed=args.jpath_elapsed, 143 | output_png=args.output_png, 144 | output_html=args.output_html, 145 | title=args.title, 146 | label_x=args.label_x, 147 | label_y=args.label_y, 148 | color_profile=args.color_profile, 149 | plotly_theme=args.plotly_theme, 150 | legend_position=args.legend_position, 151 | weight=args.weight, 152 | nextroute=args.nextroute, 153 | ) 154 | elif args.command == MODE_GEOJSON: 155 | geojson.plot( 156 | input_geojson=args.input_geojson, 157 | jpath_geojson=args.jpath_geojson, 158 | output_map=args.output_map, 159 | style=args.style, 160 | custom_map_tile=args.custom_map_tile, 161 | ) 162 | elif args.command == MODE_TEST: 163 | test.test_filter( 164 | input=args.input, 165 | jpath=args.jpath, 166 | stats=args.stats, 167 | ) 168 | else: 169 | print("Unexpected input arguments. Please consult --help") 170 | -------------------------------------------------------------------------------- /nextplot/osrm.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import sys 3 | import urllib.parse 4 | 5 | import polyline 6 | import requests 7 | 8 | from nextplot import common, types 9 | 10 | TRAVEL_SPEED = 10 # assuming 10m/s travel speed for missing segments and snapping 11 | 12 | 13 | @dataclasses.dataclass 14 | class OsrmRouteRequest: 15 | positions: list[types.Position] 16 | 17 | 18 | @dataclasses.dataclass 19 | class OsrmRouteResponse: 20 | paths: list[list[types.Position]] 21 | distances: list[float] 22 | durations: list[float] 23 | zero_distance: bool = False 24 | no_route: bool = False 25 | 26 | 27 | def query_route( 28 | endpoint: str, 29 | route: OsrmRouteRequest, 30 | ) -> OsrmRouteResponse: 31 | """ 32 | Queries a route from the OSRM server. 33 | """ 34 | # Encode positions as polyline string to better handle large amounts of positions 35 | polyline_str = polyline.encode([(p.lat, p.lon) for p in route.positions]) 36 | 37 | # Assemble request 38 | url_base = urllib.parse.urljoin(endpoint, "route/v1/driving/") 39 | url = urllib.parse.urljoin(url_base, f"polyline({polyline_str})?overview=full&geometries=polyline&steps=true") 40 | 41 | # Query OSRM 42 | try: 43 | response = requests.get(url) 44 | # If no route was found, use as-the-crow-flies fallback 45 | if response.status_code == 400 and response.json()["code"] == "NoRoute": 46 | print( 47 | f"Warning: OSRM was unable to find a route for {[(p.lat, p.lon) for p in route.positions]}" 48 | + "(lat,lon ordering), using as-the-crow-flies fallback" 49 | ) 50 | paths, distances, durations = [], [], [] 51 | for f, t in zip(route.positions, route.positions[1:], strict=False): 52 | paths.append( 53 | [types.Position(lon=f.lon, lat=f.lat, desc=None), types.Position(lon=t.lon, lat=t.lat, desc=None)] 54 | ) 55 | distances.append(common.haversine(f, t)) 56 | durations.append(common.haversine(f, t) / TRAVEL_SPEED) 57 | return OsrmRouteResponse(paths=paths, distances=distances, durations=durations, no_route=True) 58 | # Make sure we are not getting an error 59 | response.raise_for_status() 60 | except requests.exceptions.RequestException as e: 61 | print(f"Error querying OSRM at {url_base}:", e) 62 | if response: 63 | print(response.text) 64 | sys.exit(1) 65 | result = response.json() 66 | if result["code"] != "Ok": 67 | raise Exception("OSRM returned an error:", result["message"]) 68 | if len(result["routes"]) == 0: 69 | raise Exception(f"No route found for {route.positions}") 70 | 71 | # Process all legs 72 | all_zero_distances = True 73 | legs, distances, durations = [], [], [] 74 | for idx, leg in enumerate(result["routes"][0]["legs"]): 75 | # Combine all steps into a single path 76 | path = [] 77 | for step in leg["steps"]: 78 | path.extend(polyline.decode(step["geometry"])) 79 | # Remove subsequent identical points 80 | path = [path[0]] + [p for i, p in enumerate(path[1:], 1) if path[i] != path[i - 1]] 81 | # Convert to Position objects 82 | path = [types.Position(lon=lon, lat=lat, desc=None) for lat, lon in path] 83 | # Add start and end 84 | path = [route.positions[idx]] + path + [route.positions[idx + 1]] 85 | # Extract distance and duration 86 | distance = leg["distance"] / 1000.0 # OSRM return is in meters, convert to km 87 | duration = leg["duration"] 88 | # Make sure we are finding any routes 89 | if distance > 0: 90 | all_zero_distances = False 91 | # Add duration for start and end 92 | start_distance = common.haversine(path[0], route.positions[idx]) 93 | end_distance = common.haversine(path[-1], route.positions[idx + 1]) 94 | distance += start_distance + end_distance 95 | duration += start_distance / TRAVEL_SPEED + end_distance / TRAVEL_SPEED 96 | # Append to list 97 | legs.append(path) 98 | distances.append(distance) 99 | durations.append(duration) 100 | 101 | # Warn if number of legs does not match number of positions 102 | if len(legs) != len(route.positions) - 1: 103 | print(f"Warning: number of legs ({len(legs)}) does not match number of positions ({len(route.positions)} - 1)") 104 | 105 | # Extract route 106 | return OsrmRouteResponse(paths=legs, distances=distances, durations=durations, zero_distance=all_zero_distances) 107 | 108 | 109 | def query_routes( 110 | endpoint: str, 111 | routes: list[types.Route], 112 | ) -> list[OsrmRouteResponse]: 113 | """ 114 | Queries multiple routes from the OSRM server. 115 | 116 | param str endpoint: URL of the OSRM server. 117 | param list[OsrmRouteRequest] routes: List of routes to query. 118 | 119 | return: List of route results. 120 | """ 121 | 122 | # Query all routes 123 | reqs = [OsrmRouteRequest(positions=route.points) for route in routes] 124 | zero_distance_routes, no_route_routes = 0, 0 125 | for r, req in enumerate(reqs): 126 | result = query_route(endpoint, req) 127 | routes[r].legs = result.paths 128 | routes[r].leg_distances = result.distances 129 | routes[r].leg_durations = result.durations 130 | if result.zero_distance: 131 | zero_distance_routes += 1 132 | if result.no_route: 133 | no_route_routes += 1 134 | if zero_distance_routes > 0: 135 | print(f"Warning: {zero_distance_routes} / {len(routes)} routes have zero distance according to OSRM") 136 | if no_route_routes > 0: 137 | print(f"Warning: {no_route_routes} / {len(routes)} routes could not be found by OSRM") 138 | -------------------------------------------------------------------------------- /nextplot/point.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from collections.abc import Callable 4 | 5 | import folium 6 | import plotly.graph_objects as go 7 | from folium import plugins 8 | 9 | from . import common, types 10 | 11 | # ==================== This file contains plain point plotting code (mode: 'point') 12 | 13 | 14 | # ==================== Point mode argument definition 15 | 16 | 17 | def arguments(parser): 18 | """ 19 | Defines arguments specific to point plotting. 20 | """ 21 | parser.add_argument( 22 | "--input_point", 23 | type=str, 24 | nargs="?", 25 | default="", 26 | help="path to the point file to plot", 27 | ) 28 | parser.add_argument( 29 | "--jpath_point", 30 | type=str, 31 | nargs="?", 32 | default="state.clusters[*].points", 33 | help="JSON path to the point elements (XPATH like," 34 | + " see https://goessner.net/articles/JsonPath/," 35 | + ' example: "state.clusters[*].points")', 36 | ) 37 | parser.add_argument( 38 | "--input_pos", 39 | type=str, 40 | nargs="?", 41 | default="", 42 | help="path to file containing the positions, if not supplied by point file", 43 | ) 44 | parser.add_argument( 45 | "--jpath_pos", 46 | type=str, 47 | nargs="?", 48 | default="", 49 | help="JSON path to the positions, if point elements are stored as indices", 50 | ) 51 | parser.add_argument( 52 | "--weight_points", 53 | type=float, 54 | nargs="?", 55 | default=1, 56 | help="point size (<1 decreases, >1 increases)", 57 | ) 58 | 59 | 60 | # ==================== Point plotting specific functionality 61 | 62 | 63 | def parse( 64 | input_point: str, 65 | jpath_point: str, 66 | input_pos: str, 67 | jpath_pos: str, 68 | jpath_x: str, 69 | jpath_y: str, 70 | ) -> tuple[list[list[types.Position]], list[list[types.Position]]]: 71 | """ 72 | Parses the point data from the file(s). 73 | """ 74 | # Load json data 75 | content_point, content_coordinate = common.load_data(input_point, input_pos) 76 | 77 | # Extract points 78 | positions = common.extract_position_groups( 79 | content_point, 80 | jpath_point, 81 | content_coordinate, 82 | jpath_pos, 83 | jpath_x, 84 | jpath_y, 85 | ) 86 | 87 | return positions 88 | 89 | 90 | def plot( 91 | input_point: str, 92 | jpath_point: str, 93 | input_pos: str, 94 | jpath_pos: str, 95 | jpath_x: str, 96 | jpath_y: str, 97 | swap: bool, 98 | coords: str, 99 | output_image: str, 100 | output_plot: str, 101 | output_map: str, 102 | stats_file: str, 103 | colors: str, 104 | sort_color: bool, 105 | weight_points: float, 106 | custom_map_tile: list[str], 107 | plotly_theme: str, 108 | ): 109 | """ 110 | Plots points based on the given arguments. 111 | Interprets args, reads .json, collects some stats, 112 | plots a .png and plots an interactive .html map. 113 | """ 114 | 115 | # Determine base filename 116 | base_name = "plot" # Default for STDIN 117 | if input_point: 118 | base_name = input_point 119 | 120 | # Parse data 121 | positions = parse( 122 | input_point, 123 | jpath_point, 124 | input_pos, 125 | jpath_pos, 126 | jpath_x, 127 | jpath_y, 128 | ) 129 | 130 | # Quit on no points 131 | if len(positions) <= 0: 132 | print("no points found in given file(s) using given filter(s)") 133 | return 134 | 135 | # Conduct some checks 136 | positions, world_coords, dataerror = common.preprocess_coordinates(positions, swap, coords) 137 | if dataerror: 138 | print(dataerror) 139 | return 140 | 141 | # Determine bbox 142 | bbox = common.bounding_box(positions) 143 | 144 | # Wrap in meta object 145 | points = [types.Point(p) for p in positions] # Wrap it 146 | if len(points) <= 0: 147 | print(f"no points could be extracted at the given path: {jpath_point}") 148 | return 149 | 150 | measure = common.haversine if world_coords else common.euclidean 151 | 152 | # Enumerate point groups 153 | for i in range(len(points)): 154 | points[i].group = i + 1 155 | 156 | # Prepares colors for the points 157 | common.prepare_colors(points, colors, sort_color) 158 | 159 | # Dump some stats 160 | statistics(points, measure, stats_file) 161 | 162 | # Make simple plot of points 163 | aspect_ratio = (bbox.height) / (bbox.width) if bbox.width > 0 else 1 164 | 165 | # Init plot 166 | fig = go.Figure( 167 | layout=go.Layout( 168 | xaxis_title="lon" if world_coords else "x", 169 | yaxis_title="lat" if world_coords else "y", 170 | template=plotly_theme, 171 | margin={"l": 20, "r": 20, "b": 20, "t": 20, "pad": 4}, 172 | font={"size": 18}, 173 | showlegend=False, 174 | ) 175 | ) 176 | 177 | # Plot points 178 | for i, pg in enumerate(points): 179 | if len(pg.points) <= 0: 180 | continue 181 | # Plot points 182 | fig.add_trace( 183 | go.Scatter( 184 | x=[p.lon for p in pg.points], 185 | y=[p.lat for p in pg.points], 186 | mode="markers", 187 | marker={ 188 | "size": weight_points * 5, 189 | "color": pg.color.hex, 190 | }, 191 | name=f"Group {i+1}", 192 | ) 193 | ) 194 | 195 | # Save interactive plot 196 | plot_file = output_plot 197 | if not plot_file: 198 | plot_file = base_name + ".plot.html" 199 | print(f"Plotting interactive plot to {plot_file}") 200 | fig.write_html(plot_file) 201 | 202 | # Save plot image 203 | image_file = output_image 204 | if not image_file: 205 | image_file = base_name + ".plot.png" 206 | print(f"Plotting image to {image_file}") 207 | fig.write_image( 208 | image_file, 209 | width=min(common.IMAGE_SIZE, common.IMAGE_SIZE / aspect_ratio), 210 | height=min(common.IMAGE_SIZE, common.IMAGE_SIZE * aspect_ratio), 211 | ) 212 | 213 | # Skip plotting on map, if no geo-coordinates 214 | if not world_coords: 215 | print("No world coordinates, skipping map plotting") 216 | quit() 217 | 218 | # Make map plot of routes 219 | map_file = output_map 220 | if not map_file: 221 | map_file = base_name + ".map.html" 222 | print(f"Plotting map to {map_file}") 223 | m, base_tree = common.create_map( 224 | (bbox.max_x + bbox.min_x) / 2.0, 225 | (bbox.max_y + bbox.min_y) / 2.0, 226 | custom_map_tile, 227 | ) 228 | plot_groups = {} 229 | group_names = {} 230 | 231 | for i, ps in enumerate(points): 232 | if len(ps.points) <= 0: 233 | continue 234 | layer_name = f"Point group {i+1}" 235 | plot_groups[i] = folium.FeatureGroup(name=layer_name) 236 | group_names[plot_groups[i]] = layer_name 237 | for point in ps.points: 238 | d = point.desc.replace("\n", "
").replace(r"`", r"\`") 239 | popup_text = folium.Html( 240 | "

" 241 | + f"Location (lon/lat): {point[0]}, {point[1]}
" 242 | + f"Group: {ps.group}
" 243 | + f"Group size: {len(ps.points)}
" 244 | + "

" 245 | + f"JSON:
{d}

", 246 | script=True, 247 | ) 248 | popup = folium.Popup(popup_text, max_width=450, sticky=True) 249 | marker = folium.Circle( 250 | (point[1], point[0]), # folium operates on lat/lon 251 | color=ps.color.hex, 252 | popup=popup, 253 | radius=15 * weight_points, 254 | fill=True, 255 | fillOpacity=1.0, 256 | ) 257 | marker.options["fillOpacity"] = 1.0 258 | marker.add_to(plot_groups[i]) 259 | 260 | # Add all grouped parts to the map 261 | for g in plot_groups: 262 | plot_groups[g].add_to(m) 263 | 264 | # Add button to expand the map to fullscreen 265 | plugins.Fullscreen( 266 | position="topright", 267 | title="Expand me", 268 | title_cancel="Exit me", 269 | ).add_to(m) 270 | 271 | # Create overlay tree for advanced control of route/unassigned layers 272 | overlay_tree = { 273 | "label": "Overlays", 274 | "select_all_checkbox": "Un/select all", 275 | "children": [ 276 | { 277 | "label": "Point groups", 278 | "select_all_checkbox": True, 279 | "collapsed": True, 280 | "children": [{"label": group_names[v], "layer": v} for v in plot_groups.values()], 281 | } 282 | ], 283 | } 284 | 285 | # Add control for all layers and write file 286 | plugins.TreeLayerControl(base_tree=base_tree, overlay_tree=overlay_tree).add_to(m) 287 | 288 | # Fit bounds 289 | m.fit_bounds([[bbox.min_y, bbox.min_x], [bbox.max_y, bbox.max_x]]) 290 | 291 | # Save map 292 | m.save(map_file) 293 | 294 | 295 | def statistics( 296 | groups: list[list[types.Position]], 297 | measure: Callable[[types.Position, types.Position], float], 298 | stats_file: str, 299 | ): 300 | """ 301 | Outlines some route statistics. Statistics are written to file, if provided. 302 | """ 303 | # Collect statistics 304 | all_points = [item for sublist in groups for item in sublist.points] 305 | max, min, agg, avg = 0.0, sys.float_info.max, 0.0, 0.0 306 | dist_count = 0 307 | for i1, p1 in enumerate(all_points): 308 | for i2, p2 in enumerate(all_points): 309 | if i2 < i1: 310 | continue 311 | dist_count += 1 312 | dist = measure(p1, p2) 313 | agg += dist 314 | if max < dist: 315 | max = dist 316 | if min > dist: 317 | min = dist 318 | if min == sys.float_info.max: 319 | min = 0.0 320 | if dist_count > 0: 321 | avg = agg / dist_count 322 | 323 | stats = [ 324 | types.Stat("npoints", "Total points", len(all_points)), 325 | types.Stat("distance_min", "Distance (min)", min), 326 | types.Stat("distance_max", "Distance (max)", max), 327 | types.Stat("distance_avg", "Distance (avg)", avg), 328 | ] 329 | 330 | # Log statistics 331 | print("Point stats") 332 | for stat in stats: 333 | print(f"{stat.desc}: {stat.val:.2f}") 334 | 335 | # Write statistics to file 336 | if stats_file: 337 | stats_table = {} 338 | for stat in stats: 339 | stats_table[stat.name] = stat.val 340 | with open(stats_file, "w+") as f: 341 | json.dump(stats_table, f) 342 | -------------------------------------------------------------------------------- /nextplot/progression.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import gzip 4 | import json 5 | import os 6 | import re 7 | import sys 8 | 9 | import jsonpath_ng 10 | import plotly.graph_objects as go 11 | 12 | from . import common 13 | 14 | # ==================== This file contains value progression plotting code (mode: 'progression') 15 | 16 | # Define some helper data structures 17 | Point = collections.namedtuple("Point", ["time", "value"]) 18 | Progression = collections.namedtuple("Progression", ["label", "points"]) 19 | Series = collections.namedtuple("Series", ["file", "label"]) 20 | 21 | 22 | # ==================== Progression plot profiles 23 | 24 | 25 | class ProgressionPlotProfile: 26 | """ 27 | Pre-configured plot profiles for progression plotting. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | jpath_solution: str = "", 33 | jpath_value: str = "", 34 | jpath_elapsed: str = "", 35 | ): 36 | self.jpath_solution = jpath_solution 37 | self.jpath_value = jpath_value 38 | self.jpath_elapsed = jpath_elapsed 39 | 40 | def __str__(self): 41 | return ( 42 | "ProgressionPlotProfile(" 43 | + f"jpath_solution={self.jpath_solution}, " 44 | + f"jpath_value={self.jpath_value}, " 45 | + f"jpath_elapsed={self.jpath_elapsed})" 46 | ) 47 | 48 | 49 | def nextroute_profile() -> ProgressionPlotProfile: 50 | """ 51 | Returns the nextroute profile. 52 | """ 53 | return ProgressionPlotProfile( 54 | jpath_solution="statistics.series_data.value.data_points[*]", 55 | jpath_elapsed="x", 56 | jpath_value="y", 57 | ) 58 | 59 | 60 | # ==================== Duration parsing 61 | 62 | 63 | # Convert durations to datetime.timedelta 64 | # Based on: https://github.com/icholy/durationpy 65 | 66 | # Define unit sizes 67 | NANOSECOND_SIZE = 1 68 | MICROSECOND_SIZE = 1000 * NANOSECOND_SIZE 69 | MILLISECOND_SIZE = 1000 * MICROSECOND_SIZE 70 | SECOND_SIZE = 1000 * MILLISECOND_SIZE 71 | MINUTE_SIZE = 60 * SECOND_SIZE 72 | HOUR_SIZE = 60 * MINUTE_SIZE 73 | DAY_SIZE = 24 * HOUR_SIZE 74 | WEEK_SIZE = 7 * DAY_SIZE 75 | MONTH_SIZE = 30 * DAY_SIZE 76 | YEAR_SIZE = 365 * DAY_SIZE 77 | 78 | UNITS = { 79 | "ns": NANOSECOND_SIZE, 80 | "us": MICROSECOND_SIZE, 81 | "µs": MICROSECOND_SIZE, 82 | "μs": MICROSECOND_SIZE, 83 | "ms": MILLISECOND_SIZE, 84 | "s": SECOND_SIZE, 85 | "m": MINUTE_SIZE, 86 | "h": HOUR_SIZE, 87 | "d": DAY_SIZE, 88 | "w": WEEK_SIZE, 89 | "mm": MONTH_SIZE, 90 | "y": YEAR_SIZE, 91 | } 92 | 93 | 94 | class DurationError(ValueError): 95 | """duration error""" 96 | 97 | 98 | # ==================== Progression mode argument definition 99 | 100 | 101 | def arguments(parser): 102 | """ 103 | Defines arguments specific to value progression plotting. 104 | """ 105 | parser.add_argument( 106 | "--input_progression", 107 | type=str, 108 | nargs="*", 109 | help="Solution files with labels (e.g.: --input_progression file1,file1.json)", 110 | ) 111 | parser.add_argument( 112 | "--jpath_solution", 113 | type=str, 114 | nargs="?", 115 | default="solutions[*]", 116 | help="Path to solution element in JSON", 117 | ) 118 | parser.add_argument( 119 | "--jpath_value", 120 | type=str, 121 | nargs="?", 122 | default="statistics.value", 123 | help="Path to value element within solution element", 124 | ) 125 | parser.add_argument( 126 | "--jpath_elapsed", 127 | type=str, 128 | nargs="?", 129 | default="statistics.time.elapsed", 130 | help="Path to elapsed element within solution element", 131 | ) 132 | parser.add_argument( 133 | "--output_png", 134 | type=str, 135 | nargs="?", 136 | default=None, 137 | help="Image file path", 138 | ) 139 | parser.add_argument( 140 | "--output_html", 141 | type=str, 142 | nargs="?", 143 | default=None, 144 | help="Interactive html file path", 145 | ) 146 | parser.add_argument( 147 | "--title", 148 | type=str, 149 | nargs="?", 150 | default=None, 151 | help="Title of the plot (will automatically infer one, if not set)", 152 | ) 153 | parser.add_argument( 154 | "--label_x", 155 | type=str, 156 | nargs="?", 157 | default="time (seconds)", 158 | help="X-axis label", 159 | ) 160 | parser.add_argument( 161 | "--label_y", 162 | type=str, 163 | nargs="?", 164 | default="solution value", 165 | help="Y-axis label", 166 | ) 167 | parser.add_argument( 168 | "--color_profile", 169 | type=str, 170 | nargs="?", 171 | default="default", 172 | help="color profile to use (e.g.: cloud, rainbow)", 173 | ) 174 | parser.add_argument( 175 | "--plotly_theme", 176 | type=str, 177 | nargs="?", 178 | default="plotly_dark", 179 | help="plotly theme to use (e.g.: plotly_dark, plotly_white, etc. - " 180 | + "see https://plotly.com/python/templates/ for more)", 181 | ) 182 | parser.add_argument( 183 | "--legend_position", 184 | type=str, 185 | nargs="?", 186 | default="top", 187 | help="legend position (e.g.: top, bottom, left, right)", 188 | ) 189 | parser.add_argument( 190 | "--weight", 191 | type=float, 192 | nargs="?", 193 | default=1, 194 | help="weight / width factor to apply to the points and lines (e.g., 1.5)", 195 | ) 196 | parser.add_argument( 197 | "--nextroute", 198 | dest="nextroute", 199 | action="store_true", 200 | default=False, 201 | help="overrides jpaths for nextroute outputs", 202 | ) 203 | 204 | 205 | # ==================== Progression plotting specific functionality 206 | 207 | 208 | def duration_from_str(duration): 209 | """ 210 | Parse a duration string to a datetime.timedelta. 211 | """ 212 | 213 | if duration in ("0", "+0", "-0"): 214 | return datetime.timedelta() 215 | 216 | pattern = re.compile(r"([\d\.]+)([a-zµμ]+)") 217 | matches = pattern.findall(duration) 218 | if not len(matches): 219 | raise DurationError(f"Invalid duration {duration}") 220 | 221 | total = 0 222 | sign = -1 if duration[0] == "-" else 1 223 | 224 | for value, unit in matches: 225 | if unit not in UNITS: 226 | raise DurationError(f"Unknown unit {unit} in duration {duration}") 227 | try: 228 | total += float(value) * UNITS[unit] 229 | except Exception: 230 | raise DurationError(f"Invalid value {value} in duration {duration}") from None 231 | 232 | microseconds = total / MICROSECOND_SIZE 233 | return datetime.timedelta(microseconds=sign * microseconds) 234 | 235 | 236 | def read_content(input): 237 | """ 238 | Reads the content of the given file. 239 | If the file is gzipped, it will be gunzipped during the process. 240 | If an empty string is given, content is read from stdin instead. 241 | """ 242 | # Determine input (stdin vs. file) 243 | content = "" 244 | if input != "": 245 | # Check for gzip file 246 | gz = False 247 | with open(input, "rb") as test_f: 248 | gz = test_f.read(2) == b"\x1f\x8b" 249 | if gz: 250 | # Read gzip file 251 | with gzip.open(input, "rb") as f: 252 | content = f.read().decode("utf-8") 253 | else: 254 | # Read file 255 | with open(input) as output_file: 256 | content = output_file.read() 257 | else: 258 | # TODO: test for gzip 259 | # gz = False 260 | # sys.stdin.buffer.read(2) 261 | # Read from stdin 262 | content = sys.stdin.read() 263 | return content 264 | 265 | 266 | # Return the longest prefix of all list elements. 267 | def commonprefix(m): 268 | """ 269 | Given a list of pathnames, returns the longest common leading component 270 | Source: https://stackoverflow.com/questions/6718196/determine-prefix-from-a-set-of-similar-strings 271 | """ 272 | if not m: 273 | return "" 274 | s1 = min(m) 275 | s2 = max(m) 276 | for i, c in enumerate(s1): 277 | if c != s2[i]: 278 | return s1[:i] 279 | return s1 280 | 281 | 282 | def parse( 283 | progression_data: list[tuple[str, str]], 284 | jpath_solution: str, 285 | jpath_value: str, 286 | jpath_elapsed: str, 287 | ) -> list[Progression]: 288 | """ 289 | Parses the given content and returns the progression data. 290 | 291 | :param input_progressions: List of input data as string. 292 | :param jpath_solution: Path to solution element in JSON. 293 | :param jpath_value: Path to value element within solution element. 294 | :param jpath_elapsed: Path to elapsed element within solution element. 295 | :return: List of parsed progressions. 296 | """ 297 | # Prepare 298 | progressions = [] 299 | 300 | # Process all inputs 301 | for label, data in progression_data: 302 | # Process JSON 303 | json_data = json.loads(data) 304 | 305 | # Extract values 306 | points = [] 307 | expr_solution = jsonpath_ng.parse(jpath_solution) 308 | expr_value = jsonpath_ng.parse(jpath_value) 309 | expr_elapsed = jsonpath_ng.parse(jpath_elapsed) 310 | for match_solution in expr_solution.find(json_data): 311 | value = expr_value.find(match_solution.value) 312 | elapsed = expr_elapsed.find(match_solution.value) 313 | if len(value) != 1: 314 | raise Exception( 315 | f"Invalid number of value matches ({len(value)}) for {jpath_value}: {match_solution.value}" 316 | ) 317 | if len(elapsed) != 1: 318 | raise Exception( 319 | f"Invalid number of elapsed matches ({len(elapsed)}) for {jpath_elapsed}: {match_solution.value}" 320 | ) 321 | try: 322 | # Try to parse seconds directly 323 | elapsed_sec = float(elapsed[0].value) 324 | except ValueError: 325 | # Parse duration string 326 | elapsed_sec = duration_from_str(elapsed[0].value).total_seconds() 327 | points.append(Point(elapsed_sec, value[0].value)) 328 | 329 | # Collect progression 330 | progressions.append(Progression(label, points)) 331 | 332 | # Return 333 | return progressions 334 | 335 | 336 | def create_figure( 337 | progressions: list[Progression], 338 | title: str, 339 | label_x: str, 340 | label_y: str, 341 | color_profile: str, 342 | plotly_theme: str, 343 | legend_position: str, 344 | weight: float, 345 | ) -> go.Figure: 346 | """ 347 | Creates a plotly figure from the given progressions. 348 | 349 | :param progressions: List of progressions. 350 | :param title: Title of the plot. 351 | :param label_x: Label of the x-axis. 352 | :param label_y: Label of the y-axis. 353 | :param color_profile: Color profile to use. 354 | :param plotly_theme: Plotly theme to use. 355 | :param legend_position: Legend position (top, bottom, left, right). 356 | :return: Plotly figure. 357 | """ 358 | # Prepare colors 359 | colors = None 360 | if color_profile != "default": 361 | colors = common.get_colors(color_profile, len(progressions)) 362 | 363 | # Set legend position 364 | if legend_position == "top": 365 | leg = {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1} 366 | elif legend_position == "bottom": 367 | leg = {"orientation": "h", "yanchor": "top", "y": -0.2, "xanchor": "right", "x": 1} 368 | elif legend_position == "left": 369 | leg = {"orientation": "v", "yanchor": "top", "y": 1, "xanchor": "right", "x": -0.2} 370 | elif legend_position == "right": 371 | leg = {"orientation": "v", "yanchor": "top", "y": 1, "xanchor": "left", "x": 1.02} 372 | else: 373 | leg = None 374 | 375 | # Plot value progression 376 | fig = go.Figure( 377 | layout=go.Layout( 378 | title=go.layout.Title(text=title), 379 | xaxis_title=label_x, 380 | yaxis_title=label_y, 381 | template=plotly_theme, 382 | # margin=dict(l=20, r=20, b=20, t=20, pad=4), 383 | legend=leg, 384 | ) 385 | ) 386 | for i, prog in enumerate(progressions): 387 | xs = [p.time for p in prog.points] 388 | ys = [p.value for p in prog.points] 389 | if colors is not None: 390 | color = f"rgb({colors[i].rgb[0]},{colors[i].rgb[1]},{colors[i].rgb[2]})" 391 | fig.add_trace( 392 | go.Scatter( 393 | x=xs, 394 | y=ys, 395 | line={ 396 | "shape": "hv", 397 | "color": color, 398 | "width": weight * 2, 399 | }, 400 | marker={ 401 | "size": weight * 4, 402 | }, 403 | mode="lines+markers", 404 | name=prog.label, 405 | ) 406 | ) 407 | else: 408 | fig.add_trace( 409 | go.Scatter( 410 | x=xs, 411 | y=ys, 412 | line={ 413 | "shape": "hv", 414 | "width": weight * 2, 415 | }, 416 | marker={ 417 | "size": weight * 4, 418 | }, 419 | mode="lines+markers", 420 | name=prog.label, 421 | ) 422 | ) 423 | 424 | # Enforce legend (also for single trace plots) 425 | fig.update_layout(showlegend=True) 426 | 427 | # Return 428 | return fig 429 | 430 | 431 | def plot( 432 | input_progression: list[str], 433 | jpath_solution: str, 434 | jpath_value: str, 435 | jpath_elapsed: str, 436 | output_png: str, 437 | output_html: str, 438 | title: str, 439 | label_x: str, 440 | label_y: str, 441 | color_profile: str, 442 | plotly_theme: str, 443 | legend_position: str, 444 | weight: float, 445 | nextroute: bool, 446 | ): 447 | """ 448 | Plots value progression based on the given arguments. 449 | Interprets args, reads .json, plots a .png and plots an interactive .html. 450 | """ 451 | # Apply profiles, if requested 452 | profile = ProgressionPlotProfile( 453 | jpath_solution=jpath_solution, 454 | jpath_value=jpath_value, 455 | jpath_elapsed=jpath_elapsed, 456 | ) 457 | if nextroute: 458 | profile = nextroute_profile() 459 | 460 | # Prepare inputs 461 | inputs = input_progression 462 | # Default to one stdin file, if no inputs given 463 | if not inputs or len(inputs) <= 0: 464 | inputs = ["stdin,"] 465 | 466 | # Prepare inputs 467 | series = [] 468 | for i in inputs: 469 | # Process input args 470 | label, file = i.split(",") 471 | # Add series 472 | series.append(Series(file, label)) 473 | 474 | # Read input files as strings 475 | raw_data = [(s.label, read_content(s.file)) for s in series] 476 | 477 | # Parse input data 478 | progressions = parse( 479 | raw_data, 480 | profile.jpath_solution, 481 | profile.jpath_value, 482 | profile.jpath_elapsed, 483 | ) 484 | 485 | # Determine common prefix (used as default for title and filenames) 486 | prefix = commonprefix([os.path.basename(s.file) for s in series]) 487 | directory = os.path.dirname(commonprefix([s.file for s in series])) 488 | 489 | # Determine title 490 | if title is None: 491 | title = prefix 492 | if title == "": 493 | title = "stdin" 494 | 495 | # Plot 496 | fig = create_figure( 497 | progressions, 498 | title, 499 | label_x, 500 | label_y, 501 | color_profile, 502 | plotly_theme, 503 | legend_position, 504 | weight, 505 | ) 506 | 507 | # Write image 508 | if output_png is None: 509 | output_png = os.path.join(directory, prefix + ".png") 510 | if output_png == "": 511 | output_png = "plot.png" 512 | print(f"Plotting image to {output_png}") 513 | fig.write_image(output_png, scale=3) 514 | 515 | # Write html 516 | if output_html is None: 517 | output_html = os.path.join(directory, prefix + ".html") 518 | if output_html == "": 519 | output_html = "plot.html" 520 | print(f"Plotting html to {output_html}") 521 | fig.write_html(output_html) 522 | -------------------------------------------------------------------------------- /nextplot/routingkit.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import enum 3 | import json 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | 9 | from nextplot import common, types 10 | 11 | TRAVEL_SPEED = 10 # assuming 10m/s travel speed for missing segments and snapping 12 | 13 | 14 | class RoutingKitProfile(enum.Enum): 15 | """ 16 | Distinguishes the different travel profiles usable with routingkit. 17 | """ 18 | 19 | car = "car" 20 | bike = "bike" 21 | pedestrian = "pedestrian" 22 | 23 | def __str__(self): 24 | return self.value 25 | 26 | 27 | @dataclasses.dataclass 28 | class Trip: 29 | start: types.Position 30 | end: types.Position 31 | connected: bool 32 | distance: float 33 | shape: list[types.Position] 34 | 35 | def __str__(self) -> str: 36 | return f"Trip({self.start},{self.end})" 37 | 38 | 39 | def check_prerequisites(rk: str, osm: str, profile: RoutingKitProfile, distance: bool): 40 | if not os.path.isfile(osm): 41 | print("routingkit osm file specified, but not found") 42 | quit() 43 | if not os.path.isfile(rk) and not shutil.which(rk): 44 | print("routingkit binary not found") 45 | quit() 46 | ch = osm + "_" + profile.value + "_" + ("distance" if distance else "duration") + ".ch" 47 | if os.path.isfile(ch): 48 | print("Re-using previously generated CH file") 49 | else: 50 | print(f"Generating new CH file at {ch}") 51 | 52 | 53 | def query_routes( 54 | rk: str, 55 | osm: str, 56 | routes: list[types.Route], 57 | profile: RoutingKitProfile = RoutingKitProfile.car, 58 | distance: bool = False, 59 | travel_speed: float = TRAVEL_SPEED, 60 | ): 61 | """ 62 | Queries road-network paths and distance/travel time for a set of routes. 63 | The information is added to the routes in their path and path_costs fields. 64 | """ 65 | 66 | # Small sanity check and preparation 67 | check_prerequisites(rk, osm, profile, distance) 68 | 69 | # Prepare query structure 70 | query_routes = {} 71 | query_segments = {} 72 | queries = [] 73 | for route in routes: 74 | points = route.points 75 | for q in [(points[i], points[i + 1]) for i in range(len(points) - 1)]: 76 | queries.append(q) 77 | query_routes[len(queries) - 1] = route 78 | query_segments[len(queries) - 1] = q 79 | 80 | # Query routingkit 81 | paths, costs = query(rk, osm, queries, profile, distance) 82 | 83 | # Clear any previously existing information 84 | for route in routes: 85 | route.legs = None 86 | route.leg_distances = None 87 | route.leg_durations = None 88 | 89 | # Add results to routes 90 | for i, path in enumerate(paths): 91 | cost = costs[i] 92 | route = query_routes[i] 93 | start, end = query_segments[i] 94 | 95 | # Check how to handle 96 | no_path = len(path) <= 0 97 | not_moving = types.Position.equal(start, end) 98 | 99 | # If no path was found, assume straight line 100 | if no_path or not_moving: 101 | # Path: start -> end 102 | leg = [start, end] 103 | # Costs: simply assume straight line 104 | cost = common.haversine(start, end) 105 | if not distance: 106 | cost /= travel_speed 107 | else: 108 | # Path: start -> rk path ... -> end 109 | leg = [start, *path, end] 110 | # Costs: account for start/end snapping in costs 111 | start_cost = common.haversine(start, path[0]) 112 | if not distance: 113 | start_cost /= travel_speed 114 | end_cost = common.haversine(path[-1], end) 115 | if not distance: 116 | end_cost /= travel_speed 117 | cost += start_cost + end_cost 118 | 119 | # RK uses milliseconds and meters, convert to seconds and kilometers (same factor) 120 | cost /= 1000.0 121 | 122 | # Add leg to route 123 | if route.legs is None: 124 | route.legs = [leg] 125 | if distance: 126 | route.leg_distances = [cost] 127 | else: 128 | route.leg_durations = [cost] 129 | else: 130 | route.legs.append(leg) 131 | if distance: 132 | route.leg_distances.append(cost) 133 | else: 134 | route.leg_durations.append(cost) 135 | 136 | 137 | def query( 138 | rk: str, 139 | osm: str, 140 | queries: list[tuple[types.Position, types.Position]], 141 | profile: RoutingKitProfile = RoutingKitProfile.car, 142 | distance: bool = False, 143 | ) -> tuple[list[list[types.Position]], list[float]]: 144 | """ 145 | Queries paths and road distances for a list of given tuples. 146 | 147 | param str rk: Path to routingkit binary. 148 | param str osm: Path to the OpenStreetMap data file. 149 | param str queries: All queries as (start,end) position tuples. 150 | param bool distance: Indicates whether to query distance instead of duration. 151 | """ 152 | 153 | # Prepare query 154 | rk_tuples = [] 155 | for f, t in queries: 156 | rk_tuples.append( 157 | { 158 | "from": {"lon": f.lon, "lat": f.lat}, 159 | "to": {"lon": t.lon, "lat": t.lat}, 160 | } 161 | ) 162 | rk_input = {"tuples": rk_tuples} 163 | 164 | # >> Query routingkit 165 | rk_process = subprocess.Popen( 166 | [ 167 | rk, 168 | "-map", 169 | osm, 170 | "-measure", 171 | "distance" if distance else "traveltime", 172 | "-profile", 173 | profile.value, 174 | ], 175 | stdout=subprocess.PIPE, 176 | stdin=subprocess.PIPE, 177 | ) 178 | rk_output = rk_process.communicate(input=json.dumps(rk_input).encode("utf-8")) 179 | if rk_process.returncode != 0: 180 | print() 181 | print("error in routingkit, stopping") 182 | sys.exit(1) 183 | result = json.loads(rk_output[0]) 184 | 185 | # Collect results 186 | trips, distances = [], [] 187 | for trip in result["trips"]: 188 | trips.append([types.Position(wp["lon"], wp["lat"], None) for wp in trip["waypoints"]]) 189 | distances.append(trip["cost"]) 190 | return trips, distances 191 | -------------------------------------------------------------------------------- /nextplot/test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | import jsonpath_ng 5 | 6 | # ==================== This file contains testing code (mode: 'test') 7 | 8 | 9 | # ==================== Test mode argument definition 10 | 11 | 12 | def arguments(parser): 13 | """ 14 | Defines arguments specific to testing. 15 | """ 16 | parser.add_argument( 17 | "--input", 18 | type=str, 19 | nargs="?", 20 | default="", 21 | help="path to the file to test", 22 | ) 23 | parser.add_argument( 24 | "--jpath", 25 | type=str, 26 | nargs="?", 27 | default="", 28 | required=True, 29 | help="JSON path to test (XPATH like," 30 | + " see https://goessner.net/articles/JsonPath/," 31 | + ' example: "state.clusters[*].points")', 32 | ) 33 | parser.add_argument( 34 | "--stats", 35 | action="store_true", 36 | help="plots some statistics about tested matching", 37 | ) 38 | 39 | 40 | # ==================== Test specific functionality 41 | 42 | 43 | def test_filter( 44 | input: str, 45 | jpath: str, 46 | stats: bool, 47 | ): 48 | """ 49 | Simply filters a file using the given path and prints the result. 50 | """ 51 | # Load json data and extract information 52 | content = "" 53 | if len(input) > 0: 54 | with open(input) as jsonFile: 55 | content = jsonFile.read() 56 | else: 57 | content = "".join(sys.stdin.readlines()) 58 | data = json.loads(content) 59 | try: 60 | expression = jsonpath_ng.parse(jpath) 61 | except Exception: 62 | print(f'error in path syntax: "{jpath}"') 63 | return 64 | # Find and print all matching results 65 | matches = 0 66 | for match in expression.find(data): 67 | print(match.value) 68 | matches += 1 69 | if stats: 70 | print("Statistics:") 71 | print(f"{matches} matches") 72 | -------------------------------------------------------------------------------- /nextplot/types.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import enum 3 | 4 | 5 | class ColorProfile(enum.Enum): 6 | auto = "auto" 7 | cloud = "cloud" 8 | rainbow = "rainbow" 9 | 10 | def __str__(self): 11 | return self.value 12 | 13 | 14 | class Stat: 15 | """ 16 | Defines a summary stat describing the solution. 17 | """ 18 | 19 | def __init__(self, name, desc, stat): 20 | self.desc = desc 21 | self.name = name 22 | self.val = stat 23 | 24 | 25 | @dataclasses.dataclass 26 | class BoundingBox: 27 | """ 28 | Represents a bounding box. 29 | """ 30 | 31 | min_x: float 32 | max_x: float 33 | min_y: float 34 | max_y: float 35 | 36 | def __post_init__(self): 37 | self.width = self.max_x - self.min_x 38 | self.height = self.max_y - self.min_y 39 | 40 | def __str__(self): 41 | return f"BoundingBox(min_x={self.min_x}, max_x={self.max_x}, " + f"min_y={self.min_y}, max_y={self.max_y})" 42 | 43 | 44 | @dataclasses.dataclass 45 | class Position: 46 | lon: float 47 | lat: float 48 | desc: str 49 | distance: float = 0 50 | 51 | def __getitem__(self, key): 52 | if key == 0: 53 | return self.lon 54 | elif key == 1: 55 | return self.lat 56 | else: 57 | raise Exception(f'Unrecognized key "{key}", use 0 for lon and 1 for lat') 58 | 59 | def __str__(self) -> str: 60 | return f"Pos({self.lon},{self.lat})" 61 | 62 | @staticmethod 63 | def equal(p1, p2) -> bool: 64 | """ 65 | Compares the two points for equality. 66 | """ 67 | return p1.lon == p2.lon and p1.lat == p2.lat 68 | 69 | def clone(self): 70 | """ 71 | Creates a clone of this position. 72 | """ 73 | return Position(self.lon, self.lat, self.desc, self.distance) 74 | 75 | 76 | class Point: 77 | """ 78 | Defines one point and is used to append additional info to it. 79 | """ 80 | 81 | def __init__(self, points): 82 | self.points = points 83 | 84 | def __str__(self): 85 | return ",".join(self.point[0]) if len(self.point) > 0 else "empty" 86 | 87 | 88 | class Cluster: 89 | """ 90 | Defines one cluster and is used to append additional info to it. 91 | """ 92 | 93 | def __init__(self, cluster): 94 | self.points = cluster 95 | 96 | def __str__(self): 97 | return f"len: {len(self.points)}" 98 | 99 | 100 | class Route: 101 | """ 102 | Defines one route and is used to append additional info for it. 103 | """ 104 | 105 | def __init__(self, points: list[Position]): 106 | self.points = points 107 | self.legs = None 108 | self.leg_distances = None 109 | self.leg_durations = None 110 | 111 | def to_points(self, omit_start: bool, omit_end: bool) -> list[Position]: 112 | """ 113 | Returns all points of the route. 114 | """ 115 | ps = [] 116 | start = 1 if omit_start else 0 117 | stop = len(self.points) - 1 if omit_end else len(self.points) 118 | for i in range(start, stop): 119 | ps.append(self.points[i]) 120 | return ps 121 | 122 | def to_polyline(self, omit_start: bool, omit_end: bool) -> list[Position]: 123 | """ 124 | Returns the full polyline that can be used for plotting. 125 | """ 126 | line = [] 127 | start = 1 if omit_start else 0 128 | stop = len(self.points) - 1 if omit_end else len(self.points) 129 | if self.legs is not None: 130 | for i in range(start, stop - 1): 131 | line.extend(self.legs[i]) 132 | else: 133 | for i in range(start, stop): 134 | line.append(self.points[i]) 135 | return line 136 | 137 | def __str__(self): 138 | return f"len: {len(self.points)}" 139 | 140 | 141 | class RouteDirectionIndicator(enum.Enum): 142 | """ 143 | Distinguishes the different route direction indicators. 144 | """ 145 | 146 | none = "none" 147 | arrow = "arrow" 148 | animation = "animation" 149 | 150 | def __str__(self): 151 | return self.value 152 | -------------------------------------------------------------------------------- /plot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # PYTHON_ARGCOMPLETE_OK 3 | from nextplot import main 4 | 5 | if __name__ == "__main__": 6 | main.entry_point() 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling >= 1.13.0"] 4 | 5 | [project] 6 | authors = [ 7 | { email = "tech@nextmv.io", name = "Nextmv" } 8 | ] 9 | classifiers = [ 10 | "License :: OSI Approved :: Apache Software License", 11 | "Operating System :: OS Independent", 12 | "Programming Language :: Python :: 3", 13 | ] 14 | dependencies = [ 15 | "argcomplete>=2.0.0", 16 | "colorutils>=0.3.0", 17 | "folium>=0.17.0", 18 | "jsonpath_ng>=1.5.3", 19 | "kaleido>=0.2.1", 20 | "numpy>=1.22.3", 21 | "plotly>=5.7.0", 22 | "polyline>=2.0.2", 23 | "scipy>=1.8.0", 24 | ] 25 | description = "Tools for plotting routes, clusters and more from JSON" 26 | dynamic = [ 27 | "version", 28 | ] 29 | keywords = [ 30 | "visualization", 31 | "vehicle routing", 32 | "clustering", 33 | "locations", 34 | "geospatial", 35 | "operations research", 36 | ] 37 | license = { file = "LICENSE" } 38 | maintainers = [ 39 | { email = "tech@nextmv.io", name = "Nextmv" } 40 | ] 41 | name = "nextplot" 42 | readme = "README.md" 43 | requires-python = ">=3.10" 44 | 45 | [project.urls] 46 | Homepage = "https://www.nextmv.io" 47 | Documentation = "https://github.com/nextmv-io/nextplot" 48 | Repository = "https://github.com/nextmv-io/nextplot" 49 | 50 | [project.scripts] 51 | nextplot = "nextplot.main:entry_point" 52 | 53 | [tool.ruff] 54 | target-version = "py312" 55 | select = [ 56 | "E", # pycodestyle errors 57 | "W", # pycodestyle warnings 58 | "F", # pyflakes 59 | "I", # isort 60 | "C", # flake8-comprehensions 61 | "B", # flake8-bugbear 62 | "UP", # pyupgrade 63 | ] 64 | line-length = 120 65 | [tool.ruff.lint.mccabe] 66 | max-complexity = 30 67 | 68 | [tool.hatch.version] 69 | path = "nextplot/__about__.py" 70 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | argcomplete==2.0.0 2 | colorutils==0.3.0 3 | folium @ git+https://github.com/python-visualization/folium@b80e7e92 4 | jsonpath_ng==1.6.1 5 | kaleido==0.2.1 6 | numpy==1.26.4 7 | plotly==5.21.0 8 | polyline==2.0.2 9 | scipy==1.13.0 10 | pytest==7.1.1 11 | imagehash==4.3.1 12 | ruff==0.1.7 13 | hatch==1.9.1 14 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import collections 3 | import difflib 4 | import os 5 | import pathlib 6 | import re 7 | import subprocess 8 | import sys 9 | 10 | import imagehash 11 | import pytest 12 | from PIL import Image 13 | 14 | # Define variables used by all tests (will be filled in by pre-test fixture) 15 | READY = False 16 | UPDATE = False 17 | OUTPUT_DIR = None 18 | DATA_DIR = None 19 | NEXTPLOT_PATH = None 20 | 21 | 22 | # Define CLI test parameters 23 | MapTest = collections.namedtuple( 24 | "Test", 25 | [ 26 | "name", 27 | "args", 28 | "out_img", 29 | "out_plot", 30 | "out_map", 31 | "golden_log", 32 | "golden_img", 33 | "golden_plot", 34 | "golden_map", 35 | ], 36 | ) 37 | GeoJSONTest = collections.namedtuple( 38 | "Test", 39 | [ 40 | "name", 41 | "args", 42 | "out_html", 43 | "golden_log", 44 | "golden_html", 45 | ], 46 | ) 47 | ProgressionTest = collections.namedtuple( 48 | "Test", 49 | [ 50 | "name", 51 | "args", 52 | "out_img", 53 | "out_html", 54 | "golden_log", 55 | "golden_img", 56 | "golden_html", 57 | ], 58 | ) 59 | 60 | 61 | def _diff_report(expected: str, got: str) -> str: 62 | """ 63 | Create a unified diff report between two strings. 64 | """ 65 | diff = difflib.unified_diff(expected.splitlines(), got.splitlines()) 66 | return "\n".join(list(diff)) 67 | 68 | 69 | def _prepare_tests() -> None: 70 | global READY, UPDATE, OUTPUT_DIR, DATA_DIR, NEXTPLOT_PATH 71 | # If it's the first test, setup all variables 72 | if not READY: 73 | # Read arguments 74 | parser = argparse.ArgumentParser(description="nextplot golden file tests") 75 | parser.add_argument( 76 | "--update", 77 | dest="update", 78 | action="store_true", 79 | default=False, 80 | help="updates the golden files", 81 | ) 82 | args, _ = parser.parse_known_args() # Ignore potentially forwarded pytest args 83 | UPDATE = args.update 84 | 85 | # Update if requested by env var 86 | update_requested = os.environ.get("UPDATE", "0") 87 | if update_requested == "1" or update_requested.lower() == "true": 88 | UPDATE = True 89 | 90 | # Set paths 91 | OUTPUT_DIR = str(pathlib.Path(__file__).parent.joinpath("./output").resolve()) 92 | DATA_DIR = str(pathlib.Path(__file__).parent.joinpath("./testdata").resolve(strict=True)) 93 | NEXTPLOT_PATH = str(pathlib.Path(__file__).parent.joinpath("../plot.py").resolve(strict=True)) 94 | 95 | # Prepare output directory 96 | os.makedirs(OUTPUT_DIR, exist_ok=True) 97 | 98 | # Mark as ready 99 | READY = True 100 | 101 | 102 | @pytest.fixture(autouse=True) 103 | def run_around_tests() -> None: 104 | """ 105 | Prepare tests and clean up. 106 | """ 107 | _prepare_tests() 108 | 109 | # Run a test 110 | yield 111 | 112 | # Clean up 113 | # - nothing to do, we keep the output for manual inspection 114 | 115 | 116 | def _clean(data: str) -> str: 117 | """ 118 | Remove data subject to change from the given string. 119 | """ 120 | # Remove any GUIDs 121 | data = re.sub(r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", "", data) 122 | # Remove folium IDs 123 | data = re.sub(r"[a-f0-9]{32}", "", data) 124 | return data 125 | 126 | 127 | def _run_map_test(test: MapTest) -> None: 128 | # Clear old results 129 | if os.path.isfile(test.out_img): 130 | os.remove(test.out_img) 131 | if os.path.isfile(test.out_map): 132 | os.remove(test.out_map) 133 | 134 | # Assemble command and arguments 135 | base = [sys.executable, NEXTPLOT_PATH] 136 | cmd = [*base, *test.args] 137 | cmd.extend( 138 | [ 139 | "--output_image", 140 | test.out_img, 141 | "--output_plot", 142 | test.out_plot, 143 | "--output_map", 144 | test.out_map, 145 | ] 146 | ) 147 | 148 | # Log 149 | cmd_string = " ".join(test.args) 150 | print(f"Invoking: {cmd_string}") 151 | 152 | # Run command 153 | result = subprocess.run(cmd, stdout=subprocess.PIPE) 154 | 155 | # Expect no errors 156 | assert result.returncode == 0 157 | 158 | # Compare log output 159 | output = result.stdout.decode("utf-8") 160 | if UPDATE: 161 | with open(test.golden_log, "w") as file: 162 | file.write(output) 163 | else: 164 | expected = "" 165 | with open(test.golden_log) as file: 166 | expected = file.read() 167 | assert output == expected, _diff_report(expected, output) 168 | 169 | # Compare plot file against expectation 170 | if UPDATE: 171 | # Copy plot file, but replace any GUIDs 172 | with open(test.out_plot) as fr: 173 | with open(test.golden_plot, "w") as fw: 174 | fw.write(_clean(fr.read())) 175 | else: 176 | # Compare plot file 177 | expected, got = "", "" 178 | with open(test.golden_plot) as f: 179 | expected = f.read() 180 | with open(test.out_plot) as f: 181 | got = _clean(f.read()) 182 | assert got == expected, _diff_report(expected, got) 183 | 184 | # Compare map file against expectation 185 | if UPDATE: 186 | # Copy map file, but replace any GUIDs 187 | with open(test.out_map) as fr: 188 | with open(test.golden_map, "w") as fw: 189 | fw.write(_clean(fr.read())) 190 | else: 191 | # Compare map file 192 | expected, got = "", "" 193 | with open(test.golden_map) as f: 194 | expected = f.read() 195 | with open(test.out_map) as f: 196 | got = _clean(f.read()) 197 | assert got == expected, _diff_report(expected, got) 198 | 199 | # Compare image file against expectation 200 | # (we cannot compare the html file, as it is not deterministic) 201 | hash_gotten = imagehash.phash(Image.open(test.out_img)) 202 | if UPDATE: 203 | # Update expected hash 204 | with open(test.golden_img, "w") as f: 205 | f.write(str(hash_gotten) + "\n") 206 | else: 207 | # Compare image similarity via imagehash library 208 | hash_expected = None 209 | with open(test.golden_img) as f: 210 | hash_expected = imagehash.hex_to_hash(f.read().strip()) 211 | distance = hash_gotten - hash_expected 212 | assert distance < 7, ( 213 | f"hash distance too large: {distance},\n" + f"got:\t{hash_gotten}\n" + f"want:\t{hash_expected}" 214 | ) 215 | 216 | 217 | def _run_geojson_test(test: GeoJSONTest) -> None: 218 | # Clear old results 219 | if os.path.isfile(test.out_html): 220 | os.remove(test.out_html) 221 | 222 | # Assemble command and arguments 223 | base = [sys.executable, NEXTPLOT_PATH] 224 | cmd = [*base, *test.args] 225 | cmd.extend( 226 | [ 227 | "--output_map", 228 | test.out_html, 229 | ] 230 | ) 231 | 232 | # Log 233 | cmd_string = " ".join(test.args) 234 | print(f"Invoking: {cmd_string}") 235 | 236 | # Run command 237 | result = subprocess.run(cmd, stdout=subprocess.PIPE) 238 | 239 | # Expect no errors 240 | assert result.returncode == 0 241 | 242 | # Compare log output 243 | output = result.stdout.decode("utf-8") 244 | if UPDATE: 245 | with open(test.golden_log, "w") as file: 246 | file.write(output) 247 | else: 248 | expected = "" 249 | with open(test.golden_log) as file: 250 | expected = file.read() 251 | assert output == expected, _diff_report(expected, output) 252 | 253 | # Compare html file against expectation 254 | if UPDATE: 255 | # Copy html file, but replace any GUIDs 256 | with open(test.out_html) as fr: 257 | with open(test.golden_html, "w") as fw: 258 | fw.write(_clean(fr.read())) 259 | else: 260 | # Compare html file 261 | expected, got = "", "" 262 | with open(test.golden_html) as f: 263 | expected = f.read() 264 | with open(test.out_html) as f: 265 | got = _clean(f.read()) 266 | assert got == expected, _diff_report(expected, got) 267 | 268 | 269 | def _run_progression_test(test: ProgressionTest) -> None: 270 | # Clear old results 271 | if os.path.isfile(test.out_img): 272 | os.remove(test.out_img) 273 | if os.path.isfile(test.out_html): 274 | os.remove(test.out_html) 275 | 276 | # Assemble command and arguments 277 | base = [sys.executable, NEXTPLOT_PATH] 278 | cmd = [*base, *test.args] 279 | cmd.extend( 280 | [ 281 | "--output_png", 282 | test.out_img, 283 | "--output_html", 284 | test.out_html, 285 | ] 286 | ) 287 | 288 | # Log 289 | cmd_string = " ".join(test.args) 290 | print(f"Invoking: {cmd_string}") 291 | 292 | # Run command 293 | result = subprocess.run(cmd, stdout=subprocess.PIPE) 294 | 295 | # Expect no errors 296 | assert result.returncode == 0 297 | 298 | # Compare log output 299 | output = result.stdout.decode("utf-8") 300 | if UPDATE: 301 | with open(test.golden_log, "w") as file: 302 | file.write(output) 303 | else: 304 | expected = "" 305 | with open(test.golden_log) as file: 306 | expected = file.read() 307 | assert output == expected, _diff_report(expected, output) 308 | 309 | # Compare html file against expectation 310 | if UPDATE: 311 | # Copy html file, but replace any GUIDs 312 | with open(test.out_html) as fr: 313 | with open(test.golden_html, "w") as fw: 314 | fw.write(_clean(fr.read())) 315 | else: 316 | # Compare html file 317 | expected, got = "", "" 318 | with open(test.golden_html) as f: 319 | expected = f.read() 320 | with open(test.out_html) as f: 321 | got = _clean(f.read()) 322 | assert got == expected, _diff_report(expected, got) 323 | 324 | # Compare image file against expectation 325 | # (we cannot compare the html file, as it is not deterministic) 326 | hash_gotten = imagehash.phash(Image.open(test.out_img)) 327 | if UPDATE: 328 | # Update expected hash 329 | with open(test.golden_img, "w") as f: 330 | f.write(str(hash_gotten) + "\n") 331 | else: 332 | # Compare image similarity via imagehash library 333 | hash_expected = None 334 | with open(test.golden_img) as f: 335 | hash_expected = imagehash.hex_to_hash(f.read().strip()) 336 | distance = hash_gotten - hash_expected 337 | assert distance < 7, ( 338 | f"hash distance too large: {distance},\n" + f"got:\t{hash_gotten}\n" + f"want:\t{hash_expected}" 339 | ) 340 | 341 | 342 | def test_map_plot_cli_paris_route(): 343 | test = MapTest( 344 | "paris-route", 345 | [ 346 | "route", 347 | "--input_route", 348 | os.path.join(DATA_DIR, "paris-route.json"), 349 | "--jpath_route", 350 | "state.tours[*].route", 351 | "--jpath_x", 352 | "location[1]", 353 | "--jpath_y", 354 | "location[0]", 355 | "--omit_start", 356 | "--omit_end", 357 | "--custom_map_tile", 358 | 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png,DarkMatter no labels,OpenStreetMap', 359 | "--weight_route", 360 | "4", 361 | "--weight_points", 362 | "4", 363 | ], 364 | os.path.join(OUTPUT_DIR, "paris-route.plot.png"), 365 | os.path.join(OUTPUT_DIR, "paris-route.plot.html"), 366 | os.path.join(OUTPUT_DIR, "paris-route.html"), 367 | os.path.join(DATA_DIR, "paris-route.json.golden"), 368 | os.path.join(DATA_DIR, "paris-route.plot.png.golden"), 369 | os.path.join(DATA_DIR, "paris-route.plot.html.golden"), 370 | os.path.join(DATA_DIR, "paris-route.map.html.golden"), 371 | ) 372 | _run_map_test(test) 373 | 374 | 375 | def test_map_plot_cli_paris_cluster(): 376 | test = MapTest( 377 | "paris-cluster", 378 | [ 379 | "cluster", 380 | "--input_cluster", 381 | os.path.join(DATA_DIR, "paris-cluster.json"), 382 | "--jpath_cluster", 383 | "state.tours[*].route", 384 | "--jpath_x", 385 | "location[1]", 386 | "--jpath_y", 387 | "location[0]", 388 | "--no_points", 389 | "--custom_map_tile", 390 | 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png,DarkMatter no labels,OpenStreetMap', 391 | "--weight_points", 392 | "4", 393 | ], 394 | os.path.join(OUTPUT_DIR, "paris-cluster.plot.png"), 395 | os.path.join(OUTPUT_DIR, "paris-cluster.plot.html"), 396 | os.path.join(OUTPUT_DIR, "paris-cluster.html"), 397 | os.path.join(DATA_DIR, "paris-cluster.json.golden"), 398 | os.path.join(DATA_DIR, "paris-cluster.plot.png.golden"), 399 | os.path.join(DATA_DIR, "paris-cluster.plot.html.golden"), 400 | os.path.join(DATA_DIR, "paris-cluster.map.html.golden"), 401 | ) 402 | _run_map_test(test) 403 | 404 | 405 | def test_map_plot_cli_paris_point(): 406 | test = MapTest( 407 | "paris-point", 408 | [ 409 | "point", 410 | "--input_point", 411 | os.path.join(DATA_DIR, "paris-point.json"), 412 | "--jpath_point", 413 | "state[*].tours[*].route", 414 | "--jpath_x", 415 | "location[1]", 416 | "--jpath_y", 417 | "location[0]", 418 | "--weight_points", 419 | "4", 420 | ], 421 | os.path.join(OUTPUT_DIR, "paris-point.plot.png"), 422 | os.path.join(OUTPUT_DIR, "paris-point.plot.html"), 423 | os.path.join(OUTPUT_DIR, "paris-point.html"), 424 | os.path.join(DATA_DIR, "paris-point.json.golden"), 425 | os.path.join(DATA_DIR, "paris-point.plot.png.golden"), 426 | os.path.join(DATA_DIR, "paris-point.plot.html.golden"), 427 | os.path.join(DATA_DIR, "paris-point.map.html.golden"), 428 | ) 429 | _run_map_test(test) 430 | 431 | 432 | def test_map_plot_cli_paris_route_indexed(): 433 | test = MapTest( 434 | "paris-route-indexed", 435 | [ 436 | "route", 437 | "--input_route", 438 | os.path.join(DATA_DIR, "paris-route-indexed.json"), 439 | "--jpath_route", 440 | "state.tours[*].route", 441 | "--input_pos", 442 | os.path.join(DATA_DIR, "paris-pos.json"), 443 | "--jpath_pos", 444 | "positions", 445 | "--jpath_x", 446 | "", 447 | "--jpath_y", 448 | "", 449 | "--custom_map_tile", 450 | 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png,DarkMatter no labels,OpenStreetMap', 451 | "--weight_route", 452 | "1.5", 453 | "--weight_points", 454 | "2", 455 | "--swap", 456 | ], 457 | os.path.join(OUTPUT_DIR, "paris-route-indexed.plot.png"), 458 | os.path.join(OUTPUT_DIR, "paris-route-indexed.plot.html"), 459 | os.path.join(OUTPUT_DIR, "paris-route-indexed.html"), 460 | os.path.join(DATA_DIR, "paris-route-indexed.json.golden"), 461 | os.path.join(DATA_DIR, "paris-route-indexed.plot.png.golden"), 462 | os.path.join(DATA_DIR, "paris-route-indexed.plot.html.golden"), 463 | os.path.join(DATA_DIR, "paris-route-indexed.map.html.golden"), 464 | ) 465 | _run_map_test(test) 466 | 467 | 468 | def test_map_plot_cli_geojson(): 469 | test = GeoJSONTest( 470 | "geojson", 471 | [ 472 | "geojson", 473 | "--input_geojson", 474 | os.path.join(DATA_DIR, "geojson-data.json"), 475 | ], 476 | os.path.join(OUTPUT_DIR, "geojson-data.json.map.html"), 477 | os.path.join(DATA_DIR, "geojson-data.json.golden"), 478 | os.path.join(DATA_DIR, "geojson-data.json.map.html.golden"), 479 | ) 480 | _run_geojson_test(test) 481 | 482 | 483 | def test_map_plot_cli_geojson_nested(): 484 | test = GeoJSONTest( 485 | "geojson", 486 | [ 487 | "geojson", 488 | "--input_geojson", 489 | os.path.join(DATA_DIR, "geojson-nested-data.json"), 490 | "--jpath_geojson", 491 | "assets[*].content", 492 | ], 493 | os.path.join(OUTPUT_DIR, "geojson-nested-data.json.map.html"), 494 | os.path.join(DATA_DIR, "geojson-nested-data.json.golden"), 495 | os.path.join(DATA_DIR, "geojson-nested-data.json.map.html.golden"), 496 | ) 497 | _run_geojson_test(test) 498 | 499 | 500 | def test_progression_plot_cli_fleet_cloud_comparison(): 501 | test = ProgressionTest( 502 | "fleet-cloud-comparison", 503 | [ 504 | "progression", 505 | "--input_progression", 506 | "0.7.3," + os.path.join(DATA_DIR, "fleet-cloud-all-0.7.3.json"), 507 | "0.8," + os.path.join(DATA_DIR, "fleet-cloud-all-0.8.json"), 508 | "--title", 509 | "Fleet cloud comparison", 510 | ], 511 | os.path.join(OUTPUT_DIR, "fleet-cloud-comparison.png"), 512 | os.path.join(OUTPUT_DIR, "fleet-cloud-comparison.html"), 513 | os.path.join(DATA_DIR, "fleet-cloud-comparison.golden"), 514 | os.path.join(DATA_DIR, "fleet-cloud-comparison.png.golden"), 515 | os.path.join(DATA_DIR, "fleet-cloud-comparison.html.golden"), 516 | ) 517 | _run_progression_test(test) 518 | 519 | 520 | if __name__ == "__main__": 521 | _prepare_tests() 522 | test_map_plot_cli_paris_route() 523 | test_map_plot_cli_paris_cluster() 524 | test_map_plot_cli_paris_point() 525 | test_map_plot_cli_paris_route_indexed() 526 | test_map_plot_cli_geojson() 527 | test_map_plot_cli_geojson_nested() 528 | test_progression_plot_cli_fleet_cloud_comparison() 529 | print("Everything passed") 530 | -------------------------------------------------------------------------------- /tests/testdata/fleet-cloud-comparison.golden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextmv-io/nextplot/c2356734796cce0c95d8d006fabeb7f1ade33502/tests/testdata/fleet-cloud-comparison.golden -------------------------------------------------------------------------------- /tests/testdata/fleet-cloud-comparison.png.golden: -------------------------------------------------------------------------------- 1 | f0f281e3796a031f 2 | -------------------------------------------------------------------------------- /tests/testdata/geojson-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "geometry": { 7 | "type": "Point", 8 | "coordinates": [-115.22033625210064, 18.575192081267197] 9 | }, 10 | "properties": {} 11 | }, 12 | { 13 | "type": "Feature", 14 | "geometry": { 15 | "type": "Point", 16 | "coordinates": [26.063782545842447, 80.39436534400363] 17 | }, 18 | "properties": {} 19 | }, 20 | { 21 | "type": "Feature", 22 | "geometry": { 23 | "type": "Point", 24 | "coordinates": [49.05261435048705, -4.815956446107803] 25 | }, 26 | "properties": {} 27 | }, 28 | { 29 | "type": "Feature", 30 | "geometry": { 31 | "type": "Point", 32 | "coordinates": [8.298298403258588, 3.265395718979831] 33 | }, 34 | "properties": {} 35 | }, 36 | { 37 | "type": "Feature", 38 | "geometry": { 39 | "type": "Point", 40 | "coordinates": [130.3834619679142, 17.94832898544832] 41 | }, 42 | "properties": {} 43 | }, 44 | { 45 | "type": "Feature", 46 | "geometry": { 47 | "type": "Point", 48 | "coordinates": [41.0643987376985, 23.10496993075887] 49 | }, 50 | "properties": {} 51 | }, 52 | { 53 | "type": "Feature", 54 | "geometry": { 55 | "type": "Point", 56 | "coordinates": [140.09288702107037, -79.82588120665534] 57 | }, 58 | "properties": {} 59 | }, 60 | { 61 | "type": "Feature", 62 | "geometry": { 63 | "type": "Point", 64 | "coordinates": [-31.33508848342683, 65.68458929468702] 65 | }, 66 | "properties": {} 67 | }, 68 | { 69 | "type": "Feature", 70 | "geometry": { 71 | "type": "Point", 72 | "coordinates": [58.44881548901721, 84.8853824878401] 73 | }, 74 | "properties": {} 75 | }, 76 | { 77 | "type": "Feature", 78 | "geometry": { 79 | "type": "Point", 80 | "coordinates": [77.04454017583608, 29.486980779188666] 81 | }, 82 | "properties": {} 83 | }, 84 | { 85 | "type": "Feature", 86 | "geometry": { 87 | "type": "Point", 88 | "coordinates": [107.47713770103157, 6.917930384972415] 89 | }, 90 | "properties": {} 91 | }, 92 | { 93 | "type": "Feature", 94 | "geometry": { 95 | "type": "Point", 96 | "coordinates": [-68.17183799661818, 27.691434423742535] 97 | }, 98 | "properties": {} 99 | }, 100 | { 101 | "type": "Feature", 102 | "geometry": { 103 | "type": "Point", 104 | "coordinates": [-105.87254645545934, -49.14751775337236] 105 | }, 106 | "properties": {} 107 | }, 108 | { 109 | "type": "Feature", 110 | "geometry": { 111 | "type": "Point", 112 | "coordinates": [-155.62605321754808, 70.97382140916633] 113 | }, 114 | "properties": {} 115 | }, 116 | { 117 | "type": "Feature", 118 | "geometry": { 119 | "type": "Point", 120 | "coordinates": [-123.7349853874018, -14.647829768918847] 121 | }, 122 | "properties": {} 123 | }, 124 | { 125 | "type": "Feature", 126 | "geometry": { 127 | "type": "Point", 128 | "coordinates": [-0.36734519059763215, 88.2822470543127] 129 | }, 130 | "properties": {} 131 | }, 132 | { 133 | "type": "Feature", 134 | "geometry": { 135 | "type": "Point", 136 | "coordinates": [157.74995809552584, 80.99874246047408] 137 | }, 138 | "properties": {} 139 | }, 140 | { 141 | "type": "Feature", 142 | "geometry": { 143 | "type": "Point", 144 | "coordinates": [145.95945856456706, -84.4296510912169] 145 | }, 146 | "properties": {} 147 | }, 148 | { 149 | "type": "Feature", 150 | "geometry": { 151 | "type": "Point", 152 | "coordinates": [42.96815134503646, -19.59615491929395] 153 | }, 154 | "properties": {} 155 | }, 156 | { 157 | "type": "Feature", 158 | "geometry": { 159 | "type": "Point", 160 | "coordinates": [72.79396334324804, -34.56200984243971] 161 | }, 162 | "properties": {} 163 | }, 164 | { 165 | "type": "Feature", 166 | "geometry": { 167 | "type": "Point", 168 | "coordinates": [-150.43597038790634, 28.109470894931103] 169 | }, 170 | "properties": {} 171 | }, 172 | { 173 | "type": "Feature", 174 | "geometry": { 175 | "type": "Point", 176 | "coordinates": [116.74794885910944, 69.22591756002855] 177 | }, 178 | "properties": {} 179 | }, 180 | { 181 | "type": "Feature", 182 | "geometry": { 183 | "type": "Point", 184 | "coordinates": [50.05807001557586, 74.29939249996231] 185 | }, 186 | "properties": {} 187 | }, 188 | { 189 | "type": "Feature", 190 | "geometry": { 191 | "type": "Point", 192 | "coordinates": [5.2769045824745575, 86.27124808396252] 193 | }, 194 | "properties": {} 195 | }, 196 | { 197 | "type": "Feature", 198 | "geometry": { 199 | "type": "Point", 200 | "coordinates": [79.23081298019815, 3.3840202530143326] 201 | }, 202 | "properties": {} 203 | } 204 | ] 205 | } 206 | -------------------------------------------------------------------------------- /tests/testdata/geojson-data.json.golden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextmv-io/nextplot/c2356734796cce0c95d8d006fabeb7f1ade33502/tests/testdata/geojson-data.json.golden -------------------------------------------------------------------------------- /tests/testdata/geojson-data.json.map.html.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 192 | -------------------------------------------------------------------------------- /tests/testdata/geojson-nested-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets": [ 3 | { 4 | "type": "geojson", 5 | "content": { 6 | "type": "FeatureCollection", 7 | "features": [ 8 | { 9 | "type": "Feature", 10 | "geometry": { 11 | "type": "Point", 12 | "coordinates": [-74.076, 4.598] 13 | }, 14 | "properties": {} 15 | }, 16 | { 17 | "type": "Feature", 18 | "geometry": { 19 | "type": "Point", 20 | "coordinates": [-75.1649, 39.9525] 21 | }, 22 | "properties": {} 23 | } 24 | ] 25 | } 26 | }, 27 | { 28 | "type": "geojson", 29 | "content": { 30 | "type": "Feature", 31 | "geometry": { 32 | "type": "Point", 33 | "coordinates": [7.6281, 51.962] 34 | }, 35 | "properties": {} 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/testdata/geojson-nested-data.json.golden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextmv-io/nextplot/c2356734796cce0c95d8d006fabeb7f1ade33502/tests/testdata/geojson-nested-data.json.golden -------------------------------------------------------------------------------- /tests/testdata/geojson-nested-data.json.map.html.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 226 | -------------------------------------------------------------------------------- /tests/testdata/paris-cluster.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "tours": [ 4 | { 5 | "id": "tour1", 6 | "route": [ 7 | { 8 | "id": "Gare du Nord", 9 | "location": [48.880769277577656, 2.3552878933106447], 10 | "quantity": 0 11 | }, 12 | { 13 | "id": "Louvre", 14 | "location": [48.86064983816991, 2.3373478124467524], 15 | "quantity": -1 16 | }, 17 | { 18 | "id": "Place de la Concorde", 19 | "location": [48.86548659130954, 2.3211691026573686], 20 | "quantity": -1 21 | }, 22 | { 23 | "id": "Arc de Triomphe", 24 | "location": [48.873730646108235, 2.2950561481174456], 25 | "quantity": 1 26 | }, 27 | { 28 | "id": "Tour Eiffel", 29 | "location": [48.85814487640506, 2.2945793548805833], 30 | "quantity": -1 31 | }, 32 | { 33 | "id": "Tour Montparnasse", 34 | "location": [48.842085594729355, 2.321845363923588], 35 | "quantity": -1 36 | }, 37 | { 38 | "id": "Gare du Nord", 39 | "location": [48.880769277577656, 2.3552878933106447], 40 | "quantity": 0 41 | } 42 | ] 43 | }, 44 | { 45 | "id": "tour2", 46 | "route": [ 47 | { 48 | "id": "Gare du Nord", 49 | "location": [48.880769277577656, 2.3552878933106447], 50 | "quantity": 0 51 | }, 52 | { 53 | "id": "Panthéon", 54 | "location": [48.84616060048901, 2.346233405549605], 55 | "quantity": 0 56 | }, 57 | { 58 | "id": "Notre-Dame", 59 | "location": [48.853070514317345, 2.349489020192572], 60 | "quantity": 0 61 | }, 62 | { 63 | "id": "Sacré-Cœur", 64 | "location": [48.88634368898782, 2.343046834223321], 65 | "quantity": 1 66 | }, 67 | { 68 | "id": "Gare du Nord", 69 | "location": [48.880769277577656, 2.3552878933106447], 70 | "quantity": 0 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/testdata/paris-cluster.json.golden: -------------------------------------------------------------------------------- 1 | Cluster stats 2 | Total points: 12.00 3 | Cluster count: 2.00 4 | Cluster size (max): 7.00 5 | Cluster size (min): 5.00 6 | Cluster size (avg): 6.00 7 | Cluster size (variance): 1.00 8 | Cluster diameter (max): 5.10 9 | Cluster diameter (min): 4.47 10 | Cluster diameter (avg): 4.79 11 | Sum of max distances from centroid: 5.31 12 | Max distance from centroid: 2.71 13 | Sum of distances from centroid: 23.33 14 | Sum of squares from centroid: 52.17 15 | Bad assignments: 2.00 16 | -------------------------------------------------------------------------------- /tests/testdata/paris-cluster.map.html.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 237 | -------------------------------------------------------------------------------- /tests/testdata/paris-cluster.plot.png.golden: -------------------------------------------------------------------------------- 1 | 85b53e4b7254c1da 2 | -------------------------------------------------------------------------------- /tests/testdata/paris-point.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "tours": [ 4 | { 5 | "id": "tour1", 6 | "route": [ 7 | { 8 | "id": "Gare du Nord", 9 | "location": [48.880769277577656, 2.3552878933106447], 10 | "quantity": 0 11 | }, 12 | { 13 | "id": "Louvre", 14 | "location": [48.86064983816991, 2.3373478124467524], 15 | "quantity": -1 16 | }, 17 | { 18 | "id": "Place de la Concorde", 19 | "location": [48.86548659130954, 2.3211691026573686], 20 | "quantity": -1 21 | }, 22 | { 23 | "id": "Arc de Triomphe", 24 | "location": [48.873730646108235, 2.2950561481174456], 25 | "quantity": 1 26 | }, 27 | { 28 | "id": "Tour Eiffel", 29 | "location": [48.85814487640506, 2.2945793548805833], 30 | "quantity": -1 31 | }, 32 | { 33 | "id": "Tour Montparnasse", 34 | "location": [48.842085594729355, 2.321845363923588], 35 | "quantity": -1 36 | }, 37 | { 38 | "id": "Gare du Nord", 39 | "location": [48.880769277577656, 2.3552878933106447], 40 | "quantity": 0 41 | } 42 | ] 43 | }, 44 | { 45 | "id": "tour2", 46 | "route": [ 47 | { 48 | "id": "Gare du Nord", 49 | "location": [48.880769277577656, 2.3552878933106447], 50 | "quantity": 0 51 | }, 52 | { 53 | "id": "Panthéon", 54 | "location": [48.84616060048901, 2.346233405549605], 55 | "quantity": 0 56 | }, 57 | { 58 | "id": "Notre-Dame", 59 | "location": [48.853070514317345, 2.349489020192572], 60 | "quantity": 0 61 | }, 62 | { 63 | "id": "Sacré-Cœur", 64 | "location": [48.88634368898782, 2.343046834223321], 65 | "quantity": 1 66 | }, 67 | { 68 | "id": "Gare du Nord", 69 | "location": [48.880769277577656, 2.3552878933106447], 70 | "quantity": 0 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/testdata/paris-point.json.golden: -------------------------------------------------------------------------------- 1 | Point stats 2 | Total points: 12.00 3 | Distance (min): 0.00 4 | Distance (max): 5.16 5 | Distance (avg): 2.52 6 | -------------------------------------------------------------------------------- /tests/testdata/paris-point.map.html.golden: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 27 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 434 | -------------------------------------------------------------------------------- /tests/testdata/paris-point.plot.png.golden: -------------------------------------------------------------------------------- 1 | ff228137dc05d172 2 | -------------------------------------------------------------------------------- /tests/testdata/paris-pos.json: -------------------------------------------------------------------------------- 1 | { 2 | "positions": [ 3 | [48.880769277577656, 2.3552878933106447], 4 | [48.86064983816991, 2.3373478124467524], 5 | [48.86548659130954, 2.3211691026573686], 6 | [48.873730646108235, 2.2950561481174456], 7 | [48.85814487640506, 2.2945793548805833], 8 | [48.842085594729355, 2.321845363923588], 9 | [48.880769277577656, 2.3552878933106447], 10 | [48.880769277577656, 2.3552878933106447], 11 | [48.84616060048901, 2.346233405549605], 12 | [48.853070514317345, 2.349489020192572], 13 | [48.88634368898782, 2.343046834223321], 14 | [48.880769277577656, 2.3552878933106447] 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tests/testdata/paris-route-indexed.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "tours": [ 4 | { 5 | "id": "tour1", 6 | "route": [0, 1, 2, 3, 4, 5, 6] 7 | }, 8 | { 9 | "id": "tour2", 10 | "route": [7, 8, 9, 10, 11] 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/testdata/paris-route-indexed.json.golden: -------------------------------------------------------------------------------- 1 | Route stats 2 | Route count: 2.00 3 | Route stops (max): 7.00 4 | Route stops (min): 5.00 5 | Route stops (avg): 6.00 6 | Route stops (total): 12.00 7 | Route length (max): 15.37 8 | Route length (min): 9.53 9 | Route length (avg): 12.45 10 | Route length (total): 24.90 11 | Route diameter (max): 5.10 12 | Route diameter (min): 4.47 13 | Route diameter (avg): 4.79 14 | Unassigned stops: 0.00 15 | -------------------------------------------------------------------------------- /tests/testdata/paris-route-indexed.plot.png.golden: -------------------------------------------------------------------------------- 1 | b5a44a535ec5617a 2 | -------------------------------------------------------------------------------- /tests/testdata/paris-route.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": { 3 | "tours": [ 4 | { 5 | "id": "tour1", 6 | "route": [ 7 | { 8 | "id": "Gare du Nord", 9 | "location": [48.880769277577656, 2.3552878933106447], 10 | "quantity": 0 11 | }, 12 | { 13 | "id": "Louvre", 14 | "location": [48.86064983816991, 2.3373478124467524], 15 | "quantity": -1 16 | }, 17 | { 18 | "id": "Place de la Concorde", 19 | "location": [48.86548659130954, 2.3211691026573686], 20 | "quantity": -1 21 | }, 22 | { 23 | "id": "Arc de Triomphe", 24 | "location": [48.873730646108235, 2.2950561481174456], 25 | "quantity": 1 26 | }, 27 | { 28 | "id": "Tour Eiffel", 29 | "location": [48.85814487640506, 2.2945793548805833], 30 | "quantity": -1 31 | }, 32 | { 33 | "id": "Tour Montparnasse", 34 | "location": [48.842085594729355, 2.321845363923588], 35 | "quantity": -1 36 | }, 37 | { 38 | "id": "Gare du Nord", 39 | "location": [48.880769277577656, 2.3552878933106447], 40 | "quantity": 0 41 | } 42 | ] 43 | }, 44 | { 45 | "id": "tour2", 46 | "route": [ 47 | { 48 | "id": "Gare du Nord", 49 | "location": [48.880769277577656, 2.3552878933106447], 50 | "quantity": 0 51 | }, 52 | { 53 | "id": "Panthéon", 54 | "location": [48.84616060048901, 2.346233405549605], 55 | "quantity": 0 56 | }, 57 | { 58 | "id": "Notre-Dame", 59 | "location": [48.853070514317345, 2.349489020192572], 60 | "quantity": 0 61 | }, 62 | { 63 | "id": "Sacré-Cœur", 64 | "location": [48.88634368898782, 2.343046834223321], 65 | "quantity": 1 66 | }, 67 | { 68 | "id": "Gare du Nord", 69 | "location": [48.880769277577656, 2.3552878933106447], 70 | "quantity": 0 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/testdata/paris-route.json.golden: -------------------------------------------------------------------------------- 1 | Route stats 2 | Route count: 2.00 3 | Route stops (max): 7.00 4 | Route stops (min): 5.00 5 | Route stops (avg): 6.00 6 | Route stops (total): 12.00 7 | Route length (max): 15.37 8 | Route length (min): 9.53 9 | Route length (avg): 12.45 10 | Route length (total): 24.90 11 | Route diameter (max): 5.10 12 | Route diameter (min): 4.47 13 | Route diameter (avg): 4.79 14 | Unassigned stops: 0.00 15 | -------------------------------------------------------------------------------- /tests/testdata/paris-route.plot.png.golden: -------------------------------------------------------------------------------- 1 | ad351722dacf6078 2 | --------------------------------------------------------------------------------