├── .github ├── dependabot.yml └── workflows │ ├── deploy.yml │ └── test_build.yml ├── .gitignore ├── CNAME ├── LICENSE ├── README.md ├── css ├── fonts │ ├── NationalPark-Bold.otf │ ├── NationalPark-ExtraBold.otf │ ├── NationalPark-ExtraLight.otf │ ├── NationalPark-Light.otf │ ├── NationalPark-Medium.otf │ ├── NationalPark-Regular.otf │ └── NationalPark-SemiBold.otf ├── main.css └── reset.css ├── img ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── arrow-right.png ├── favicon.ico ├── opentrailmap-logo-filled.svg ├── opentrailmap-logo.svg └── select-arrows.png ├── index.html ├── js ├── controlsController.js ├── hashController.js ├── mapController.js ├── optionsData.js ├── osmController.js ├── sidebarController.js ├── stateController.js ├── styleGenerator.js └── utils.js ├── json └── focus.json ├── package.json ├── rollup.config.js ├── scripts └── buildStaticStyles.js ├── serve.js └── style ├── basestyle.json └── sprites ├── opentrailmap.json ├── opentrailmap.png ├── opentrailmap@2x.json ├── opentrailmap@2x.png └── svg ├── access_point-minor.svg ├── access_point-noaccess.svg ├── access_point.svg ├── arrow-left.svg ├── arrow-right.svg ├── arrows-leftright.svg ├── beaver_dam-canoeable.svg ├── beaver_dam-hazard.svg ├── beaver_dam.svg ├── bird_refuge.svg ├── bison_refuge.svg ├── cairn.svg ├── campground-noaccess.svg ├── campground.svg ├── campsite.svg ├── caravan_site-noaccess.svg ├── caravan_site.svg ├── dam-canoeable.svg ├── dam-hazard.svg ├── dam.svg ├── disallowed-stripes.svg ├── ferry-noaccess.svg ├── ferry.svg ├── forest_reserve.svg ├── game_land.svg ├── grassland_reserve.svg ├── guidepost.svg ├── lean_to.svg ├── lock-canoeable.svg ├── lock-hazard.svg ├── lock.svg ├── nature_reserve.svg ├── park.svg ├── peak.svg ├── protected_area.svg ├── question.svg ├── ranger_station-noaccess.svg ├── ranger_station.svg ├── restricted-zone.svg ├── route_marker.svg ├── slipway-canoe-noaccess.svg ├── slipway-canoe-trailer-noaccess.svg ├── slipway-canoe-trailer.svg ├── slipway-canoe.svg ├── streamgage.svg ├── trailhead-noaccess.svg ├── trailhead.svg ├── viewpoint.svg ├── waterfall-canoeable.svg ├── waterfall-hazard.svg ├── waterfall.svg ├── watershed_reserve.svg ├── wilderness_preserve.svg └── wildlife_refuge.svg /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy site to GitHub Pages 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Setup Pages 31 | uses: actions/configure-pages@v5 32 | - name: Install 33 | run: npm install 34 | - name: Build 35 | run: npm run build 36 | - name: Build static styles 37 | run: npm run build-static-styles 38 | - name: Generate directory listing pages 39 | uses: jayanta525/github-pages-directory-listing@v4.0.0 40 | with: 41 | FOLDER: '.' 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload entire repository 46 | path: '.' 47 | 48 | # Deployment job 49 | deploy: 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | runs-on: ubuntu-latest 54 | needs: build 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v4 59 | -------------------------------------------------------------------------------- /.github/workflows/test_build.yml: -------------------------------------------------------------------------------- 1 | name: Run test build 2 | 3 | on: 4 | # Runs on pull requests targeting the default branch 5 | pull_request: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Install 21 | run: npm install 22 | - name: Build 23 | run: npm run build 24 | - name: Build static styles 25 | run: npm run build-static-styles -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | dist -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | opentrailmap.us -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OpenStreetMap US 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenTrailMap 2 | 3 | _[opentrailmap.us](https://opentrailmap.us)_ 4 | 5 | This is a prototype web map application for viewing [OpenStreetMap](https://openstreetmap.org/about) (OSM) trail data. The tool is being developed in support of our [Trails Stewardship Initiative](https://openstreetmap.us/our-work/trails/), a community project to improve the quality of trail data in OSM. 6 | 7 | ⚠️ This tool is still in early development and serves as a proof-of-concept. OpenStreetMap US is seeking funding partners to build out the tool as the primary app for visualizing, updating, validating, and maintaining OpenStreetMap trail data in the United States. The app will close the feedback loop between trail users, trail managers, and trail mappers. If you or your organization are interested in supporting this tool, please [contact us](https://openstreetmap.us/contact/) or consider [donating](https://openstreetmap.app.neoncrm.com/forms/trails-stewardship-initiative). 8 | 9 | ## Prototype functionality 10 | 11 | ### UI features 12 | 13 | - View OpenStreetMap trail data using various map styles. 14 | - Click a feature to view its current tags, relations, and metadata. 15 | - Use quick links to open the feature on [openstreetmap.org](https://openstreetmap.org), iD, JOSM, and other viewers. 16 | 17 | ### Map styles 18 | 19 | OpenTrailMap aims to display all land trails, snow trails, and water trails present in OpenStreetMap. 20 | 21 | #### Land and snow trails 22 | 23 | The following styles show allowed trail access for different travel modes. Dark green lines are public paths, while striped pale green lines are restricted or infeasible for the given travel mode. Dashed lines are `informal=yes`, while solid lines are `infomal=no` or `informal` not given. 24 | 25 | - Hiking & Walking Trails ([`foot`](https://wiki.openstreetmap.org/wiki/Key:foot) access) 26 | - Wheelchair Trails ([`wheelchair`](https://wiki.openstreetmap.org/wiki/Key:wheelchair) access) 27 | - Biking Trails ([`bicycle`](https://wiki.openstreetmap.org/wiki/Key:bicycle) access) 28 | - Mountain Biking Trails ([`mtb`](https://wiki.openstreetmap.org/wiki/Key:mtb) access) 29 | - Inline Skating Trails ([`inline_skates`](https://wiki.openstreetmap.org/wiki/Key:inline_skates) access) 30 | - Horseback Riding Trails ([`horse`](https://wiki.openstreetmap.org/wiki/Key:horse) access) 31 | - ATV Trails ([`atv`](https://wiki.openstreetmap.org/wiki/Key:atv) access) 32 | - Cross-Country Ski Trails ([`ski:nordic`](https://wiki.openstreetmap.org/wiki/Key:ski:nordic) access) 33 | - Snowmobile Trails ([`snowmobile`](https://wiki.openstreetmap.org/wiki/Key:snowmobile) access) 34 | 35 | The following styles highlight the presence and values of trail attribute tags. Purple lines mean an attribute is missing, incomplete, or needs review, while teal lines indicate the attribute is good to go. 36 | 37 | - [`operator`](https://wiki.openstreetmap.org/wiki/Key:operator)/[`informal`](https://wiki.openstreetmap.org/wiki/Key:informal) 38 | - [`name`](https://wiki.openstreetmap.org/wiki/Key:name)/[`noname`](https://wiki.openstreetmap.org/wiki/Key:noname) 39 | - [`surface`](https://wiki.openstreetmap.org/wiki/Key:surface) 40 | - [`smoothness`](https://wiki.openstreetmap.org/wiki/Key:smoothness) 41 | - [`trail_visibility`](https://wiki.openstreetmap.org/wiki/Key:trail_visibility) 42 | - [`width`](https://wiki.openstreetmap.org/wiki/Key:width) 43 | - [`incline`](https://wiki.openstreetmap.org/wiki/Key:incline) 44 | - [`fixme`](https://wiki.openstreetmap.org/wiki/Key:fixme)/[`todo`](https://wiki.openstreetmap.org/wiki/Key:todo) 45 | - [`check_date`](https://wiki.openstreetmap.org/wiki/Key:check_date)/[`survey:date`](https://wiki.openstreetmap.org/wiki/Key:survey:date) 46 | - Last Edited Date: the timestamp of the latest version of the feature 47 | 48 | In all the land and snow styles, some trail-related points of interest are included on the map: 49 | 50 | - [`amenity=ranger_station`](https://wiki.openstreetmap.org/wiki/Tag:amenity%3Dranger_station): ranger stations are generally public visitor centers where trail users can get info or seek help 51 | - [`highway=trailhead`](https://wiki.openstreetmap.org/wiki/Tag:highway%3Dtrailhead): trailheads are access points to trail networks, often with various amenities 52 | - [`information=guidepost`](https://wiki.openstreetmap.org/wiki/Tag:information%3Dguidepost): signage marking the direction of one or more trails, typically at a trailhead or junction 53 | - [`information=route_marker`](https://wiki.openstreetmap.org/wiki/Tag:information%3Droute_marker): signage marking the route of a trail 54 | 55 | #### Water trails 56 | 57 | Currently, just one marine travel mode is supported: 58 | 59 | - Canoe & Kayak Trails ([`canoe`](https://wiki.openstreetmap.org/wiki/Key:canoe)/[`portage`](https://wiki.openstreetmap.org/wiki/Key:portage) access) 60 | 61 | The following water trail attribute styles are supported: 62 | 63 | - [`name`](https://wiki.openstreetmap.org/wiki/Key:name)/[`noname`](https://wiki.openstreetmap.org/wiki/Key:noname)/[`waterbody:name`](https://wiki.openstreetmap.org/wiki/Key:waterbody:name) 64 | - [`tidal`](https://wiki.openstreetmap.org/wiki/Key:tidal) 65 | - [`intermittent`](https://wiki.openstreetmap.org/wiki/Key:intermittent) 66 | - [`rapids`](https://wiki.openstreetmap.org/wiki/Key:rapids) 67 | - [`open_water`](https://wiki.openstreetmap.org/wiki/Key:open_water) 68 | - [`oneway:canoe`](https://wiki.openstreetmap.org/wiki/Key:oneway:canoe)/[`oneway:boat`](https://wiki.openstreetmap.org/wiki/Key:oneway:boat) 69 | - [`width`](https://wiki.openstreetmap.org/wiki/Key:width) 70 | - [`fixme`](https://wiki.openstreetmap.org/wiki/Key:fixme)/[`todo`](https://wiki.openstreetmap.org/wiki/Key:todo) 71 | - [`check_date`](https://wiki.openstreetmap.org/wiki/Key:check_date)/[`survey:date`](https://wiki.openstreetmap.org/wiki/Key:survey:date) 72 | - Last Edited Date: the timestamp of the latest version of the feature 73 | 74 | ### Map tiles 75 | Trail vector tiles are rendered and hosted by OpenStreetMap US using the schema files [here](https://github.com/osmus/tileservice/blob/main/renderer/layers). Thank you to [@zelonewolf](https://github.com/zelonewolf) for setting up the vector tile pipeline. Render time is currently about 4 hours, so any changes you make will take 4 to 8 hours to appear on the map. Map tiles are not available for public use at this time. 76 | 77 | ### Static stylesheets 78 | OpenTrailMap has complex, parameter-driven styling. For performance, styles are generated at runtime. However, static stylesheets are also generated at build time for ease-of-use by other apps. You can browse the full list of available styles [here](https://opentrailmap.us/dist/styles/). Generated styles are subject to the same license as the rest of OpenTrailMap (see below). 79 | 80 | ## Get involved 81 | 82 | ### Code of Conduct 83 | Participation in OpenTrailMap is subject to the [OpenStreetMap US Code of Conduct](https://wiki.openstreetmap.org/wiki/Foundation/Local_Chapters/United_States/Code_of_Conduct_Committee/OSM_US_Code_of_Conduct). Please take a moment to review the CoC prior to contributing, and remember to be nice :) 84 | 85 | ### Contributing 86 | 87 | You can open an [issue](https://github.com/osmus/OpenTrailMap/issues) in this repository if you have a question or comment. Please search existing issues first in case someone else had the same thought. [Pull request](https://github.com/osmus/OpenTrailMap/pulls) are public, but we recommend opening or commenting on an issue before writing any code so that we can make sure your work is aligned with the goals of the project. 88 | 89 | We also collaborate via the [#opentrailmap](https://osmus.slack.com/archives/opentrailmap) channel on [OpenStreetMap US Slack](https://openstreetmap.us/slack). Anyone is free to join. 90 | 91 | ### Development setup 92 | 1. [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) 93 | 2. Open your terminal and `cd` into the repo's directory 94 | 3. Run `npm install` and `npm run build` (first-time setup only) 95 | 4. Run `node serve.js` to start the development server 96 | 5. Visit [http://localhost:4001](http://localhost:4001) in your browser 97 | 6. That's it! 98 | 99 | #### Building sprites 100 | 101 | Source vector images for use in the map are located at [/style/sprites/svg/](/style/sprites/svg/). If you add or change any of these, you'll need to rebuild the spritesheets. 102 | 103 | 1. Install the [spreet](https://github.com/flother/spreet) command line tool 104 | 2. Run `npm run sprites` 105 | 106 | ## License 107 | 108 | The OpenTrailMap source code is distributed under the [MIT license](https://github.com/osmus/OpenTrailMap/blob/main/LICENSE). Dependencies are subject to their respective licenses. 109 | -------------------------------------------------------------------------------- /css/fonts/NationalPark-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-Bold.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-ExtraBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-ExtraBold.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-ExtraLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-ExtraLight.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-Light.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-Medium.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-Regular.otf -------------------------------------------------------------------------------- /css/fonts/NationalPark-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/css/fonts/NationalPark-SemiBold.otf -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | @font-face{ 2 | src: url('fonts/NationalPark-ExtraBold.otf'); 3 | font-family: "National Park"; 4 | font-weight: 800; 5 | } 6 | @font-face{ 7 | src: url('fonts/NationalPark-Bold.otf'); 8 | font-family: "National Park"; 9 | font-weight: 700; 10 | } 11 | @font-face{ 12 | src: url('fonts/NationalPark-SemiBold.otf'); 13 | font-family: "National Park"; 14 | font-weight: 600; 15 | } 16 | @font-face{ 17 | src: url('fonts/NationalPark-Medium.otf'); 18 | font-family: "National Park"; 19 | font-weight: 500; 20 | } 21 | @font-face{ 22 | src: url('fonts/NationalPark-Regular.otf'); 23 | font-family: "National Park"; 24 | font-weight: 400; 25 | } 26 | 27 | b, strong { 28 | font-weight: bold; 29 | } 30 | i { 31 | font-style: italic; 32 | } 33 | 34 | td, th { 35 | padding: 4px; 36 | } 37 | table { 38 | max-width: 100%; 39 | } 40 | th { 41 | font-weight: bold; 42 | } 43 | td { 44 | border: 1px solid #eee; 45 | } 46 | 47 | body { 48 | margin: 0; 49 | padding: 0; 50 | font-family: "National Park", "Source Sans", "Helvetica", sans-serif; 51 | line-height: 1.25em; 52 | } 53 | h1, h2, h3, h4, h5, h6 { 54 | font-weight: 700; 55 | } 56 | select { 57 | 58 | font-family: inherit; 59 | line-height: 1.4em; 60 | 61 | background: white; 62 | border: 1px solid #ddd; 63 | border-radius: 5px; 64 | padding: 4px; 65 | 66 | appearance: none; 67 | -webkit-appearance: none; 68 | -moz-appearance:none; 69 | 70 | background-image: url(/img/select-arrows.png) !important; 71 | background-size: 11px; 72 | background-repeat: no-repeat !important; 73 | background-position-x: 100% !important; 74 | background-position-y: 50% !important; 75 | } 76 | #header { 77 | width: 100%; 78 | color: #FFE2A8; 79 | z-index: 1; 80 | position: relative; 81 | display: flex; 82 | align-items: center; 83 | height: 30px; 84 | background: #4f2e28; 85 | padding: 0 10px 0 8px; 86 | /*background: linear-gradient(90deg, #4A282A, #66373a);*/ 87 | overflow-x: auto; 88 | } 89 | #header a { 90 | text-decoration: none; 91 | color: inherit; 92 | } 93 | #header a:hover { 94 | text-decoration: underline; 95 | } 96 | #header .main-menu { 97 | display: flex; 98 | flex-basis: 100%; 99 | justify-content: end; 100 | } 101 | #header .main-menu li { 102 | margin-left: 20px; 103 | } 104 | #header img { 105 | height: 20px; 106 | width: 20px; 107 | margin-right: 6px; 108 | } 109 | #header .site-title { 110 | white-space: nowrap; 111 | display: flex; 112 | align-items: center; 113 | flex-direction: row; 114 | font-size: 15px; 115 | } 116 | #header .site-title a { 117 | display: inline-flex; 118 | margin-right: 3px; 119 | } 120 | 121 | #body { 122 | display: flex; 123 | width: 100%; 124 | position: absolute; 125 | bottom: 0; 126 | top: 30px; 127 | } 128 | #sidebar p { 129 | margin-bottom: 12px; 130 | } 131 | #map-wrap { 132 | z-index: 0; 133 | flex-basis: 100%; 134 | padding: 10px; 135 | position: relative; 136 | } 137 | body.sidebar-open #map-wrap { 138 | padding-right: 0; 139 | } 140 | #map { 141 | border: 1px solid #ccc; 142 | height: 100%; 143 | width: 100%; 144 | } 145 | #map .quickinfo { 146 | font-family: "National Park", "Source Sans", "Helvetica", sans-serif; 147 | } 148 | #top-left { 149 | position: absolute; 150 | top: 10px; 151 | left: 10px; 152 | z-index: 2; 153 | max-width: 100%; 154 | } 155 | #nameplate { 156 | color: #000;/*#331d1a;*/ 157 | line-height: 1.4; 158 | padding: 0 10px 10px 0; 159 | border-right: 1px solid #ccc; 160 | border-bottom: 1px solid #ccc; 161 | background: #fff; 162 | text-align: left; 163 | display: none; 164 | } 165 | .area-focused #nameplate { 166 | display: flex !important; 167 | } 168 | #nameplate select { 169 | font-size: 16px; 170 | width: 100%; 171 | } 172 | #nameplate > *:not(:last-child) { 173 | margin-bottom: 8px; 174 | } 175 | #nameplate .swatch { 176 | width: 1em; 177 | height: 1em; 178 | display: inline-block; 179 | vertical-align: text-bottom; 180 | margin-right: 6px; 181 | border: 1px solid #738C40; 182 | background: #D8E8B7; 183 | } 184 | #nameplate a { 185 | color: #aaa; 186 | text-decoration: none; 187 | vertical-align: text-bottom; 188 | padding-left: 8px; 189 | margin-top: -2px; 190 | } 191 | #nameplate a:hover { 192 | text-decoration: underline; 193 | } 194 | #nameplate h1 { 195 | font-size: 20px; 196 | } 197 | #controls { 198 | position: absolute; 199 | left: 10px; 200 | top: 10px; 201 | z-index: 3; 202 | padding: 10px; 203 | display: flex; 204 | flex-direction: column; 205 | } 206 | .area-focused #controls { 207 | top: 49px; 208 | } 209 | #controls select { 210 | font-size: 15px; 211 | width: 200px; 212 | max-width: 100%; 213 | box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); 214 | margin-bottom: 8px; 215 | } 216 | #attribution-logo { 217 | position: absolute; 218 | bottom: 10px; 219 | z-index: 100; 220 | } 221 | #inspect-toggle { 222 | display: block; 223 | position: absolute; 224 | overflow: hidden; 225 | top: calc(50% - 20px); 226 | right: 10px; 227 | height: 40px; 228 | width: 20px; 229 | background: white; 230 | border-radius: 20px 0 0 20px; 231 | border: 1px solid #aaa; 232 | border-right: 0; 233 | box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); 234 | color: inherit; 235 | text-decoration: none; 236 | z-index: 9999; 237 | } 238 | #inspect-toggle:before { 239 | content: ""; 240 | height: 100%; 241 | width: 100%; 242 | position: relative; 243 | margin-left: 2px; 244 | display: inline-block; 245 | background-image: url(/img/arrow-right.png) !important; 246 | background-size: 8px; 247 | background-repeat: no-repeat !important; 248 | background-position-x: 50% !important; 249 | background-position-y: 50% !important; 250 | } 251 | body.sidebar-open #inspect-toggle { 252 | right: 0; 253 | } 254 | body:not(.sidebar-open) #inspect-toggle:before { 255 | rotate: 180deg; 256 | } 257 | #sidebar { 258 | display: none; 259 | flex-direction: column; 260 | background: white; 261 | height: 100%; 262 | min-width: 250px; 263 | width: 33.3333vw; 264 | padding: 10px; 265 | max-height: 100%; 266 | overflow-y: auto; 267 | } 268 | body.sidebar-open #sidebar { 269 | display: flex; 270 | } 271 | #sidebar table { 272 | width: 100%; 273 | } 274 | #sidebar table, 275 | #sidebar p { 276 | margin-bottom: 1em; 277 | } 278 | #sidebar table td { 279 | font-family: 'SF Mono', SFMono-Regular, ui-monospace, -apple-system, monospace; 280 | font-size: 14px; 281 | overflow-wrap: anywhere; 282 | } 283 | #sidebar #tag-table td { 284 | width: 50%; 285 | } 286 | #sidebar #relations-table td:not(:first-child) { 287 | overflow-wrap: normal; 288 | } 289 | #sidebar #meta-table td { 290 | background: rgba(0, 0, 0, 0.02); 291 | } 292 | #sidebar #meta-table td:first-child { 293 | font-family: inherit; 294 | font-weight: 600; 295 | width: 90px; 296 | } 297 | .link-list a { 298 | white-space: nowrap; 299 | } 300 | 301 | @media only screen and (max-width: 600px) { 302 | #body { 303 | flex-direction: column; 304 | } 305 | #top-left { 306 | position: relative; 307 | left: 0; 308 | top: 0; 309 | } 310 | #nameplate { 311 | padding: 10px; 312 | border-right: 0; 313 | } 314 | #header .tagline { 315 | display: none; 316 | } 317 | #sidebar { 318 | width: 100%; 319 | height: 30%; 320 | flex: 0 0 auto; 321 | min-width: 100%; 322 | } 323 | #map-wrap { 324 | position: relative; 325 | display: flex; 326 | flex-direction: column; 327 | padding: 0; 328 | } 329 | #map { 330 | border-width: 0 0 1px 0; 331 | } 332 | #controls { 333 | top: 0 !important; 334 | left: 0; 335 | } 336 | #inspect-toggle { 337 | top: auto; 338 | border-radius: 20px 20px 0 0; 339 | border-right: 1px solid #aaa;; 340 | border-bottom: none; 341 | left: calc(50% - 20px); 342 | bottom: 0; 343 | height: 20px; 344 | width: 40px; 345 | } 346 | #inspect-toggle:before { 347 | rotate: 90deg; 348 | margin-left: auto; 349 | margin-top: 2px; 350 | } 351 | body:not(.sidebar-open) #inspect-toggle:before { 352 | rotate: 270deg; 353 | } 354 | } -------------------------------------------------------------------------------- /css/reset.css: -------------------------------------------------------------------------------- 1 | /* Reset ♥ 2 | http://meyerweb.com/eric/tools/css/reset/ 3 | v2.0 | 20110126 4 | License: none (public domain) 5 | ------------------------------------------------------- */ 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin:0; 20 | padding:0; 21 | border:0; 22 | font-size:100%; 23 | font:inherit; 24 | vertical-align:baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display:block; 30 | } 31 | body { line-height:1; } 32 | ol, ul { list-style:none; } 33 | blockquote, q { quotes:none; } 34 | blockquote:before, blockquote:after, 35 | q:before, q:after { content:''; content:none; } 36 | /* tables still need 'cellspacing="0"' in the markup */ 37 | table { border-collapse: collapse; border-spacing:0; } 38 | /* remember to define focus styles. Hee Haw */ 39 | :focus { outline:0; } 40 | 41 | *, *:after, *:before { 42 | -webkit-box-sizing: border-box; 43 | -moz-box-sizing: border-box; 44 | box-sizing: border-box; 45 | } -------------------------------------------------------------------------------- /img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/apple-touch-icon.png -------------------------------------------------------------------------------- /img/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/arrow-right.png -------------------------------------------------------------------------------- /img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/favicon.ico -------------------------------------------------------------------------------- /img/opentrailmap-logo-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /img/opentrailmap-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /img/select-arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/img/select-arrows.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | 22 | 23 | 24 | OpenTrailMap by OpenStreetMap US 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 43 |
44 |
45 | 48 |
49 |
50 | 51 |
52 | 71 | 72 |
73 |
74 |
75 | 76 |
77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /js/controlsController.js: -------------------------------------------------------------------------------- 1 | import { state } from "./stateController.js"; 2 | import { lensOptionsByMode, lensStrings } from "./optionsData.js"; 3 | import { createElement, getElementById } from "./utils.js"; 4 | 5 | function updateLensControl() { 6 | let select = getElementById("lens") 7 | .replaceChildren( 8 | createElement('option') 9 | .setAttribute('value', '') 10 | .append('General') 11 | ); 12 | 13 | let items = lensOptionsByMode[state.travelMode]; 14 | items.forEach(function(item) { 15 | if (!item.subitems) return; 16 | let group = createElement('optgroup') 17 | .setAttribute('label', item.label) 18 | .append('General'); 19 | group.append( 20 | ...item.subitems.map(function(item) { 21 | let label = item.label ? item.label : lensStrings[item].label; 22 | return createElement('option') 23 | .setAttribute('value', item) 24 | .append(label) 25 | }) 26 | ); 27 | select.append(group); 28 | }); 29 | 30 | select.value = state.lens; 31 | } 32 | 33 | window.addEventListener('load', function() { 34 | 35 | updateLensControl(); 36 | 37 | getElementById("travel-mode").addEventListener('change', function(e) { 38 | state.setTravelMode(e.target.value); 39 | }); 40 | getElementById("lens").addEventListener('change', function(e) { 41 | state.setLens(e.target.value); 42 | }); 43 | getElementById("clear-focus").addEventListener('click', function(e) { 44 | e.preventDefault(); 45 | state.focusEntity(); 46 | }); 47 | 48 | state.addEventListener('travelModeChange', function() { 49 | updateLensControl(); 50 | getElementById("travel-mode").value = state.travelMode; 51 | }); 52 | state.addEventListener('lensChange', function() { 53 | getElementById("lens").value = state.lens; 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /js/hashController.js: -------------------------------------------------------------------------------- 1 | import { state } from "./stateController.js"; 2 | 3 | function setHashParameters(params) { 4 | let searchParams = new URLSearchParams(window.location.hash.slice(1)); 5 | for (let key in params) { 6 | if (params[key]) { 7 | searchParams.set(key, params[key]); 8 | } else if (searchParams.has(key)) { 9 | searchParams.delete(key); 10 | } 11 | } 12 | let hash = "#" + decodeURIComponent(searchParams.toString()); 13 | if (hash !== window.location.hash) { 14 | window.location.hash = hash; 15 | } 16 | } 17 | 18 | function hashValue(key) { 19 | let searchParams = new URLSearchParams(window.location.hash.slice(1)); 20 | if (searchParams.has(key)) return searchParams.get(key); 21 | return null; 22 | } 23 | 24 | function parseEntityInfoFromString(string) { 25 | let components = string.split("/"); 26 | if (components.length == 2) { 27 | let type = components[0]; 28 | let id = parseInt(components[1]); 29 | if (["node", "way", "relation"].includes(type)) { 30 | return { 31 | type: type, 32 | id: id, 33 | }; 34 | } 35 | } 36 | } 37 | 38 | function focusedEntityInfoFromHash() { 39 | let value = hashValue("focus"); 40 | if (value) return parseEntityInfoFromString(value); 41 | return null; 42 | } 43 | 44 | function selectedEntityInfoFromHash() { 45 | let value = hashValue("selected"); 46 | if (value) return parseEntityInfoFromString(value); 47 | return null; 48 | } 49 | 50 | function updateForHash() { 51 | state.setInspectorOpen(hashValue("inspect")); 52 | state.setTravelMode(hashValue("mode")); 53 | state.setLens(hashValue("lens")); 54 | state.selectEntity(selectedEntityInfoFromHash()); 55 | state.focusEntity(focusedEntityInfoFromHash()); 56 | } 57 | 58 | window.addEventListener('load', function() { 59 | 60 | updateForHash(); 61 | 62 | window.addEventListener("hashchange", function() { 63 | updateForHash(); 64 | }); 65 | 66 | state.addEventListener('inspectorOpenChange', function() { 67 | setHashParameters({ inspect: state.inspectorOpen ? '1' : null }); 68 | }); 69 | state.addEventListener('lensChange', function() { 70 | setHashParameters({ lens: state.lens === state.defaultLens ? null : state.lens }); 71 | }); 72 | state.addEventListener('travelModeChange', function() { 73 | setHashParameters({ mode: state.travelMode === state.defaultTravelMode ? null : state.travelMode }); 74 | }); 75 | state.addEventListener('selectedEntityChange', function() { 76 | let selectedEntityInfo = state.selectedEntityInfo; 77 | let type = selectedEntityInfo?.type; 78 | let entityId = selectedEntityInfo?.id; 79 | setHashParameters({ 80 | selected: selectedEntityInfo ? type + "/" + entityId : null 81 | }); 82 | }); 83 | state.addEventListener('focusedEntityChange', function() { 84 | let focusedEntityInfo = state.focusedEntityInfo; 85 | let type = focusedEntityInfo?.type; 86 | let entityId = focusedEntityInfo?.id; 87 | setHashParameters({ 88 | focus: focusedEntityInfo ? type + "/" + entityId : null 89 | }); 90 | }); 91 | 92 | }); 93 | 94 | -------------------------------------------------------------------------------- /js/mapController.js: -------------------------------------------------------------------------------- 1 | import { state } from "./stateController.js"; 2 | import { osm } from "./osmController.js"; 3 | import { generateStyle } from './styleGenerator.js'; 4 | import { createElement, getElementById } from "./utils.js"; 5 | 6 | let map; 7 | 8 | let activePopup; 9 | let baseStyleJsonString; 10 | 11 | let cachedStyles = {}; 12 | 13 | let focusAreaGeoJson; 14 | let focusAreaGeoJsonBuffered; 15 | let focusAreaBoundingBox; 16 | 17 | let hoveredEntityInfo; 18 | 19 | const possibleLayerIdsByCategory = { 20 | clickable: ["trails-pointer-targets", "peaks", "trail-pois", "major-trail-pois", "trail-centerpoints"], 21 | hovered: ["hovered-paths", "hovered-peaks", "hovered-trail-centerpoints", "hovered-pois"], 22 | selected: ["selected-paths", "selected-peaks", "selected-trail-centerpoints", "selected-pois"], 23 | }; 24 | const layerIdsByCategory = { 25 | clickable: [], 26 | hovered: [], 27 | selected: [], 28 | }; 29 | 30 | window.addEventListener('load', function() { 31 | initializeMap(); 32 | 33 | state.addEventListener('inspectorOpenChange', function() { 34 | if (state.inspectorOpen && activePopup) { 35 | activePopup.remove(); 36 | activePopup = null; 37 | } 38 | }); 39 | }); 40 | 41 | async function initializeMap() { 42 | 43 | baseStyleJsonString = await fetch('/style/basestyle.json').then(response => response.text()); 44 | 45 | document.addEventListener('keydown', function(e) { 46 | 47 | if (e.isComposing || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; 48 | 49 | switch(e.key) { 50 | case 'z': 51 | let info = state.selectedEntityInfo || state.focusedEntityInfo; 52 | let feature = info && getFeatureFromLayers(info.id, info.type, ['park', 'trail', 'trail_poi', {source: 'openmaptiles', layer: 'mountain_peak'}]) || info?.rawFeature; 53 | if (feature) { 54 | let bounds = getEntityBoundingBox(feature); 55 | if (bounds) { 56 | fitMapToBounds(bounds); 57 | } else if (feature.geometry.type === "Point") { 58 | map.flyTo({center: feature.geometry.coordinates, zoom: Math.max(map.getZoom(), 12)}); 59 | } 60 | } 61 | break; 62 | } 63 | }); 64 | 65 | // default 66 | let initialCenter = [-111.545, 39.546]; 67 | let initialZoom = 6; 68 | 69 | // show last-open area if any (this is overriden by the URL hash map parameter) 70 | let cachedTransformString = localStorage?.getItem('map_transform'); 71 | let cachedTransform = cachedTransformString && JSON.parse(cachedTransformString); 72 | if (cachedTransform && cachedTransform.zoom && cachedTransform.lat && cachedTransform.lng) { 73 | initialZoom = cachedTransform.zoom; 74 | initialCenter = cachedTransform; 75 | } 76 | 77 | map = new maplibregl.Map({ 78 | container: 'map', 79 | hash: "map", 80 | center: initialCenter, 81 | zoom: initialZoom, 82 | fadeDuration: 0, 83 | }); 84 | 85 | // Add zoom and rotation controls to the map. 86 | map 87 | .addControl(new maplibregl.NavigationControl({ 88 | visualizePitch: true 89 | })) 90 | .addControl(new maplibregl.GeolocateControl({ 91 | positionOptions: { 92 | enableHighAccuracy: true 93 | }, 94 | trackUserLocation: true 95 | })) 96 | .addControl(new maplibregl.ScaleControl({ 97 | maxWidth: 150, 98 | unit: 'imperial' 99 | }), "bottom-left"); 100 | 101 | reloadMapStyle(); 102 | 103 | map.on('mousemove', didMouseMoveMap); 104 | map.on('click', didClickMap); 105 | map.on('dblclick', didDoubleClickMap); 106 | map.on('moveend', checkMapExtent); 107 | map.on('moveend', function() { 108 | if (localStorage) { 109 | let transform = map.getCenter(); 110 | transform.zoom = map.getZoom(); 111 | localStorage.setItem('map_transform', JSON.stringify(transform)); 112 | } 113 | }); 114 | map.on('sourcedata', function(event) { 115 | if (event.sourceId === 'trails' && event.isSourceLoaded) { 116 | reloadFocusAreaIfNeeded(); 117 | } 118 | }); 119 | 120 | state.addEventListener('travelModeChange', function() { 121 | reloadMapStyle(); 122 | }); 123 | state.addEventListener('lensChange', function() { 124 | reloadMapStyle(); 125 | }); 126 | state.addEventListener('selectedEntityChange', function() { 127 | updateMapForSelection(); 128 | 129 | let selectedEntityInfo = state.selectedEntityInfo; 130 | if (selectedEntityInfo && selectedEntityInfo?.type === "relation") { 131 | osm.fetchOsmEntity(selectedEntityInfo.type, selectedEntityInfo.id).then(function() { 132 | // update map again to add highlighting to any relation members 133 | updateMapForSelection(); 134 | }); 135 | } 136 | }); 137 | state.addEventListener('focusedEntityChange', function() { 138 | getElementById("map-title").innerText = ''; 139 | reloadFocusAreaIfNeeded(); 140 | updateMapForSelection(); 141 | }); 142 | } 143 | 144 | function getStyleId() { 145 | return state.travelMode + '/' + state.lens; 146 | } 147 | 148 | function getCachedStyleLayer(layerId) { 149 | let cachedStyle = JSON.parse(cachedStyles[getStyleId()]); 150 | return cachedStyle.layers.find(layer => layer.id === layerId); 151 | } 152 | 153 | function reloadMapStyle() { 154 | 155 | if (!baseStyleJsonString) return; 156 | 157 | let styleId = getStyleId(); 158 | if (!cachedStyles[styleId]) cachedStyles[styleId] = JSON.stringify(generateStyle(baseStyleJsonString, state.travelMode, state.lens)); 159 | 160 | // always parse from string to avoid stale referenced objects 161 | let style = JSON.parse(cachedStyles[styleId]); 162 | 163 | // MapLibre requires an absolute URL for `sprite` 164 | style.sprite = window.location.origin + style.sprite; 165 | 166 | for (let cat in possibleLayerIdsByCategory) { 167 | layerIdsByCategory[cat] = possibleLayerIdsByCategory[cat].filter(id => style.layers.find(layer => layer.id === id)); 168 | } 169 | 170 | applyStyleAddendumsToStyle(style, styleAddendumsForHover()); 171 | applyStyleAddendumsToStyle(style, styleAddendumsForSelection()); 172 | applyStyleAddendumsToStyle(style, styleAddendumsForFocus()); 173 | 174 | map.setStyle(style, { 175 | diff: true, 176 | validate: true, 177 | }); 178 | } 179 | 180 | function reloadFocusAreaIfNeeded() { 181 | let focusedEntityInfo = state.focusedEntityInfo; 182 | let newFocusAreaGeoJson = buildFocusAreaGeoJson(); 183 | 184 | if ((newFocusAreaGeoJson && JSON.stringify(newFocusAreaGeoJson)) !== 185 | (focusAreaGeoJson && JSON.stringify(focusAreaGeoJson))) { 186 | 187 | focusAreaBoundingBox = focusedEntityInfo && getEntityBoundingBoxFromLayer(focusedEntityInfo.id, focusedEntityInfo.type, "park"); 188 | 189 | focusAreaGeoJson = newFocusAreaGeoJson; 190 | focusAreaGeoJsonBuffered = focusAreaGeoJson?.geometry?.coordinates?.length ? turfBuffer.buffer(focusAreaGeoJson, 0.25, {units: 'kilometers'}) : focusAreaGeoJson; 191 | 192 | if (focusAreaGeoJson) getElementById("map-title").innerText = focusAreaGeoJson.properties.name; 193 | 194 | updateMapForFocus(); 195 | } 196 | } 197 | 198 | function omtId(id, type) { 199 | let codes = { 200 | "node": "1", 201 | "way": "2", 202 | "relation": "3", 203 | }; 204 | return parseInt(id.toString() + codes[type]); 205 | } 206 | 207 | function applyStyleAddendumsToStyle(style, obj) { 208 | for (let layerId in obj) { 209 | let layer = style.layers.find(layer => layer.id === layerId); 210 | for (let key in obj[layerId]) { 211 | if (key === 'paint' || key === 'layout') { 212 | if (!layer[key]) layer[key] = {}; 213 | Object.assign(layer[key], obj[layerId][key]); 214 | } else { 215 | layer[key] = obj[layerId][key]; 216 | } 217 | } 218 | } 219 | } 220 | 221 | function applyStyleAddendumsToMap(obj) { 222 | for (let layerId in obj) { 223 | for (let key in obj[layerId]) { 224 | switch(key) { 225 | case "filter": 226 | map.setFilter(layerId, obj[layerId][key]); 227 | break; 228 | case "layout": 229 | for (let prop in obj[layerId][key]) { 230 | map.setLayoutProperty(layerId, prop, obj[layerId][key][prop]); 231 | } 232 | break; 233 | case "paint": 234 | for (let prop in obj[layerId][key]) { 235 | map.setPaintProperty(layerId, prop, obj[layerId][key][prop]); 236 | } 237 | break; 238 | case "minzoom": 239 | map.setLayerZoomRange(layerId, obj[layerId][key], 24); 240 | default: 241 | break; 242 | } 243 | } 244 | } 245 | } 246 | 247 | function updateMapForFocus() { 248 | applyStyleAddendumsToMap(styleAddendumsForFocus()); 249 | } 250 | 251 | function styleAddendumsForFocus() { 252 | let focusedEntityInfo = state.focusedEntityInfo; 253 | let focusedId = focusedEntityInfo?.id ? omtId(focusedEntityInfo.id, focusedEntityInfo.type) : null; 254 | return { 255 | "trail-pois": { 256 | "minzoom": focusedEntityInfo ? 0 : getCachedStyleLayer('trail-pois').minzoom, 257 | "filter": [ 258 | "all", 259 | getCachedStyleLayer('trail-pois').filter, 260 | ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? [["within", focusAreaGeoJsonBuffered]] : []), 261 | ] 262 | }, 263 | "major-trail-pois": { 264 | "filter": [ 265 | "all", 266 | getCachedStyleLayer('major-trail-pois').filter, 267 | // don't show icon and label for currently focused feature 268 | ["!=", ["get", "OSM_ID"], focusedEntityInfo ? focusedEntityInfo.id : null], 269 | ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? [["within", focusAreaGeoJsonBuffered]] : []), 270 | ], 271 | }, 272 | "peaks": { 273 | "filter": [ 274 | "all", 275 | getCachedStyleLayer('peaks').filter, 276 | ...(focusAreaGeoJsonBuffered?.geometry?.coordinates?.length ? [["within", focusAreaGeoJsonBuffered]] : []), 277 | ] 278 | }, 279 | "park-fill": { 280 | "filter": [ 281 | "any", 282 | getCachedStyleLayer('park-fill').filter, 283 | ["==", ["id"], focusedId], 284 | ], 285 | "layout": { 286 | "fill-sort-key": [ 287 | "case", 288 | ["==", ["id"], focusedId], 2, 289 | 1 290 | ] 291 | }, 292 | "paint": { 293 | "fill-color": [ 294 | "case", 295 | ["==", ["id"], focusedId], "#B1D06F", 296 | "#DFEAB8" 297 | ] 298 | } 299 | }, 300 | "park-outline": { 301 | "layout": { 302 | "line-sort-key": [ 303 | "case", 304 | ["==", ["id"], focusedId], 2, 305 | 1 306 | ] 307 | }, 308 | "paint": { 309 | "line-color": [ 310 | "case", 311 | ["==", ["id"], focusedId], "#738C40", 312 | "#ACC47A" 313 | ] 314 | } 315 | } 316 | }; 317 | } 318 | 319 | function updateMapForSelection() { 320 | applyStyleAddendumsToMap(styleAddendumsForSelection()); 321 | updateMapForHover(); 322 | } 323 | 324 | function styleAddendumsForSelection() { 325 | let selectedEntityInfo = state.selectedEntityInfo; 326 | let focusedEntityInfo = state.focusedEntityInfo; 327 | 328 | let id = selectedEntityInfo && selectedEntityInfo.id; 329 | let type = selectedEntityInfo && selectedEntityInfo.type; 330 | 331 | let focusedId = focusedEntityInfo?.id; 332 | 333 | let idsToHighlight = [id && id !== focusedId ? id : -1]; 334 | 335 | if (type === "relation") { 336 | let members = osm.getCachedEntity(type, id)?.members || []; 337 | members.forEach(function(member) { 338 | if (member.role !== 'inner') idsToHighlight.push(member.ref); 339 | 340 | if (member.type === "relation") { 341 | // only recurse down if we have the entity cached 342 | let childRelationMembers = osm.getCachedEntity(member.type, member.ref)?.members || []; 343 | childRelationMembers.forEach(function(member) { 344 | idsToHighlight.push(member.ref); 345 | // don't recurse relations again in case of self-references 346 | }); 347 | } 348 | }); 349 | } 350 | 351 | let styleAddendums = {}; 352 | layerIdsByCategory.selected.forEach(function(layerId) { 353 | // this will fail in rare cases where two features of different types but the same ID are both onscreen 354 | styleAddendums[layerId] = { 355 | "filter": [ 356 | "any", 357 | ["in", ["id"], ["literal", idsToHighlight.map(function(id) { 358 | return omtId(id, "node"); 359 | })]], 360 | ["in", ["get", "OSM_ID"], ["literal", idsToHighlight]] 361 | ] 362 | }; 363 | }); 364 | return styleAddendums; 365 | } 366 | 367 | function updateMapForHover() { 368 | applyStyleAddendumsToMap(styleAddendumsForHover()); 369 | } 370 | 371 | function styleAddendumsForHover() { 372 | 373 | let selectedEntityInfo = state.selectedEntityInfo; 374 | 375 | let entityId = hoveredEntityInfo?.id || -1; 376 | 377 | if (hoveredEntityInfo?.id == selectedEntityInfo?.id && 378 | hoveredEntityInfo?.type == selectedEntityInfo?.type) { 379 | // don't show hover styling if already selected 380 | entityId = -1; 381 | } 382 | 383 | let styleAddendums = {}; 384 | layerIdsByCategory.hovered.forEach(function(layerId) { 385 | // this will fail in rare cases where two features of different types but the same ID are both onscreen 386 | styleAddendums[layerId] = { 387 | "filter": [ 388 | "any", 389 | ["==", ["get", "OSM_ID"], entityId], 390 | ["==", ["id"], omtId(entityId, hoveredEntityInfo?.type)], 391 | ] 392 | }; 393 | }); 394 | return styleAddendums; 395 | } 396 | 397 | function entityForEvent(e, layerIds) { 398 | let features = map.queryRenderedFeatures(e.point, { layers: layerIds }); 399 | let feature = features.length && features[0]; 400 | if (feature) { 401 | let focusLngLat = feature.geometry.type === 'Point' ? feature.geometry.coordinates : e.lngLat; 402 | if (feature.properties.OSM_ID && feature.properties.OSM_TYPE) { 403 | return { 404 | id: feature.properties.OSM_ID, 405 | type: feature.properties.OSM_TYPE, 406 | changeset: feature.properties.OSM_CHANGESET, 407 | focusLngLat: focusLngLat, 408 | rawFeature: feature, 409 | }; 410 | } 411 | return { 412 | id: feature.id.toString().slice(0, -1), 413 | type: 'node', 414 | focusLngLat: focusLngLat, 415 | rawFeature: feature, 416 | }; 417 | } 418 | return null; 419 | } 420 | 421 | function didClickMap(e) { 422 | 423 | let entity = entityForEvent(e, layerIdsByCategory.clickable); 424 | state.selectEntity(entity); 425 | 426 | if (!entity || state.inspectorOpen) return; 427 | 428 | let coordinates = entity.focusLngLat; 429 | 430 | // Ensure that if the map is zoomed out such that multiple 431 | // copies of the feature are visible, the popup appears 432 | // over the copy being pointed to. 433 | while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { 434 | coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; 435 | } 436 | 437 | let tags = entity.rawFeature.properties; 438 | 439 | let div = createElement('div'); 440 | if (tags.name) { 441 | div.append( 442 | createElement('b') 443 | .append(tags.name), 444 | createElement('br') 445 | ); 446 | } 447 | div.append( 448 | createElement('a') 449 | .setAttribute('href', '#') 450 | .setAttribute('class', 'button') 451 | .addEventListener('click', didClickViewDetails) 452 | .append('View Details') 453 | ); 454 | 455 | activePopup = new maplibregl.Popup({ 456 | className: 'quickinfo', 457 | closeButton: false, 458 | }) 459 | .setLngLat(coordinates) 460 | .setDOMContent(div) 461 | .addTo(map); 462 | } 463 | 464 | function didClickViewDetails(e) { 465 | e.preventDefault(); 466 | state.setInspectorOpen(true); 467 | return false; 468 | } 469 | 470 | function didDoubleClickMap(e) { 471 | 472 | let entity = entityForEvent(e, ['major-trail-pois']); 473 | if (entity) { 474 | e.preventDefault(); 475 | state.focusEntity(entity); 476 | } 477 | } 478 | 479 | function didMouseMoveMap(e) { 480 | 481 | let newHoveredEntityInfo = entityForEvent(e, layerIdsByCategory.clickable); 482 | 483 | if (hoveredEntityInfo?.id != newHoveredEntityInfo?.id || 484 | hoveredEntityInfo?.type != newHoveredEntityInfo?.type) { 485 | hoveredEntityInfo = newHoveredEntityInfo; 486 | 487 | // Change the cursor style as a UI indicator 488 | map.getCanvas().style.cursor = hoveredEntityInfo ? 'pointer' : ''; 489 | 490 | updateMapForHover(); 491 | } 492 | } 493 | 494 | function compositeGeoJson(features) { 495 | if (!features.length) return; 496 | let coordinates = []; 497 | features.forEach(function(feature) { 498 | if (feature.geometry.type === 'Polygon') { 499 | coordinates.push(feature.geometry.coordinates); 500 | } else if (feature.geometry.type === 'MultiPolygon') { 501 | coordinates = coordinates.concat(feature.geometry.coordinates); 502 | } 503 | }); 504 | return { 505 | type: "Feature", 506 | geometry: { 507 | type: "MultiPolygon", 508 | coordinates: coordinates, 509 | }, 510 | properties: features[0].properties 511 | }; 512 | } 513 | 514 | function getEntityBoundingBox(entity) { 515 | const props = entity?.properties; 516 | if (props?.hasOwnProperty('MIN_LON') && 517 | props?.hasOwnProperty('MIN_LAT') && 518 | props?.hasOwnProperty('MAX_LON') && 519 | props?.hasOwnProperty('MAX_LAT')) { 520 | return [ 521 | props.MIN_LON, 522 | props.MIN_LAT, 523 | props.MAX_LON, 524 | props.MAX_LAT 525 | ]; 526 | } 527 | } 528 | 529 | function getFeatureFromLayers(id, type, layers) { 530 | for (let i in layers) { 531 | let layer = layers[i]; 532 | let features = map.querySourceFeatures(layer.source || 'trails', { 533 | filter: [ 534 | "any", 535 | [ 536 | "all", 537 | ["==", ["get", "OSM_ID"], id], 538 | ["==", ["get", "OSM_TYPE"], type], 539 | ], 540 | ["==", ["id"], omtId(id, type)], 541 | ], 542 | sourceLayer: layer.layer || layer, 543 | }); 544 | if (features.length) return features[0]; 545 | } 546 | } 547 | 548 | function getEntityBoundingBoxFromLayer(id, type, layer) { 549 | let focusedEntityInfo = state.focusedEntityInfo; 550 | if (!focusedEntityInfo) return null; 551 | let feature = getFeatureFromLayers(id, type, [layer]); 552 | if (feature) { 553 | return getEntityBoundingBox(feature); 554 | } 555 | } 556 | 557 | function buildFocusAreaGeoJson() { 558 | let focusedEntityInfo = state.focusedEntityInfo; 559 | if (!focusedEntityInfo) return null; 560 | let results = map.querySourceFeatures('trails', { 561 | filter: [ 562 | "all", 563 | ["==", ["get", "OSM_ID"], focusedEntityInfo.id], 564 | ["==", ["get", "OSM_TYPE"], focusedEntityInfo.type], 565 | ], 566 | sourceLayer: "park", 567 | }); 568 | return compositeGeoJson(results); 569 | } 570 | // check the current extent of the map, and if focused area is too far offscreen, put it back onscreen 571 | function checkMapExtent() { 572 | if (!focusAreaBoundingBox) return; 573 | let currentBounds = map.getBounds(); 574 | let targetBounds = currentBounds.toArray(); 575 | let width = focusAreaBoundingBox[2] - focusAreaBoundingBox[0]; 576 | let height = focusAreaBoundingBox[3] - focusAreaBoundingBox[1]; 577 | let maxExtent = Math.max(width, height); 578 | let margin = maxExtent / 4; 579 | 580 | if (currentBounds.getNorth() < focusAreaBoundingBox[1] - margin) targetBounds[1][1] = focusAreaBoundingBox[1] + margin; 581 | if (currentBounds.getSouth() > focusAreaBoundingBox[3] + margin) targetBounds[0][1] = focusAreaBoundingBox[3] - margin; 582 | if (currentBounds.getEast() < focusAreaBoundingBox[0] - margin) targetBounds[1][0] = focusAreaBoundingBox[0] + margin; 583 | if (currentBounds.getWest() > focusAreaBoundingBox[2] + margin) targetBounds[0][0] = focusAreaBoundingBox[2] - margin; 584 | if (currentBounds.toArray().toString() !== targetBounds.toString()) { 585 | map.fitBounds(targetBounds); 586 | } 587 | } 588 | 589 | function fitMapToBounds(bbox) { 590 | let width = bbox[2] - bbox[0]; 591 | let height = bbox[3] - bbox[1]; 592 | let maxExtent = Math.max(width, height); 593 | let fitBbox = extendBbox(bbox, maxExtent / 16); 594 | map.fitBounds(fitBbox); 595 | } 596 | 597 | function extendBbox(bbox, buffer) { 598 | bbox = bbox.slice(); 599 | bbox[0] -= buffer; // west 600 | bbox[1] -= buffer; // south 601 | bbox[2] += buffer; // east 602 | bbox[3] += buffer; // north 603 | return bbox; 604 | } -------------------------------------------------------------------------------- /js/optionsData.js: -------------------------------------------------------------------------------- 1 | // Data objects for the options shown in the UI. 2 | 3 | export const lensStrings = { 4 | access: { 5 | label: "Access" 6 | }, 7 | covered: { 8 | label: "Covered" 9 | }, 10 | dog: { 11 | label: "Dog Access" 12 | }, 13 | incline: { 14 | label: "Incline" 15 | }, 16 | lit: { 17 | label: "Lit" 18 | }, 19 | maxspeed: { 20 | label: "Speed Limit" 21 | }, 22 | name: { 23 | label: "Name" 24 | }, 25 | oneway: { 26 | label: "Oneway" 27 | }, 28 | operator: { 29 | label: "Operator" 30 | }, 31 | sac_scale: { 32 | label: "SAC Hiking Scale" 33 | }, 34 | smoothness: { 35 | label: "Smoothness" 36 | }, 37 | surface: { 38 | label: "Surface" 39 | }, 40 | trail_visibility: { 41 | label: "Trail Visibility" 42 | }, 43 | width: { 44 | label: "Width" 45 | }, 46 | fixme: { 47 | label: "Fixme Requests" 48 | }, 49 | check_date: { 50 | label: "Last Checked Date" 51 | }, 52 | OSM_TIMESTAMP: { 53 | label: "Last Edited Date" 54 | }, 55 | intermittent: { 56 | label: "Intermittent" 57 | }, 58 | open_water: { 59 | label: "Open Water" 60 | }, 61 | rapids: { 62 | label: "Rapids" 63 | }, 64 | tidal: { 65 | label: "Tidal" 66 | }, 67 | hand_cart: { 68 | label: "Hand Cart" 69 | }, 70 | }; 71 | 72 | export const metadataLenses = { 73 | label: "Metadata", 74 | subitems: [ 75 | "fixme", 76 | "check_date", 77 | "OSM_TIMESTAMP", 78 | ] 79 | }; 80 | 81 | export const allLensOptions = [ 82 | { 83 | label: "Attributes", 84 | subitems: [ 85 | "access", 86 | "covered", 87 | "dog", 88 | "hand_cart", 89 | "incline", 90 | "lit", 91 | "name", 92 | "oneway", 93 | "operator", 94 | "sac_scale", 95 | "smoothness", 96 | "maxspeed", 97 | "surface", 98 | "trail_visibility", 99 | "width", 100 | ], 101 | }, 102 | { 103 | label: "Waterway Attributes", 104 | subitems: [ 105 | "intermittent", 106 | "open_water", 107 | "rapids", 108 | "tidal", 109 | ] 110 | }, 111 | metadataLenses, 112 | ]; 113 | 114 | export const basicLensOptions = [ 115 | { 116 | label: "Attributes", 117 | subitems: [ 118 | "access", 119 | "covered", 120 | "dog", 121 | "incline", 122 | "lit", 123 | "name", 124 | "oneway", 125 | "operator", 126 | "smoothness", 127 | "surface", 128 | "trail_visibility", 129 | "width", 130 | ] 131 | }, 132 | metadataLenses, 133 | ]; 134 | 135 | export const vehicleLensOptions = [ 136 | { 137 | label: "Attributes", 138 | subitems: [ 139 | "access", 140 | "covered", 141 | "dog", 142 | "incline", 143 | "lit", 144 | "name", 145 | "oneway", 146 | "operator", 147 | "smoothness", 148 | "maxspeed", 149 | "surface", 150 | "trail_visibility", 151 | "width", 152 | ] 153 | }, 154 | metadataLenses, 155 | ]; 156 | 157 | export const hikingLensOptions = [ 158 | { 159 | label: "Attributes", 160 | subitems: [ 161 | "access", 162 | "covered", 163 | "dog", 164 | "incline", 165 | "lit", 166 | "name", 167 | "oneway", 168 | "operator", 169 | "sac_scale", 170 | "smoothness", 171 | "surface", 172 | "trail_visibility", 173 | "width", 174 | ] 175 | }, 176 | metadataLenses, 177 | ]; 178 | 179 | export const canoeLensOptions = [ 180 | { 181 | label: "Attributes", 182 | subitems: [ 183 | "access", 184 | "covered", 185 | "dog", 186 | "name", 187 | "oneway", 188 | "width", 189 | ] 190 | }, 191 | { 192 | label: "Waterway Attributes", 193 | subitems: [ 194 | "intermittent", 195 | "open_water", 196 | "rapids", 197 | "tidal", 198 | ] 199 | }, 200 | { 201 | label: "Portage Attributes", 202 | subitems: [ 203 | "hand_cart", 204 | "incline", 205 | "lit", 206 | "operator", 207 | "surface", 208 | "smoothness", 209 | "trail_visibility", 210 | ] 211 | }, 212 | metadataLenses, 213 | ]; 214 | 215 | export const lensOptionsByMode = { 216 | "all": allLensOptions, 217 | "atv": vehicleLensOptions, 218 | "bicycle": vehicleLensOptions, 219 | "mtb": vehicleLensOptions, 220 | "canoe": canoeLensOptions, 221 | "foot": hikingLensOptions, 222 | "horse": vehicleLensOptions, 223 | "inline_skates": basicLensOptions, 224 | "snowmobile": vehicleLensOptions, 225 | "ski:nordic": basicLensOptions, 226 | "wheelchair": basicLensOptions, 227 | }; 228 | -------------------------------------------------------------------------------- /js/osmController.js: -------------------------------------------------------------------------------- 1 | export class OsmController { 2 | 3 | osmEntityCache = {}; 4 | osmEntityMembershipCache = {}; 5 | osmChangesetCache = {}; 6 | 7 | cacheEntities(elements, full) { 8 | for (let i in elements) { 9 | let element = elements[i]; 10 | let type = element.type; 11 | let id = element.id; 12 | let key = type[0] + id; 13 | 14 | this.osmEntityCache[key] = element; 15 | this.osmEntityCache[key].full = full; 16 | } 17 | } 18 | 19 | getCachedEntity(type, id) { 20 | return this.osmEntityCache[type[0] + id]; 21 | } 22 | 23 | async fetchOsmEntity(type, id) { 24 | let key = type[0] + id; 25 | if (!this.osmEntityCache[key] || !this.osmEntityCache[key].full) { 26 | let url = `https://api.openstreetmap.org/api/0.6/${type}/${id}`; 27 | if (type !== 'node') { 28 | url += '/full'; 29 | } 30 | url += '.json'; 31 | let response = await fetch(url); 32 | let json = await response.json(); 33 | this.cacheEntities(json && json.elements || [], true); 34 | } 35 | return this.osmEntityCache[key]; 36 | } 37 | 38 | async fetchOsmEntityMemberships(type, id) { 39 | let key = type[0] + id; 40 | 41 | if (!this.osmEntityMembershipCache[key]) { 42 | let response = await fetch(`https://api.openstreetmap.org/api/0.6/${type}/${id}/relations.json`); 43 | let json = await response.json(); 44 | let rels = json && json.elements || []; 45 | 46 | this.osmEntityMembershipCache[key] = []; 47 | for (let i in rels) { 48 | let rel = rels[i]; 49 | for (let j in rel.members) { 50 | let membership = rel.members[j]; 51 | if (membership.ref === id && membership.type === type) { 52 | this.osmEntityMembershipCache[key].push({ 53 | type: rel.type, 54 | id: rel.id, 55 | role: membership.role, 56 | }); 57 | } 58 | } 59 | } 60 | // response relations are fully defined entities so we can cache them for free 61 | this.cacheEntities(rels, false); 62 | } 63 | 64 | return this.osmEntityMembershipCache[key]; 65 | } 66 | 67 | async fetchOsmChangeset(id) { 68 | if (!this.osmChangesetCache[id]) { 69 | let url = `https://api.openstreetmap.org/api/0.6/changeset/${id}.json`; 70 | let response = await fetch(url); 71 | let json = await response.json(); 72 | this.osmChangesetCache[id] = json && json.changeset; 73 | } 74 | return this.osmChangesetCache[id]; 75 | } 76 | 77 | } 78 | 79 | export const osm = new OsmController(); -------------------------------------------------------------------------------- /js/sidebarController.js: -------------------------------------------------------------------------------- 1 | import { osm } from "./osmController.js"; 2 | import { state } from "./stateController.js"; 3 | import { createElement, getElementById } from "./utils.js"; 4 | 5 | function isSidebarOpen() { 6 | return document.getElementsByTagName('body')[0].classList.contains('sidebar-open'); 7 | } 8 | function openSidebar() { 9 | if (!isSidebarOpen()) { 10 | document.getElementsByTagName('body')[0].classList.add('sidebar-open'); 11 | updateSidebar(state.selectedEntityInfo); 12 | } 13 | } 14 | function closeSidebar() { 15 | if (isSidebarOpen()) { 16 | document.getElementsByTagName('body')[0].classList.remove('sidebar-open'); 17 | } 18 | } 19 | 20 | function updateSidebar(entity) { 21 | 22 | let sidebarElement = getElementById('sidebar'); 23 | if (!sidebarElement) return; 24 | 25 | if (!entity) { 26 | sidebarElement.replaceChildren(''); 27 | return; 28 | } 29 | 30 | let type = entity.type; 31 | let entityId = entity.id; 32 | 33 | // non-breaking space for placeholder 34 | let nbsp = String.fromCharCode(160); 35 | 36 | let opQuery = encodeURIComponent(`${type}(${entityId});\n(._;>;);\nout;`); 37 | 38 | let xmlLink = `https://www.openstreetmap.org/api/0.6/${type}/${entityId}`; 39 | if (type == 'way' || type == 'relation') xmlLink += '/full'; 40 | 41 | sidebarElement.replaceChildren( 42 | createElement('table') 43 | .setAttribute('id', 'tag-table') 44 | .append( 45 | // placeholder layout, so transition is less jarring when data appears in a moment 46 | createElement('tr').append( 47 | createElement('th').append('Key'), 48 | createElement('th').append('Value') 49 | ), 50 | createElement('tr').append( 51 | createElement('td').append(nbsp), 52 | createElement('td').append(nbsp) 53 | ) 54 | ), 55 | createElement('table') 56 | .setAttribute('id', 'relations-table') 57 | .append( 58 | createElement('tr').append(createElement('th').append('Relations')), 59 | createElement('tr').append(createElement('td').append(nbsp)) 60 | ), 61 | createElement('table') 62 | .setAttribute('id', 'meta-table') 63 | .append( 64 | createElement('tr').append(createElement('th').append('Meta')), 65 | createElement('tr').append(createElement('td').append(nbsp)) 66 | ), 67 | createElement('h3').append('View'), 68 | createElement('p') 69 | .setAttribute('class', 'link-list') 70 | .append( 71 | ...[ 72 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://openstreetmap.org/${type}/${entityId}`).append('osm.org'), ' ', 73 | createElement('a').setAttribute('target', '_blank').setAttribute('href', xmlLink).append('XML'), ' ', 74 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://pewu.github.io/osm-history/#/${type}/${entityId}`).append('PeWu'), ' ', 75 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://overpass-turbo.eu?Q=${opQuery}&R=`).append('Overpass Turbo'), ' ', 76 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://osmcha.org/changesets/${entity.changeset}`).append('OSMCha'), ' ', 77 | type === 'relation' && createElement('a').setAttribute('target', '_blank').setAttribute('href', `http://ra.osmsurround.org/analyzeRelation?relationId=${entityId}`).append('Relation Analyzer') 78 | ].filter(Boolean) 79 | ), 80 | createElement('h3').append('Edit'), 81 | createElement('p') 82 | .setAttribute('class', 'link-list') 83 | .append( 84 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://openstreetmap.org/edit?editor=id&${type}=${entityId}`).append('iD'), ' ', 85 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://openstreetmap.org/edit?editor=remote&${type}=${entityId}`).append('JOSM'), ' ', 86 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://level0.osmz.ru/?url=${type}/${entityId}`).append('Level0') 87 | ), 88 | ); 89 | 90 | osm.fetchOsmEntity(type, entityId).then(function(entity) { 91 | if (entity) { 92 | osm.fetchOsmChangeset(entity.changeset).then(function(changeset) { 93 | updateMetaTable(entity, changeset); 94 | }); 95 | } 96 | let tags = entity && entity.tags; 97 | if (tags) updateTagsTable(tags); 98 | }); 99 | 100 | osm.fetchOsmEntityMemberships(type, entityId).then(function(memberships) { 101 | updateMembershipsTable(memberships); 102 | }); 103 | } 104 | 105 | function updateMetaTable(entity, changeset) { 106 | 107 | const table = getElementById('meta-table'); 108 | if (!table) return; 109 | 110 | let formattedDate = getFormattedDate(new Date(entity.timestamp)); 111 | let comment = changeset && changeset.tags && changeset.tags.comment || ''; 112 | let sources = changeset && changeset.tags && changeset.tags.source || ''; 113 | 114 | table.replaceChildren( 115 | createElement('tr').append( 116 | createElement('th') 117 | .setAttribute('colspan', '2') 118 | .append('Meta') 119 | ), 120 | createElement('tr').append( 121 | createElement('td').append('ID'), 122 | createElement('td').append( 123 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://www.openstreetmap.org/${entity.type}/${entity.id}`).append(`${entity.type}/${entity.id}`) 124 | ) 125 | ), 126 | createElement('tr').append( 127 | createElement('td').append('Version'), 128 | createElement('td').append( 129 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://www.openstreetmap.org/${entity.type}/${entity.id}/history`).append(entity.version) 130 | ) 131 | ), 132 | createElement('tr').append( 133 | createElement('td').append('Uploaded'), 134 | createElement('td').append(formattedDate) 135 | ), 136 | createElement('tr').append( 137 | createElement('td').append('User'), 138 | createElement('td').append( 139 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://www.openstreetmap.org/user/${entity.user}`).append(entity.user) 140 | ) 141 | ), 142 | createElement('tr').append( 143 | createElement('td').append('Changeset'), 144 | createElement('td').append( 145 | createElement('a').setAttribute('target', '_blank').setAttribute('href', `https://www.openstreetmap.org/changeset/${entity.changeset}`).append(entity.changeset) 146 | ) 147 | ), 148 | createElement('tr').append( 149 | createElement('td').append('Comment'), 150 | createElement('td').append(comment) 151 | ), 152 | createElement('tr').append( 153 | createElement('td').append('Source'), 154 | createElement('td').append(sources) 155 | ), 156 | ); 157 | } 158 | 159 | const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/i; 160 | const qidRegex = /^Q\d+$/; 161 | const wikipediaRegex = /^(.+):(.+)$/; 162 | const nwisRegex = /^\d{8,15}$/; 163 | 164 | function externalLinkForValue(key, value, tags) { 165 | if (urlRegex.test(value)) { 166 | return value; 167 | } else if ((key === 'wikidata' || key.endsWith(':wikidata')) && qidRegex.test(value)) { 168 | return `https://www.wikidata.org/wiki/${value}`; 169 | } else if ((key === 'wikipedia' || key.endsWith(':wikipedia')) && wikipediaRegex.test(value)) { 170 | let results = wikipediaRegex.exec(value); 171 | return `https://${results[1]}.wikipedia.org/wiki/${results[2]}`; 172 | } else if (key === 'ref' && tags.man_made === 'monitoring_station' && tags.operator === "United States Geological Survey" && nwisRegex.test(value)) { 173 | return `https://waterdata.usgs.gov/monitoring-location/${value}/`; 174 | } 175 | return null; 176 | } 177 | 178 | function updateTagsTable(tags) { 179 | const table = getElementById('tag-table'); 180 | if (!table) return; 181 | 182 | table.replaceChildren( 183 | createElement('tr') 184 | .append( 185 | createElement('th') 186 | .append('Key'), 187 | createElement('th') 188 | .append('Value'), 189 | ), 190 | ...Object.keys(tags).sort().map(key => { 191 | let value = tags[key]; 192 | let href = externalLinkForValue(key, value, tags); 193 | let valElement = href ? createElement('a') 194 | .setAttribute('target', '_blank') 195 | .setAttribute('rel', 'nofollow') 196 | .setAttribute('href', href) 197 | .append(value) : value; 198 | 199 | return createElement('tr') 200 | .append( 201 | createElement('td') 202 | .append( 203 | createElement('a') 204 | .setAttribute('target', '_blank') 205 | .setAttribute('href', `https://wiki.openstreetmap.org/wiki/Key:${key}`) 206 | .append(key) 207 | ), 208 | createElement('td') 209 | .append(valElement) 210 | ); 211 | }) 212 | ); 213 | } 214 | 215 | function updateMembershipsTable(memberships) { 216 | const table = getElementById('relations-table'); 217 | if (!table) return; 218 | 219 | if (memberships.length) { 220 | table.replaceChildren( 221 | createElement('tr') 222 | .append( 223 | createElement('th') 224 | .append('Relation'), 225 | createElement('th') 226 | .append('Type'), 227 | createElement('th') 228 | .append('Role') 229 | ) 230 | ); 231 | for (let i in memberships) { 232 | let membership = memberships[i]; 233 | let rel = osm.getCachedEntity(membership.type, membership.id); 234 | let label = rel.tags.name || rel.id; 235 | let relType = rel.tags.type || ''; 236 | if ((relType === "route" || relType === "superroute") && rel.tags.route) { 237 | relType += " (" + (rel.tags.route || rel.tags.superroute) + ")"; 238 | } 239 | table.append( 240 | createElement('tr') 241 | .append( 242 | createElement('td') 243 | .append( 244 | createElement('a') 245 | .setAttribute('href', '#') 246 | .setAttribute('type', membership.type) 247 | .setAttribute('id', membership.id) 248 | .addEventListener('click', didClickEntityLink) 249 | .append(label) 250 | ), 251 | createElement('td') 252 | .append(relType), 253 | createElement('td') 254 | .append(membership.role) 255 | ) 256 | ); 257 | } 258 | } else { 259 | table.replaceChildren( 260 | createElement('tr') 261 | .append( 262 | createElement('th') 263 | .append('Relations') 264 | ), 265 | createElement('tr') 266 | .append( 267 | createElement('td') 268 | .append( 269 | createElement('i') 270 | .append('none') 271 | ) 272 | ) 273 | ); 274 | } 275 | } 276 | 277 | function didClickEntityLink(e) { 278 | e.preventDefault(); 279 | state.selectEntity(osm.getCachedEntity(e.target.getAttribute("type"), e.target.getAttribute("id"))); 280 | } 281 | 282 | function getFormattedDate(date) { 283 | let offsetDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)); 284 | let components = offsetDate.toISOString().split('T') 285 | return components[0] + " " + components[1].split(".")[0]; 286 | } 287 | 288 | window.addEventListener('load', function() { 289 | 290 | getElementById("inspect-toggle").addEventListener('click', function(e) { 291 | e.preventDefault(); 292 | state.setInspectorOpen(!isSidebarOpen()); 293 | }); 294 | 295 | state.addEventListener('selectedEntityChange', function() { 296 | if (isSidebarOpen()) updateSidebar(state.selectedEntityInfo); 297 | }); 298 | 299 | state.addEventListener('inspectorOpenChange', function() { 300 | if (state.inspectorOpen) { 301 | openSidebar(); 302 | } else { 303 | closeSidebar(); 304 | } 305 | }); 306 | 307 | }); 308 | -------------------------------------------------------------------------------- /js/stateController.js: -------------------------------------------------------------------------------- 1 | // Manages the state of the UI in a generalized sort of way. 2 | // The various UI components can listen for state changes and 3 | // update themselves accordingly. 4 | 5 | import { lensOptionsByMode } from "./optionsData.js"; 6 | 7 | const defaultTravelMode = "all"; 8 | const defaultLens = ""; 9 | 10 | function isValidEntityInfo(entityInfo) { 11 | return ["node", "way", "relation"].includes(entityInfo?.type) && 12 | entityInfo?.id > 0; 13 | } 14 | 15 | function lensesForMode(travelMode) { 16 | return lensOptionsByMode[travelMode].flatMap(function(item) { 17 | return item.subitems; 18 | }); 19 | } 20 | 21 | class StateController extends EventTarget { 22 | 23 | defaultTravelMode = defaultTravelMode 24 | defaultLens = defaultLens; 25 | travelMode = defaultTravelMode; 26 | lens = defaultLens; 27 | 28 | inspectorOpen = false; 29 | 30 | focusedEntityInfo; 31 | selectedEntityInfo; 32 | 33 | focusEntity(entityInfo) { 34 | if (!isValidEntityInfo(entityInfo)) entityInfo = null; 35 | 36 | if (this.focusedEntityInfo?.id === entityInfo?.id && 37 | this.focusedEntityInfo?.type === entityInfo?.type 38 | ) return; 39 | 40 | this.focusedEntityInfo = entityInfo; 41 | 42 | let bodyElement = document.getElementsByTagName('body')[0]; 43 | this.focusedEntityInfo ? bodyElement.classList.add('area-focused') : bodyElement.classList.remove('area-focused'); 44 | 45 | this.dispatchEvent(new Event('focusedEntityChange')); 46 | } 47 | 48 | selectEntity(entityInfo) { 49 | 50 | if (this.selectedEntityInfo?.id === entityInfo?.id && 51 | this.selectedEntityInfo?.type === entityInfo?.type 52 | ) return; 53 | 54 | this.selectedEntityInfo = entityInfo; 55 | 56 | this.dispatchEvent(new Event('selectedEntityChange')); 57 | } 58 | 59 | setTravelMode(value) { 60 | if (value === null) value = defaultTravelMode; 61 | if (this.travelMode === value) return; 62 | this.travelMode = value; 63 | if (!lensesForMode(value).includes(this.lens)) this.setLens(defaultLens); 64 | 65 | this.dispatchEvent(new Event('travelModeChange')); 66 | } 67 | 68 | setLens(value) { 69 | if (value === null) value = defaultLens; 70 | if (!lensesForMode(this.travelMode).includes(value)) value = defaultLens; 71 | 72 | if (this.lens === value) return; 73 | this.lens = value; 74 | 75 | this.dispatchEvent(new Event('lensChange')); 76 | } 77 | 78 | setInspectorOpen(value) { 79 | value = !!value; 80 | if (this.inspectorOpen === value) return; 81 | this.inspectorOpen = value; 82 | 83 | this.dispatchEvent(new Event('inspectorOpenChange')); 84 | } 85 | } 86 | 87 | export const state = new StateController(); 88 | -------------------------------------------------------------------------------- /js/utils.js: -------------------------------------------------------------------------------- 1 | 2 | // Creates a new HTML element where certain functions return the element itself. 3 | export function createElement(...args) { 4 | let el = document.createElement(...args); 5 | wrapElementFunctions(el); 6 | return el; 7 | } 8 | 9 | // Gets an HTML element where certain functions return the element itself. 10 | export function getElementById(...args) { 11 | let el = document.getElementById(...args); 12 | if (el) wrapElementFunctions(el); 13 | return el; 14 | } 15 | 16 | // Wraps certain functions of the element so they return the 17 | // element itself in order to enable chaining. 18 | function wrapElementFunctions(el) { 19 | let fnNames = ['addEventListener', 'append', 'appendChild', 'replaceChildren', 'setAttribute']; 20 | for (let i in fnNames) { 21 | let fnName = fnNames[i]; 22 | let fn = el[fnName]; 23 | el[fnName] = function(...args) { 24 | fn.apply(this, args); 25 | return el; 26 | }; 27 | } 28 | } -------------------------------------------------------------------------------- /json/focus.json: -------------------------------------------------------------------------------- 1 | { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": {}, "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [ -180, -89 ], [ -180, 89 ], [ 180, 89 ], [ 180, -89 ], [ -180, -89 ] ], [[-112.164359,41.995232],[-111.047063,42.000709],[-111.047063,40.998429],[-109.04798,40.998429],[-109.053457,39.125316],[-109.058934,38.27639],[-109.042503,38.166851],[-109.042503,37.000263],[-110.499369,37.00574],[-114.048427,37.000263],[-114.04295,41.995232],[-112.164359,41.995232]]] ] } } ] } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentrailmap", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "build": "rm -rf dist/ && rollup --config rollup.config.js && cp -a node_modules/maplibre-gl/dist/. dist/maplibre/", 7 | "build-static-styles": "node ./scripts/buildStaticStyles.js", 8 | "serve": "node ./serve.js", 9 | "sprites": "spreet --unique --minify-index-file style/sprites/svg style/sprites/opentrailmap && spreet --retina --unique --minify-index-file style/sprites/svg style/sprites/opentrailmap@2x" 10 | }, 11 | "devDependencies": { 12 | "@rollup/plugin-commonjs": "~28.0.0", 13 | "@rollup/plugin-node-resolve": "~16.0.0", 14 | "@turf/buffer": "~7.2.0", 15 | "maplibre-gl": "~5.6.0", 16 | "rollup": "~4.42.0", 17 | "rollup-plugin-polyfill-node": "~0.13.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import nodePolyfills from 'rollup-plugin-polyfill-node'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | export default { 6 | input: 'node_modules/@turf/buffer/dist/esm/index.js', 7 | output: { 8 | name: 'turfBuffer', 9 | file: 'dist/turf-buffer.js', 10 | format: 'iife', 11 | }, 12 | plugins: [ 13 | nodeResolve(), 14 | commonjs(), 15 | nodePolyfills(), 16 | ] 17 | }; -------------------------------------------------------------------------------- /scripts/buildStaticStyles.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; 2 | import { generateStyle } from '../js/styleGenerator.js'; 3 | import { lensOptionsByMode } from '../js/optionsData.js'; 4 | 5 | import { dirname } from 'path'; 6 | import { fileURLToPath } from 'url'; 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)); 9 | 10 | const baseStyleJsonString = readFileSync(__dirname + '/../style/basestyle.json'); 11 | 12 | const outDir = __dirname + '/../dist/styles'; 13 | 14 | if (!existsSync(outDir)) mkdirSync(outDir); 15 | 16 | let total = 0; 17 | 18 | for (let mode in lensOptionsByMode) { 19 | let lenses = lensOptionsByMode[mode].flatMap(function(item) { 20 | return item.subitems; 21 | }); 22 | // add item for the "general" lens 23 | lenses.unshift(''); 24 | for (let i in lenses) { 25 | let lens = lenses[i]; 26 | let style = generateStyle(baseStyleJsonString, mode, lens); 27 | let filename = `otm-${mode}`; 28 | if (lens !== '') filename += `-${lens}`; 29 | filename = filename.replaceAll(':', '_'); 30 | writeFileSync(`${outDir}/${filename}.json`, JSON.stringify(style, null, 2)); 31 | writeFileSync(`${outDir}/${filename}.min.json`, JSON.stringify(style)); 32 | total += 1; 33 | } 34 | } 35 | 36 | console.log(`Wrote ${total} styles to disk (plus ${total} minified)`); 37 | -------------------------------------------------------------------------------- /serve.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import url from 'url'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const baseDirectory = path.dirname(url.fileURLToPath(import.meta.url)); 7 | const port = 4001; 8 | 9 | http.createServer(function (request, response) { 10 | try { 11 | 12 | let requestUrl = url.parse(request.url) 13 | 14 | // need to use path.normalize so people can't access directories underneath baseDirectory 15 | let fsPath = baseDirectory + path.normalize(requestUrl.pathname) 16 | 17 | if (fs.statSync(fsPath).isDirectory()) { 18 | if (!fsPath.endsWith("/")) fsPath += "/"; 19 | fsPath += "index.html"; 20 | } 21 | 22 | let options = {}; 23 | if (request.headers.range && request.headers.range.startsWith('bytes=')) { 24 | let matches = /bytes=(\d*)-(\d*)/g.exec(request.headers.range); 25 | options.start = parseInt(matches[1]); 26 | options.end = parseInt(matches[2])+1; 27 | response.setHeader('Content-Length', options.end - options.start); 28 | } 29 | // set MIME types 30 | if (fsPath.endsWith(".svg")) { 31 | response.setHeader('Content-Type', "image/svg+xml"); 32 | } else if (fsPath.endsWith(".js")) { 33 | response.setHeader('Content-Type', "text/javascript"); 34 | } 35 | 36 | let fileStream = fs.createReadStream(fsPath, options) 37 | fileStream.pipe(response) 38 | fileStream.on('open', function() { 39 | response.writeHead(200) 40 | }) 41 | fileStream.on('error',function(e) { 42 | response.writeHead(404) // assume the file doesn't exist 43 | response.end() 44 | }) 45 | } catch(e) { 46 | response.writeHead(500) 47 | response.end() // end the response so browsers don't hang 48 | console.log(e.stack) 49 | } 50 | }).listen(port) 51 | 52 | console.log("listening on port " + port) -------------------------------------------------------------------------------- /style/sprites/opentrailmap.json: -------------------------------------------------------------------------------- 1 | {"access_point":{"height":24,"pixelRatio":1,"width":24,"x":0,"y":128},"access_point-minor":{"height":18,"pixelRatio":1,"width":18,"x":92,"y":202},"access_point-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":0,"y":152},"arrow-left":{"height":15,"pixelRatio":1,"width":23,"x":90,"y":172},"arrow-right":{"height":15,"pixelRatio":1,"width":23,"x":92,"y":187},"arrows-leftright":{"height":15,"pixelRatio":1,"width":33,"x":80,"y":80},"beaver_dam":{"height":23,"pixelRatio":1,"width":21,"x":80,"y":95},"beaver_dam-canoeable":{"height":24,"pixelRatio":1,"width":24,"x":0,"y":176},"beaver_dam-hazard":{"height":24,"pixelRatio":1,"width":24,"x":0,"y":200},"bird_refuge":{"height":32,"pixelRatio":1,"width":32,"x":0,"y":0},"bison_refuge":{"height":32,"pixelRatio":1,"width":32,"x":32,"y":0},"cairn":{"height":24,"pixelRatio":1,"width":18,"x":104,"y":52},"campground":{"height":24,"pixelRatio":1,"width":24,"x":0,"y":224},"campground-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":24,"y":128},"campsite":{"height":20,"pixelRatio":1,"width":24,"x":104,"y":32},"caravan_site":{"height":24,"pixelRatio":1,"width":24,"x":32,"y":32},"caravan_site-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":32,"y":56},"dam":{"height":18,"pixelRatio":1,"width":18,"x":101,"y":95},"dam-canoeable":{"height":24,"pixelRatio":1,"width":24,"x":32,"y":80},"dam-hazard":{"height":24,"pixelRatio":1,"width":24,"x":32,"y":104},"disallowed-stripes":{"height":6,"pixelRatio":1,"width":7,"x":80,"y":118},"ferry":{"height":24,"pixelRatio":1,"width":24,"x":24,"y":152},"ferry-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":24,"y":176},"forest_reserve":{"height":32,"pixelRatio":1,"width":32,"x":64,"y":0},"game_land":{"height":32,"pixelRatio":1,"width":32,"x":96,"y":0},"grassland_reserve":{"height":32,"pixelRatio":1,"width":32,"x":128,"y":0},"guidepost":{"height":24,"pixelRatio":1,"width":18,"x":72,"y":152},"lean_to":{"height":20,"pixelRatio":1,"width":18,"x":90,"y":152},"lock":{"height":23,"pixelRatio":1,"width":14,"x":96,"y":118},"lock-canoeable":{"height":24,"pixelRatio":1,"width":24,"x":24,"y":200},"lock-hazard":{"height":24,"pixelRatio":1,"width":24,"x":24,"y":224},"nature_reserve":{"height":32,"pixelRatio":1,"width":32,"x":160,"y":0},"park":{"height":32,"pixelRatio":1,"width":32,"x":192,"y":0},"peak":{"height":18,"pixelRatio":1,"width":22,"x":72,"y":220},"protected_area":{"height":32,"pixelRatio":1,"width":32,"x":224,"y":0},"question":{"height":24,"pixelRatio":1,"width":24,"x":48,"y":128},"ranger_station":{"height":24,"pixelRatio":1,"width":24,"x":56,"y":32},"ranger_station-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":56,"y":56},"restricted-zone":{"height":4,"pixelRatio":1,"width":4,"x":80,"y":124},"route_marker":{"height":24,"pixelRatio":1,"width":18,"x":72,"y":176},"slipway-canoe":{"height":24,"pixelRatio":1,"width":24,"x":56,"y":80},"slipway-canoe-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":56,"y":104},"slipway-canoe-trailer":{"height":24,"pixelRatio":1,"width":24,"x":48,"y":152},"slipway-canoe-trailer-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":48,"y":176},"streamgage":{"height":24,"pixelRatio":1,"width":24,"x":48,"y":200},"trailhead":{"height":24,"pixelRatio":1,"width":24,"x":48,"y":224},"trailhead-noaccess":{"height":24,"pixelRatio":1,"width":24,"x":72,"y":128},"viewpoint":{"height":16,"pixelRatio":1,"width":22,"x":72,"y":238},"waterfall":{"height":20,"pixelRatio":1,"width":20,"x":72,"y":200},"waterfall-canoeable":{"height":24,"pixelRatio":1,"width":24,"x":80,"y":32},"waterfall-hazard":{"height":24,"pixelRatio":1,"width":24,"x":80,"y":56},"watershed_reserve":{"height":32,"pixelRatio":1,"width":32,"x":0,"y":32},"wilderness_preserve":{"height":32,"pixelRatio":1,"width":32,"x":0,"y":64},"wildlife_refuge":{"height":32,"pixelRatio":1,"width":32,"x":0,"y":96}} -------------------------------------------------------------------------------- /style/sprites/opentrailmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/style/sprites/opentrailmap.png -------------------------------------------------------------------------------- /style/sprites/opentrailmap@2x.json: -------------------------------------------------------------------------------- 1 | {"access_point":{"height":48,"pixelRatio":2,"width":48,"x":0,"y":256},"access_point-minor":{"height":36,"pixelRatio":2,"width":36,"x":184,"y":404},"access_point-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":0,"y":304},"arrow-left":{"height":30,"pixelRatio":2,"width":46,"x":180,"y":344},"arrow-right":{"height":30,"pixelRatio":2,"width":46,"x":184,"y":374},"arrows-leftright":{"height":30,"pixelRatio":2,"width":66,"x":160,"y":160},"beaver_dam":{"height":46,"pixelRatio":2,"width":42,"x":160,"y":190},"beaver_dam-canoeable":{"height":48,"pixelRatio":2,"width":48,"x":0,"y":352},"beaver_dam-hazard":{"height":48,"pixelRatio":2,"width":48,"x":0,"y":400},"bird_refuge":{"height":64,"pixelRatio":2,"width":64,"x":0,"y":0},"bison_refuge":{"height":64,"pixelRatio":2,"width":64,"x":64,"y":0},"cairn":{"height":48,"pixelRatio":2,"width":36,"x":208,"y":104},"campground":{"height":48,"pixelRatio":2,"width":48,"x":0,"y":448},"campground-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":48,"y":256},"campsite":{"height":40,"pixelRatio":2,"width":48,"x":208,"y":64},"caravan_site":{"height":48,"pixelRatio":2,"width":48,"x":64,"y":64},"caravan_site-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":64,"y":112},"dam":{"height":36,"pixelRatio":2,"width":36,"x":202,"y":190},"dam-canoeable":{"height":48,"pixelRatio":2,"width":48,"x":64,"y":160},"dam-hazard":{"height":48,"pixelRatio":2,"width":48,"x":64,"y":208},"disallowed-stripes":{"height":12,"pixelRatio":2,"width":14,"x":160,"y":236},"ferry":{"height":48,"pixelRatio":2,"width":48,"x":48,"y":304},"ferry-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":48,"y":352},"forest_reserve":{"height":64,"pixelRatio":2,"width":64,"x":128,"y":0},"game_land":{"height":64,"pixelRatio":2,"width":64,"x":192,"y":0},"grassland_reserve":{"height":64,"pixelRatio":2,"width":64,"x":256,"y":0},"guidepost":{"height":48,"pixelRatio":2,"width":36,"x":144,"y":304},"lean_to":{"height":40,"pixelRatio":2,"width":36,"x":180,"y":304},"lock":{"height":46,"pixelRatio":2,"width":28,"x":192,"y":236},"lock-canoeable":{"height":48,"pixelRatio":2,"width":48,"x":48,"y":400},"lock-hazard":{"height":48,"pixelRatio":2,"width":48,"x":48,"y":448},"nature_reserve":{"height":64,"pixelRatio":2,"width":64,"x":320,"y":0},"park":{"height":64,"pixelRatio":2,"width":64,"x":384,"y":0},"peak":{"height":36,"pixelRatio":2,"width":44,"x":144,"y":440},"protected_area":{"height":64,"pixelRatio":2,"width":64,"x":448,"y":0},"question":{"height":48,"pixelRatio":2,"width":48,"x":96,"y":256},"ranger_station":{"height":48,"pixelRatio":2,"width":48,"x":112,"y":64},"ranger_station-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":112,"y":112},"restricted-zone":{"height":8,"pixelRatio":2,"width":8,"x":160,"y":248},"route_marker":{"height":48,"pixelRatio":2,"width":36,"x":144,"y":352},"slipway-canoe":{"height":48,"pixelRatio":2,"width":48,"x":112,"y":160},"slipway-canoe-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":112,"y":208},"slipway-canoe-trailer":{"height":48,"pixelRatio":2,"width":48,"x":96,"y":304},"slipway-canoe-trailer-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":96,"y":352},"streamgage":{"height":48,"pixelRatio":2,"width":48,"x":96,"y":400},"trailhead":{"height":48,"pixelRatio":2,"width":48,"x":96,"y":448},"trailhead-noaccess":{"height":48,"pixelRatio":2,"width":48,"x":144,"y":256},"viewpoint":{"height":32,"pixelRatio":2,"width":44,"x":144,"y":476},"waterfall":{"height":40,"pixelRatio":2,"width":40,"x":144,"y":400},"waterfall-canoeable":{"height":48,"pixelRatio":2,"width":48,"x":160,"y":64},"waterfall-hazard":{"height":48,"pixelRatio":2,"width":48,"x":160,"y":112},"watershed_reserve":{"height":64,"pixelRatio":2,"width":64,"x":0,"y":64},"wilderness_preserve":{"height":64,"pixelRatio":2,"width":64,"x":0,"y":128},"wildlife_refuge":{"height":64,"pixelRatio":2,"width":64,"x":0,"y":192}} -------------------------------------------------------------------------------- /style/sprites/opentrailmap@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmus/OpenTrailMap/1b75ac81f4bd858eb416550cc952a5a5b1264dad/style/sprites/opentrailmap@2x.png -------------------------------------------------------------------------------- /style/sprites/svg/access_point-minor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | access_point-minor 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/access_point-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | canoe-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /style/sprites/svg/access_point.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | canoe 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-left 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrow-right 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/arrows-leftright.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | arrows-leftright 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/beaver_dam-canoeable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | beaver_dam-canoeable 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /style/sprites/svg/beaver_dam-hazard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | beaver_dam-hazard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /style/sprites/svg/beaver_dam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | beaver_dam 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /style/sprites/svg/bird_refuge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | bird_refuge 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/bison_refuge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | bison_refuge 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/cairn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | cairn 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/campground-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | campground-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /style/sprites/svg/campground.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | campground 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /style/sprites/svg/campsite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | campsite 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/caravan_site-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | caravan_site-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /style/sprites/svg/caravan_site.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | caravan_site 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/dam-canoeable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | dam-canoeable 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /style/sprites/svg/dam-hazard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | dam-hazard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /style/sprites/svg/dam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | dam 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/disallowed-stripes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | disallowed-stripes 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/ferry-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ferry-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /style/sprites/svg/ferry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ferry 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /style/sprites/svg/forest_reserve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | forest_reserve 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /style/sprites/svg/game_land.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | game_land 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/grassland_reserve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | grassland_reserve 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/guidepost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | guidepost 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/lean_to.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | lean_to 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/lock-canoeable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | lock-canoeable 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/lock-hazard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | lock-hazard 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | lock 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /style/sprites/svg/nature_reserve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | nature_reserve 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/park.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | park 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/peak.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | peak 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/protected_area.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | protected_area 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /style/sprites/svg/question.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | question 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /style/sprites/svg/ranger_station-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ranger_station-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /style/sprites/svg/ranger_station.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ranger_station 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/restricted-zone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | restricted-zone 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/route_marker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | route_marker 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /style/sprites/svg/slipway-canoe-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | slipway-canoe-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /style/sprites/svg/slipway-canoe-trailer-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | slipway-canoe-trailer-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /style/sprites/svg/slipway-canoe-trailer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | slipway-canoe-trailer 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/slipway-canoe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | slipway-canoe 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/streamgage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | streamgage 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /style/sprites/svg/trailhead-noaccess.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | trailhead-noaccess 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /style/sprites/svg/trailhead.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | trailhead 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/viewpoint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | viewpoint 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /style/sprites/svg/waterfall-canoeable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | waterfall-canoeable 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /style/sprites/svg/waterfall-hazard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | waterfall-hazard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /style/sprites/svg/waterfall.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | waterfall 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /style/sprites/svg/watershed_reserve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | watershed_reserve 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /style/sprites/svg/wilderness_preserve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | wilderness_preserve 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /style/sprites/svg/wildlife_refuge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | wildlife_refuge 4 | 5 | 6 | 7 | 8 | --------------------------------------------------------------------------------