├── .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 |
7 |
--------------------------------------------------------------------------------
/img/opentrailmap-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/style/sprites/svg/access_point-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/access_point.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/arrow-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/arrows-leftright.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/beaver_dam-canoeable.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/beaver_dam-hazard.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/beaver_dam.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/bird_refuge.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/bison_refuge.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/cairn.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/campground-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/campground.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/campsite.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/caravan_site-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/caravan_site.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/dam-canoeable.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/dam-hazard.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/dam.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/disallowed-stripes.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/ferry-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/ferry.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/forest_reserve.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/game_land.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/grassland_reserve.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/guidepost.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/lean_to.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/lock-canoeable.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/lock-hazard.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/lock.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/nature_reserve.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/park.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/peak.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/protected_area.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/question.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/ranger_station-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/ranger_station.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/restricted-zone.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/route_marker.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/slipway-canoe-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/slipway-canoe-trailer-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/slipway-canoe-trailer.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/slipway-canoe.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/streamgage.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/trailhead-noaccess.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/trailhead.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/viewpoint.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/waterfall-canoeable.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/waterfall-hazard.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/waterfall.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/watershed_reserve.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/wilderness_preserve.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/style/sprites/svg/wildlife_refuge.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------