├── src ├── html │ ├── map.html │ ├── status-popup.html │ ├── small-screen-warning.html │ ├── main.html │ ├── menu.html │ └── help-popup.html ├── images │ ├── layers.png │ ├── layers-2x.png │ ├── add-point.svg │ ├── delete-point.svg │ ├── global-settings.svg │ ├── help.svg │ ├── redo.svg │ ├── undo.svg │ ├── edit-point.svg │ ├── move-point.svg │ ├── clear.svg │ ├── point-info.svg │ ├── rotate-device.svg │ ├── zoom-to-fit.svg │ └── multi-point-line.svg ├── css │ ├── main.css │ └── leaflet.css └── js │ ├── leaflet-polylinedecorator.js │ ├── nmea.js │ └── tests.js ├── requirements.txt ├── .github └── workflows │ ├── static-code-analysis.yml │ ├── functional-tests.yml │ └── deployment.yml ├── run-tests ├── .gitignore └── README.md /src/html/map.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dassencio/nmeagen/HEAD/src/images/layers.png -------------------------------------------------------------------------------- /src/images/layers-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dassencio/nmeagen/HEAD/src/images/layers-2x.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | csscompressor==0.9.5 2 | html5validator==0.3.3 3 | htmlmin==0.1.12 4 | jsmin==3.0.1 5 | -------------------------------------------------------------------------------- /src/html/status-popup.html: -------------------------------------------------------------------------------- 1 |
2 |

Status

3 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /src/html/small-screen-warning.html: -------------------------------------------------------------------------------- 1 |
2 | Please rotate your device to landscape mode.

3 | 4 |
5 | In the current orientation, your screen's width is too small to use the NMEA 6 | Generator. 7 |
8 | -------------------------------------------------------------------------------- /.github/workflows/static-code-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static code analysis 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | codeql: 12 | name: Scan code with CodeQL 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | with: 19 | fetch-depth: 2 20 | 21 | # For PRs, checkout the PR's head instead of the merge commit. 22 | - name: Checkout pull request head 23 | if: github.event_name == 'pull_request' 24 | run: git checkout HEAD^2 25 | 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v1 28 | with: 29 | languages: python 30 | queries: security-and-quality 31 | 32 | - name: Perform CodeQL analysis 33 | uses: github/codeql-action/analyze@v1 34 | -------------------------------------------------------------------------------- /.github/workflows/functional-tests.yml: -------------------------------------------------------------------------------- 1 | name: Functional tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build-and-test: 11 | name: Build and test 12 | runs-on: ${{matrix.os}} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | python-version: ["3.10", "3.11"] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{matrix.python-version}} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{matrix.python-version}} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements.txt 31 | npm install puppeteer 32 | 33 | - name: Build NMEA Generator 34 | run: ./build -o test.html 35 | 36 | - name: Run functional tests 37 | run: ./run-tests test.html 38 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy-to-github-pages: 10 | name: Deploy to GitHub Pages 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.8 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | 27 | - name: Build NMEA Generator 28 | run: | 29 | mkdir app 30 | ./build -o app/index.html 31 | 32 | - name: Deploy NMEA Generator to GitHub Pages 33 | env: 34 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 35 | uses: crazy-max/ghaction-github-pages@v2 36 | with: 37 | build_dir: app 38 | commit_message: Deploy build from ${{github.sha}} 39 | fqdn: nmeagen.org 40 | target_branch: gh-pages 41 | -------------------------------------------------------------------------------- /run-tests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path'); 4 | const puppeteer = require('puppeteer'); 5 | const { setTimeout } = require('node:timers/promises'); 6 | 7 | (async () => { 8 | const browser = await puppeteer.launch(); 9 | const page = await browser.newPage(); 10 | const fileName = process.argv[2] || 'index.html'; 11 | 12 | // Open NMEA Generator inside a headless browser. 13 | try { 14 | await page.goto(`file:${path.join(__dirname, fileName)}`); 15 | await setTimeout(1000); 16 | } catch (error) { 17 | console.error(`Could not open file: '${fileName}'.`); 18 | process.exit(1); 19 | } 20 | 21 | // Use console messages to detect test failure/success. 22 | page.on('console', (message) => { 23 | const testResult = message.text(); 24 | if (testResult.startsWith('[FAIL]')) { 25 | console.error(testResult); 26 | process.exit(1); 27 | } 28 | console.log(testResult); 29 | }); 30 | 31 | // Tests are executed by invoking runTests() inside the browser. 32 | await page.evaluate('runTests()'); 33 | await browser.close(); 34 | })(); 35 | -------------------------------------------------------------------------------- /src/html/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | NMEA Generator 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | 23 | __NMEAGEN__MENU_HTML__ 24 | __NMEAGEN__MAP_HTML__ 25 | __NMEAGEN__STATUS_POPUP_HTML__ 26 | __NMEAGEN__HELP_POPUP_HTML__ 27 | __NMEAGEN__SMALL_SCREEN_WARNING_HTML__ 28 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Visual Studio Code project settings 107 | .eslintrc.json 108 | .vscode 109 | jsconfig.json 110 | 111 | # Output HTML file 112 | index.html 113 | 114 | # Node.js packages 115 | node_modules/ 116 | package-lock.json 117 | -------------------------------------------------------------------------------- /src/images/add-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/images/delete-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 62 | 66 | 71 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Deployment](https://github.com/dassencio/nmeagen/workflows/Deployment/badge.svg) 2 | ![Functional tests](https://github.com/dassencio/nmeagen/workflows/Functional%20tests/badge.svg) 3 | ![Static code analysis](https://github.com/dassencio/nmeagen/workflows/Static%20code%20analysis/badge.svg) 4 | 5 | # Description 6 | 7 | NMEA Generator is a drawing tool for generating GPS logs in NMEA format. Being 8 | a web application consisting of a single HTML file, it can be opened in a web 9 | browser and used directly without the need for any installation or configuration 10 | work. 11 | 12 | This project contains the script (written in Python 3) used to build the 13 | NMEA Generator. An example of a publicly available instance of this tool can be 14 | found on [nmeagen.org](https://nmeagen.org). 15 | 16 | # License 17 | 18 | All files from this project are licensed under the GPLv3 except for those 19 | belonging to the following projects: 20 | 21 | - [jQuery](https://jquery.com/): licensed under the 22 | [MIT license](https://github.com/jquery/jquery/blob/master/LICENSE.txt). 23 | - [Leaflet](https://leafletjs.com/): licensed under the 24 | [2-clause BSD license](https://github.com/Leaflet/Leaflet/blob/master/LICENSE). 25 | - [Leaflet.PolylineDecorator](https://github.com/bbecquet/Leaflet.PolylineDecorator): 26 | licensed under the [MIT license](https://github.com/bbecquet/Leaflet.PolylineDecorator/blob/master/LICENSE). 27 | - [Leaflet Search](https://github.com/stefanocudini/leaflet-search): licensed 28 | under the [MIT license](https://github.com/stefanocudini/leaflet-search/blob/master/license.txt). 29 | - [nmea-0183](https://github.com/nherment/node-nmea): licensed under the 30 | [MIT license](https://github.com/nherment/node-nmea/blob/master/LICENSE). 31 | 32 | See the [`LICENSE`](https://github.com/dassencio/nmeagen/tree/master/LICENSE) 33 | file for more information. 34 | 35 | # Required modules 36 | 37 | All Python modules needed to build the NMEA Generator are listed in the 38 | [`requirements.txt`](https://github.com/dassencio/nmeagen/tree/master/requirements.txt) 39 | file. You can install them with the following command: 40 | 41 | pip3 install -r requirements.txt 42 | 43 | # Usage instructions 44 | 45 | To build the NMEA Generator, simply run the following command: 46 | 47 | ./build 48 | 49 | This will compile the NMEA Generator application as an HTML file named 50 | `index.html` on the current working directory. If you wish to specify another 51 | name or location for the output HTML file, use the `-o` option: 52 | 53 | ./build -o /path/to/nmeagen.html 54 | 55 | # Running the functional tests 56 | 57 | The NMEA Generator contains a set of functional tests which can be executed in 58 | a web browser or in a terminal. 59 | 60 | To execute the tests in a web browser, load the NMEA Generator and then 61 | invoke the `runTests()` function in the browser's console. 62 | 63 | To execute the tests in a terminal, run: 64 | 65 | npm install puppeteer 66 | ./run-tests /path/to/nmeagen.html 67 | 68 | # Contributors & contact information 69 | 70 | Diego Assencio / diego@assencio.com 71 | -------------------------------------------------------------------------------- /src/images/global-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 29 | 49 | 54 | 55 | -------------------------------------------------------------------------------- /src/images/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 67 | 72 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/images/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 35 | 36 | 44 | 50 | 51 | 52 | 74 | 76 | 77 | 79 | image/svg+xml 80 | 82 | 83 | 84 | 85 | 86 | 91 | 103 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/images/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 35 | 36 | 44 | 50 | 51 | 52 | 74 | 76 | 77 | 79 | image/svg+xml 80 | 82 | 83 | 84 | 85 | 86 | 91 | 104 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/images/edit-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 34 | 35 | 43 | 49 | 50 | 51 | 69 | 71 | 72 | 74 | image/svg+xml 75 | 77 | 78 | 79 | 80 | 81 | 86 | 92 | 98 | 104 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/images/move-point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 34 | 35 | 43 | 49 | 50 | 51 | 69 | 71 | 72 | 74 | image/svg+xml 75 | 77 | 78 | 79 | 80 | 81 | 86 | 92 | 98 | 104 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/images/clear.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /src/images/point-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 34 | 35 | 43 | 49 | 50 | 51 | 69 | 71 | 72 | 74 | image/svg+xml 75 | 77 | 78 | 79 | 80 | 81 | 86 | 92 | 97 | 105 | 110 | 115 | 120 | 125 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /src/html/menu.html: -------------------------------------------------------------------------------- 1 | 196 | -------------------------------------------------------------------------------- /src/html/help-popup.html: -------------------------------------------------------------------------------- 1 |
2 |

Help topics

3 | 4 |

What is this tool?

5 |

6 | NMEA Generator is an 7 | open-source drawing tool 8 | for generating GPS logs in 9 | NMEA format. Its 10 | primary purpose is to make it easy to draw a path representing a person 11 | walking or driving around while carrying a GPS device which measures their 12 | current position at regular time intervals (e.g. once per second). After the 13 | path is drawn, its representation as a sequence of NMEA sentences can be 14 | generated and downloaded as a text file. 15 |

16 | 17 |

Adding single points

18 |
19 |

20 | The "Add point" tool is selected by default when the NMEA Generator is 21 | started. When you click on the map using this tool, a point is added to 22 | your path at the location you clicked on. 23 |

24 |

25 | A point represents a position measured using a GPS device being carried 26 | around. The time elapsed between two consecutive points is always the same 27 | and equal to one second by default, but this can be changed under "Global 28 | settings". 29 |

30 |
31 | 32 |

Deleting a point

33 |

34 | To delete a point, select the "Delete point" tool and click on the point you 35 | wish to delete. 36 |

37 | 38 |

39 | Deleting all points (clearing the map) 40 |

41 |

42 | To delete all points, simply reload the application. The map view will be 43 | preserved, so you will not lose the location you are currently working on. 44 | All other settings (e.g. the GPS frequency) will however not be preserved. 45 |

46 | 47 |

Moving a point manually

48 |

49 | The "Move point" tool can be used to manually move points around. To move a 50 | point, click on it first to select it: the point will then start following 51 | the mouse cursor. Clicking once again will release the point at its current 52 | position. 53 |

54 | 55 |

56 | 57 | Setting latitude and longitude values for a point 58 | 59 |

60 |

61 | The "Edit point" tool allows you to directly set the latitude and longitude 62 | values for a point. First, select a point, then you will be able to change 63 | its latitude and longitude values on the menu (left side). Pressing the 64 | "Enter" key will move the point to the new specified location. To release 65 | the point, click anywhere on the map. 66 |

67 | 68 |

69 | 70 | Adding multiple points on a straight line 71 | 72 |

73 |
74 |

75 | With the "Multi-point line" tool, you can draw multiple points along a 76 | line with the same distance from each other. When you click on the map, 77 | all preview points will be added to your path. 78 |

79 |

80 | Since the time elapsed between two consecutive points is always the same, 81 | the "Multi-point line" tool represents motion along a straight line with 82 | constant velocity. The distance between consecutive points can be changed 83 | on the menu (left side), and the associated speed is shown right below it. 84 |

85 |
86 | 87 |

Changing the GPS frequency

88 |
89 |

90 | By default, the time elapsed between two consecutive points on the map is 91 | one second. To change this, click on "Global settings" and modify the GPS 92 | frequency value. This value defines how many positions are obtained per 93 | second, so setting it to 10Hz means the time elapsed between two 94 | consecutive points will be 100 milliseconds (0.1 seconds). 95 |

96 |

97 | The GPS frequency is a global setting, so changing its value will affect 98 | the associated speeds at all points. As an example, increasing the GPS 99 | frequency by a factor of 10 means all speeds will be increased by a factor 100 | of 10 as well. 101 |

102 |
103 | 104 |

Generating and loading NMEA files

105 |
106 |

107 | After drawing the desired path, you can generate a file containing a 108 | sequence of NMEA (GGA, GSA and 112 | RMC) 113 | sentences representing the path drawn. To do that, click on the "Generate 114 | NMEA file" button. 115 |

116 |

117 | You can also load an NMEA file generated with this or another application 118 | by clicking on the "Load NMEA file" button. The path represented by this 119 | file will then be drawn automatically on the map. The positions of the 120 | points are extracted from the GGA sentences. 121 |

122 |
123 | 124 |

125 | 126 | Downloading and loading coordinate files 127 | 128 |

129 |
130 |

131 | After drawing the desired path, you can generate a file containing its 132 | coordinates. The file will be in CSV format, with each line containing the 133 | coordinates of a point in the path (one latitude value and one longitude 134 | value separated by a comma). To do that, click on the "Download 135 | coordinates (CSV)" button. 136 |

137 |

138 | You can also load a CSV file containing coordinates represented in this 139 | same format by clicking the "Load coordinates (CSV)" button. The path 140 | represented by this file will then be drawn automatically on the map. 141 |

142 |
143 | 144 |

Reporting bugs

145 |

146 | To report a bug, please send an e-mail to 147 | diego@assencio.com. 148 |

149 | 150 |

Who created this marvelous tool?

151 |

152 | The NMEA Generator was created by 153 | Diego Assencio. 154 |

155 | 156 | 157 |
158 | -------------------------------------------------------------------------------- /src/images/rotate-device.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 33 | 41 | 47 | 48 | 49 | 71 | 73 | 74 | 76 | image/svg+xml 77 | 79 | 80 | 81 | 82 | 83 | 88 | 94 | 107 | 115 | 123 | 132 | 140 | 149 | 158 | 167 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /src/images/zoom-to-fit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 35 | 36 | 44 | 50 | 51 | 59 | 65 | 66 | 74 | 80 | 81 | 89 | 95 | 96 | 97 | 120 | 122 | 123 | 125 | image/svg+xml 126 | 128 | 129 | 130 | 131 | 132 | 137 | 143 | 149 | 155 | 161 | 167 | 173 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /src/images/multi-point-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 29 | 34 | 35 | 43 | 48 | 49 | 57 | 63 | 64 | 72 | 78 | 79 | 87 | 93 | 94 | 95 | 113 | 115 | 116 | 118 | image/svg+xml 119 | 121 | 122 | 123 | 124 | 125 | 130 | 136 | 142 | 148 | 154 | 160 | 166 | 172 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * 3 | * GLOBAL VARIABLES 4 | * 5 | ******************************************************************************/ 6 | 7 | :root { 8 | --component-active-border-color: rgb(80, 80, 80); 9 | --component-default-border-color: rgb(170, 170, 170); 10 | --component-default-text-color: rgb(45, 45, 45); 11 | --component-disabled-border-color: rgb(200, 200, 200); 12 | --component-disabled-text-color: rgb(125, 125, 125); 13 | --component-focus-hover-border-color: rgb(140, 140, 140); 14 | --gray-component-active-background-color: rgb(220, 220, 220); 15 | --gray-component-default-background-color: rgb(245, 245, 245); 16 | --gray-component-disabled-background-color: rgb(250, 250, 250); 17 | --gray-component-focus-hover-background-color: rgb(235, 235, 235); 18 | --search-box-height: 42px; 19 | --search-box-max-width: calc(100vw - 355px); 20 | --search-box-width: 400px; 21 | --section-border-color: rgb(190, 190, 190); 22 | --white-component-active-background-color: rgb(235, 235, 235); 23 | --white-component-disabled-background-color: rgb(240, 240, 240); 24 | --white-component-focus-hover-background-color: rgb(245, 245, 245); 25 | --white-component-selected-background-color: palegreen; 26 | } 27 | 28 | /******************************************************************************* 29 | * 30 | * GLOBAL STYLES 31 | * 32 | ******************************************************************************/ 33 | 34 | * { 35 | box-sizing: border-box; 36 | color: var(--component-default-text-color); 37 | font-family: Arial, sans-serif; 38 | font-size: 14px; 39 | font-weight: normal; 40 | margin: 0; 41 | outline: none; 42 | padding: 0; 43 | text-align: left; 44 | } 45 | html, 46 | body { 47 | background-color: black; 48 | height: 100%; 49 | width: 100%; 50 | } 51 | a { 52 | color: rgb(0, 70, 241); 53 | text-decoration: none; 54 | } 55 | a:focus, 56 | a:hover { 57 | color: dodgerblue; 58 | text-decoration: underline; 59 | } 60 | button { 61 | align-items: center; 62 | background-color: var(--gray-component-default-background-color); 63 | border: 1px solid var(--component-default-border-color); 64 | cursor: pointer; 65 | display: flex; 66 | flex-direction: row; 67 | justify-content: center; 68 | user-select: none; 69 | } 70 | button[disabled] { 71 | background-color: var(--gray-component-disabled-background-color); 72 | border: 1px solid var(--component-disabled-border-color); 73 | cursor: default; 74 | } 75 | button:not([disabled]):focus, 76 | button:not([disabled]):hover { 77 | background-color: var(--gray-component-focus-hover-background-color); 78 | border: 1px solid var(--component-focus-hover-border-color); 79 | } 80 | button:not([disabled]):active { 81 | background-color: var(--gray-component-active-background-color); 82 | border: 1px solid var(--component-active-border-color); 83 | } 84 | button::-moz-focus-inner { 85 | border: 0; 86 | } 87 | button[disabled] * { 88 | opacity: 0.3; 89 | } 90 | input[type="text"] { 91 | background-color: white; 92 | border-radius: 0; 93 | border: 1px solid var(--component-default-border-color); 94 | } 95 | input[type="text"][disabled] { 96 | background-color: var(--white-component-disabled-background-color); 97 | border: 1px solid var(--component-disabled-border-color); 98 | color: var(--component-disabled-text-color); 99 | } 100 | input[type="text"]:not([disabled]):focus, 101 | input[type="text"]:not([disabled]):hover { 102 | border: 1px solid var(--component-focus-hover-border-color); 103 | } 104 | input[type="text"].invalid-value { 105 | background-color: pink; 106 | border: 1px solid crimson; 107 | } 108 | p { 109 | margin-top: 15px; 110 | text-align: justify; 111 | } 112 | table { 113 | border-collapse: collapse; 114 | width: 100%; 115 | } 116 | td { 117 | vertical-align: middle; 118 | } 119 | [unclickable] { 120 | cursor: default; 121 | pointer-events: none; 122 | user-select: none; 123 | } 124 | 125 | /******************************************************************************* 126 | * 127 | * MENU 128 | * 129 | ******************************************************************************/ 130 | 131 | #menu { 132 | background-color: white; 133 | border-right: 1px solid var(--component-default-border-color); 134 | float: left; 135 | height: 100%; 136 | overflow-y: auto; 137 | padding: 0 10px; 138 | width: 291px; 139 | } 140 | #menu > div { 141 | padding: 18px 10px; 142 | } 143 | #menu > div:not(:last-child) { 144 | border-bottom: 1px solid var(--section-border-color); 145 | } 146 | @media only screen and (max-width: 800px) { 147 | #menu { 148 | padding: 0 5px; 149 | width: 257px; 150 | } 151 | #menu > div { 152 | padding: 10px 5px; 153 | } 154 | } 155 | #menu input[type="text"] { 156 | padding: 4px; 157 | width: 100%; 158 | } 159 | #menu th { 160 | padding-right: 10px; 161 | white-space: nowrap; 162 | } 163 | #menu tr:not(:first-child) > td, 164 | #menu tr:not(:first-child) > th { 165 | padding-top: 12px; 166 | } 167 | #current-position td { 168 | text-align: right; 169 | } 170 | #tools button { 171 | background-color: white; 172 | border-radius: 3px; 173 | border: none; 174 | justify-content: flex-start; 175 | width: 100%; 176 | } 177 | #tools button:focus, 178 | #tools button:hover { 179 | background-color: var(--white-component-focus-hover-background-color); 180 | } 181 | #tools button:active { 182 | background: var(--white-component-active-background-color); 183 | } 184 | #tools button.selected { 185 | background: var(--white-component-selected-background-color); 186 | } 187 | #tools button img { 188 | height: 36px; 189 | margin-left: 6px; 190 | width: 36px; 191 | } 192 | #tools button span { 193 | line-height: 47px; 194 | padding-left: 15px; 195 | } 196 | #edit-point-box, 197 | #multi-point-line-box, 198 | #global-settings-box { 199 | display: none; 200 | } 201 | #extra-tools { 202 | display: flex; 203 | flex-direction: row; 204 | justify-content: space-between; 205 | } 206 | #extra-tools button { 207 | height: 36px; 208 | width: 56px; 209 | } 210 | #extra-tools button img { 211 | height: 24px; 212 | width: 24px; 213 | } 214 | #file-tools button { 215 | height: 35px; 216 | text-align: center; 217 | width: 100%; 218 | } 219 | #file-tools button:not(:first-child) { 220 | margin-top: 10px; 221 | } 222 | #file-tools input[type="file"] { 223 | display: none; 224 | } 225 | 226 | /******************************************************************************* 227 | * 228 | * HELP AND STATUS POPUPS 229 | * 230 | ******************************************************************************/ 231 | 232 | #help-popup, 233 | #status-popup { 234 | background-color: white; 235 | border-radius: 3px; 236 | display: none; 237 | left: 50%; 238 | max-height: calc(100vh - 20px); 239 | max-width: 640px; 240 | overflow-y: auto; 241 | padding: 25px 25px 0 25px; 242 | position: fixed; 243 | top: 50%; 244 | transform: translate(-50%, -50%); 245 | width: calc(100vw - 120px); 246 | } 247 | /* Firefox ignores bottom padding (https://stackoverflow.com/q/29986977). */ 248 | #help-popup > :last-child, 249 | #status-popup > :last-child { 250 | padding-bottom: 25px; 251 | } 252 | .popup-footnote { 253 | font-size: 12px; 254 | margin-top: 20px; 255 | opacity: 0.6; 256 | text-align: center; 257 | } 258 | .popup-title { 259 | border-bottom: 1px solid var(--section-border-color); 260 | font-size: 20px; 261 | padding-bottom: 5px; 262 | } 263 | 264 | /******************************************************************************* 265 | * 266 | * MAP 267 | * 268 | ******************************************************************************/ 269 | 270 | #map { 271 | background-color: gainsboro; 272 | height: 100%; 273 | } 274 | .leaflet-popup-content-wrapper, 275 | .leaflet-popup-tip { 276 | border: 1px solid var(--component-default-border-color); 277 | box-shadow: none; 278 | } 279 | .leaflet-popup-content { 280 | margin: 15px; 281 | } 282 | .leaflet-popup-close-button { 283 | display: none; 284 | } 285 | #close-point-info-popup { 286 | color: var(--component-default-border-color); 287 | cursor: pointer; 288 | font-family: monospace; 289 | font-size: 20px; 290 | line-height: 20px; 291 | position: absolute; 292 | right: 5px; 293 | top: 2px; 294 | user-select: none; 295 | } 296 | #close-point-info-popup:hover, 297 | #close-point-info-popup:focus { 298 | color: var(--component-focus-hover-border-color); 299 | } 300 | #close-point-info-popup:active { 301 | color: var(--component-active-border-color); 302 | } 303 | table.point-info-popup td, 304 | table.point-info-popup th { 305 | font-size: 12px; 306 | } 307 | table.point-info-popup td { 308 | padding-left: 15px; 309 | } 310 | table.point-info-popup tr:not(:first-child) > td, 311 | table.point-info-popup tr:not(:first-child) > th { 312 | padding-top: 10px; 313 | } 314 | .leaflet-popup-tip-container { 315 | /* Make popup tip have a correct border. */ 316 | bottom: -19px !important; 317 | } 318 | .leaflet-control-attribution a { 319 | font-size: 11px; 320 | } 321 | 322 | /******************************************************************************* 323 | * 324 | * SEARCH BOX 325 | * 326 | ******************************************************************************/ 327 | 328 | .leaflet-control-search { 329 | margin: 15px 0 0 15px !important; 330 | max-width: var(--search-box-max-width); 331 | width: var(--search-box-width); 332 | } 333 | @media only screen and (max-width: 800px) { 334 | .leaflet-control-search { 335 | margin: 10px 0 0 10px !important; 336 | } 337 | } 338 | .leaflet-control-search .search-input { 339 | border-radius: 0; 340 | border: 1px solid var(--component-default-border-color); 341 | height: var(--search-box-height); 342 | margin: 0; 343 | max-width: var(--search-box-max-width) !important; 344 | padding: 10px calc(var(--search-box-height) + 2px) 10px 10px; 345 | width: var(--search-box-width) !important; 346 | } 347 | .leaflet-control-search .search-cancel { 348 | background: url(__NMEAGEN__CLEAR_SVG__) no-repeat; 349 | height: calc(var(--search-box-height) - 2px); 350 | margin: 0; 351 | position: absolute; 352 | right: 1px; 353 | top: 1px; 354 | visibility: visible; 355 | width: calc(var(--search-box-height) - 2px); 356 | } 357 | .leaflet-control-search .search-button, 358 | .leaflet-control-search .search-cancel > span { 359 | display: none; 360 | } 361 | .leaflet-control-search .search-cancel:focus, 362 | .leaflet-control-search .search-cancel:hover { 363 | background-color: var(--white-component-focus-hover-background-color); 364 | } 365 | .leaflet-control-search .search-tooltip { 366 | max-height: calc(100vh - 90px); 367 | width: 100%; 368 | } 369 | .leaflet-control-search .search-tip { 370 | background-color: white; 371 | border-radius: 0; 372 | border: 1px solid var(--section-border-color); 373 | border-top-width: 0; 374 | color: var(--component-default-text-color); 375 | line-height: calc(var(--search-box-height) - 4px); 376 | margin: 0; 377 | overflow: hidden; 378 | padding: 0 10px; 379 | text-overflow: ellipsis; 380 | white-space: nowrap; 381 | } 382 | .leaflet-control-search .search-tip:focus, 383 | .leaflet-control-search .search-tip:hover, 384 | .leaflet-control-search .search-tip-select { 385 | background-color: var(--white-component-focus-hover-background-color); 386 | } 387 | .leaflet-control-search .search-tip:hover { 388 | cursor: pointer; 389 | } 390 | .leaflet-control-search .search-tip:active { 391 | background-color: var(--white-component-active-background-color); 392 | } 393 | 394 | /******************************************************************************* 395 | * 396 | * ZOOM AND LAYER CONTROLS 397 | * 398 | ******************************************************************************/ 399 | 400 | .leaflet-control-zoom, 401 | .leaflet-control-layers { 402 | border-radius: 0 !important; 403 | border: none !important; 404 | margin: 15px 15px 0 0 !important; 405 | } 406 | @media only screen and (max-width: 800px) { 407 | .leaflet-control-zoom, 408 | .leaflet-control-layers { 409 | margin: 10px 10px 0 0 !important; 410 | } 411 | } 412 | .leaflet-control-zoom-in, 413 | .leaflet-control-zoom-out { 414 | border-radius: 0 !important; 415 | border: 1px solid var(--component-default-border-color) !important; 416 | box-sizing: content-box; 417 | color: var(--component-default-text-color) !important; 418 | font-weight: normal; 419 | text-decoration: none !important; 420 | } 421 | /* By default, the "+" icon has no bottom border (only the "-" icon has it). */ 422 | .leaflet-control-zoom-in { 423 | border-bottom: none !important; 424 | } 425 | /* When the "+" icon is focused/hovered over, its bottom border is drawn. */ 426 | .leaflet-control-zoom-in:not(.leaflet-disabled):focus, 427 | .leaflet-control-zoom-in:not(.leaflet-disabled):hover, 428 | .leaflet-control-zoom-out:not(.leaflet-disabled):focus, 429 | .leaflet-control-zoom-out:not(.leaflet-disabled):hover { 430 | background-color: var( 431 | --white-component-focus-hover-background-color 432 | ) !important; 433 | border: 1px solid var(--component-focus-hover-border-color) !important; 434 | } 435 | /* 436 | * The :focus pseudoselector is necessary here to prevent the "+" icon from 437 | * temporarily losing its bottom border during the transition to the maximum 438 | * possible zoom level. 439 | */ 440 | .leaflet-control-zoom-in.leaflet-disabled:focus, 441 | .leaflet-control-zoom-in.leaflet-disabled:hover, 442 | .leaflet-control-zoom-out.leaflet-disabled:focus, 443 | .leaflet-control-zoom-out.leaflet-disabled:hover { 444 | border: 1px solid var(--component-default-border-color) !important; 445 | } 446 | /* When the "+" icon is active, its bottom border is drawn. */ 447 | .leaflet-control-zoom-in:not(.leaflet-disabled):active, 448 | .leaflet-control-zoom-out:not(.leaflet-disabled):active { 449 | background-color: var(--white-component-active-background-color) !important; 450 | border: 1px solid var(--component-active-border-color) !important; 451 | } 452 | /* Whenever the "+" icon has a bottom border, the "-" icon has no top border. */ 453 | .leaflet-control-zoom-in:active + .leaflet-control-zoom-out, 454 | .leaflet-control-zoom-in:focus + .leaflet-control-zoom-out, 455 | .leaflet-control-zoom-in:hover + .leaflet-control-zoom-out { 456 | border-top: none !important; 457 | } 458 | .leaflet-disabled { 459 | background-color: var(--white-component-disabled-background-color) !important; 460 | color: var(--component-disabled-text-color) !important; 461 | user-select: none; 462 | } 463 | .leaflet-control-layers-toggle, 464 | .leaflet-control-layers-expanded { 465 | border-radius: 0 !important; 466 | border: 1px solid var(--component-default-border-color) !important; 467 | box-sizing: content-box; 468 | padding: 0; 469 | } 470 | .leaflet-control-layers-toggle:focus, 471 | .leaflet-control-layers-toggle:hover, 472 | .leaflet-control-layers-expanded:focus, 473 | .leaflet-control-layers-expanded:hover { 474 | border: 1px solid var(--component-focus-hover-border-color) !important; 475 | } 476 | .leaflet-control-layers-toggle:active, 477 | .leaflet-control-layers-expanded:active { 478 | border: 1px solid var(--component-active-border-color) !important; 479 | } 480 | .leaflet-control-layers-toggle { 481 | background-image: url(__NMEAGEN__LAYERS_PNG__); 482 | } 483 | .leaflet-retina .leaflet-control-layers-toggle { 484 | background-image: url(__NMEAGEN__LAYERS_2X_PNG__); 485 | } 486 | .leaflet-control-layers-list label { 487 | display: block; 488 | } 489 | .leaflet-control-layers-list label:focus, 490 | .leaflet-control-layers-list label:hover { 491 | background-color: var(--white-component-focus-hover-background-color); 492 | } 493 | .leaflet-control-layers-list label:active { 494 | background-color: var(--white-component-active-background-color); 495 | } 496 | .leaflet-control-layers-list input[type="radio"] { 497 | display: none; 498 | } 499 | .leaflet-control-layers-list input[type="radio"]:checked + span { 500 | background-color: var(--white-component-selected-background-color); 501 | } 502 | .leaflet-control-layers-list span { 503 | cursor: pointer; 504 | display: block; 505 | line-height: 24px; 506 | padding: 4px 8px; 507 | user-select: none; 508 | width: 100%; 509 | } 510 | 511 | /******************************************************************************* 512 | * 513 | * SMALL SCREEN WARNING 514 | * 515 | ******************************************************************************/ 516 | 517 | #small-screen-warning { 518 | display: none; 519 | } 520 | @media only screen and (max-width: 500px) { 521 | #small-screen-warning { 522 | align-items: center; 523 | background-color: white; 524 | display: flex; 525 | flex-direction: column; 526 | font-size: 20px; 527 | height: 100vh; 528 | justify-content: center; 529 | left: 0; 530 | padding: 0 20px; 531 | position: fixed; 532 | text-align: center; 533 | top: 0; 534 | width: 100vw; 535 | z-index: 9999; 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /src/css/leaflet.css: -------------------------------------------------------------------------------- 1 | /* required styles */ 2 | 3 | .leaflet-pane, 4 | .leaflet-tile, 5 | .leaflet-marker-icon, 6 | .leaflet-marker-shadow, 7 | .leaflet-tile-container, 8 | .leaflet-pane > svg, 9 | .leaflet-pane > canvas, 10 | .leaflet-zoom-box, 11 | .leaflet-image-layer, 12 | .leaflet-layer { 13 | position: absolute; 14 | left: 0; 15 | top: 0; 16 | } 17 | .leaflet-container { 18 | overflow: hidden; 19 | } 20 | .leaflet-tile, 21 | .leaflet-marker-icon, 22 | .leaflet-marker-shadow { 23 | -webkit-user-select: none; 24 | -moz-user-select: none; 25 | user-select: none; 26 | -webkit-user-drag: none; 27 | } 28 | /* Prevents IE11 from highlighting tiles in blue */ 29 | .leaflet-tile::selection { 30 | background: transparent; 31 | } 32 | /* Safari renders non-retina tile on retina better with this, but Chrome is worse */ 33 | .leaflet-safari .leaflet-tile { 34 | image-rendering: -webkit-optimize-contrast; 35 | } 36 | /* hack that prevents hw layers "stretching" when loading new tiles */ 37 | .leaflet-safari .leaflet-tile-container { 38 | width: 1600px; 39 | height: 1600px; 40 | -webkit-transform-origin: 0 0; 41 | } 42 | .leaflet-marker-icon, 43 | .leaflet-marker-shadow { 44 | display: block; 45 | } 46 | /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ 47 | /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ 48 | .leaflet-container .leaflet-overlay-pane svg, 49 | .leaflet-container .leaflet-marker-pane img, 50 | .leaflet-container .leaflet-shadow-pane img, 51 | .leaflet-container .leaflet-tile-pane img, 52 | .leaflet-container img.leaflet-image-layer, 53 | .leaflet-container .leaflet-tile { 54 | max-width: none !important; 55 | max-height: none !important; 56 | } 57 | 58 | .leaflet-container.leaflet-touch-zoom { 59 | -ms-touch-action: pan-x pan-y; 60 | touch-action: pan-x pan-y; 61 | } 62 | .leaflet-container.leaflet-touch-drag { 63 | -ms-touch-action: pinch-zoom; 64 | /* Fallback for FF which doesn't support pinch-zoom */ 65 | touch-action: none; 66 | touch-action: pinch-zoom; 67 | } 68 | .leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { 69 | -ms-touch-action: none; 70 | touch-action: none; 71 | } 72 | .leaflet-container { 73 | -webkit-tap-highlight-color: transparent; 74 | } 75 | .leaflet-container a { 76 | -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); 77 | } 78 | .leaflet-tile { 79 | filter: inherit; 80 | visibility: hidden; 81 | } 82 | .leaflet-tile-loaded { 83 | visibility: inherit; 84 | } 85 | .leaflet-zoom-box { 86 | width: 0; 87 | height: 0; 88 | -moz-box-sizing: border-box; 89 | box-sizing: border-box; 90 | z-index: 800; 91 | } 92 | /* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ 93 | .leaflet-overlay-pane svg { 94 | -moz-user-select: none; 95 | } 96 | 97 | .leaflet-pane { z-index: 400; } 98 | 99 | .leaflet-tile-pane { z-index: 200; } 100 | .leaflet-overlay-pane { z-index: 400; } 101 | .leaflet-shadow-pane { z-index: 500; } 102 | .leaflet-marker-pane { z-index: 600; } 103 | .leaflet-tooltip-pane { z-index: 650; } 104 | .leaflet-popup-pane { z-index: 700; } 105 | 106 | .leaflet-map-pane canvas { z-index: 100; } 107 | .leaflet-map-pane svg { z-index: 200; } 108 | 109 | .leaflet-vml-shape { 110 | width: 1px; 111 | height: 1px; 112 | } 113 | .lvml { 114 | behavior: url(#default#VML); 115 | display: inline-block; 116 | position: absolute; 117 | } 118 | 119 | 120 | /* control positioning */ 121 | 122 | .leaflet-control { 123 | position: relative; 124 | z-index: 800; 125 | pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ 126 | pointer-events: auto; 127 | } 128 | .leaflet-top, 129 | .leaflet-bottom { 130 | position: absolute; 131 | z-index: 1000; 132 | pointer-events: none; 133 | } 134 | .leaflet-top { 135 | top: 0; 136 | } 137 | .leaflet-right { 138 | right: 0; 139 | } 140 | .leaflet-bottom { 141 | bottom: 0; 142 | } 143 | .leaflet-left { 144 | left: 0; 145 | } 146 | .leaflet-control { 147 | float: left; 148 | clear: both; 149 | } 150 | .leaflet-right .leaflet-control { 151 | float: right; 152 | } 153 | .leaflet-top .leaflet-control { 154 | margin-top: 10px; 155 | } 156 | .leaflet-bottom .leaflet-control { 157 | margin-bottom: 10px; 158 | } 159 | .leaflet-left .leaflet-control { 160 | margin-left: 10px; 161 | } 162 | .leaflet-right .leaflet-control { 163 | margin-right: 10px; 164 | } 165 | 166 | 167 | /* zoom and fade animations */ 168 | 169 | .leaflet-fade-anim .leaflet-tile { 170 | will-change: opacity; 171 | } 172 | .leaflet-fade-anim .leaflet-popup { 173 | opacity: 0; 174 | -webkit-transition: opacity 0.2s linear; 175 | -moz-transition: opacity 0.2s linear; 176 | transition: opacity 0.2s linear; 177 | } 178 | .leaflet-fade-anim .leaflet-map-pane .leaflet-popup { 179 | opacity: 1; 180 | } 181 | .leaflet-zoom-animated { 182 | -webkit-transform-origin: 0 0; 183 | -ms-transform-origin: 0 0; 184 | transform-origin: 0 0; 185 | } 186 | .leaflet-zoom-anim .leaflet-zoom-animated { 187 | will-change: transform; 188 | } 189 | .leaflet-zoom-anim .leaflet-zoom-animated { 190 | -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); 191 | -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); 192 | transition: transform 0.25s cubic-bezier(0,0,0.25,1); 193 | } 194 | .leaflet-zoom-anim .leaflet-tile, 195 | .leaflet-pan-anim .leaflet-tile { 196 | -webkit-transition: none; 197 | -moz-transition: none; 198 | transition: none; 199 | } 200 | 201 | .leaflet-zoom-anim .leaflet-zoom-hide { 202 | visibility: hidden; 203 | } 204 | 205 | 206 | /* cursors */ 207 | 208 | .leaflet-interactive { 209 | cursor: pointer; 210 | } 211 | .leaflet-grab { 212 | cursor: -webkit-grab; 213 | cursor: -moz-grab; 214 | cursor: grab; 215 | } 216 | .leaflet-crosshair, 217 | .leaflet-crosshair .leaflet-interactive { 218 | cursor: crosshair; 219 | } 220 | .leaflet-popup-pane, 221 | .leaflet-control { 222 | cursor: auto; 223 | } 224 | .leaflet-dragging .leaflet-grab, 225 | .leaflet-dragging .leaflet-grab .leaflet-interactive, 226 | .leaflet-dragging .leaflet-marker-draggable { 227 | cursor: move; 228 | cursor: -webkit-grabbing; 229 | cursor: -moz-grabbing; 230 | cursor: grabbing; 231 | } 232 | 233 | /* marker & overlays interactivity */ 234 | .leaflet-marker-icon, 235 | .leaflet-marker-shadow, 236 | .leaflet-image-layer, 237 | .leaflet-pane > svg path, 238 | .leaflet-tile-container { 239 | pointer-events: none; 240 | } 241 | 242 | .leaflet-marker-icon.leaflet-interactive, 243 | .leaflet-image-layer.leaflet-interactive, 244 | .leaflet-pane > svg path.leaflet-interactive, 245 | svg.leaflet-image-layer.leaflet-interactive path { 246 | pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ 247 | pointer-events: auto; 248 | } 249 | 250 | /* visual tweaks */ 251 | 252 | .leaflet-container { 253 | background: #ddd; 254 | outline: 0; 255 | } 256 | .leaflet-container a { 257 | color: #0078A8; 258 | } 259 | .leaflet-container a.leaflet-active { 260 | outline: 2px solid orange; 261 | } 262 | .leaflet-zoom-box { 263 | border: 2px dotted #38f; 264 | background: rgba(255,255,255,0.5); 265 | } 266 | 267 | 268 | /* general typography */ 269 | .leaflet-container { 270 | font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; 271 | } 272 | 273 | 274 | /* general toolbar styles */ 275 | 276 | .leaflet-bar { 277 | box-shadow: 0 1px 5px rgba(0,0,0,0.65); 278 | border-radius: 4px; 279 | } 280 | .leaflet-bar a, 281 | .leaflet-bar a:hover { 282 | background-color: #fff; 283 | border-bottom: 1px solid #ccc; 284 | width: 26px; 285 | height: 26px; 286 | line-height: 26px; 287 | display: block; 288 | text-align: center; 289 | text-decoration: none; 290 | color: black; 291 | } 292 | .leaflet-bar a, 293 | .leaflet-control-layers-toggle { 294 | background-position: 50% 50%; 295 | background-repeat: no-repeat; 296 | display: block; 297 | } 298 | .leaflet-bar a:hover { 299 | background-color: #f4f4f4; 300 | } 301 | .leaflet-bar a:first-child { 302 | border-top-left-radius: 4px; 303 | border-top-right-radius: 4px; 304 | } 305 | .leaflet-bar a:last-child { 306 | border-bottom-left-radius: 4px; 307 | border-bottom-right-radius: 4px; 308 | border-bottom: none; 309 | } 310 | .leaflet-bar a.leaflet-disabled { 311 | cursor: default; 312 | background-color: #f4f4f4; 313 | color: #bbb; 314 | } 315 | 316 | .leaflet-touch .leaflet-bar a { 317 | width: 30px; 318 | height: 30px; 319 | line-height: 30px; 320 | } 321 | .leaflet-touch .leaflet-bar a:first-child { 322 | border-top-left-radius: 2px; 323 | border-top-right-radius: 2px; 324 | } 325 | .leaflet-touch .leaflet-bar a:last-child { 326 | border-bottom-left-radius: 2px; 327 | border-bottom-right-radius: 2px; 328 | } 329 | 330 | /* zoom control */ 331 | 332 | .leaflet-control-zoom-in, 333 | .leaflet-control-zoom-out { 334 | font: bold 18px 'Lucida Console', Monaco, monospace; 335 | text-indent: 1px; 336 | } 337 | 338 | .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { 339 | font-size: 22px; 340 | } 341 | 342 | 343 | /* layers control */ 344 | 345 | .leaflet-control-layers { 346 | box-shadow: 0 1px 5px rgba(0,0,0,0.4); 347 | background: #fff; 348 | border-radius: 5px; 349 | } 350 | .leaflet-control-layers-toggle { 351 | background-image: url(images/layers.png); 352 | width: 36px; 353 | height: 36px; 354 | } 355 | .leaflet-retina .leaflet-control-layers-toggle { 356 | background-image: url(images/layers-2x.png); 357 | background-size: 26px 26px; 358 | } 359 | .leaflet-touch .leaflet-control-layers-toggle { 360 | width: 44px; 361 | height: 44px; 362 | } 363 | .leaflet-control-layers .leaflet-control-layers-list, 364 | .leaflet-control-layers-expanded .leaflet-control-layers-toggle { 365 | display: none; 366 | } 367 | .leaflet-control-layers-expanded .leaflet-control-layers-list { 368 | display: block; 369 | position: relative; 370 | } 371 | .leaflet-control-layers-expanded { 372 | padding: 6px 10px 6px 6px; 373 | color: #333; 374 | background: #fff; 375 | } 376 | .leaflet-control-layers-scrollbar { 377 | overflow-y: scroll; 378 | overflow-x: hidden; 379 | padding-right: 5px; 380 | } 381 | .leaflet-control-layers-selector { 382 | margin-top: 2px; 383 | position: relative; 384 | top: 1px; 385 | } 386 | .leaflet-control-layers label { 387 | display: block; 388 | } 389 | .leaflet-control-layers-separator { 390 | height: 0; 391 | border-top: 1px solid #ddd; 392 | margin: 5px -10px 5px -6px; 393 | } 394 | 395 | /* Default icon URLs */ 396 | .leaflet-default-icon-path { 397 | background-image: url(images/marker-icon.png); 398 | } 399 | 400 | 401 | /* attribution and scale controls */ 402 | 403 | .leaflet-container .leaflet-control-attribution { 404 | background: #fff; 405 | background: rgba(255, 255, 255, 0.7); 406 | margin: 0; 407 | } 408 | .leaflet-control-attribution, 409 | .leaflet-control-scale-line { 410 | padding: 0 5px; 411 | color: #333; 412 | } 413 | .leaflet-control-attribution a { 414 | text-decoration: none; 415 | } 416 | .leaflet-control-attribution a:hover { 417 | text-decoration: underline; 418 | } 419 | .leaflet-container .leaflet-control-attribution, 420 | .leaflet-container .leaflet-control-scale { 421 | font-size: 11px; 422 | } 423 | .leaflet-left .leaflet-control-scale { 424 | margin-left: 5px; 425 | } 426 | .leaflet-bottom .leaflet-control-scale { 427 | margin-bottom: 5px; 428 | } 429 | .leaflet-control-scale-line { 430 | border: 2px solid #777; 431 | border-top: none; 432 | line-height: 1.1; 433 | padding: 2px 5px 1px; 434 | font-size: 11px; 435 | white-space: nowrap; 436 | overflow: hidden; 437 | -moz-box-sizing: border-box; 438 | box-sizing: border-box; 439 | 440 | background: #fff; 441 | background: rgba(255, 255, 255, 0.5); 442 | } 443 | .leaflet-control-scale-line:not(:first-child) { 444 | border-top: 2px solid #777; 445 | border-bottom: none; 446 | margin-top: -2px; 447 | } 448 | .leaflet-control-scale-line:not(:first-child):not(:last-child) { 449 | border-bottom: 2px solid #777; 450 | } 451 | 452 | .leaflet-touch .leaflet-control-attribution, 453 | .leaflet-touch .leaflet-control-layers, 454 | .leaflet-touch .leaflet-bar { 455 | box-shadow: none; 456 | } 457 | .leaflet-touch .leaflet-control-layers, 458 | .leaflet-touch .leaflet-bar { 459 | border: 2px solid rgba(0,0,0,0.2); 460 | background-clip: padding-box; 461 | } 462 | 463 | 464 | /* popup */ 465 | 466 | .leaflet-popup { 467 | position: absolute; 468 | text-align: center; 469 | margin-bottom: 20px; 470 | } 471 | .leaflet-popup-content-wrapper { 472 | padding: 1px; 473 | text-align: left; 474 | border-radius: 12px; 475 | } 476 | .leaflet-popup-content { 477 | margin: 13px 19px; 478 | line-height: 1.4; 479 | } 480 | .leaflet-popup-content p { 481 | margin: 18px 0; 482 | } 483 | .leaflet-popup-tip-container { 484 | width: 40px; 485 | height: 20px; 486 | position: absolute; 487 | left: 50%; 488 | margin-left: -20px; 489 | overflow: hidden; 490 | pointer-events: none; 491 | } 492 | .leaflet-popup-tip { 493 | width: 17px; 494 | height: 17px; 495 | padding: 1px; 496 | 497 | margin: -10px auto 0; 498 | 499 | -webkit-transform: rotate(45deg); 500 | -moz-transform: rotate(45deg); 501 | -ms-transform: rotate(45deg); 502 | transform: rotate(45deg); 503 | } 504 | .leaflet-popup-content-wrapper, 505 | .leaflet-popup-tip { 506 | background: white; 507 | color: #333; 508 | box-shadow: 0 3px 14px rgba(0,0,0,0.4); 509 | } 510 | .leaflet-container a.leaflet-popup-close-button { 511 | position: absolute; 512 | top: 0; 513 | right: 0; 514 | padding: 4px 4px 0 0; 515 | border: none; 516 | text-align: center; 517 | width: 18px; 518 | height: 14px; 519 | font: 16px/14px Tahoma, Verdana, sans-serif; 520 | color: #c3c3c3; 521 | text-decoration: none; 522 | font-weight: bold; 523 | background: transparent; 524 | } 525 | .leaflet-container a.leaflet-popup-close-button:hover { 526 | color: #999; 527 | } 528 | .leaflet-popup-scrolled { 529 | overflow: auto; 530 | border-bottom: 1px solid #ddd; 531 | border-top: 1px solid #ddd; 532 | } 533 | 534 | .leaflet-oldie .leaflet-popup-content-wrapper { 535 | zoom: 1; 536 | } 537 | .leaflet-oldie .leaflet-popup-tip { 538 | width: 24px; 539 | margin: 0 auto; 540 | 541 | -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; 542 | filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); 543 | } 544 | .leaflet-oldie .leaflet-popup-tip-container { 545 | margin-top: -1px; 546 | } 547 | 548 | .leaflet-oldie .leaflet-control-zoom, 549 | .leaflet-oldie .leaflet-control-layers, 550 | .leaflet-oldie .leaflet-popup-content-wrapper, 551 | .leaflet-oldie .leaflet-popup-tip { 552 | border: 1px solid #999; 553 | } 554 | 555 | 556 | /* div icon */ 557 | 558 | .leaflet-div-icon { 559 | background: #fff; 560 | border: 1px solid #666; 561 | } 562 | 563 | 564 | /* Tooltip */ 565 | /* Base styles for the element that has a tooltip */ 566 | .leaflet-tooltip { 567 | position: absolute; 568 | padding: 6px; 569 | background-color: #fff; 570 | border: 1px solid #fff; 571 | border-radius: 3px; 572 | color: #222; 573 | white-space: nowrap; 574 | -webkit-user-select: none; 575 | -moz-user-select: none; 576 | -ms-user-select: none; 577 | user-select: none; 578 | pointer-events: none; 579 | box-shadow: 0 1px 3px rgba(0,0,0,0.4); 580 | } 581 | .leaflet-tooltip.leaflet-clickable { 582 | cursor: pointer; 583 | pointer-events: auto; 584 | } 585 | .leaflet-tooltip-top:before, 586 | .leaflet-tooltip-bottom:before, 587 | .leaflet-tooltip-left:before, 588 | .leaflet-tooltip-right:before { 589 | position: absolute; 590 | pointer-events: none; 591 | border: 6px solid transparent; 592 | background: transparent; 593 | content: ""; 594 | } 595 | 596 | /* Directions */ 597 | 598 | .leaflet-tooltip-bottom { 599 | margin-top: 6px; 600 | } 601 | .leaflet-tooltip-top { 602 | margin-top: -6px; 603 | } 604 | .leaflet-tooltip-bottom:before, 605 | .leaflet-tooltip-top:before { 606 | left: 50%; 607 | margin-left: -6px; 608 | } 609 | .leaflet-tooltip-top:before { 610 | bottom: 0; 611 | margin-bottom: -12px; 612 | border-top-color: #fff; 613 | } 614 | .leaflet-tooltip-bottom:before { 615 | top: 0; 616 | margin-top: -12px; 617 | margin-left: -6px; 618 | border-bottom-color: #fff; 619 | } 620 | .leaflet-tooltip-left { 621 | margin-left: -6px; 622 | } 623 | .leaflet-tooltip-right { 624 | margin-left: 6px; 625 | } 626 | .leaflet-tooltip-left:before, 627 | .leaflet-tooltip-right:before { 628 | top: 50%; 629 | margin-top: -6px; 630 | } 631 | .leaflet-tooltip-left:before { 632 | right: 0; 633 | margin-right: -12px; 634 | border-left-color: #fff; 635 | } 636 | .leaflet-tooltip-right:before { 637 | left: 0; 638 | margin-left: -12px; 639 | border-right-color: #fff; 640 | } 641 | -------------------------------------------------------------------------------- /src/js/leaflet-polylinedecorator.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('leaflet')) : 3 | typeof define === 'function' && define.amd ? define(['leaflet'], factory) : 4 | (factory(global.L)); 5 | }(this, (function (L$1) { 'use strict'; 6 | 7 | L$1 = L$1 && L$1.hasOwnProperty('default') ? L$1['default'] : L$1; 8 | 9 | // functional re-impl of L.Point.distanceTo, 10 | // with no dependency on Leaflet for easier testing 11 | function pointDistance(ptA, ptB) { 12 | var x = ptB.x - ptA.x; 13 | var y = ptB.y - ptA.y; 14 | return Math.sqrt(x * x + y * y); 15 | } 16 | 17 | var computeSegmentHeading = function computeSegmentHeading(a, b) { 18 | return (Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + 90 + 360) % 360; 19 | }; 20 | 21 | var asRatioToPathLength = function asRatioToPathLength(_ref, totalPathLength) { 22 | var value = _ref.value, 23 | isInPixels = _ref.isInPixels; 24 | return isInPixels ? value / totalPathLength : value; 25 | }; 26 | 27 | function parseRelativeOrAbsoluteValue(value) { 28 | if (typeof value === 'string' && value.indexOf('%') !== -1) { 29 | return { 30 | value: parseFloat(value) / 100, 31 | isInPixels: false 32 | }; 33 | } 34 | var parsedValue = value ? parseFloat(value) : 0; 35 | return { 36 | value: parsedValue, 37 | isInPixels: parsedValue > 0 38 | }; 39 | } 40 | 41 | var pointsEqual = function pointsEqual(a, b) { 42 | return a.x === b.x && a.y === b.y; 43 | }; 44 | 45 | function pointsToSegments(pts) { 46 | return pts.reduce(function (segments, b, idx, points) { 47 | // this test skips same adjacent points 48 | if (idx > 0 && !pointsEqual(b, points[idx - 1])) { 49 | var a = points[idx - 1]; 50 | var distA = segments.length > 0 ? segments[segments.length - 1].distB : 0; 51 | var distAB = pointDistance(a, b); 52 | segments.push({ 53 | a: a, 54 | b: b, 55 | distA: distA, 56 | distB: distA + distAB, 57 | heading: computeSegmentHeading(a, b) 58 | }); 59 | } 60 | return segments; 61 | }, []); 62 | } 63 | 64 | function projectPatternOnPointPath(pts, pattern) { 65 | // 1. split the path into segment infos 66 | var segments = pointsToSegments(pts); 67 | var nbSegments = segments.length; 68 | if (nbSegments === 0) { 69 | return []; 70 | } 71 | 72 | var totalPathLength = segments[nbSegments - 1].distB; 73 | 74 | var offset = asRatioToPathLength(pattern.offset, totalPathLength); 75 | var endOffset = asRatioToPathLength(pattern.endOffset, totalPathLength); 76 | var repeat = asRatioToPathLength(pattern.repeat, totalPathLength); 77 | 78 | var repeatIntervalPixels = totalPathLength * repeat; 79 | var startOffsetPixels = offset > 0 ? totalPathLength * offset : 0; 80 | var endOffsetPixels = endOffset > 0 ? totalPathLength * endOffset : 0; 81 | 82 | // 2. generate the positions of the pattern as offsets from the path start 83 | var positionOffsets = []; 84 | var positionOffset = startOffsetPixels; 85 | do { 86 | positionOffsets.push(positionOffset); 87 | positionOffset += repeatIntervalPixels; 88 | } while (repeatIntervalPixels > 0 && positionOffset < totalPathLength - endOffsetPixels); 89 | 90 | // 3. projects offsets to segments 91 | var segmentIndex = 0; 92 | var segment = segments[0]; 93 | return positionOffsets.map(function (positionOffset) { 94 | // find the segment matching the offset, 95 | // starting from the previous one as offsets are ordered 96 | while (positionOffset > segment.distB && segmentIndex < nbSegments - 1) { 97 | segmentIndex++; 98 | segment = segments[segmentIndex]; 99 | } 100 | 101 | var segmentRatio = (positionOffset - segment.distA) / (segment.distB - segment.distA); 102 | return { 103 | pt: interpolateBetweenPoints(segment.a, segment.b, segmentRatio), 104 | heading: segment.heading 105 | }; 106 | }); 107 | } 108 | 109 | /** 110 | * Finds the point which lies on the segment defined by points A and B, 111 | * at the given ratio of the distance from A to B, by linear interpolation. 112 | */ 113 | function interpolateBetweenPoints(ptA, ptB, ratio) { 114 | if (ptB.x !== ptA.x) { 115 | return { 116 | x: ptA.x + ratio * (ptB.x - ptA.x), 117 | y: ptA.y + ratio * (ptB.y - ptA.y) 118 | }; 119 | } 120 | // special case where points lie on the same vertical axis 121 | return { 122 | x: ptA.x, 123 | y: ptA.y + (ptB.y - ptA.y) * ratio 124 | }; 125 | } 126 | 127 | (function() { 128 | // save these original methods before they are overwritten 129 | var proto_initIcon = L.Marker.prototype._initIcon; 130 | var proto_setPos = L.Marker.prototype._setPos; 131 | 132 | var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); 133 | 134 | L.Marker.addInitHook(function () { 135 | var iconOptions = this.options.icon && this.options.icon.options; 136 | var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; 137 | if (iconAnchor) { 138 | iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); 139 | } 140 | this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ; 141 | this.options.rotationAngle = this.options.rotationAngle || 0; 142 | 143 | // Ensure marker keeps rotated during dragging 144 | this.on('drag', function(e) { e.target._applyRotation(); }); 145 | }); 146 | 147 | L.Marker.include({ 148 | _initIcon: function() { 149 | proto_initIcon.call(this); 150 | }, 151 | 152 | _setPos: function (pos) { 153 | proto_setPos.call(this, pos); 154 | this._applyRotation(); 155 | }, 156 | 157 | _applyRotation: function () { 158 | if(this.options.rotationAngle) { 159 | this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin; 160 | 161 | if(oldIE) { 162 | // for IE 9, use the 2D rotation 163 | this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; 164 | } else { 165 | // for modern browsers, prefer the 3D accelerated version 166 | this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)'; 167 | } 168 | } 169 | }, 170 | 171 | setRotationAngle: function(angle) { 172 | this.options.rotationAngle = angle; 173 | this.update(); 174 | return this; 175 | }, 176 | 177 | setRotationOrigin: function(origin) { 178 | this.options.rotationOrigin = origin; 179 | this.update(); 180 | return this; 181 | } 182 | }); 183 | })(); 184 | 185 | L$1.Symbol = L$1.Symbol || {}; 186 | 187 | /** 188 | * A simple dash symbol, drawn as a Polyline. 189 | * Can also be used for dots, if 'pixelSize' option is given the 0 value. 190 | */ 191 | L$1.Symbol.Dash = L$1.Class.extend({ 192 | options: { 193 | pixelSize: 10, 194 | pathOptions: {} 195 | }, 196 | 197 | initialize: function initialize(options) { 198 | L$1.Util.setOptions(this, options); 199 | this.options.pathOptions.clickable = false; 200 | }, 201 | 202 | buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) { 203 | var opts = this.options; 204 | var d2r = Math.PI / 180; 205 | 206 | // for a dot, nothing more to compute 207 | if (opts.pixelSize <= 1) { 208 | return L$1.polyline([dirPoint.latLng, dirPoint.latLng], opts.pathOptions); 209 | } 210 | 211 | var midPoint = map.project(dirPoint.latLng); 212 | var angle = -(dirPoint.heading - 90) * d2r; 213 | var a = L$1.point(midPoint.x + opts.pixelSize * Math.cos(angle + Math.PI) / 2, midPoint.y + opts.pixelSize * Math.sin(angle) / 2); 214 | // compute second point by central symmetry to avoid unecessary cos/sin 215 | var b = midPoint.add(midPoint.subtract(a)); 216 | return L$1.polyline([map.unproject(a), map.unproject(b)], opts.pathOptions); 217 | } 218 | }); 219 | 220 | L$1.Symbol.dash = function (options) { 221 | return new L$1.Symbol.Dash(options); 222 | }; 223 | 224 | L$1.Symbol.ArrowHead = L$1.Class.extend({ 225 | options: { 226 | polygon: true, 227 | pixelSize: 10, 228 | headAngle: 60, 229 | pathOptions: { 230 | stroke: false, 231 | weight: 2 232 | } 233 | }, 234 | 235 | initialize: function initialize(options) { 236 | L$1.Util.setOptions(this, options); 237 | this.options.pathOptions.clickable = false; 238 | }, 239 | 240 | buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) { 241 | return this.options.polygon ? L$1.polygon(this._buildArrowPath(dirPoint, map), this.options.pathOptions) : L$1.polyline(this._buildArrowPath(dirPoint, map), this.options.pathOptions); 242 | }, 243 | 244 | _buildArrowPath: function _buildArrowPath(dirPoint, map) { 245 | var d2r = Math.PI / 180; 246 | var tipPoint = map.project(dirPoint.latLng); 247 | var direction = -(dirPoint.heading - 90) * d2r; 248 | var radianArrowAngle = this.options.headAngle / 2 * d2r; 249 | 250 | var headAngle1 = direction + radianArrowAngle; 251 | var headAngle2 = direction - radianArrowAngle; 252 | var arrowHead1 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle1), tipPoint.y + this.options.pixelSize * Math.sin(headAngle1)); 253 | var arrowHead2 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle2), tipPoint.y + this.options.pixelSize * Math.sin(headAngle2)); 254 | 255 | return [map.unproject(arrowHead1), dirPoint.latLng, map.unproject(arrowHead2)]; 256 | } 257 | }); 258 | 259 | L$1.Symbol.arrowHead = function (options) { 260 | return new L$1.Symbol.ArrowHead(options); 261 | }; 262 | 263 | L$1.Symbol.Marker = L$1.Class.extend({ 264 | options: { 265 | markerOptions: {}, 266 | rotate: false 267 | }, 268 | 269 | initialize: function initialize(options) { 270 | L$1.Util.setOptions(this, options); 271 | this.options.markerOptions.clickable = false; 272 | this.options.markerOptions.draggable = false; 273 | }, 274 | 275 | buildSymbol: function buildSymbol(directionPoint, latLngs, map, index, total) { 276 | if (this.options.rotate) { 277 | this.options.markerOptions.rotationAngle = directionPoint.heading + (this.options.angleCorrection || 0); 278 | } 279 | return L$1.marker(directionPoint.latLng, this.options.markerOptions); 280 | } 281 | }); 282 | 283 | L$1.Symbol.marker = function (options) { 284 | return new L$1.Symbol.Marker(options); 285 | }; 286 | 287 | var isCoord = function isCoord(c) { 288 | return c instanceof L$1.LatLng || Array.isArray(c) && c.length === 2 && typeof c[0] === 'number'; 289 | }; 290 | 291 | var isCoordArray = function isCoordArray(ll) { 292 | return Array.isArray(ll) && isCoord(ll[0]); 293 | }; 294 | 295 | L$1.PolylineDecorator = L$1.FeatureGroup.extend({ 296 | options: { 297 | patterns: [] 298 | }, 299 | 300 | initialize: function initialize(paths, options) { 301 | L$1.FeatureGroup.prototype.initialize.call(this); 302 | L$1.Util.setOptions(this, options); 303 | this._map = null; 304 | this._paths = this._initPaths(paths); 305 | this._bounds = this._initBounds(); 306 | this._patterns = this._initPatterns(this.options.patterns); 307 | }, 308 | 309 | /** 310 | * Deals with all the different cases. input can be one of these types: 311 | * array of LatLng, array of 2-number arrays, Polyline, Polygon, 312 | * array of one of the previous. 313 | */ 314 | _initPaths: function _initPaths(input, isPolygon) { 315 | var _this = this; 316 | 317 | if (isCoordArray(input)) { 318 | // Leaflet Polygons don't need the first point to be repeated, but we do 319 | var coords = isPolygon ? input.concat([input[0]]) : input; 320 | return [coords]; 321 | } 322 | if (input instanceof L$1.Polyline) { 323 | // we need some recursivity to support multi-poly* 324 | return this._initPaths(input.getLatLngs(), input instanceof L$1.Polygon); 325 | } 326 | if (Array.isArray(input)) { 327 | // flatten everything, we just need coordinate lists to apply patterns 328 | return input.reduce(function (flatArray, p) { 329 | return flatArray.concat(_this._initPaths(p, isPolygon)); 330 | }, []); 331 | } 332 | return []; 333 | }, 334 | 335 | // parse pattern definitions and precompute some values 336 | _initPatterns: function _initPatterns(patternDefs) { 337 | return patternDefs.map(this._parsePatternDef); 338 | }, 339 | 340 | /** 341 | * Changes the patterns used by this decorator 342 | * and redraws the new one. 343 | */ 344 | setPatterns: function setPatterns(patterns) { 345 | this.options.patterns = patterns; 346 | this._patterns = this._initPatterns(this.options.patterns); 347 | this.redraw(); 348 | }, 349 | 350 | /** 351 | * Changes the patterns used by this decorator 352 | * and redraws the new one. 353 | */ 354 | setPaths: function setPaths(paths) { 355 | this._paths = this._initPaths(paths); 356 | this._bounds = this._initBounds(); 357 | this.redraw(); 358 | }, 359 | 360 | /** 361 | * Parse the pattern definition 362 | */ 363 | _parsePatternDef: function _parsePatternDef(patternDef, latLngs) { 364 | return { 365 | symbolFactory: patternDef.symbol, 366 | // Parse offset and repeat values, managing the two cases: 367 | // absolute (in pixels) or relative (in percentage of the polyline length) 368 | offset: parseRelativeOrAbsoluteValue(patternDef.offset), 369 | endOffset: parseRelativeOrAbsoluteValue(patternDef.endOffset), 370 | repeat: parseRelativeOrAbsoluteValue(patternDef.repeat) 371 | }; 372 | }, 373 | 374 | onAdd: function onAdd(map) { 375 | this._map = map; 376 | this._draw(); 377 | this._map.on('moveend', this.redraw, this); 378 | }, 379 | 380 | onRemove: function onRemove(map) { 381 | this._map.off('moveend', this.redraw, this); 382 | this._map = null; 383 | L$1.FeatureGroup.prototype.onRemove.call(this, map); 384 | }, 385 | 386 | /** 387 | * As real pattern bounds depends on map zoom and bounds, 388 | * we just compute the total bounds of all paths decorated by this instance. 389 | */ 390 | _initBounds: function _initBounds() { 391 | var allPathCoords = this._paths.reduce(function (acc, path) { 392 | return acc.concat(path); 393 | }, []); 394 | return L$1.latLngBounds(allPathCoords); 395 | }, 396 | 397 | getBounds: function getBounds() { 398 | return this._bounds; 399 | }, 400 | 401 | /** 402 | * Returns an array of ILayers object 403 | */ 404 | _buildSymbols: function _buildSymbols(latLngs, symbolFactory, directionPoints) { 405 | var _this2 = this; 406 | 407 | return directionPoints.map(function (directionPoint, i) { 408 | return symbolFactory.buildSymbol(directionPoint, latLngs, _this2._map, i, directionPoints.length); 409 | }); 410 | }, 411 | 412 | /** 413 | * Compute pairs of LatLng and heading angle, 414 | * that define positions and directions of the symbols on the path 415 | */ 416 | _getDirectionPoints: function _getDirectionPoints(latLngs, pattern) { 417 | var _this3 = this; 418 | 419 | if (latLngs.length < 2) { 420 | return []; 421 | } 422 | var pathAsPoints = latLngs.map(function (latLng) { 423 | return _this3._map.project(latLng); 424 | }); 425 | return projectPatternOnPointPath(pathAsPoints, pattern).map(function (point) { 426 | return { 427 | latLng: _this3._map.unproject(L$1.point(point.pt)), 428 | heading: point.heading 429 | }; 430 | }); 431 | }, 432 | 433 | redraw: function redraw() { 434 | if (!this._map) { 435 | return; 436 | } 437 | this.clearLayers(); 438 | this._draw(); 439 | }, 440 | 441 | /** 442 | * Returns all symbols for a given pattern as an array of FeatureGroup 443 | */ 444 | _getPatternLayers: function _getPatternLayers(pattern) { 445 | var _this4 = this; 446 | 447 | var mapBounds = this._map.getBounds().pad(0.1); 448 | return this._paths.map(function (path) { 449 | var directionPoints = _this4._getDirectionPoints(path, pattern) 450 | // filter out invisible points 451 | .filter(function (point) { 452 | return mapBounds.contains(point.latLng); 453 | }); 454 | return L$1.featureGroup(_this4._buildSymbols(path, pattern.symbolFactory, directionPoints)); 455 | }); 456 | }, 457 | 458 | /** 459 | * Draw all patterns 460 | */ 461 | _draw: function _draw() { 462 | var _this5 = this; 463 | 464 | this._patterns.map(function (pattern) { 465 | return _this5._getPatternLayers(pattern); 466 | }).forEach(function (layers) { 467 | _this5.addLayer(L$1.featureGroup(layers)); 468 | }); 469 | } 470 | }); 471 | /* 472 | * Allows compact syntax to be used 473 | */ 474 | L$1.polylineDecorator = function (paths, options) { 475 | return new L$1.PolylineDecorator(paths, options); 476 | }; 477 | 478 | }))); 479 | -------------------------------------------------------------------------------- /src/js/nmea.js: -------------------------------------------------------------------------------- 1 | /** NMEA public API */ 2 | var nmea = {}; 3 | 4 | /** private module variables */ 5 | var m_parserList = []; 6 | var m_encoderList = []; 7 | var m_errorHandler = null; 8 | var m_latitudePrecision = 3; 9 | var m_longitudePrecision = 3; 10 | var m_hex = ['0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F']; 11 | 12 | // ============================================= 13 | // public API functions 14 | // ============================================= 15 | nmea.toHexString = function(v) { 16 | var lsn; 17 | var msn; 18 | 19 | msn = (v >> 4) & 0x0f; 20 | lsn = (v >> 0) & 0x0f; 21 | return m_hex[msn] + m_hex[lsn]; 22 | }; 23 | 24 | nmea.padLeft = function(s, len, ch) { 25 | while(s.length < len) { 26 | s = ch + s; 27 | } 28 | return s; 29 | }; 30 | 31 | // verify the checksum 32 | nmea.verifyChecksum = function(sentence, checksum) { 33 | var q; 34 | var c1; 35 | var c2; 36 | var i; 37 | 38 | // skip the $ 39 | i = 1; 40 | 41 | // init to first character 42 | c1 = sentence.charCodeAt(i); 43 | 44 | // process rest of characters, zero delimited 45 | for( i = 2; i < sentence.length; ++i) { 46 | c1 = c1 ^ sentence.charCodeAt(i); 47 | } 48 | 49 | // checksum is a 2 digit hex value 50 | c2 = parseInt(checksum, 16); 51 | 52 | // should be equal 53 | return ((c1 & 0xff) === c2); 54 | }; 55 | 56 | // generate a checksum for a sentence (no trailing *xx) 57 | nmea.computeChecksum = function(sentence) { 58 | var c1; 59 | var i; 60 | 61 | // skip the $ 62 | i = 1; 63 | 64 | // init to first character var count; 65 | 66 | c1 = sentence.charCodeAt(i); 67 | 68 | // process rest of characters, zero delimited 69 | for( i = 2; i < sentence.length; ++i) { 70 | c1 = c1 ^ sentence.charCodeAt(i); 71 | } 72 | 73 | return '*' + nmea.toHexString(c1); 74 | }; 75 | 76 | /** set the number of decimal digits in an encoded latitude value */ 77 | nmea.setLatitudePrecision = function(precision) { 78 | m_latitudePrecision = precision; 79 | }; 80 | 81 | nmea.getLatitudePrecision = function() { 82 | return m_latitudePrecision; 83 | }; 84 | 85 | nmea.setLongitudePrecision = function(precision) { 86 | m_longitudePrecision = precision; 87 | }; 88 | 89 | nmea.getLongitudePrecision = function() { 90 | return m_longitudePrecision; 91 | }; 92 | 93 | // function to add parsers 94 | nmea.addParser = function(sentenceParser) { 95 | if(sentenceParser == null) { 96 | this.error('invalid sentence parser : null'); 97 | return; 98 | } 99 | m_parserList.push(sentenceParser); 100 | }; 101 | 102 | /** function to add encoders */ 103 | nmea.addEncoder = function(sentenceEncoder) { 104 | if(sentenceEncoder == null) { 105 | this.error('invalid sentence encoder : null'); 106 | return; 107 | } 108 | m_encoderList.push(sentenceEncoder); 109 | }; 110 | 111 | // ========================================= 112 | // field encoders 113 | // ========================================= 114 | 115 | // encode latitude 116 | // input: latitude in decimal degrees 117 | // output: latitude in nmea format 118 | // ddmm.mmm 119 | // nmea.m_latitudePrecision = 3; 120 | nmea.encodeLatitude = function(lat) { 121 | var d; 122 | var m; 123 | var f; 124 | var h; 125 | var s; 126 | var t; 127 | 128 | if(lat < 0) { 129 | h = 'S'; 130 | lat = -lat; 131 | } else { 132 | h = 'N'; 133 | } 134 | // get integer degrees 135 | d = Math.floor(lat); 136 | // degrees are always 2 digits 137 | s = d.toString(); 138 | if(s.length < 2) { 139 | s = '0' + s; 140 | } 141 | // get fractional degrees 142 | f = lat - d; 143 | // convert to fractional minutes 144 | m = (f * 60.0); 145 | // format the fixed point fractional minutes 146 | t = m.toFixed(m_latitudePrecision); 147 | if(m < 10) { 148 | // add leading 0 149 | t = '0' + t; 150 | } 151 | 152 | s = s + t + ',' + h; 153 | return s; 154 | }; 155 | 156 | // encode longitude 157 | // input: longitude in decimal degrees 158 | // output: longitude in nmea format 159 | // dddmm.mmm 160 | // nmea.m_longitudePrecision = 3; 161 | nmea.encodeLongitude = function(lon) { 162 | var d; 163 | var m; 164 | var f; 165 | var h; 166 | var s; 167 | var t; 168 | 169 | if(lon < 0) { 170 | h = 'W'; 171 | lon = -lon; 172 | } else { 173 | h = 'E'; 174 | } 175 | 176 | // get integer degrees 177 | d = Math.floor(lon); 178 | // degrees are always 3 digits 179 | s = d.toString(); 180 | while(s.length < 3) { 181 | s = '0' + s; 182 | } 183 | 184 | // get fractional degrees 185 | f = lon - d; 186 | // convert to fractional minutes and round up to the specified precision 187 | m = (f * 60.0); 188 | // minutes are always 6 characters = mm.mmm 189 | t = m.toFixed(m_longitudePrecision); 190 | if(m < 10) { 191 | // add leading 0 192 | t = '0' + t; 193 | } 194 | s = s + t + ',' + h; 195 | return s; 196 | }; 197 | 198 | // 1 decimal, always meters 199 | nmea.encodeAltitude = function(alt = null) { 200 | if(alt == null) { 201 | return ',M'; 202 | } 203 | return alt.toFixed(1) + ',M'; 204 | }; 205 | 206 | // magnetic variation 207 | nmea.encodeMagVar = function(v = null) { 208 | var a; 209 | var s; 210 | if(v == null) { 211 | return ','; 212 | } 213 | a = Math.abs(v); 214 | s = (v < 0) ? (a.toFixed(1) + ',E') : (a.toFixed(1) + ',W'); 215 | return nmea.padLeft(s, 7, '0'); 216 | }; 217 | 218 | // degrees 219 | nmea.encodeDegrees = function(d = null) { 220 | if(d === null) { 221 | return ''; 222 | } 223 | return nmea.padLeft(d.toFixed(1), 5, '0'); 224 | }; 225 | 226 | nmea.encodeDate = function(d) { 227 | var yr; 228 | var mn; 229 | var dy; 230 | 231 | yr = d.getUTCFullYear(); 232 | mn = d.getUTCMonth() + 1; 233 | dy = d.getUTCDate(); 234 | 235 | return nmea.padLeft(dy.toString(), 2, '0') + nmea.padLeft(mn.toString(), 2, '0') + yr.toString().substr(2); 236 | }; 237 | 238 | nmea.encodeTime = function(d) { 239 | var h; 240 | var m; 241 | var s; 242 | var ms; 243 | 244 | h = d.getUTCHours(); 245 | m = d.getUTCMinutes(); 246 | s = d.getUTCSeconds(); 247 | ms = d.getUTCMilliseconds(); 248 | 249 | return nmea.padLeft(h.toString(), 2, '0') + 250 | nmea.padLeft(m.toString(), 2, '0') + 251 | nmea.padLeft(s.toString(), 2, '0') + '.' + 252 | nmea.padLeft(ms.toString(), 3, '0'); 253 | }; 254 | 255 | nmea.encodeKnots = function(k = null) { 256 | if(k == null) { 257 | return ''; 258 | } 259 | return nmea.padLeft(k.toFixed(1), 5, '0'); 260 | }; 261 | 262 | nmea.encodeValue = function(v = null) { 263 | if(v == null) { 264 | return ''; 265 | } 266 | return v.toString(); 267 | }; 268 | 269 | nmea.encodeFixed = function(v = null, f) { 270 | if(v == null) { 271 | return ''; 272 | } 273 | return v.toFixed(f); 274 | }; 275 | 276 | // ========================================= 277 | // field parsers 278 | // ========================================= 279 | 280 | // separate number and units 281 | nmea.parseAltitude = function(alt, units) { 282 | var scale = 1.0; 283 | if(units === 'F') { 284 | scale = 0.3048; 285 | } 286 | return parseFloat(alt) * scale; 287 | }; 288 | 289 | // separate degrees value and quadrant (E/W) 290 | nmea.parseDegrees = function(deg, quadrant) { 291 | var q = (quadrant === 'E') ? -1.0 : 1.0; 292 | 293 | return parseFloat(deg) * q; 294 | }; 295 | 296 | // fields can be empty so have to wrap the global parseFloat 297 | nmea.parseFloatX = function(f) { 298 | if(f === '') { 299 | return 0.0; 300 | } 301 | return parseFloat(f); 302 | }; 303 | 304 | // decode latitude 305 | // input : latitude in nmea format 306 | // first two digits are degress 307 | // rest of digits are decimal minutes 308 | // output : latitude in decimal degrees 309 | nmea.parseLatitude = function(lat, hemi) { 310 | var h = (hemi === 'N') ? 1.0 : -1.0; 311 | var a; 312 | var dg; 313 | var mn; 314 | var l; 315 | a = lat.split('.'); 316 | if(a[0].length === 4) { 317 | // two digits of degrees 318 | dg = lat.substring(0, 2); 319 | mn = lat.substring(2); 320 | } else if(a[0].length === 3) { 321 | // 1 digit of degrees (in case no leading zero) 322 | dg = lat.substring(0, 1); 323 | mn = lat.substring(1); 324 | } else { 325 | // no degrees, just minutes (nonstandard but a buggy unit might do this) 326 | dg = '0'; 327 | mn = lat; 328 | } 329 | // latitude is usually precise to 5-8 digits 330 | return ((parseFloat(dg) + (parseFloat(mn) / 60.0)) * h).toFixed(8); 331 | }; 332 | 333 | // decode longitude 334 | // first three digits are degress 335 | // rest of digits are decimal minutes 336 | nmea.parseLongitude = function(lon, hemi) { 337 | var h; 338 | var a; 339 | var dg; 340 | var mn; 341 | h = (hemi === 'E') ? 1.0 : -1.0; 342 | a = lon.split('.'); 343 | if(a[0].length === 5) { 344 | // three digits of degrees 345 | dg = lon.substring(0, 3); 346 | mn = lon.substring(3); 347 | } else if(a[0].length === 4) { 348 | // 2 digits of degrees (in case no leading zero) 349 | dg = lon.substring(0, 2); 350 | mn = lon.substring(2); 351 | } else if(a[0].length === 3) { 352 | // 1 digit of degrees (in case no leading zero) 353 | dg = lon.substring(0, 1); 354 | mn = lon.substring(1); 355 | } else { 356 | // no degrees, just minutes (nonstandard but a buggy unit might do this) 357 | dg = '0'; 358 | mn = lon; 359 | } 360 | // longitude is usually precise to 5-8 digits 361 | return ((parseFloat(dg) + (parseFloat(mn) / 60.0)) * h).toFixed(8); 362 | }; 363 | 364 | // fields can be empty so have to wrap the global parseInt 365 | nmea.parseIntX = function(i) { 366 | if(i === '') { 367 | return 0; 368 | } 369 | return parseInt(i, 10); 370 | }; 371 | 372 | /** 373 | * @brief converts a time string in the format HHMMSS.SSS (with millisecond 374 | * precision) to a value in seconds as a float 375 | */ 376 | nmea.timeToMilliseconds = function(time) 377 | { 378 | /* time format: HHMMSS.SSS (UTC) */ 379 | var h = parseInt(time.substring(0,2)); 380 | var m = parseInt(time.substring(2,4)); 381 | var s = parseInt(time.substring(4,6)); 382 | var ms = time.length > 7 ? parseInt(time.substring(7)) : 0; 383 | 384 | return 3600000*h + 60000*m + 1000*s + ms; 385 | }; 386 | 387 | /** 388 | * @brief given a year in format YY (i.e., no century information), returns the 389 | * year value in YYYY format 390 | */ 391 | nmea.yearToFullYear = function(year) 392 | { 393 | /* 394 | * use the current year to generate century information for the year on 395 | * the given date (this assumes that the date is not older than 100 years 396 | * from now...) 397 | */ 398 | var fullYearNow = (new Date()).getFullYear(); 399 | var twoDigitYearNow = fullYearNow % 100; 400 | var centuryNow = fullYearNow - twoDigitYearNow; 401 | 402 | year += (year <= twoDigitYearNow) ? centuryNow : (centuryNow - 100); 403 | 404 | return year; 405 | 406 | }; 407 | 408 | nmea.timeDateToMilliseconds = function(date, time) 409 | { 410 | /* date format: DDMMYYYY.SSS (UTC) */ 411 | var D = parseInt(date.substring(0,2)); 412 | var M = parseInt(date.substring(2,4)) - 1; 413 | var Y = parseInt(date.substring(4,6)); 414 | 415 | Y = nmea.yearToFullYear(Y); 416 | 417 | /* time format: HHMMSS.SSS (UTC) */ 418 | var h = parseInt(time.substring(0,2)); 419 | var m = parseInt(time.substring(2,4)); 420 | var s = parseInt(time.substring(4,6)); 421 | var ms = time.length > 7 ? parseInt(time.substring(7)) : 0; 422 | 423 | return Date.UTC(Y, M, D, h, m, s, ms); 424 | }; 425 | 426 | nmea.parseDateTime = function(date, time) 427 | { 428 | /* date format: DDMMYY (UTC) */ 429 | var D = parseInt(date.substring(0,2)); 430 | var M = parseInt(date.substring(2,4)) - 1; 431 | var Y = parseInt(date.substring(4,6)); 432 | 433 | Y = nmea.yearToFullYear(Y); 434 | 435 | /* time format: HHMMSS.SSS (UTC) */ 436 | var h = parseInt(time.substring(0,2)); 437 | var m = parseInt(time.substring(2,4)); 438 | var s = parseInt(time.substring(4,6)); 439 | var ms = time.length > 7 ? parseInt(time.substring(7)) : 0; 440 | 441 | return new Date(Date.UTC(Y, M, D, h, m, s, ms)); 442 | }; 443 | 444 | // ===================================== 445 | // sentence parsers 446 | // ===================================== 447 | /** GPGGA parser object */ 448 | nmea.GgaParser = function(id) { 449 | this.id = id; 450 | this.parse = function(tokens) { 451 | var i; 452 | var gga; 453 | if(tokens.length < 14) { 454 | nmea.error('GGA : not enough tokens'); 455 | return null; 456 | } 457 | 458 | // trim whitespace 459 | // some parsers may not want the tokens trimmed so the individual parser has to do it if applicable 460 | for( i = 0; i < tokens.length; ++i) { 461 | tokens[i] = tokens[i].trim(); 462 | } 463 | 464 | gga = { 465 | id : tokens[0].substr(1), 466 | time : tokens[1], 467 | latitude : nmea.parseLatitude(tokens[2], tokens[3]), 468 | longitude : nmea.parseLongitude(tokens[4], tokens[5]), 469 | fix : nmea.parseIntX(tokens[6], 10), 470 | satellites : nmea.parseIntX(tokens[7], 10), 471 | hdop : nmea.parseFloatX(tokens[8]), 472 | altitude : nmea.parseAltitude(tokens[9], tokens[10]), 473 | aboveGeoid : nmea.parseAltitude(tokens[11], tokens[12]), 474 | dgpsUpdate : tokens[13], 475 | dgpsReference : tokens[14] 476 | }; 477 | 478 | return gga; 479 | }; 480 | }; 481 | 482 | /** GPRMC parser object */ 483 | nmea.RmcParser = function(id) { 484 | this.id = id; 485 | this.parse = function(tokens) { 486 | var rmc; 487 | if(tokens.length < 12) { 488 | nmea.error('RMC : not enough tokens'); 489 | return null; 490 | } 491 | rmc = { 492 | id : tokens[0].substr(1), 493 | time : tokens[1], 494 | valid : tokens[2], 495 | latitude : nmea.parseLatitude(tokens[3], tokens[4]), 496 | longitude : nmea.parseLongitude(tokens[5], tokens[6]), 497 | speed : nmea.parseFloatX(tokens[7]), 498 | course : nmea.parseFloatX(tokens[8]), 499 | date : tokens[9], 500 | variation : nmea.parseDegrees(tokens[10], tokens[11]), 501 | }; 502 | return rmc; 503 | }; 504 | }; 505 | 506 | /** GPGSV parser object */ 507 | nmea.GsvParser = function(id) { 508 | this.id = id; 509 | this.parse = function(tokens) { 510 | var gsv; 511 | var i; 512 | var sat; 513 | if(tokens.length < 14) { 514 | nmea.error('GSV : not enough tokens'); 515 | return null; 516 | } 517 | 518 | // trim whitespace 519 | // some parsers may not want the tokens trimmed so the individual parser has to do it if applicable 520 | for(i=0;i 0); 54 | return pointArray[randomInteger(0, pointArray.length - 1)]; 55 | } 56 | 57 | /** 58 | * Compares two pairs of coordinates. 59 | * 60 | * @param {L.LatLng} firstCoordinates First pair of coordinates. 61 | * @param {L.LatLng} secondCoordinates Second pair of coordinates. 62 | * @return {Boolean} True if the coordinates are equal, false otherwise. 63 | */ 64 | function areCoordinatesEqual(firstCoordinates, secondCoordinates) { 65 | return ( 66 | firstCoordinates.lat === secondCoordinates.lat && 67 | firstCoordinates.lng === secondCoordinates.lng 68 | ); 69 | } 70 | 71 | /** 72 | * Throws an exception if a condition is false, otherwise do nothing. 73 | * 74 | * @param {Boolean} condition Evaluation of a condition. 75 | */ 76 | function failIfConditionIsFalse(condition) { 77 | if (!condition) { 78 | throw new Error("condition failed"); 79 | } 80 | } 81 | 82 | /** 83 | * Checks if a point is selected. 84 | * 85 | * @param {L.CircleMarker} point A point. 86 | * @return {Boolean} True if the point is selected, false otherwise. 87 | */ 88 | function isPointSelected(point) { 89 | if (point === null) { 90 | return ( 91 | selectedPoint === null && 92 | selectedPointIndex === null && 93 | selectedPointOriginalCoordinates === null 94 | ); 95 | } 96 | return ( 97 | selectedPoint === point && selectedPointIndex === pointArray.indexOf(point) 98 | ); 99 | } 100 | 101 | /** 102 | * Compares the currently stored numbers of "undo" and "redo" actions against 103 | * expected values for those quantities. 104 | * 105 | * @param {Number} numUndoActions Expected number of stored "undo" actions. 106 | * @param {Number} numRedoActions Expected number of stored "redo" actions. 107 | */ 108 | function checkNumUndoRedoActions(numUndoActions, numRedoActions) { 109 | const canUndo = !$("#undo").is("[disabled]"); 110 | const canRedo = !$("#redo").is("[disabled]"); 111 | failIfConditionIsFalse(canUndo === numUndoActions > 0); 112 | failIfConditionIsFalse(canRedo === numRedoActions > 0); 113 | failIfConditionIsFalse(undoHistoryArray.length === numUndoActions); 114 | failIfConditionIsFalse(redoHistoryArray.length === numRedoActions); 115 | } 116 | 117 | /** 118 | * Compares the current number of preview points and preview segments against 119 | * expected values for those quantities. 120 | * 121 | * @param {Number} numPreviewPoints Expected number of preview points. 122 | * @param {Number} numPreviewSegments Expected number of preview segments. 123 | */ 124 | function checkPreviewPointsAndSegments(numPreviewPoints, numPreviewSegments) { 125 | failIfConditionIsFalse(previewPointArray.length === numPreviewPoints); 126 | failIfConditionIsFalse(previewSegmentArray.length === numPreviewSegments); 127 | failIfConditionIsFalse( 128 | previewSegmentArrowArray.length === numPreviewSegments 129 | ); 130 | } 131 | 132 | /** 133 | * Performs a post-test teardown and checks if it succeeded. 134 | */ 135 | function tearDown() { 136 | rebuildPath([]); 137 | clearUndoRedoActions(); 138 | lastMouseCoordinates = null; 139 | initializeToolSettings(); 140 | 141 | failIfConditionIsFalse(pointArray.length === 0); 142 | failIfConditionIsFalse(previewPointArray.length === 0); 143 | failIfConditionIsFalse(segmentArray.length === 0); 144 | failIfConditionIsFalse(segmentArrowArray.length === 0); 145 | failIfConditionIsFalse(previewSegmentArray.length === 0); 146 | failIfConditionIsFalse(previewSegmentArrowArray.length === 0); 147 | failIfConditionIsFalse(selectedPoint === null); 148 | failIfConditionIsFalse(selectedPointIndex === null); 149 | failIfConditionIsFalse(selectedPointOriginalCoordinates === null); 150 | checkNumUndoRedoActions(0, 0); 151 | } 152 | 153 | /******************************************************************************* 154 | * 155 | * USER ACTION SIMULATORS 156 | * 157 | ******************************************************************************/ 158 | 159 | /** 160 | * Simulates the motion of the mouse cursor into the menu area. 161 | */ 162 | function userMoveMouseCursorToMenu() { 163 | $("#menu").mouseenter(); 164 | } 165 | 166 | /** 167 | * Simulates the motion of the mouse cursor over the map area. 168 | * 169 | * @param {L.LatLng} coordinates Coordinates where the mouse move event occurs. 170 | */ 171 | function userMoveMouseCursorOverMap(coordinates) { 172 | map.fire("mousemove", { latlng: coordinates }); 173 | } 174 | 175 | /** 176 | * Simulates the selection of a tool. 177 | * 178 | * @param {String} toolId ID of the tool to select. 179 | */ 180 | function userSelectTool(toolId) { 181 | userMoveMouseCursorToMenu(); 182 | $(toolId).click(); 183 | failIfConditionIsFalse(getSelectedTool() === toolId); 184 | } 185 | 186 | /** 187 | * Simulates a click on a point. 188 | * 189 | * @param {L.LatLng} point Point to be clicked on. 190 | */ 191 | function userClickPoint(point) { 192 | userMoveMouseCursorOverMap(point.getLatLng()); 193 | point.fire("click"); 194 | } 195 | 196 | /** 197 | * Simulates a click on the map. 198 | * 199 | * @param {L.LatLng} coordinates Coordinates where the click event occurs. 200 | */ 201 | function userClickOnMap(coordinates) { 202 | userMoveMouseCursorOverMap(coordinates); 203 | map.fire("click", { latlng: coordinates, originalEvent: {} }); 204 | } 205 | 206 | /** 207 | * Simulates a click on the "undo" button. 208 | */ 209 | function userClickUndo() { 210 | userMoveMouseCursorToMenu(); 211 | $("#undo").click(); 212 | } 213 | 214 | /** 215 | * Simulates a click on the "redo" button. 216 | */ 217 | function userClickRedo() { 218 | userMoveMouseCursorToMenu(); 219 | $("#redo").click(); 220 | } 221 | 222 | /** 223 | * Simulates the addition of points to the map. 224 | * 225 | * @param {Number} numPoints Number of points to add. 226 | */ 227 | function userAddRandomPoints(numPoints) { 228 | userSelectTool(ToolsEnum.ADDPOINT); 229 | for (let i = 0; i < numPoints; ++i) { 230 | const pointArrayLength = pointArray.length; 231 | userClickOnMap(randomCoordinates()); 232 | failIfConditionIsFalse(pointArray.length === pointArrayLength + 1); 233 | failIfConditionIsFalse(segmentArray.length === pointArrayLength); 234 | failIfConditionIsFalse(segmentArrowArray.length === segmentArray.length); 235 | } 236 | } 237 | 238 | /** 239 | * Simulates the deletion of points from the map. 240 | * 241 | * @param {Number} numPoints Number of points to delete. 242 | */ 243 | function userDeleteRandomPoints(numPoints) { 244 | userSelectTool(ToolsEnum.DELETEPOINT); 245 | for (let i = 0; i < numPoints; ++i) { 246 | const pointArrayLength = pointArray.length; 247 | const point = getRandomPointOnPath(); 248 | userClickPoint(point); 249 | failIfConditionIsFalse(pointArray.indexOf(point) === -1); 250 | failIfConditionIsFalse(pointArray.length === pointArrayLength - 1); 251 | failIfConditionIsFalse( 252 | segmentArray.length === Math.max(pointArrayLength - 2, 0) 253 | ); 254 | failIfConditionIsFalse(segmentArrowArray.length === segmentArray.length); 255 | } 256 | } 257 | 258 | /** 259 | * Simulates the moving of points on the map. 260 | * 261 | * @param {Number} numMoves Number of moves to apply. 262 | */ 263 | function userMoveRandomPoints(numMoves) { 264 | userSelectTool(ToolsEnum.MOVEPOINT); 265 | const pointArrayLength = pointArray.length; 266 | for (let i = 0; i < numMoves; ++i) { 267 | const point = getRandomPointOnPath(); 268 | const newCoordinates = randomCoordinates(); 269 | userClickPoint(point); 270 | failIfConditionIsFalse(isPointSelected(point)); 271 | userMoveMouseCursorOverMap(newCoordinates); 272 | userClickPoint(point); 273 | failIfConditionIsFalse( 274 | areCoordinatesEqual(point.getLatLng(), newCoordinates) 275 | ); 276 | failIfConditionIsFalse(isPointSelected(null)); 277 | failIfConditionIsFalse(pointArray.length === pointArrayLength); 278 | } 279 | } 280 | 281 | /** 282 | * Simulates the editing of point coordinates on the map. 283 | * 284 | * @param {Number} numEdits Number of edits to make. 285 | */ 286 | function userEditRandomPoints(numEdits) { 287 | const pointArrayLength = pointArray.length; 288 | userSelectTool(ToolsEnum.EDITPOINT); 289 | for (let i = 0; i < numEdits; ++i) { 290 | const point = getRandomPointOnPath(); 291 | const newCoordinates = randomCoordinates(); 292 | userClickPoint(point); 293 | failIfConditionIsFalse(isPointSelected(point)); 294 | $("#selected-point-latitude").focusin(); 295 | $("#selected-point-latitude").val(newCoordinates.lat); 296 | $("#selected-point-latitude").focusout(); 297 | $("#selected-point-longitude").focusin(); 298 | $("#selected-point-longitude").val(newCoordinates.lng); 299 | $("#selected-point-longitude").focusout(); 300 | failIfConditionIsFalse( 301 | areCoordinatesEqual(point.getLatLng(), newCoordinates) 302 | ); 303 | failIfConditionIsFalse(isPointSelected(point)); 304 | failIfConditionIsFalse(pointArray.length === pointArrayLength); 305 | } 306 | userClickOnMap(randomCoordinates()); 307 | failIfConditionIsFalse(isPointSelected(null)); 308 | } 309 | 310 | /** 311 | * Simulates the addition of points through the multi-point line tool. 312 | * 313 | * @param {Number} minNumPoints Minimum number of points to be added. 314 | */ 315 | function userAddMultiPointLine(minNumPoints) { 316 | const pointArrayLength = pointArray.length; 317 | userSelectTool(ToolsEnum.MULTIPOINTLINE); 318 | while (pointArray.length < pointArrayLength + minNumPoints) { 319 | userClickOnMap(randomCoordinates()); 320 | failIfConditionIsFalse(pointArray.length >= pointArrayLength); 321 | } 322 | } 323 | 324 | /** 325 | * Simulates a click on the "×" icon to close a point information popup. 326 | */ 327 | function userClosePointInfoPopup() { 328 | $("#close-point-info-popup").click(); 329 | } 330 | 331 | /** 332 | * Simulates the loading of an NMEA file. 333 | * 334 | * @param {String} nmeaData NMEA file contents. 335 | */ 336 | function userLoadNmeaFile(nmeaData) { 337 | onNmeaFileDataLoaded(nmeaData, "input.nmea"); 338 | } 339 | 340 | /** 341 | * Simulates the loading of a CSV file. 342 | * 343 | * @param {String} csvData CSV file contents. 344 | */ 345 | function userLoadCsvFile(csvData) { 346 | onCsvFileDataLoaded(csvData, "input.csv"); 347 | } 348 | 349 | /******************************************************************************* 350 | * 351 | * TEST DEFINITIONS 352 | * 353 | ******************************************************************************/ 354 | 355 | function testConfiguration() { 356 | failIfConditionIsFalse(coordinatesFractionalDigits >= 0); 357 | failIfConditionIsFalse(maxMultiPointLinePoints > 0); 358 | failIfConditionIsFalse(maxMultiPointLinePoints <= 10000); 359 | failIfConditionIsFalse(pointRadius > 0); 360 | failIfConditionIsFalse(gpsFrequency > 0); 361 | failIfConditionIsFalse(multiPointLineStepSize > 0); 362 | failIfConditionIsFalse(numSatellites >= 0); 363 | failIfConditionIsFalse(numSatellites < 13); 364 | failIfConditionIsFalse(minZoomLevel > 0); 365 | failIfConditionIsFalse(maxZoomLevel > minZoomLevel); 366 | } 367 | 368 | function testSelectTool() { 369 | $.each(ToolsEnum, function(_dummy, toolId) { 370 | userSelectTool(toolId); 371 | }); 372 | } 373 | 374 | function testAddPoints() { 375 | userAddRandomPoints(numTestPoints); 376 | } 377 | 378 | function testAddPointsDeletePoints() { 379 | userAddRandomPoints(numTestPoints); 380 | userDeleteRandomPoints(numTestPoints); 381 | } 382 | 383 | function testAddPointsMovePoints() { 384 | userAddRandomPoints(numTestPoints); 385 | userSelectTool(ToolsEnum.MOVEPOINT); 386 | userMoveRandomPoints(numTestPoints); 387 | } 388 | 389 | function testAddPointsEditPoints() { 390 | userAddRandomPoints(numTestPoints); 391 | userSelectTool(ToolsEnum.EDITPOINT); 392 | userEditRandomPoints(numTestPoints); 393 | } 394 | 395 | function testAddPointsPointInfo() { 396 | userAddRandomPoints(numTestPoints); 397 | userSelectTool(ToolsEnum.POINTINFO); 398 | 399 | // Select a point, then close the info popup by clicking on the "×" icon. 400 | for (const point of pointArray) { 401 | userClickPoint(point); 402 | failIfConditionIsFalse(isPointSelected(point)); 403 | userClosePointInfoPopup(); 404 | failIfConditionIsFalse(isPointSelected(null)); 405 | } 406 | 407 | // Select a point and check its associated information. 408 | for (let i = 0; i < pointArray.length; ++i) { 409 | const point = pointArray[i]; 410 | userClickPoint(point); 411 | failIfConditionIsFalse(isPointSelected(point)); 412 | if (i === 0) { 413 | failIfConditionIsFalse(getDistanceToPreviousPoint(i) === null); 414 | } else { 415 | failIfConditionIsFalse(getDistanceToPreviousPoint(i) !== null); 416 | } 417 | if (i === pointArray.length - 1) { 418 | failIfConditionIsFalse(getDistanceToNextPoint(i) === null); 419 | } else { 420 | failIfConditionIsFalse(getDistanceToNextPoint(i) !== null); 421 | } 422 | failIfConditionIsFalse(getPointBearing(i) !== null); 423 | failIfConditionIsFalse(getSpeedAtPointMps(i) !== null); 424 | failIfConditionIsFalse(getSpeedAtPointKnots(i) !== null); 425 | } 426 | userClickOnMap(randomCoordinates()); 427 | failIfConditionIsFalse(isPointSelected(null)); 428 | } 429 | 430 | function testAddMultiPointLine() { 431 | userSelectTool(ToolsEnum.MULTIPOINTLINE); 432 | // The first click on the map adds a single point. 433 | userClickOnMap(randomCoordinates()); 434 | failIfConditionIsFalse(pointArray.length === 1); 435 | userAddMultiPointLine(500); 436 | failIfConditionIsFalse(pointArray.length > 500); 437 | } 438 | 439 | function testAddPointsUndoAll() { 440 | checkNumUndoRedoActions(0, 0); 441 | 442 | // Add numTestPoints points. 443 | for (let i = 0; i < numTestPoints; ++i) { 444 | checkNumUndoRedoActions(i, 0); 445 | userAddRandomPoints(1); 446 | checkNumUndoRedoActions(i + 1, 0); 447 | } 448 | 449 | // Undo as much as possible */ 450 | for (let i = numTestPoints; i > 0; --i) { 451 | checkNumUndoRedoActions(i, numTestPoints - i); 452 | failIfConditionIsFalse(pointArray.length === i); 453 | userClickUndo(); 454 | checkNumUndoRedoActions(i - 1, numTestPoints - (i - 1)); 455 | failIfConditionIsFalse(pointArray.length === i - 1); 456 | } 457 | } 458 | 459 | function testAddPointsUndoAllRedoAll() { 460 | testAddPointsUndoAll(); 461 | // Redo as much as possible */ 462 | for (let i = 0; i < numTestPoints; ++i) { 463 | checkNumUndoRedoActions(i, numTestPoints - i); 464 | failIfConditionIsFalse(pointArray.length === i); 465 | userClickRedo(); 466 | checkNumUndoRedoActions(i + 1, numTestPoints - (i + 1)); 467 | failIfConditionIsFalse(pointArray.length === i + 1); 468 | } 469 | } 470 | 471 | function testAddPointsUndoAllAddPoint() { 472 | testAddPointsUndoAll(); 473 | checkNumUndoRedoActions(0, numTestPoints); 474 | userAddRandomPoints(1); 475 | // After adding a point, we have a single "undo" action and no "redo" action. 476 | checkNumUndoRedoActions(1, 0); 477 | } 478 | 479 | function testMoveMouseCursor() { 480 | // Test the preview points/segments before any point is added. 481 | $.each(ToolsEnum, function(_dummy, toolId) { 482 | userSelectTool(toolId); 483 | // Mouse cursor motion: map -> menu -> map -> menu. 484 | for (let j = 0; j < 2; ++j) { 485 | const mouseCoordinates = randomCoordinates(); 486 | userMoveMouseCursorOverMap(mouseCoordinates); 487 | failIfConditionIsFalse(lastMouseCoordinates !== null); 488 | // Both "add point" and "multi-point line" tools behave the same way if 489 | // no points have been added to the map yet. 490 | if ( 491 | toolId === ToolsEnum.ADDPOINT || 492 | toolId === ToolsEnum.MULTIPOINTLINE 493 | ) { 494 | checkPreviewPointsAndSegments(1, 0); 495 | failIfConditionIsFalse( 496 | areCoordinatesEqual( 497 | previewPointArray[0].getLatLng(), 498 | mouseCoordinates 499 | ) 500 | ); 501 | } else { 502 | checkPreviewPointsAndSegments(0, 0); 503 | } 504 | userMoveMouseCursorToMenu(); 505 | failIfConditionIsFalse(lastMouseCoordinates === null); 506 | checkPreviewPointsAndSegments(0, 0); 507 | } 508 | }); 509 | 510 | // Test the preview points/segments after points are added. 511 | for (let i = 0; i < numTestPoints; ++i) { 512 | userAddRandomPoints(1); 513 | $.each(ToolsEnum, function(_dummy, toolId) { 514 | userSelectTool(toolId); 515 | // Mouse cursor motion: map -> menu -> map -> menu. 516 | for (let j = 0; j < 2; ++j) { 517 | userMoveMouseCursorOverMap(randomCoordinates()); 518 | failIfConditionIsFalse(lastMouseCoordinates !== null); 519 | if (toolId === ToolsEnum.ADDPOINT) { 520 | checkPreviewPointsAndSegments(1, 1); 521 | } else if (toolId !== ToolsEnum.MULTIPOINTLINE) { 522 | checkPreviewPointsAndSegments(0, 0); 523 | } 524 | userMoveMouseCursorToMenu(); 525 | failIfConditionIsFalse(lastMouseCoordinates === null); 526 | checkPreviewPointsAndSegments(0, 0); 527 | } 528 | }); 529 | } 530 | 531 | // Test the preview points/segments when points are selected. 532 | $.each(ToolsEnum, function(_dummy, toolId) { 533 | // Consider only the tools which can select points. 534 | if ( 535 | [ToolsEnum.MOVEPOINT, ToolsEnum.EDITPOINT, ToolsEnum.POINTINFO].indexOf( 536 | toolId 537 | ) === -1 538 | ) { 539 | return; 540 | } 541 | userSelectTool(toolId); 542 | for (let i = 0; i < pointArray.length; ++i) { 543 | const point = pointArray[i]; 544 | userClickPoint(point); 545 | failIfConditionIsFalse(isPointSelected(point)); 546 | // Mouse cursor motion: map -> menu -> map -> menu. 547 | for (let j = 0; j < 2; ++j) { 548 | const mouseCoordinates = randomCoordinates(); 549 | userMoveMouseCursorOverMap(mouseCoordinates); 550 | failIfConditionIsFalse(lastMouseCoordinates !== null); 551 | const numSegments = i === 0 || i === pointArray.length - 1 ? 1 : 2; 552 | // A selected point is not a preview point, but its inciding segments 553 | // are preview segments. 554 | checkPreviewPointsAndSegments(0, numSegments); 555 | // Move point tool: the selected point must move with the mouse cursor. 556 | if (toolId === ToolsEnum.MOVEPOINT) { 557 | failIfConditionIsFalse( 558 | areCoordinatesEqual(point.getLatLng(), mouseCoordinates) 559 | ); 560 | } 561 | userMoveMouseCursorToMenu(); 562 | failIfConditionIsFalse(lastMouseCoordinates === null); 563 | // A selected point is not a preview point, but its inciding segments 564 | // are preview segments. 565 | checkPreviewPointsAndSegments(0, numSegments); 566 | // Move point tool: the selected point must now be at its original 567 | // position (mouse cursor on menu). 568 | if (toolId === ToolsEnum.MOVEPOINT) { 569 | failIfConditionIsFalse( 570 | areCoordinatesEqual( 571 | point.getLatLng(), 572 | selectedPointOriginalCoordinates 573 | ) 574 | ); 575 | } 576 | } 577 | // Deselect the currently selected point. 578 | userClickOnMap(randomCoordinates()); 579 | failIfConditionIsFalse(isPointSelected(null)); 580 | } 581 | }); 582 | } 583 | 584 | function testMultipleActionsUndoAll() { 585 | checkNumUndoRedoActions(0, 0); 586 | userAddRandomPoints(5); 587 | checkNumUndoRedoActions(5, 0); 588 | userAddRandomPoints(3); 589 | checkNumUndoRedoActions(8, 0); 590 | userDeleteRandomPoints(3); 591 | checkNumUndoRedoActions(11, 0); 592 | userMoveRandomPoints(5); 593 | checkNumUndoRedoActions(16, 0); 594 | userEditRandomPoints(4); 595 | checkNumUndoRedoActions(20, 0); 596 | userAddMultiPointLine(1); 597 | checkNumUndoRedoActions(21, 0); 598 | 599 | for (let i = 21; i > 0; --i) { 600 | checkNumUndoRedoActions(i, 21 - i); 601 | userClickUndo(); 602 | checkNumUndoRedoActions(i - 1, 21 - (i - 1)); 603 | } 604 | } 605 | 606 | function testInnocuousActionsOnEmptyMap() { 607 | $.each(ToolsEnum, function(_dummy, toolId) { 608 | // Consider only the tools which do not add points. 609 | if ([ToolsEnum.ADDPOINT, ToolsEnum.MULTIPOINTLINE].indexOf(toolId) !== -1) { 610 | return; 611 | } 612 | userSelectTool(toolId); 613 | for (let i = 0; i < numTestPoints; ++i) { 614 | userClickOnMap(randomCoordinates()); 615 | checkNumUndoRedoActions(0, 0); 616 | } 617 | }); 618 | } 619 | 620 | function testLoadNmeaData() { 621 | const nmeaData = 622 | "$GPGGA,215909.285,5232.252,N,01321.913,E,1,12,1.0,0.0,M,0.0,M,,*6E\n" + 623 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 624 | "$GPRMC,215909.285,A,5232.252,N,01321.913,E,1314.7,090.0,251216,000.0,W*40\n" + 625 | "$GPGGA,215910.285,5232.252,N,01322.513,E,1,12,1.0,0.0,M,0.0,M,,*69\n" + 626 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 627 | "$GPRMC,215910.285,A,5232.252,N,01322.513,E,2161.5,000.0,251216,000.0,W*4F\n" + 628 | "$GPGGA,215911.285,5232.852,N,01322.513,E,1,12,1.0,0.0,M,0.0,M,,*62\n" + 629 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 630 | "$GPRMC,215911.285,A,5232.852,N,01322.513,E,1314.4,270.0,251216,000.0,W*43\n" + 631 | "$GPGGA,215912.285,5232.852,N,01321.913,E,1,12,1.0,0.0,M,0.0,M,,*6E\n" + 632 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 633 | "$GPRMC,215912.285,A,5232.852,N,01321.913,E,1314.4,270.0,251216,000.0,W*4F\n"; 634 | userLoadNmeaFile(nmeaData); 635 | 636 | const date = new Date(Date.UTC(2016, 11, 25, 21, 59, 9, 285)); 637 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 638 | failIfConditionIsFalse(gpsFrequency === 1.0); 639 | failIfConditionIsFalse($("#start-time").val() === "21:59:09.285"); 640 | failIfConditionIsFalse($("#start-date").val() === "2016-12-25"); 641 | checkNumUndoRedoActions(1, 0); 642 | failIfConditionIsFalse(generateNmeaData() === nmeaData); 643 | } 644 | 645 | function testLoadCsvData() { 646 | const csvData = 647 | "52.518509,13.399575\n" + 648 | "52.518481,13.399696\n" + 649 | "52.518509,13.399814\n" + 650 | "52.518591,13.399857\n" + 651 | "52.518652,13.399817\n" + 652 | "52.518683,13.399721\n" + 653 | "52.518664,13.399608\n" + 654 | "52.518709,13.399559\n" + 655 | "52.518755,13.399516\n"; 656 | userLoadCsvFile(csvData); 657 | failIfConditionIsFalse(generateCsvData() === csvData); 658 | } 659 | 660 | function testLoadNmeaDataCornerCases() { 661 | let date = new Date(); 662 | let nmeaData = ""; 663 | 664 | // Case #1: no RMC or GGA sentences. 665 | userLoadNmeaFile(nmeaData); 666 | failIfConditionIsFalse(gpsFrequency === 1.0); 667 | failIfConditionIsFalse( 668 | Math.abs(startDate.getTime() - date.getTime()) <= 1000 669 | ); 670 | failIfConditionIsFalse( 671 | $("#start-time").val() === timeToUtcTimeOfDay(startDate) 672 | ); 673 | failIfConditionIsFalse($("#start-date").val() === timeToUtcDate(startDate)); 674 | 675 | // Case #2: no RMC sentences, GGA sentences happen at the same day. 676 | nmeaData = 677 | "$GPGGA,132305.387,5233.053,N,01322.057,E,1,12,1.0,0.0,M,0.0,M,,*65\n" + 678 | "$GPGGA,132305.487,5233.053,N,01323.705,E,1,12,1.0,0.0,M,0.0,M,,*63\n"; 679 | userLoadNmeaFile(nmeaData); 680 | date = new Date(); 681 | date.setUTCHours(13); 682 | date.setUTCMinutes(23); 683 | date.setUTCSeconds(5); 684 | date.setUTCMilliseconds(387); 685 | 686 | failIfConditionIsFalse(gpsFrequency === 10.0); 687 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 688 | failIfConditionIsFalse($("#start-time").val() === "13:23:05.387"); 689 | failIfConditionIsFalse($("#start-date").val() === timeToUtcDate(startDate)); 690 | 691 | // Case #3: no RMC sentences, GGA sentences happen at different days. 692 | nmeaData = 693 | "$GPGGA,235959.800,5233.053,N,01322.057,E,1,12,1.0,0.0,M,0.0,M,,*66\n" + 694 | "$GPGGA,000000.200,5233.053,N,01323.705,E,1,12,1.0,0.0,M,0.0,M,,*6C\n"; 695 | userLoadNmeaFile(nmeaData); 696 | date = new Date(); 697 | date.setUTCHours(23); 698 | date.setUTCMinutes(59); 699 | date.setUTCSeconds(59); 700 | date.setUTCMilliseconds(800); 701 | 702 | failIfConditionIsFalse(gpsFrequency === 2.5); 703 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 704 | failIfConditionIsFalse($("#start-time").val() === "23:59:59.800"); 705 | failIfConditionIsFalse($("#start-date").val() === timeToUtcDate(startDate)); 706 | 707 | // Case #4: no GGA sentences, but RMC sentence present. 708 | nmeaData = "$GPRMC,140355.726,A,5231.794,N,01323.334,E,,,051216,000.0,W*76\n"; 709 | userLoadNmeaFile(nmeaData); 710 | date = new Date(Date.UTC(2016, 11, 5, 14, 3, 55, 726)); 711 | 712 | failIfConditionIsFalse(gpsFrequency === 1.0); 713 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 714 | failIfConditionIsFalse($("#start-time").val() === "14:03:55.726"); 715 | failIfConditionIsFalse($("#start-date").val() === "2016-12-05"); 716 | 717 | // Case #5: both GGA and RMC sentences, all in the same day, GGA first. 718 | nmeaData = 719 | "$GPGGA,134756.992,5231.186,N,01320.821,E,1,12,1.0,0.0,M,0.0,M,,*6F\n" + 720 | "$GPRMC,134756.992,A,5231.186,N,01320.821,E,47426.2,090.8,271216,000.0,W*7A\n" + 721 | "$GPGGA,134757.192,5231.124,N,01325.147,E,1,12,1.0,0.0,M,0.0,M,,*62\n" + 722 | "$GPRMC,134757.192,A,5231.124,N,01325.147,E,47426.2,090.8,271216,000.0,W*77\n"; 723 | userLoadNmeaFile(nmeaData); 724 | date = new Date(Date.UTC(2016, 11, 27, 13, 47, 56, 992)); 725 | 726 | failIfConditionIsFalse(gpsFrequency === 5.0); 727 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 728 | failIfConditionIsFalse($("#start-time").val() === "13:47:56.992"); 729 | failIfConditionIsFalse($("#start-date").val() === "2016-12-27"); 730 | 731 | // Case #6: both GGA and RMC sentences, all in the same day, RMC first 732 | nmeaData = 733 | "$GPRMC,144441.207,A,5232.139,N,01319.729,E,22395.9,094.5,181116,000.0,W*74\n" + 734 | "$GPGGA,144441.457,5231.938,N,01322.263,E,1,12,1.0,0.0,M,0.0,M,,*6A\n" + 735 | "$GPRMC,144441.457,A,5231.938,N,01322.263,E,21634.9,085.9,181116,000.0,W*7F\n" + 736 | "$GPGGA,144441.707,5232.114,N,01324.714,E,1,12,1.0,0.0,M,0.0,M,,*6A\n"; 737 | userLoadNmeaFile(nmeaData); 738 | date = new Date(Date.UTC(2016, 10, 18, 14, 44, 41, 207)); 739 | 740 | failIfConditionIsFalse(gpsFrequency === 4.0); 741 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 742 | failIfConditionIsFalse($("#start-time").val() === "14:44:41.207"); 743 | failIfConditionIsFalse($("#start-date").val() === "2016-11-18"); 744 | 745 | // Case #7: both GGA and RMC sentences, but in different days, GGA first. 746 | nmeaData = 747 | "$GPGGA,235959.700,5231.161,N,01322.119,E,1,12,1.0,0.0,M,0.0,M,,*60\n" + 748 | "$GPRMC,000000.200,A,5231.111,N,01325.373,E,14273.7,090.9,101016,000.0,W*7D\n" + 749 | "$GPGGA,000000.200,5231.111,N,01325.373,E,1,12,1.0,0.0,M,0.0,M,,*6A\n"; 750 | userLoadNmeaFile(nmeaData); 751 | date = new Date(Date.UTC(2016, 9, 9, 23, 59, 59, 700)); 752 | 753 | failIfConditionIsFalse(gpsFrequency === 2.0); 754 | failIfConditionIsFalse(startDate.getTime() === date.getTime()); 755 | failIfConditionIsFalse($("#start-time").val() === "23:59:59.700"); 756 | failIfConditionIsFalse($("#start-date").val() === "2016-10-09"); 757 | } 758 | 759 | function testGenerateNmeaData() { 760 | // 2016-12-25T21:59:09.285Z (as ISO string). 761 | const date = new Date(0); 762 | date.setUTCMilliseconds(Date.UTC(2016, 11, 25, 21, 59, 9, 285)); 763 | setStartDate(date); 764 | userSelectTool(ToolsEnum.ADDPOINT); 765 | userClickOnMap(L.latLng(52.537525, 13.365224)); 766 | userClickOnMap(L.latLng(52.537525, 13.375224)); 767 | userClickOnMap(L.latLng(52.547525, 13.375224)); 768 | userClickOnMap(L.latLng(52.547525, 13.365224)); 769 | const nmeaData = 770 | "$GPGGA,215909.285,5232.252,N,01321.913,E,1,12,1.0,0.0,M,0.0,M,,*6E\n" + 771 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 772 | "$GPRMC,215909.285,A,5232.252,N,01321.913,E,1314.7,090.0,251216,000.0,W*40\n" + 773 | "$GPGGA,215910.285,5232.252,N,01322.513,E,1,12,1.0,0.0,M,0.0,M,,*69\n" + 774 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 775 | "$GPRMC,215910.285,A,5232.252,N,01322.513,E,2161.5,000.0,251216,000.0,W*4F\n" + 776 | "$GPGGA,215911.285,5232.852,N,01322.513,E,1,12,1.0,0.0,M,0.0,M,,*62\n" + 777 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 778 | "$GPRMC,215911.285,A,5232.852,N,01322.513,E,1314.4,270.0,251216,000.0,W*43\n" + 779 | "$GPGGA,215912.285,5232.852,N,01321.913,E,1,12,1.0,0.0,M,0.0,M,,*6E\n" + 780 | "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,1.0,1.0,1.0*30\n" + 781 | "$GPRMC,215912.285,A,5232.852,N,01321.913,E,1314.4,270.0,251216,000.0,W*4F\n"; 782 | failIfConditionIsFalse(generateNmeaData() === nmeaData); 783 | } 784 | 785 | function runTests() { 786 | const tests = { 787 | "Configuration parameters": testConfiguration, 788 | "Tool selection": testSelectTool, 789 | "Add points": testAddPoints, 790 | "Add points, delete points": testAddPointsDeletePoints, 791 | "Add points, move points": testAddPointsMovePoints, 792 | "Add points, edit points": testAddPointsEditPoints, 793 | "Add points, get point information": testAddPointsPointInfo, 794 | "Add multi-point line": testAddMultiPointLine, 795 | "Add points, undo all": testAddPointsUndoAll, 796 | "Add points, undo all, redo all": testAddPointsUndoAllRedoAll, 797 | "Add points, undo all, add point": testAddPointsUndoAllAddPoint, 798 | "Move mouse cursor, check preview points": testMoveMouseCursor, 799 | "Do multiple things, undo all": testMultipleActionsUndoAll, 800 | "Innocuous actions on empty map": testInnocuousActionsOnEmptyMap, 801 | "Load nmea data": testLoadNmeaData, 802 | "Load CSV data": testLoadCsvData, 803 | "Load nmea data with corner cases": testLoadNmeaDataCornerCases, 804 | "Generate nmea data": testGenerateNmeaData 805 | }; 806 | 807 | for (const [testName, testFunction] of Object.entries(tests)) { 808 | try { 809 | testFunction(); 810 | tearDown(); 811 | console.log("[PASS] " + testName + "."); 812 | } catch (error) { 813 | console.error("[FAIL] " + testName + " (" + error + ")."); 814 | console.error(error.stack); 815 | break; 816 | } 817 | } 818 | } 819 | --------------------------------------------------------------------------------