diff --git a/.gitignore b/.gitignore index d8b83df..68e57c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,15 @@ package-lock.json +pnpm-lock.yaml +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local diff --git a/README.md b/README.md index 636a987..f093fbf 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,267 @@ [![npm version](https://badge.fury.io/js/mapclay.svg)](https://badge.fury.io/js/mapclay) -In short, this module allows user build web map as soon as possible by simple options. +## Quick Start -No third-party Map Framework dominates. -This module just creates an interface to connect options and pre-defined map renderer. +### The minimal use case: + +Specify **CSS selector** for target HTML element by **data attribute** `data-target` (`pre` in this case): + +```html +

+
+```
+
+
+Or by **query paremeter** `target`: + +```html +

+
+```
+
+
+The text content of target element would be parsed as [YAML], So user can specify [options] to configure map. + +```html +
+use: Maplibre
+width: 400px
+height: 50vh
+center: [139.6917,35.6895]
+zoom: 8
+
+ +``` + +_Check [the result][test1] with online markdown editor_ -## Quick Start -Create an HTML file with the following lines: +
+Of course it can render multiple targets: ```html -
- +
+use: Leaflet
+
+
+use: Maplibre
+
+
+use: Openlayers
+
+ ``` -`mapclay.js` simply renders elements with `class="map"`(by default) as web map: +_Check [the result][test2] with online markdown editor_ + +
- +### API calls + +If **target** is not given by ` + + +
+
+
+ + + +``` +### Render by text content -Here is another example: +Still, get target element and write `textContent` for options: ```html -
-use: maplibre
-width: 40vw
-height: 300px
-center: [142.73, 43.83]
+
+
+width: 400px
+height: 400px
+center: [139.6917,35.6895]
+zoom: 8
 
-
- ``` -Here is the result, another map is rendered: +Use `mapclay.renderByTextContent` for this case + +```js +// In + + + + + +``` + +## See Also -### odyssey.js -http://cartodb.github.io/odyssey.js/ +- MapML: https://maps4html.org/web-map-doc/ +- odyssey.js: http://cartodb.github.io/odyssey.js/ -[maplibre]: https://maplibre.org/projects/maplibre-gl-js/ -[demotiles]: https://github.com/maplibre/demotiles/ -[How it works?]: https://github.com/typebrook/mapclay.js/wiki/How-it-works -[Integration]: https://github.com/typebrook/mapclay.js/wiki/Integration +[test1]: https://markdown-it.github.io/#md3=%7B%22source%22%3A%22%60%60%60map%5Cncenter%3A%20%5B121%2C%2024%5D%5Cnwidth%3A%20100%25%5CnXYZ%3A%20https%3A%2F%2Ftile.openstreetmap.jp%2Fstyles%2Fosm-bright%2F512%2F%7Bz%7D%2F%7Bx%7D%2F%7By%7D.png%5Cn%60%60%60%5Cn%5Cn%3Cscript%20src%3D%27http%3A%2F%2Flocalhost%3A8080%2Fdist%2Frenderers%2Fopenlayers.js%3Ftarget%3Dpre%27%3E%3C%2Fscript%3E%22%2C%22defaults%22%3A%7B%22html%22%3Atrue%2C%22xhtmlOut%22%3Afalse%2C%22breaks%22%3Afalse%2C%22langPrefix%22%3A%22%22%2C%22linkify%22%3Atrue%2C%22typographer%22%3Afalse%2C%22_highlight%22%3Afalse%2C%22_strict%22%3Afalse%2C%22_view%22%3A%22html%22%7D%7D +[options]: #options +[YAML]: https://nodeca.github.io/js-yaml/ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5d383f6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,15 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; + + +export default [ + { + languageOptions: { + globals: globals.browser + }, + rules: { + 'no-undef': 'off', + }, + }, + pluginJs.configs.recommended, +]; diff --git a/examples/input.html b/examples/input.html deleted file mode 100644 index 6aa5586..0000000 --- a/examples/input.html +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - - -
-
- - -
- - -
-
- -
-
- use -

Use which renderer?

-
- - -
-
- - -
-
- - -
-
- - -
-
-
- width -

Width of map container

-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- height -

Height of map container

-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- center -

Center of map, format is [Longitude, Latitude]

-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- zoom -

Zoom level of map

-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- XYZ -

Template of slippy Tile Service

-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- control.fullscreen -

Fullscreen button

-
- - -
-
- - -
-
-
- control.scale -

Scale Bar

-
- - -
-
- - -
-
-
- debug -

Show boundary and index of each tile

-
- - -
-
- - -
-
-
- link -

Use permalink for map location

-
- - -
-
- - -
-
-
- STYLE -

URL of Mapbox Style file

-
- - -
-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- GPX -

URL of GPX file

-
- - -
-
- - - -
-
-
-
- - -
- - - - diff --git a/examples/input.js b/examples/input.js deleted file mode 100644 index df7f9d1..0000000 --- a/examples/input.js +++ /dev/null @@ -1,146 +0,0 @@ -import 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'; - -const map = document.querySelector('#map'); -const textArea = document.querySelector('#map-text'); -const fieldsets = document.querySelectorAll('fieldset'); - -//const sharedElement = document.getElementById('shared-element'); -map.addEventListener('map-loaded', () => { - refresh(false); -}); - -// When focus out textArea, refresh Map and set radio buttons -textArea.addEventListener('focusout', (event) => { - refresh(autoRefresh()); -}); - -const choices = document.querySelectorAll('div[class="field"]'); -choices.forEach((choice) => { - choice.addEventListener('click', (event) => { - // Check radio button - choice.querySelector('input[type="radio"]').checked = true; - - // Focus text input if possible - const textInput = choice.querySelector('input[type="text"]'); - if (textInput && textInput.focus){ - textInput.focus(); - } - - // Get field for current option - const field = choice.querySelector('input').name - - // Get value from div or text input - var value = choice.dataset.value - if (! value) { - value = textInput ? textInput.value : "" - } - - // Change value by type; - switch (choice.parentElement.dataset.type) { - case "boolean": - value = value === 'true'; - break; - case "array": - value = JSON.parse(value); - break; - case "number": - value = Number.parseFloat(value); - break; - } - - // Get assignment of new value - // Considering nested attribute, use object here - var assign = {}; - field.split('.').reverse().forEach((key, index) => { - assign = { [key]: index == 0 ? value : assign } - }) - - // Get current options from textArea - var options = getOptions(); - - // Set new value - Object.assign(options, assign) - removeEmptyStrings(options) - - const newText = jsyaml.dump(options); - textArea.value = newText.startsWith('{}') ? '' : newText - refresh(autoRefresh()); - }); -}); - -const textInputs = document.querySelectorAll('input[type="text"]'); -textInputs.forEach((input) => { - input.addEventListener('focusout', (event) => { - input.parentElement.click() - }); -}); - -function removeEmptyStrings(obj) { - for (let key in obj) { - if (typeof obj[key] === 'object' && obj[key] !== null) { - removeEmptyStrings(obj[key]); - if (Object.keys(obj[key]).length === 0) { - delete obj[key] - } - } else if (obj[key] === '') { - delete obj[key]; - } - } -} - -// Check if auto refresh is checked -function autoRefresh() { - const checkbox = document.querySelector('.auto-refresh') - return checkbox ? checkbox.checked : false -} - -// Refresh Map -async function refresh(alsoRefreshMap) { - // Refresh Map if needed - alsoRefreshMap && await refreshMap(); - - const options = getOptions(); - - fieldsets.forEach((fieldset) => { - const legend = fieldset.querySelector('legend').textContent - - // Hide fieldsets which are not supported by current renderer - if (renderer.supportOptions.includes(legend)) { - fieldset.style.display = "block"; - } else { - fieldset.style.display = "none"; - } - - function getInner(path, obj) { - const properties = path.split('.'); - let value = obj; - - for (let property of properties) { - if (value.hasOwnProperty(property)) { - value = value[property]; - } else { - return ""; - } - } - - return value; - } - - // Get current value of each field from textarea - var value = getInner(legend, options); - if (fieldset.dataset.type == 'array') { - value = `[${value.toString()}]` - } - - // Set field by content of textarea - var field = fieldset.querySelector(`div.field[data-value="${value}"]`) - field = field ? field : fieldset.querySelector(`div.field[data-value=""]`) - field.querySelector('input[type="radio"]').checked = true; - }) -} - -// Get current options from textarea -function getOptions() { - var options = loadOptions(textArea.value); - return options ? options : {} -} diff --git a/examples/preset.yml b/examples/preset.yml deleted file mode 100644 index 4702692..0000000 --- a/examples/preset.yml +++ /dev/null @@ -1,58 +0,0 @@ -aliases: - OSM Carto: http://b.tile.openstreetmap.org/{z}/{x}/{y}.png - OpenCycleMap: https://a.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a5dd6a2f1c934394bce6b0fb077203eb - Rudymap: https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png - Happyman_gpx: https://tile.happyman.idv.tw/map/gpxtrack/{z}/{x}/{y}.png - Happyman_moi: https://tile.happyman.idv.tw/map/moi_osm/{z}/{x}/{y}.png - NLSC_aeril: http://wmts.nlsc.gov.tw/wmts/PHOTO2/default/GoogleMapsCompatible/{z}/{y}/{x} - NLSC_topo: http://wmts.nlsc.gov.tw/wmts/PHOTO_MIX/default/GoogleMapsCompatible/{z}/{y}/{x} - SINICA: https://gis.sinica.edu.tw/tileserver/wmts - NLSC: https://wmts.nlsc.gov.tw/wmts - WMTS TAIWAN_TERRAIN: https://osmhacktw.github.io/terrain-rgb/wmts.xml - XYZ TAIWAN_TERRAIN: https://osmhacktw.github.io/terrain-rgb/tiles/{z}/{x}/{y}.png - Tokyo: [ 139.6917, 35.6895 ] - Delhi: [ 77.1025, 28.7041 ] - Shanghai: [ 121.4737, 31.2304 ] - Sao Paulo: [ -46.6333, -23.5505 ] - Mumbai: [ 72.8777, 19.0760 ] - Mexico City: [ -99.1332, 19.4326 ] - Beijing: [ 116.4074, 39.9042 ] - Osaka: [ 135.5022, 34.6937 ] - Cairo: [ 31.2357, 30.0444 ] - New York City: [ -74.0059, 40.7128 ] - Dhaka: [ 90.4125, 23.8103 ] - Karachi: [ 67.0011, 24.8607 ] - Buenos Aires: [ -58.3816, -34.6037 ] - Istanbul: [ 28.9784, 41.0082 ] - Kolkata: [ 88.3639, 22.5726 ] - Manila: [ 120.9842, 14.5995 ] - Lagos: [ 3.3792, 6.5244 ] - Rio de Janeiro: [ -43.1729, -22.9068 ] - Tianjin: [ 117.1767, 39.0842 ] - -switch (this.id) { - case 'openlayers': - map.getViewport().addEventListener('contextmenu', function (evt) { - evt.preventDefault(); - const text = map.getEventCoordinate(evt).map(f=>f.toFixed(7)); - const coord = `[${text}]` - navigator.clipboard.writeText(coord); - alert('Copied coordiantes: ' + coord); - }) - break; - case 'leaflet': - map.on('contextmenu', function(e) { - var coords = '[' + e.latlng.lat.toFixed(7) + ', ' + e.latlng.lng.toFixed(7) + ']'; - navigator.clipboard.writeText(coords); - alert('Copied coordiantes (LatLong format): ' + coords); - }); - break; - case 'maplibre': - console.log('maplibre') - map.on('contextmenu', function(e) { - var coords = '[' + e.lngLat.lng.toFixed(7) + ', ' + e.lngLat.lat.toFixed(7) + ']'; - navigator.clipboard.writeText(coords); - alert('Copied coordiantes: ' + coords); - }); - break; -} diff --git a/examples/run.gpx b/examples/run.gpx deleted file mode 100644 index 97f0c09..0000000 --- a/examples/run.gpx +++ /dev/null @@ -1,1872 +0,0 @@ - - - - - Garmin Connect - - - - - Untitled - - - 25.600000381469727 - - - - 130 - - - - - 35.599998474121094 - - - - 134 - - - - - 38.0 - - - - 139 - - - - - 40.0 - - - - 144 - - - - - 40.79999923706055 - - - - 149 - - - - - 44.20000076293945 - - - - 161 - - - - - 45.20000076293945 - - - - 164 - - - - - 48.599998474121094 - - - - 171 - - - - - 49.0 - - - - 177 - - - - - 49.599998474121094 - - - - 181 - - - - - 53.79999923706055 - - - - 184 - - - - - 57.20000076293945 - - - - 188 - - - - - 56.79999923706055 - - - - 189 - - - - - 57.79999923706055 - - - - 191 - - - - - 58.599998474121094 - - - - 191 - - - - - 58.20000076293945 - - - - 192 - - - - - 58.20000076293945 - - - - 193 - - - - - 58.20000076293945 - - - - 194 - - - - - 56.79999923706055 - - - - 195 - - - - - 56.79999923706055 - - - - 195 - - - - - 57.20000076293945 - - - - 196 - - - - - 57.20000076293945 - - - - 196 - - - - - 57.20000076293945 - - - - 197 - - - - - 57.79999923706055 - - - - 197 - - - - - 59.20000076293945 - - - - 198 - - - - - 59.20000076293945 - - - - 198 - - - - - 60.599998474121094 - - - - 198 - - - - - 61.0 - - - - 199 - - - - - 61.599998474121094 - - - - 199 - - - - - 61.599998474121094 - - - - 200 - - - - - 61.599998474121094 - - - - 200 - - - - - 61.0 - - - - 200 - - - - - 61.0 - - - - 200 - - - - - 62.0 - - - - 201 - - - - - 63.0 - - - - 202 - - - - - 63.0 - - - - 202 - - - - - 64.0 - - - - 202 - - - - - 65.4000015258789 - - - - 203 - - - - - 65.80000305175781 - - - - 202 - - - - - 67.4000015258789 - - - - 202 - - - - - 69.80000305175781 - - - - 202 - - - - - 77.0 - - - - 202 - - - - - 82.80000305175781 - - - - 202 - - - - - 82.19999694824219 - - - - 202 - - - - - 82.19999694824219 - - - - 202 - - - - - 81.80000305175781 - - - - 203 - - - - - 81.80000305175781 - - - - 203 - - - - - 81.19999694824219 - - - - 203 - - - - - 80.80000305175781 - - - - 203 - - - - - 79.80000305175781 - - - - 203 - - - - - 78.4000015258789 - - - - 203 - - - - - 76.4000015258789 - - - - 203 - - - - - 75.5999984741211 - - - - 203 - - - - - 74.0 - - - - 203 - - - - - 74.0 - - - - 203 - - - - - 74.0 - - - - 203 - - - - - 74.0 - - - - 203 - - - - - 73.5999984741211 - - - - 202 - - - - - 73.0 - - - - 202 - - - - - 73.5999984741211 - - - - 202 - - - - - 73.5999984741211 - - - - 201 - - - - - 72.5999984741211 - - - - 201 - - - - - 73.0 - - - - 201 - - - - - 72.5999984741211 - - - - 200 - - - - - 72.5999984741211 - - - - 200 - - - - - 73.0 - - - - 200 - - - - - 73.0 - - - - 200 - - - - - 72.19999694824219 - - - - 200 - - - - - 72.5999984741211 - - - - 199 - - - - - 72.19999694824219 - - - - 199 - - - - - 72.5999984741211 - - - - 197 - - - - - 73.0 - - - - 197 - - - - - 73.5999984741211 - - - - 197 - - - - - 71.5999984741211 - - - - 198 - - - - - 69.19999694824219 - - - - 198 - - - - - 66.4000015258789 - - - - 198 - - - - - 62.0 - - - - 198 - - - - - 60.20000076293945 - - - - 198 - - - - - 57.79999923706055 - - - - 198 - - - - - 56.20000076293945 - - - - 198 - - - - - 53.79999923706055 - - - - 198 - - - - - 53.79999923706055 - - - - 197 - - - - - 53.79999923706055 - - - - 197 - - - - - 55.79999923706055 - - - - 197 - - - - - 54.79999923706055 - - - - 197 - - - - - 55.400001525878906 - - - - 197 - - - - - 56.79999923706055 - - - - 196 - - - - - 57.79999923706055 - - - - 196 - - - - - 61.599998474121094 - - - - 195 - - - - - 64.0 - - - - 195 - - - - - 65.80000305175781 - - - - 195 - - - - - 64.4000015258789 - - - - 195 - - - - - 56.79999923706055 - - - - 195 - - - - - 55.400001525878906 - - - - 194 - - - - - 53.79999923706055 - - - - 193 - - - - - 50.0 - - - - 193 - - - - - 44.79999923706055 - - - - 194 - - - - - 40.0 - - - - 195 - - - - - 35.20000076293945 - - - - 196 - - - - - 32.79999923706055 - - - - 196 - - - - - 30.399999618530273 - - - - 196 - - - - - 28.399999618530273 - - - - 196 - - - - - 27.0 - - - - 196 - - - - - 26.399999618530273 - - - - 196 - - - - - 27.0 - - - - 196 - - - - - 37.599998474121094 - - - - 146 - - - - - 37.599998474121094 - - - - 146 - - - - - 37.0 - - - - 147 - - - - - 36.599998474121094 - - - - 147 - - - - - 34.20000076293945 - - - - 152 - - - - - 34.20000076293945 - - - - 154 - - - - - 34.599998474121094 - - - - 151 - - - - - 36.0 - - - - 156 - - - - - 39.0 - - - - 162 - - - - - 39.0 - - - - 164 - - - - - 37.599998474121094 - - - - 169 - - - - - 37.0 - - - - 172 - - - - - 36.0 - - - - 177 - - - - - 34.20000076293945 - - - - 182 - - - - - 34.20000076293945 - - - - 184 - - - - - 35.20000076293945 - - - - 186 - - - - - 35.599998474121094 - - - - 188 - - - - - 36.599998474121094 - - - - 188 - - - - - 36.599998474121094 - - - - 188 - - - - - 37.0 - - - - 189 - - - - - 34.20000076293945 - - - - 190 - - - - - 33.599998474121094 - - - - 191 - - - - - 31.799999237060547 - - - - 192 - - - - - 33.599998474121094 - - - - 193 - - - - - 36.0 - - - - 194 - - - - - 36.0 - - - - 194 - - - - - 34.599998474121094 - - - - 194 - - - - - 38.0 - - - - 194 - - - - - 39.400001525878906 - - - - 194 - - - - - 40.400001525878906 - - - - 194 - - - - - 42.79999923706055 - - - - 194 - - - - - 42.79999923706055 - - - - 195 - - - - - 43.20000076293945 - - - - 195 - - - - - 42.400001525878906 - - - - 195 - - - - - 42.400001525878906 - - - - 195 - - - - - 42.79999923706055 - - - - 196 - - - - - 41.400001525878906 - - - - 196 - - - - - 39.400001525878906 - - - - 196 - - - - - 40.79999923706055 - - - - 197 - - - - - 42.79999923706055 - - - - 197 - - - - - 44.20000076293945 - - - - 198 - - - - - 47.599998474121094 - - - - 197 - - - - - 51.0 - - - - 198 - - - - - 50.599998474121094 - - - - 198 - - - - - 51.0 - - - - 198 - - - - - 52.400001525878906 - - - - 197 - - - - - 53.0 - - - - 196 - - - - - 53.400001525878906 - - - - 194 - - - - - 52.400001525878906 - - - - 189 - - - - - 51.400001525878906 - - - - 189 - - - - - 46.599998474121094 - - - - 190 - - - - - 45.20000076293945 - - - - 190 - - - - - 41.400001525878906 - - - - 191 - - - - - 39.400001525878906 - - - - 192 - - - - - 37.599998474121094 - - - - 193 - - - - - 37.599998474121094 - - - - 195 - - - - - 40.400001525878906 - - - - 196 - - - - - 42.79999923706055 - - - - 196 - - - - - 43.20000076293945 - - - - 198 - - - - - 45.599998474121094 - - - - 198 - - - - - 48.20000076293945 - - - - 199 - - - - - 50.0 - - - - 200 - - - - - 50.599998474121094 - - - - 200 - - - - - 52.0 - - - - 200 - - - - - 53.400001525878906 - - - - 200 - - - - - 54.79999923706055 - - - - 201 - - - - - 55.79999923706055 - - - - 201 - - - - - 58.20000076293945 - - - - 201 - - - - - 59.20000076293945 - - - - 201 - - - - - 60.599998474121094 - - - - 201 - - - - - 60.20000076293945 - - - - 202 - - - - - 60.599998474121094 - - - - 203 - - - - - 60.599998474121094 - - - - 203 - - - - - 48.599998474121094 - - - - 167 - - - - - 48.20000076293945 - - - - 167 - - - - - 48.599998474121094 - - - - 166 - - - - - 49.0 - - - - 167 - - - - - 48.599998474121094 - - - - 169 - - - - - 48.599998474121094 - - - - 172 - - - - - 47.20000076293945 - - - - 177 - - - - - 46.20000076293945 - - - - 178 - - - - - 44.79999923706055 - - - - 179 - - - - - 43.79999923706055 - - - - 181 - - - - - 43.20000076293945 - - - - 181 - - - - - 44.79999923706055 - - - - 182 - - - - - 44.79999923706055 - - - - 183 - - - - - 45.20000076293945 - - - - 185 - - - - - 44.79999923706055 - - - - 185 - - - - - 43.20000076293945 - - - - 185 - - - - - 42.400001525878906 - - - - 185 - - - - - 46.599998474121094 - - - - 184 - - - - - 45.599998474121094 - - - - 183 - - - - - 46.599998474121094 - - - - 182 - - - - - 44.79999923706055 - - - - 182 - - - - - 44.79999923706055 - - - - 182 - - - - - 44.20000076293945 - - - - 182 - - - - - 44.79999923706055 - - - - 183 - - - - - 46.599998474121094 - - - - 184 - - - - - 48.599998474121094 - - - - 185 - - - - - 48.599998474121094 - - - - 186 - - - - - 48.599998474121094 - - - - 186 - - - - - - \ No newline at end of file diff --git a/favicon.ico b/favicon.ico deleted file mode 100644 index 5af1857..0000000 Binary files a/favicon.ico and /dev/null differ diff --git a/js/BaseRenderer.js b/js/BaseRenderer.js deleted file mode 100644 index 253710c..0000000 --- a/js/BaseRenderer.js +++ /dev/null @@ -1,223 +0,0 @@ -export default class { - - // Resources about Script or CSS file - resources = new Set(); - - supportOptions = [ - "use", - "width", - "height", - "center", - "zoom", - "updates", - "XYZ", - "GPX", - "WMTS", - ] - - // Default configuation for map - defaultConfig = { - width: "300px", - height: "300px", - center: [121, 24], - zoom: 7, - updates: [], - data: [], - aliases: [], - } - - // Used for animation - at = 0 - - // Get list of necessary resources - appendResources(config) { return this.resources } - - // Import modules based on config - importModules(config){}; - // Create map object - createMap(element, config){}; - // After map object is created, apply configurations - afterMapCreated(map, config) { - this.setData(map, config); - this.setInteraction(map, config); - this.setControl(map, config); - this.setExtra(map, config); - }; - // Add Interaction Options - setInteraction(map, config){ - const renderer = this - window.addEventListener('keydown', function(e){ - renderer.handleKey(map, config, e.keyCode) - }) - }; - // Add Control Options - setControl(map, config){}; - // Add GIS data - setData(map, config){ - // Tile - this.addTileData(map, config.data); - - // Set GPX file - const gpxData = config.data.filter(datum => datum.type == 'gpx') - if (gpxData.length != 0) { - gpxData.forEach(datum => { - this.addGPXFile(map, datum.url) - }) - } - - if (config.markers) { - this.addMarkers(map, config.markers) - } - }; - // Do extra stuff - setExtra(map, config){}; - - // Update camera, like center or zoom level - updateCamera(map, options, useAnimation){}; - - // Import GPX files - addTileData(map, tileData) {}; - - // Import GPX files - addGPXFile(map, gpxUrl) {}; - - // Handle key events - handleKey(map, config, code) { - if (config.updates.length < 2) { return false; } - - if (code == 78) { - ++this.at; - if (this.at == config.updates.length) { this.at = 0; } - } - else if (code == 80) { - --this.at; - if (this.at == -1) { this.at = config.updates.length - 1; } - } - else { return false; } - - return true - } - - // Clean original content - // And pretty-print config at a new
upon map - printConfig = (mapElement, config) => { - mapElement.innerHTML = '' - let configDiv = document.createElement('div'); - configDiv.innerHTML = ` -
- CONFIG -
${enumerateProps(config)}
-      
- `; - mapElement.parentNode.insertBefore(configDiv, mapElement); - } - - handleAliases(options) { - if (options.XYZ) { - const xyzArray = typeof options.XYZ == 'string' - ? [ options.XYZ ] - : options.XYZ - xyzArray.forEach((record, index) => { - var obj; - if (typeof record == 'string') { - obj = { - type: "tile", - url: record, - title: `Anonymous_${index}`, - } - } else if (typeof record == 'object') { - obj = { - type: "tile", - url: record.url, - title: record.title ? record.title : `Anonymous_${index}`, - } - } else { - return; - } - options.data.push(obj) - }) - delete options.XYZ - } - - if (options.WMTS) { - options.data.push({ - type: "wmts", - url: options.WMTS, - }) - delete options.WMTS - } - - if (options.GPX) { - options.data.push({ - type: "gpx", - url: options.GPX, - }) - delete options.GPX - } - - // Replace aliases into real string - if (typeof options.center == 'string' && options.aliases.hasOwnProperty(options.center)) { - options.center = options.aliases[options.center] - } - options.updates.forEach(record => { - if (typeof record.center == 'string' && options.aliases.hasOwnProperty(record.center)) { - record.center = options.aliases[record.center] - } - }) - options.data.forEach(record => { - if (options.aliases.hasOwnProperty(record.url)) { - record.title = record.url - record.url = options.aliases[record.url] - } - }) - } - - // Transform element contains config text into map - async renderMap(element) { - // Remove all childs - element.replaceChildren([]) - - // Set width/height for div - element.style.width = element.config.width; - element.style.height = element.config.height; - - // Set current center/zoom as the first element of updates[] - element.config.updates.unshift({ - center: element.config.center, - zoom: element.config.zoom - }) - // If some options are missing in an update, use previous one's - element.config.updates.forEach((update, index) => { - if (! update.center) { update.center = element.config.updates[index - 1].center } - if (! update.zoom) { update.zoom = element.config.updates[index - 1].zoom } - }) - - // Configure Map - await this.importModules(element.config); - const map = this.createMap(element, element.config); - element.map = map // Used to check element is already a map container - this.afterMapCreated(map, element.config); - } - - showLayerSwitcher(data) { - const wmtsRecords = data.filter(record => record.type == 'wmts') - const tileRecords = data.filter(record => record.type == 'tile') - - return wmtsRecords.length > 0 || tileRecords.length > 1 - } -} - -function enumerateProps(obj, initial = ''){ - let content = '' + initial - for (const prop in obj) { - let val = obj[prop] - if (typeof val === 'object' && ! Array.isArray(val)){ - content += `${prop}:\n` - content += enumerateProps(val, ' ') - } else { - content += `${prop}: ${obj[prop]}\n` - } - } - - return content -} diff --git a/js/BasicLeafletRenderer.js b/js/BasicLeafletRenderer.js deleted file mode 100644 index af9fa78..0000000 --- a/js/BasicLeafletRenderer.js +++ /dev/null @@ -1,184 +0,0 @@ -import defaultExport from './BaseRenderer.js'; - -export default class extends defaultExport { - id = 'leaflet'; - version = '1.9.3'; - - resources = [ - `https://unpkg.com/leaflet@${this.version}/dist/leaflet.js`, - `https://unpkg.com/leaflet@${this.version}/dist/leaflet.css` - ] - - supportOptions = this.supportOptions.concat([ - "control.fullscreen", - "control.scale", - "GPX", - "link", - "debug" - ]) - - defaultConfig = Object.assign(this.defaultConfig, { - control: { - fullscreen: false, - scale: false - }, - }) - - async importModules(config) { - if (config.link) { - await import('https://rawgit.com/MarcChasse/leaflet.Permalink/master/leaflet.permalink.min.js'); - } - } - - createMap(element, config) { - // If Map Container is initialized, remove it - if (element.map && element.map.remove) { - element.map.off() - element.map.remove() - } - delete element._leaflet_id - - const map = L.map(element).setView(Array.from(config.center).reverse(), config.zoom) - - return map; - }; - - // Configure interactions - setInteraction(map, config) { - // Set center of map - if (config.link) { - this.addPermalink(map, config); - } - - super.setInteraction(map, config) - }; - - // Configure controls - setControl(map, config){ - if (config.control.fullscreen) { - let css = document.createElement('link'); - css.rel = 'stylesheet'; - css.href = 'https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/leaflet.fullscreen.css'; - document.body.append(css); - - let script = document.createElement('script'); - script.src = "https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/Leaflet.fullscreen.min.js"; - document.body.append(script); - script.onload = () => { - map.addControl(new L.Control.Fullscreen()); - } - } - if (config.control.scale) { - L.control.scale().addTo(map); - } - }; - - // Configure extra stuff - setExtra(map, config) { - if (config.debug == true) { - L.GridLayer.GridDebug = L.GridLayer.extend({ - createTile: function (coords) { - const tile = document.createElement('div'); - tile.style.outline = '2px solid'; - tile.style.fontWeight = 'bold'; - tile.style.fontSize = '14pt'; - tile.innerHTML = [coords.z, coords.x, coords.y].join('/'); - return tile; - } - }); - - L.gridLayer.gridDebug = function (opts) { - return new L.GridLayer.GridDebug(opts); - }; - - map.addLayer(L.gridLayer.gridDebug()); - } - if (config.eval) { - eval(config.eval); - } - }; - - addMarkers(map, markers) { - markers.forEach(marker => { - let xy = Array.from(marker.xy).reverse() - L.marker(xy).addTo(map) - .bindPopup(marker.message) - }); - } - - addTileData(map, tileData) { - var baseLayers = {} - var overlayMaps = {} - if (tileData.length == 0) { - const osmTile = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' - L.tileLayer(osmTile).addTo(map); - } else { - tileData.forEach((datum, index) => { - const customTile = datum.url - const layer = L.tileLayer(customTile); - const title = datum.title ? datum.title : `Anonymous_${index}` - if (index == 0) { - layer.addTo(map) - } - baseLayers[title] = layer - }) - } - var layerControl = L.control.layers(baseLayers, overlayMaps).addTo(map); - } - - addGPXFile(map, gpxUrl) { - let script = document.createElement('script'); - script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet-gpx/1.7.0/gpx.min.js"; - document.body.append(script); - - let options = { - gpx_options: { - joinTrackSegments: false - }, - polyline_options: { - color: 'red', - weight: 3, - lineCap: 'round' - }, - marker_options: { - startIconUrl: null, - endIconUrl: null, - shadowUrl: '', - wptIconUrls: { - '': null - } - }, - async: true - } - script.onload = () => { - new L.GPX(gpxUrl, options).addTo(map); - } - } - - addPermalink(map, config) { - L.Permalink.setup(map); - var mappos = L.Permalink.getMapLocation( - config.zoom, - Array.from(config.center).reverse() - ) - map.setView(mappos.center, mappos.zoom) - } - - handleKey(map, config, code) { - if (! super.handleKey(map, config, code)) { return; } - - let nextStatus = config.updates[this.at]; - let center = nextStatus.center ? nextStatus.center : map.getCenter().reverse(); - let zoom = nextStatus.zoom ? nextStatus.zoom : map.getZoom(); - this.updateCamera(map, { center: center, zoom: zoom }, true) - } - - updateCamera(map, options, useAnimation) { - let latLon = L.latLng(options.center[1], options.center[0]) - if (useAnimation) { - map.flyTo(latLon, options.zoom); - } else { - map.setView(latLon, options.zoom); - } - } -} diff --git a/js/BasicMaplibreRenderer.js b/js/BasicMaplibreRenderer.js deleted file mode 100644 index 54becec..0000000 --- a/js/BasicMaplibreRenderer.js +++ /dev/null @@ -1,173 +0,0 @@ -import defaultExport from './BaseRenderer.js'; - -export default class extends defaultExport { - id = 'maplibre'; - version = '2.4.0'; - - resources = [ - `https://unpkg.com/maplibre-gl@${this.version}/dist/maplibre-gl.js`, - `https://unpkg.com/maplibre-gl@${this.version}/dist/maplibre-gl.css` - ] - - supportOptions = this.supportOptions.concat([ - "control.fullscreen", - "control.scale", - "STYLE", - "GPX", - "link", - "debug", - ]) - - defaultConfig = Object.assign(this.defaultConfig, { - control: { - fullscreen: false, - scale: false - }, - }) - - createMap(element, config) { - const styleDatum = config.data.filter(datum => datum.type == 'style')[0]; - const tileData = config.data.filter(datum => datum.type == 'tile'); - - var style; - if (styleDatum) { - style = styleDatum.url - } else if (tileData.length != 0 ){ - style = { - version: 8, - sources: {}, - layers: [], - } - } else { - style = 'https://demotiles.maplibre.org/style.json' - } - - let map = new maplibregl.Map({ - container: element, - style: style, - hash: config.link == true ? true : false, - center: config.center, - zoom: config.zoom, - }); - - return map; - }; - - handleAliases(options) { - super.handleAliases(options) - if (options.STYLE) { - options.data.push({ - type: "style", - url: options.STYLE - }) - delete options.STYLE - } - } - - afterMapCreated(map, config){ - this.setInteraction(map, config); - this.setControl(map, config); - map.on('load', () => { - this.setData(map, config) - this.setExtra(map, config); - }); - }; - - // Configure interactions - setInteraction(map, config) { - super.setInteraction(map, config) - }; - - // Configure controls - setControl(map, config){ - if (config.control.fullscreen == true) { - map.addControl(new maplibregl.FullscreenControl()); - } - if (config.control.scale == true) { - let scale = new maplibregl.ScaleControl({ - unit: 'metric' - }); - map.addControl(scale); - } - }; - - // Configure extra stuff - setExtra(map, config) { - if (config.debug == true) { - map.showTileBoundaries = true; - } - if (config.eval) { - eval(config.eval) - }; - }; - - addMarkers(map, markers) { - markers.forEach(marker => { - let popup = new maplibregl.Popup() - .setText(marker.message) - .setMaxWidth("300px") - new maplibregl.Marker() - .setLngLat(marker.xy) - .setPopup(popup) - .addTo(map); - }); - } - - addTileData(map, tileData) { - const style = map.getStyle(); - tileData.forEach((datum, index) => { - const source = datum.name ? datum.name : index.toString() - style.sources[source] = { type: "raster", tiles: [datum.url], tileSize: 256 } - style.layers.push({ id: source, type: "raster", source: source}) - }) - map.setStyle(style) - } - - addGPXFile(map, gpxUrl) { - let script = document.createElement('script'); - script.src = "https://loc8.us/maplibre-gl-vector-text-protocol/dist/maplibre-gl-vector-text-protocol.js"; - document.body.append(script); - - script.onload = () => { - VectorTextProtocol.addProtocols(maplibregl); - - const gpxSourceName = 'gpx'; - const gpxLink = 'gpx://' + gpxUrl; - - map.addSource(gpxSourceName, { - 'type': 'geojson', - 'data': gpxLink, - }); - map.addLayer({ - 'id': "id_" + gpxSourceName, - 'type': 'line', - 'source': gpxSourceName, - 'paint': { - 'line-color': 'red', - 'line-width': 3 - } - }); - }; - } - - handleKey(map, config, code) { - if (! super.handleKey(map, config, code)) { return; } - - let nextStatus = config.updates[this.at]; - let center = nextStatus.center ? nextStatus.center : map.getCenter().reverse(); - let zoom = nextStatus.zoom ? nextStatus.zoom : map.getZoom(); - this.updateCamera(map, { center: center, zoom: zoom }, true) - } - - updateCamera(map, options, useAnimation) { - if (useAnimation) { - map.flyTo({ - center: options.center, - zoom: options.zoom - }) - } else { - map.setCenter(options.center) - map.setZoom(options.zoom) - } - } -} diff --git a/js/BasicOpenlayersRenderer.js b/js/BasicOpenlayersRenderer.js deleted file mode 100644 index a944147..0000000 --- a/js/BasicOpenlayersRenderer.js +++ /dev/null @@ -1,498 +0,0 @@ -import defaultExport from './BaseRenderer.js'; - -export default class extends defaultExport { - id = 'openlayers' - version = '7.3.0'; - - resources = new Set([ - `https://cdn.jsdelivr.net/npm/ol@${this.version}/dist/ol.js`, - `https://cdn.jsdelivr.net/npm/ol@${this.version}/ol.css` - ]) - - supportOptions = this.supportOptions.concat([ - "control.fullscreen", - "control.scale", - "STYLE", - "link", - "debug", - ]) - - defaultConfig = Object.assign(this.defaultConfig, { - control: { - fullscreen: false, - scale: false - }, - }) - - appendResources(config) { - if (this.showLayerSwitcher(config.data)) { - this.resources.add("https://unpkg.com/ol-layerswitcher@4.1.1/dist/ol-layerswitcher.js") - this.resources.add("https://unpkg.com/ol-layerswitcher@4.1.1/dist/ol-layerswitcher.css") - } - } - - async importModules(config) { - if (config.Style || config.data.filter(datum => datum.type == 'style').length != 0) { - await import('https://unpkg.com/ol-mapbox-style@9.4.0/dist/olms.js'); - } - } - - createMap(element, config) { - // Set projection to WGS84 - ol.proj.useGeographic(); - - // Add class for popup - this.definePopup(); - - // Set base layer by config - var baseLayer; - - // Set basemap and camera - const map = new ol.Map({ - target: element, - view: new ol.View({ - constrainResolution: true, - center: config.center, - zoom: config.zoom, - }), - }); - - return map; - }; - - handleAliases(options) { - super.handleAliases(options) - if (options.STYLE) { - options.data.push({ - type: "style", - url: options.STYLE - }) - delete options.STYLE - } - } - - setData(map, config) { - // Process Maplibre/Mapbox Style - const styleDatum = config.data.filter(datum => datum.type == 'style')[0] - if (styleDatum) { - olms.apply(map, styleDatum.url).then(map => - super.setData(map, config) - ) - } else { - super.setData(map, config) - } - } - - // Configure interactions - setInteraction(map, config) { - // Set Interactions - if (config.link == true) { - map.addInteraction( - new ol.interaction.Link() - ); - } - - super.setInteraction(map, config) - }; - - // Configure controls - setControl(map, config) { - if (config.control.fullscreen == true) { - map.addControl(new ol.control.FullScreen()); - } - // TODO Add more options by config - if (config.control.scale == true) { - map.addControl(new ol.control.ScaleLine({ - units: 'metric' - })) - } - if (this.showLayerSwitcher(config.data)) { - const layerSwitcher = new LayerSwitcher({ - reverse: true, - groupSelectStyle: 'group' - }); - map.addControl(layerSwitcher); - } - }; - - // Configure extra stuff - setExtra(map, config) { - if (config.debug == true) { - map.addLayer( - new ol.layer.Tile({ - source: new ol.source.TileDebug(), - }) - ); - } - if (config.eval) { - eval(config.eval) - } - }; - - // Apply vector layer for markers onto map - addMarkers(map, markers) { - let features = markers.map(marker => - new ol.Feature({ - geometry: new ol.geom.Point(marker.xy), - name: marker.message - }) - ) - let markerSource = new ol.source.Vector({ features: features }); - const clusterSource = new ol.source.Cluster({ source: markerSource }); - const clusters = new ol.layer.Vector({ - source: clusterSource, - style: (feature) => { - const size = feature.get('features').length; - const image = size == 1 - ? new ol.style.Icon({ - opacity: 1, - img: this.defaultMarkerImage(), - imgSize:[30, 30], - anchor: [0.5, 1], - scale: 1.4 - }) - : new ol.style.Circle({ - radius: 10, - stroke: new ol.style.Stroke({ - color: '#fff', - }), - fill: new ol.style.Fill({ - color: '#3399CC', - }), - }) - const text = size == 1 - ? null - : new ol.style.Text({ - text: size.toString(), - fill: new ol.style.Fill({ - color: '#fff', - }), - }) - - return new ol.style.Style({ - image: image, - text: text - }); - }, - }); - map.addLayer(clusters); - - this.addPopup(map) - } - - defaultMarkerImage() { - let svg = ` - - - `; - let marker = new Image(); - marker.src = `data:image/svg+xml,${encodeURIComponent(svg)}` - return marker; - } - - addTileData(map, data) { - const styleDatum = data.filter(datum => datum.type == 'style')[0] - const tileData = data.filter(datum => datum.type == 'tile') - if (!styleDatum && tileData.length == 0) { - let baseLayer = new ol.layer.Tile({ - source: new ol.source.OSM(), - title: 'OSM Carto' - }) - map.addLayer(baseLayer) - } else { - tileData.forEach(datum => { - let tileLayer = new ol.layer.Tile({ - source: new ol.source.XYZ({ url: datum.url }), - title: datum.title ? datum.title : "Anonymous" - }) - map.addLayer(tileLayer) - }) - } - - const wmtsData = data.filter(datum => datum.type == 'wmts')[0] - if (map, wmtsData) { - this.addLayersInWMTS(map, wmtsData) - } - } - - addGPXFile(map, gpxUrl) { - const style = { - 'MultiLineString': new ol.style.Style({ - stroke: new ol.style.Stroke({ - color: 'red', - width: 3, - }) - }) - }; - - map.addLayer( - new ol.layer.Vector({ - source: new ol.source.Vector({ - url: gpxUrl, - format: new ol.format.GPX(), - }), - style: function (feature) { - return style['MultiLineString']; - }, - }) - ); - } - - addLayersInWMTS(map, wmtsData) { - const parser = new ol.format.WMTSCapabilities(); - fetch(wmtsData.url) - .then(function (response) { - return response.text(); - }) - .then(function (text) { - const result = parser.read(text); - result.Contents.Layer.forEach(wmtsLayer => { - const options = ol.source.WMTS.optionsFromCapabilities(result, { - layer: wmtsLayer.Identifier, - matrixSet: 'EPSG:3857', - }); - const layer = new ol.layer.Tile({ - source: new ol.source.WMTS(options), - title: wmtsLayer.Title, - visible: false, - }) - map.addLayer(layer) - }) - }) - } - - definePopup() { - ol.Overlay.Popup = class Popup extends ol.Overlay { - - constructor(opt_options) { - var options = opt_options || {}; - - if (options.autoPan === undefined) { - options.autoPan = true; - } - - if (options.autoPanAnimation === undefined) { - options.autoPanAnimation = { - duration: 250 - }; - } - - var element = document.createElement('div'); - options.element = element; - super(options); - - this.container = element; - this.container.className = 'ol-popup'; - - this.closer = document.createElement('a'); - this.closer.className = 'ol-popup-closer'; - this.closer.href = '#'; - this.container.appendChild(this.closer); - - var that = this; - this.closer.addEventListener('click', function(evt) { - that.container.style.display = 'none'; - that.closer.blur(); - evt.preventDefault(); - }, false); - - this.content = document.createElement('div'); - this.content.className = 'ol-popup-content'; - this.container.appendChild(this.content); - - // Apply workaround to enable scrolling of content div on touch devices - Popup.enableTouchScroll_(this.content); - } - - /** - * Show the popup. - * @param {ol.Coordinate} coord Where to anchor the popup. - * @param {String|HTMLElement} html String or element of HTML to display within the popup. - * @returns {Popup} The Popup instance - */ - show(coord, html) { - if (html instanceof HTMLElement) { - this.content.innerHTML = ""; - this.content.appendChild(html); - } else { - this.content.innerHTML = html; - } - this.container.style.display = 'block'; - this.content.scrollTop = 0; - this.setPosition(coord); - return this; - } - - /** - * @private - * @desc Determine if the current browser supports touch events. Adapted from - * https://gist.github.com/chrismbarr/4107472 - */ - static isTouchDevice_() { - try { - document.createEvent("TouchEvent"); - return true; - } catch(e) { - return false; - } - } - - /** - * @private - * @desc Apply workaround to enable scrolling of overflowing content within an - * element. Adapted from https://gist.github.com/chrismbarr/4107472 - */ - static enableTouchScroll_(elm) { - if(Popup.isTouchDevice_()){ - var scrollStartPos = 0; - elm.addEventListener("touchstart", function(event) { - scrollStartPos = this.scrollTop + event.touches[0].pageY; - }, false); - elm.addEventListener("touchmove", function(event) { - this.scrollTop = scrollStartPos - event.touches[0].pageY; - }, false); - } - } - - /** - * Hide the popup. - * @returns {Popup} The Popup instance - */ - hide() { - this.container.style.display = 'none'; - return this; - } - - /** - * Indicates if the popup is in open state - * @returns {Boolean} Whether the popup instance is open - */ - isOpened() { - return this.container.style.display == 'block'; - } - }; - - const popupCSS = ` - .ol-popup { - position: absolute; - background-color: white; - box-shadow: 0 1px 4px rgba(0,0,0,0.2); - padding: 15px; - border-radius: 10px; - border: 1px solid #cccccc; - bottom: 12px; - left: -50px; - min-width: 280px; - } - .ol-popup:after, .ol-popup:before { - top: 100%; - border: solid transparent; - content: " "; - height: 0; - width: 0; - position: absolute; - pointer-events: none; - } - .ol-popup:after { - border-top-color: white; - border-width: 10px; - left: 48px; - margin-left: -10px; - } - .ol-popup:before { - border-top-color: #cccccc; - border-width: 11px; - left: 48px; - margin-left: -11px; - } - .ol-popup-closer { - text-decoration: none; - position: absolute; - top: 2px; - right: 8px; - } - .ol-popup-closer:after { - content: "ร—"; - } - .ol-popup-content { - font-size: 12px; - } - ` - document.head.appendChild(document.createElement("style")).innerHTML=popupCSS; - } - - addPopup(map) { - let popup = new ol.Overlay.Popup(); - map.addOverlay(popup); - map.on('singleclick', function(evt) { - const feature = map.forEachFeatureAtPixel(evt.pixel, function (feature) { - let features = feature.get('features'); - return features ? features[0] : null - }); - if (feature){ - popup.show(evt.coordinate, feature.get('name')); - } else { - popup.hide() - } - }); - } - - handleKey(map, config, code) { - if (! super.handleKey(map, config, code)) { return; } - - let nextStatus = config.updates[this.at] - flyTo(map, nextStatus, function(){}) - } - - updateCamera(map, options, useAnimation) { - const view = map.getView(); - if (useAnimation) { - flyTo(map, { center: options.center, zoom: options.zoom }) - } else { - view.setCenter(options.center) - view.setZoom(options.zoom) - } - } -} - -// Pan map to a specific location -function flyTo(map, status, done) { - const duration = 2500; - const view = map.getView(); - const nextZoom = status.zoom ? status.zoom : view.getZoom(); - const nextCenter = status.center ? status.center : view.center; - - let parts = 2; - let called = false; - function callback(complete) { - --parts; - if (called) { - return; - } - if (parts === 0 || !complete) { - called = true; - done(complete); - } - } - - // Move view to the given location - view.animate( - { - center: nextCenter, - duration: duration, - }, - callback - ); - // At the same time, zoom out and zoom in - view.animate( - { - zoom: (view.getZoom() + nextZoom) /2 -1, - duration: duration / 2, - }, - { - zoom: nextZoom, - duration: duration / 2, - }, - callback - ); -} diff --git a/js/mapclay.js b/js/mapclay.js deleted file mode 100644 index d3db1bb..0000000 --- a/js/mapclay.js +++ /dev/null @@ -1,164 +0,0 @@ -import 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'; - -const scriptName = 'mapclay.js' - -const rendererInfo = Object.freeze({ - openlayers: './BasicOpenlayersRenderer.js', - leaflet: './BasicLeafletRenderer.js', - maplibre: './BasicMaplibreRenderer.js' -}); - -function loadResource(url) { - return new Promise(function(resolve, reject) { - if (url.endsWith('.js')) { - let script = document.createElement('script'); - Object.assign(script, { - src: url, - async: false, - onload: function() { - resolve(url); - }, - onerror: function() { - reject(url); - }, - }) - document.head.appendChild(script); - } else if (url.endsWith('.css')) { - let link = document.createElement('link'); - Object.assign(link, { - rel: 'stylesheet', - href: url, - onload: function() { - resolve(url); - }, - onerror: function() { - reject(url); - }, - }) - document.head.appendChild(link); - } - }); -} - -// Get related script tag and its parent element -const currentScript = Array.from(document.querySelectorAll("script")) - .find(script => script.src.endsWith(scriptName) ); -const parentElement = currentScript.parentElement; - -// Use targetSelector to get elements which will render maps -let targetSelector = currentScript.dataset.to; -targetSelector ??= ".map"; -const targetElements = Array.from(parentElement.querySelectorAll(targetSelector)); - -// Use fromSelector to get elements which contains config text -let fromSelector = currentScript.dataset.from; -fromSelector ??= targetSelector; -const fromElements = Array.from(parentElement.querySelectorAll(fromSelector)); - -// Set of map renders in config texts -const usedRenderers = new Set(); - -function loadOptions(rawText) { - let text = rawText.replace(/^\s*\n+/, ''); - const paragraphs = text.split(/\n\s*\n/); - text = paragraphs[0]; - - const evaltext = paragraphs.length > 1 ? paragraphs.slice(1).join("\n\n") : undefined; - const optionsObj = jsyaml.load(text) ?? {}; - if (evaltext) { - optionsObj.eval = evaltext; - } - - return optionsObj; -} - -// Get config from elements -async function assignConfig() { - return Promise.all(fromElements.map(async (element, index) => { - let config = loadOptions(element.value ?? element.textContent) - - // If preset is define, apply previous config as prototype - if (config.hasOwnProperty("preset")) { - if (index != 0 && config.preset == "last") { - // Apply last element's config as preset - let lastConfig = targetElements[index - 1].config - Object.setPrototypeOf(config, lastConfig); - } else { - // Fetch remote resource as preset - const response = await fetch(config.preset) - const text = await response.text() - const presetObj = loadOptions(text) - if (presetObj.eval) { - config.eval = presetObj.eval + "\n" + config.eval - } - config = Object.assign({}, presetObj, config) - } - } - - // Set use with default value (if not set) - if (! config.use) { - config.use = Object.keys(rendererInfo)[0] - } - - // Apply config onto element - targetElements[index].config = config; - - // Append necessary renderer - usedRenderers.add(config.use); - })) -} - -/* For each map renderer: - 1. Load related methods in renderer file - 2. Put scripts and CSS into DOM - 3. Render maps which use this renderer */ - -async function refreshMap() { - await assignConfig() - - for (let rendererName of usedRenderers) { - // TODO handle undefined renderer - let renderer = new (await import(rendererInfo[rendererName])).default(); - - // Get elements which this renderer applys on - let shouldRenderElements = targetElements.filter(ele => - ele.config.use == rendererName - ) - shouldRenderElements.forEach( ele => { - // If config has no prototype, apply defautConfig - // This prevents necessary configs are not defined - Object.setPrototypeOf(ele.config, renderer.defaultConfig) - renderer.handleAliases(ele.config) - renderer.appendResources(ele.config) - }) - - // Set widow.renderer as current used renderer - if (shouldRenderElements.length > 0) { - window.renderer = renderer - } else { - continue - } - - // Load necessary resources - let promises = []; - renderer.resources.forEach(url => { - promises.push(loadResource(url)); - }); - - Promise.all(promises).then(function() { - // After map renderer script is loaded, render maps - shouldRenderElements.forEach(ele => { - renderer.renderMap(ele); - ele.dispatchEvent(new Event('map-rendered')); - }); - }).catch(function(script) { - console.log(script + ' failed to load'); - }); - } -} - -refreshMap() - -export { refreshMap }; -window.refreshMap = refreshMap -window.loadOptions = loadOptions diff --git a/package.json b/package.json index 57652d5..a972e13 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,40 @@ { "name": "mapclay", - "version": "0.4.2", + "description": "Create interactive maps by options", + "version": "0.5.0", + "license": "MIT", "type": "module", - "description": "A simple stupid abstraction for web map", - "main": "mapclay.js", + "main": "dist/mapclay.mjs", + "module": "dist/mapclay.mjs", + "keywords": [ + "map", + "yaml" + ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "watch": "npx rollup -c -w", + "build": "npx rollup -c", + "lint": "npx eslint src" + }, + "devDependencies": { + "rollup": "^4.21.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "eslint": "^9.9.1", + "@eslint/js": "^9.9.1" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "leaflet": "^1.9.4", + "maplibre-gl": "^4.6.0", + "maplibre-gl-vector-text-protocol": "github:jimmyrocks/maplibre-gl-vector-text-protocol", + "ol": "^10.0.0", + "proj4": "^2.12.0", + "terra-draw": "1.0.0-beta.1" }, + "author": "Hsiehg Chin Fan ", + "homepage": "https://osmhacktw.github.io/mapclay", "repository": { "type": "git", - "url": "https://github.com/typebrook/mapclay.git" - }, - "keywords": [ - "map", - "gis" - ], - "author": "Hsieh Chin Fan", - "license": "GPL-3.0" + "url": "https://github.com/osmhacktw/mapclay" + } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..89eec26 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,93 @@ +import node from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; + +const production = !process.env.ROLLUP_WATCH; + +const general = { + watch: { + clearScreen: false, + include: ["src/**"] + }, + context: "window", +} + +const generalPlugins = [ + node({ mainFields: ['module', 'main'] }), + production && terser(), +] + +const insertAutoRenderFunc = (input) => ({ + name: 'auto-render', + transform(code, id) { + if (id.endsWith(input)) { + console.log('input', input) + return `${code}\n\nrenderByScriptTarget()`; + } + return null; + } +}) + +const outputForMain = [ + { + dir: 'dist/', + format: 'esm', + entryFileNames: '[name].mjs', + }, + { + name: 'mapclay', + format: 'umd', + file: `dist/mapclay.js`, + exports: "named", + esModule: false, + }, +] + +const outputForRenderer = (name) => [ + { + dir: 'dist/renderers/', + format: 'esm', + entryFileNames: name + '.mjs', + exports: "named", + }, + { + name: name, + format: 'umd', + file: `dist/renderers/${name}.js`, + exports: "named", + }, +] + +export default [ + { + input: 'src/mapclay.mjs', + output: outputForMain, + plugins: [ + ...generalPlugins, + insertAutoRenderFunc('src/mapclay.mjs') + ], + }, + { + input: 'src/BasicLeafletRenderer.mjs', + output: outputForRenderer('leaflet'), + plugins: [ + ...generalPlugins, + insertAutoRenderFunc('src/BasicLeafletRenderer.mjs') + ], + }, + { + input: 'src/BasicMaplibreRenderer.mjs', + output: outputForRenderer('maplibre'), + plugins: [ + ...generalPlugins, + insertAutoRenderFunc('src/BasicMaplibreRenderer.mjs') + ], + }, + { + input: 'src/BasicOpenlayersRenderer.mjs', + output: outputForRenderer('openlayers'), + plugins: [ + ...generalPlugins, + insertAutoRenderFunc('src/BasicOpenlayersRenderer.mjs') + ], + }, +].map(c => ({ ...general, ...c })) diff --git a/src/BaseRenderer.mjs b/src/BaseRenderer.mjs new file mode 100644 index 0000000..63d18b8 --- /dev/null +++ b/src/BaseRenderer.mjs @@ -0,0 +1,258 @@ +import { BasicDrawComponent, addSimpleSelector } from './BasicDrawComponent' + +// Dynamically import CSS, simply add stylesheet at +export const loadCSS = (url) => { + if (document.head.querySelector(`link[href="${url}"]`)) return + + let link = document.createElement('link'); + Object.assign(link, { + rel: 'stylesheet', + href: url, + onerror: () => console.error('Fail to load stylesheet:', url) + }) + document.head.appendChild(link); +} + +// Class for valid options {{{ +export class MapOption { + constructor({ name, desc, example, example_desc, isValid }) { + this.name = name; + this.desc = desc; + this.example = example + this.example_desc = example_desc + this.isValid = isValid + } + valueOf() { + return this.name; + } +} +// }}} + +export default class { + constructor(config = {}) { + this.config = Object.setPrototypeOf( + config, + structuredClone(this.constructor.defaultConfig) + ) + this.setOptionAliases(this.config) + } + + // Valid Options {{{ + static validOptions = Object.freeze([ + new MapOption({ + name: "id", + desc: "id of map HTML element", + isValid: (value) => value.match(/\w+/) + }), + new MapOption({ + name: "width", + desc: "CSS width of map HTML element", + example: "200px", + example_desc: "", + isValid: (value) => CSS.supports(`width: ${value}`) + }), + new MapOption({ + name: "height", + desc: "CSS height of map HTML element", + example: "200px", + example_desc: "", + isValid: (value) => CSS.supports(`height: ${value}`) + }), + new MapOption({ + name: "center", + desc: "Center of camera map, value: [lon, lat]", + example: "[121, 24]", + example_desc: "Center of Taiwan", + isValid: (value) => { + // TODO xy value other than WGS84 + try { + const [x, y] = JSON.parse(value) + return !isNaN(x) && !isNaN(y) && x <= 180 && x >= -180 && y <= 90 && y >= -90 + } catch { + return false + } + } + }), + new MapOption({ + name: "zoom", + desc: "Zoom level for map camera, number between: 0-22", + example: "7.0", + example_desc: "Small country / US state", + isValid: (value) => { + const zoom = Number(value) + return !isNaN(zoom) && zoom >= 0 && zoom <= 22 + } + }), + new MapOption({ + name: "control", + desc: "Object of control options, supports: fullscreen, scale", + example: "\n scale: true", + example_desc: "Add Scale bar", + isValid: (value) => typeof value === 'object' + }), + new MapOption({ + name: "debug", + desc: "Set true to show tile boundary", + example: "true", + example_desc: "", + isValid: (value) => value == 'true' + }), + new MapOption({ + name: "XYZ", + desc: "Raster tile format with {x}, {y} and {z}", + example: "https://tile.openstreetmap.jp/styles/osm-bright/512/{z}/{x}/{y}.png", + example_desc: "Tile from OSM Japan!", + isValid: (value) => { + return URL.parse(value) && value.includes('{x}') && value.includes('{y}') && value.includes('z') + } + }), + new MapOption({ + name: "GPX", + desc: "URL of GPX file", + example: "https://raw.githubusercontent.com/openlayers/openlayers/main/examples/data/gpx/fells_loop.gpx", + example_desc: "Example from topografix", + isValid: (value) => URL.parse(value) + }), + new MapOption({ + name: "WMTS", + desc: "URL of WMTS document", + example: "https://www.topografix.com/fells_loop.gpx", + example_desc: "Example from topografix", + isValid: (value) => URL.parse(value) + }), + new MapOption({ + name: "draw", + desc: "Draw Something on map", + example: "true", + example_desc: "Enable Draw Tools", + isValid: (value) => value == 'true' + }) + ]) + // }}} + // Default configuation for map {{{ + static defaultConfig = Object.freeze({ + width: "300px", + height: "300px", + center: [121, 24], + zoom: 7, + control: { + scale: false, + fullscreen: false + }, + layers: [], + data: [], + aliases: [], + }) + // }}} + + // Transform element contains config text into map + async createView(target) { + this.target = target + target.style.width = this.config.width + target.style.height = this.config.height + } + + setDrawComponent = (adapter) => { + const draw = BasicDrawComponent(adapter) + addSimpleSelector(this.target, draw) + } + + // Add GIS data + setData(map, config) { + // Tile + this.addTileData(map, config.data.filter(d => d.type == 'tile')); + + // Set GPX file + const gpxData = config.data.filter(datum => datum.type == 'gpx') + if (gpxData.length != 0) { + gpxData.forEach(datum => { + this.addGPXFile(map, datum.url) + }) + } + + if (config.markers) { + this.addMarkers(map, config.markers) + } + }; + + // TODO Add containers for possible controls at top right + // Add Control Options + setControl() { }; + + // Do extra stuff + setExtra() { }; + + // Update camera, like center or zoom level + updateCamera() { }; + + // Import GPX files + addTileData() { }; + + // Import GPX files + addGPXFile() { }; + + setOptionAliases(config) { + if (config.XYZ) { + const xyzArray = typeof config.XYZ == 'string' + ? [config.XYZ] + : config.XYZ + xyzArray.forEach((record) => { + let obj; + let url + if (typeof record == 'string') { + url = new URL(record) + obj = { + type: "tile", + url: record, + title: `${url.host}${url.pathname.split('%7B')[0]}`, + } + } else if (typeof record == 'object') { + url = new URL(record.url) + obj = { + type: "tile", + url: record.url, + title: record.title ? record.title : `${url.host}${url.pathname.split('%7B')[0]}` + } + } else { + return; + } + config.data.push(obj) + }) + delete config.XYZ + } + + if (config.WMTS) { + config.data.push({ + type: "wmts", + url: config.aliases[config.WMTS] ?? config.WMTS, + }) + delete config.WMTS + } + + if (config.GPX) { + config.data.push({ + type: "gpx", + url: config.GPX, + }) + delete config.GPX + } + + // Replace aliases into real string + if (typeof config.center == 'string' && Object.prototype.hasOwnProperty.call(config.aliases, config.center)) { + config.center = config.aliases[config.center] + } + config.data?.forEach(record => { + if (Object.prototype.hasOwnProperty.call(config.aliases, record.url)) { + record.title = record.url + record.url = config.aliases[record.url] + } + }) + } + + showLayerSwitcher(data) { + const wmtsRecords = data.filter(record => record.type == 'wmts') + const tileRecords = data.filter(record => record.type == 'tile') + + return wmtsRecords.length > 0 || tileRecords.length > 1 + } +} diff --git a/src/BasicDrawComponent.mjs b/src/BasicDrawComponent.mjs new file mode 100644 index 0000000..7eb41e0 --- /dev/null +++ b/src/BasicDrawComponent.mjs @@ -0,0 +1,175 @@ +import { + TerraDraw, + TerraDrawSelectMode, + TerraDrawPointMode, + TerraDrawLineStringMode, + TerraDrawPolygonMode, + TerraDrawCircleMode, + TerraDrawRectangleMode, +} from "terra-draw"; + +// ref: https://github.com/JamesLMilner/terra-draw/blob/main/guides/4.MODES.md#selection-mode +export const BasicDrawComponent = (adapter) => new TerraDraw({ + adapter: adapter, + modes: [ + new TerraDrawSelectMode({ + modename: 'modify', + flags: { + point: { + feature: { + coordinates: { + midpoints: true, + draggable: true, + deletable: true, + validation: () => true + }, + }, + }, + linestring: { + feature: { + coordinates: { + midpoints: true, + draggable: true, + deletable: true, + validation: () => true + }, + }, + }, + polygon: { + feature: { + coordinates: { + midpoints: true, + draggable: true, + deletable: true, + validation: () => true + }, + }, + }, + }, + }), + new TerraDrawPointMode({ + styles: { + pointColor: "red", + }, + }), + new TerraDrawLineStringMode({ + styles: { + // Fill colour (a string containing a 6 digit Hex color) + fillColor: "#00FFFF", + + // Fill opacity (0 - 1) + fillOpacity: 0.7, + + // Outline colour (Hex color) + outlineColor: "#00FF00", + + //Outline width (Integer) + outlineWidth: 2, + }, + }), + // TODO More than triangle + new TerraDrawPolygonMode(), + new TerraDrawCircleMode(), + new TerraDrawRectangleMode(), + ] +}) + +export const addSimpleSelector = (target, draw) => { + const selector = document.createElement('select') + target.appendChild(selector) + selector.name = 'Draw' + selector.style = 'position: absolute; top: 0.5rem; right: 0.5rem; z-index: 500;' + selector.innerHTML = ` + + + + + + + + + + + + + + + + + ` + + // FIXME Debug only + window.draw = draw + + draw.start(); + draw.setMode('static'); + + // Resume drawn features + const retrievedFeatures = localStorage.getItem('terra-draw-data'); + if (retrievedFeatures) { + try { + draw.addFeatures(JSON.parse(retrievedFeatures)) + } catch (err) { + console.error("Fail to drawn features from local storage.", err) + } + } + + const cursorHolder = target.querySelector('canvas') ?? target + selector.onchange = () => { + selector.children[0].textContent = '--STOP--' + cursorHolder.style.removeProperty('cursor') + const features = draw.getSnapshot() + + switch (selector.value) { + case 'nothing': + draw.setMode("static"); + selector.children[0].textContent = 'Draw Something' + break; + case 'modify': + draw.setMode("select"); + break; + case 'delete': + draw.setMode('static'); + cursorHolder.style.cursor = "not-allowed" + break; + case 'clear': + localStorage.removeItem('terra-draw-data') + selector.value = 'nothing' + selector.onchange() + draw.clear() + break; + case 'features': + alert(`${features.length} features\n\n${JSON.stringify(features, null, 4)}`) + break; + default: + draw.setMode(selector.value); + break; + } + } + + draw.on("select", () => { + }); + draw.on("change", () => { + localStorage.setItem('terra-draw-data', JSON.stringify(draw.getSnapshot())); + }); + draw.on("finish", (_, context) => { + if (context.mode != 'point' && context.action == 'draw') { + selector.value = 'nothing' + selector.onchange() + } + }); + target.children[0].onclick = (event) => { + if (selector.value == 'delete') { + const features = draw.getFeaturesAtPointerEvent(event, { + pointerDistance: 40, + }); + if (features.length > 0) { + draw.removeFeatures([features[0].id]) + if (draw.getSnapshot.length == 0) { + selector.value = 'nothing' + selector.onchange() + } + } + } + } +} diff --git a/src/BasicLeafletRenderer.mjs b/src/BasicLeafletRenderer.mjs new file mode 100644 index 0000000..cf44286 --- /dev/null +++ b/src/BasicLeafletRenderer.mjs @@ -0,0 +1,178 @@ +import defaultExport, { loadCSS } from './BaseRenderer'; +import { renderWith, renderByTextContentWith, renderByScriptTargetWith } from './mapclay.mjs'; +import * as L from 'leaflet/dist/leaflet-src.esm' +import { TerraDrawLeafletAdapter } from 'terra-draw' +loadCSS('https://unpkg.com/leaflet@1.9.4/dist/leaflet.css') + + +const Renderer = class extends defaultExport { + id = 'leaflet'; + version = '1.9.4'; + + static validOptions = super.validOptions.concat([ + ]) + + async createView(target) { + super.createView(target) + + const map = L.map(target) + .setView( + this.config.center.reverse(), + this.config.zoom + ) + + // Update map by element size + const resizeObserver = new ResizeObserver(() => { + map.invalidateSize(); + }); + resizeObserver.observe(target); + + this.setControl(map, this.config) + this.setData(map, this.config) + this.setExtra(map, this.config) + + if (this.config.draw) { + const adapter = new TerraDrawLeafletAdapter({ lib: L, map }) + this.setDrawComponent(adapter) + } + return map + }; + + // FIXME + // Configure controls + setControl(map, config) { + if (config.control.fullscreen) { + let css = document.createElement('link'); + css.rel = 'stylesheet'; + css.href = 'https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/leaflet.fullscreen.css'; + document.body.append(css); + + let script = document.createElement('script'); + script.src = "https://api.mapbox.com/mapbox.js/plugins/leaflet-fullscreen/v1.0.1/Leaflet.fullscreen.min.js"; + document.body.append(script); + script.onload = () => { + map.addControl(new L.Control.Fullscreen()); + } + } + if (config.control.scale) { + L.control.scale().addTo(map); + } + }; + + debugLayer() { + L.GridLayer.GridDebug = L.GridLayer.extend({ + createTile: function(coords) { + const tile = document.createElement('div'); + tile.style.outline = '2px solid'; + tile.style.fontWeight = 'bold'; + tile.style.fontSize = '14pt'; + tile.innerHTML = [coords.z, coords.x, coords.y].join('/'); + return tile; + } + }); + + return new L.GridLayer.GridDebug(); + } + + // Configure extra stuff + setExtra(map, config) { + if (config.debug == true) { + map.addLayer(this.debugLayer()); + } + if (config.eval) { + const func = Function('map, config, L', config.eval).bind(this) + func(map, config, L) + } + }; + + addMarkers(map, markers) { + var markerIcon = L.icon({ + iconUrl: `https://unpkg.com/leaflet@${this.version}/dist/images/marker-icon.png`, + iconRetinaUrl: `https://unpkg.com/leaflet@${this.version}/dist/images/marker-icon-2x.png`, + shadowUrl: `https://unpkg.com/leaflet@${this.version}/dist/images/marker-shadow.png`, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + tooltipAnchor: [16, -28], + shadowSize: [41, 41] + }) + markers.forEach(config => { + let xy = Array.from(config.xy).reverse() + let marker = L.marker(xy, { icon: markerIcon }) + .addTo(map) + .bindPopup(config.message) + marker.getElement().classList.add('marker') + marker.getElement().title = config.title + }); + } + + addTileData(map, tileData) { + var baseLayers = {} + var overlayMaps = {} + if (tileData.length == 0) { + const osmTile = 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png' + L.tileLayer(osmTile).addTo(map); + } else { + tileData.forEach((datum, index) => { + const customTile = datum.url + const layer = L.tileLayer(customTile); + const title = datum.title ? datum.title : `Anonymous_${index}` + if (index == 0) { + layer.addTo(map) + } + baseLayers[title] = layer + }) + L.control.layers(baseLayers, overlayMaps).addTo(map); + } + } + + addGPXFile(map, gpxUrl) { + let script = document.createElement('script'); + script.src = "https://cdnjs.cloudflare.com/ajax/libs/leaflet-gpx/1.7.0/gpx.min.js"; + document.body.append(script); + + let options = { + gpx_options: { + joinTrackSegments: false + }, + polyline_options: { + color: 'red', + weight: 3, + lineCap: 'round' + }, + marker_options: { + startIconUrl: null, + endIconUrl: null, + shadowUrl: '', + wptIconUrls: { + '': null + } + }, + async: true + } + script.onload = () => { + new L.GPX(gpxUrl, options).addTo(map); + } + } + + updateCamera(options, animation) { + let latLon = L.latLng(options.center[1], options.center[0]) + if (animation) { + this.map.flyTo(latLon, options.zoom); + } else { + this.map.setView(latLon, options.zoom); + } + } +} + + +const render = renderWith({ use: Renderer }) +const renderByTextContent = renderByTextContentWith({ use: Renderer }) +const renderByScriptTarget = renderByScriptTargetWith({ use: Renderer }) + +if (document.currentScript) { + window.mapclay = { render, renderByTextContent } +} + +export { render, renderByTextContent, renderByScriptTarget } +export default Renderer diff --git a/src/BasicMaplibreRenderer.mjs b/src/BasicMaplibreRenderer.mjs new file mode 100644 index 0000000..0919309 --- /dev/null +++ b/src/BasicMaplibreRenderer.mjs @@ -0,0 +1,197 @@ +import defaultExport, { MapOption, loadCSS } from './BaseRenderer' +import { renderWith, renderByTextContentWith, renderByScriptTargetWith } from './mapclay.mjs'; +import "maplibre-gl" +import { addProtocols } from 'maplibre-gl-vector-text-protocol' +import { TerraDrawMapLibreGLAdapter } from 'terra-draw' +loadCSS('https://unpkg.com/maplibre-gl@4.5.2/dist/maplibre-gl.css') + +const maplibregl = window.maplibregl + +const Renderer = class extends defaultExport { + id = 'maplibre'; + + // Options {{{ + static validOptions = this.validOptions.concat([ + new MapOption({ + name: "pitch", + desc: "Pitch toward the horizon measured in degrees", + example: "60", + example_desc: "Look a little upward", + isValid: (value) => value <= 90 && value >= 0 + }), + new MapOption({ + name: "bearing", + desc: "The compass direction that is 'up'", + example: "-30", + example_desc: "Rotate map a little", + isValid: (value) => value <= 180 && value >= -180 + }), + new MapOption({ + name: "link", + desc: "Syn map's position with the hash fragment of the page's URL", + example: "true", + example_desc: "Add hash for page URL", + isValid: (value) => value == 'true' + }), + new MapOption({ + name: "style", + desc: "URL of style document, read https://maplibre.org/maplibre-style-spec/", + example: "https://tile.openstreetmap.jp/styles/openmaptiles/style.json", + example_desc: "Style form OSM japan!!!", + isValid: (value) => URL.parse(value) + }) + ]) + // }}} + // Default Config {{{ + static defaultConfig = Object.freeze({ + ...super.defaultConfig, + ...{ + pitch: 90, + bearing: 0, + style: 'https://demotiles.maplibre.org/style.json', + link: false, + } + }) + // }}} + // Map Creation {{{ + async createView(target) { + super.createView(target) + + const tileData = this.config.data.filter(datum => datum.type == 'tile'); + const style = tileData.length != 0 + ? { version: 8, sources: {}, layers: [], } + : this.config.style + + const map = new maplibregl.Map({ + container: target, + style: style, + center: this.config.center, + zoom: this.config.zoom, + pitch: this.config.bearing, + bearing: this.config.bearing, + hash: this.config.link, + }); + + return new Promise((resolve, reject) => { + map.on('load', () => { + try { + // FIXME + if (this.config.draw) { + // Create Terra Draw + const adapter = new TerraDrawMapLibreGLAdapter({map, maplibregl}) + this.setDrawComponent(adapter) + } + this.setControl(map, this.config); + this.setData(map, this.config) + this.setExtra(map, this.config); + resolve(map) + } catch (err) { + reject(err) + } + }) + }) + }; + // }}} + + // Configure controls + setControl(map, config) { + if (config.control.fullscreen == true) { + map.addControl(new maplibregl.FullscreenControl()); + } + if (config.control.scale == true) { + let scale = new maplibregl.ScaleControl({ + unit: 'metric' + }); + map.addControl(scale); + } + }; + + // Configure extra stuff + setExtra(map, config) { + if (config.debug == true) { + map.showTileBoundaries = true; + } + if (config.eval) { + const func = Function('map, config', config.eval).bind(this) + func(map, config) + }; + }; + + addMarkers(map, markers) { + markers.forEach(config => { + let marker = new maplibregl.Marker() + .setLngLat(config.xy) + .addTo(map); + marker.getElement().classList.add('marker') + marker.getElement().title = config.title + }); + } + + addTileData(map, tileData) { + const style = map.getStyle(); + tileData.forEach((datum, index) => { + const source = datum.name ? datum.name : index.toString() + style.sources[source] = { type: "raster", tiles: [datum.url], tileSize: 256 } + style.layers.push({ id: source, type: "raster", source: source }) + }) + map.setStyle(style) + } + + // FIXME + addGPXFile = async (map, gpxUrl) => { + addProtocols(maplibregl); + + const gpxSourceName = 'gpx'; + const gpxLink = 'gpx://' + gpxUrl; + + const source = { + 'type': 'geojson', + 'data': gpxLink, + } + map.addSource(gpxSourceName, source); + map.addLayer({ + 'id': "id_" + gpxSourceName, + 'type': 'line', + 'source': gpxSourceName, + 'paint': { + 'line-color': 'red', + 'line-width': 3 + } + }) + + if (!Object.prototype.hasOwnProperty.call(this.config,'center')) { + const data = await map.getSource(gpxSourceName).getData() + const coordinates = data.features[0].geometry.coordinates + const bounds = coordinates.reduce((bounds, coord) => { + return bounds.extend(coord); + }, new maplibregl.LngLatBounds(coordinates[0], coordinates[0])); + map.fitBounds(bounds, { + padding: 20 + }); + } + } + + updateCamera(options, useAnimation) { + if (useAnimation) { + this.map.flyTo({ + center: options.center, + zoom: options.zoom + }) + } else { + this.map.setCenter(options.center) + this.map.setZoom(options.zoom) + } + } +} + + +const render = renderWith({ use: Renderer }) +const renderByTextContent = renderByTextContentWith({ use: Renderer }) +const renderByScriptTarget = renderByScriptTargetWith({ use: Renderer }) + +if (document.currentScript) { + window.mapclay = { render, renderByTextContent } +} + +export { render, renderByTextContent, renderByScriptTarget } +export default Renderer diff --git a/src/BasicOpenlayersRenderer.mjs b/src/BasicOpenlayersRenderer.mjs new file mode 100644 index 0000000..50db141 --- /dev/null +++ b/src/BasicOpenlayersRenderer.mjs @@ -0,0 +1,275 @@ +import defaultExport, { MapOption, loadCSS } from './BaseRenderer'; +import { renderWith, renderByTextContentWith, renderByScriptTargetWith } from './mapclay'; +import { TerraDrawOpenLayersAdapter } from 'terra-draw' +loadCSS('https://cdn.jsdelivr.net/npm/ol@10.1.0/ol.css') + +import * as ol from 'ol' +import * as control from 'ol/control'; +import * as format from 'ol/format'; +import * as geom from 'ol/geom'; +import * as layer from 'ol/layer'; +import * as olProj4 from 'ol/proj/proj4'; +import * as source from 'ol/source'; +import * as style from 'ol/style'; +import * as proj from 'ol/proj'; +import proj4 from 'proj4' + + +const Renderer = class extends defaultExport { + id = 'openlayers' + + static validOptions = super.validOptions.concat([ + new MapOption({ + name: "proj", + desc: "Projection of map view", + example: "EPSG:3826", + example_desc: "Taiwan TM2", + isValid: () => true + }), + ]) + + static defaultConfig = Object.freeze({ + ...super.defaultConfig, + ...{ + proj: "EPSG:4326", + control: { + fullscreen: false, + scale: false + } + } + }) + + static includedProjections = ['EPSG:4326', "EPSG:3857"] + + async createView(target) { + super.createView(target) + + // TODO Consider apply cursor style same to maplibre or leaflet + // That is: grab for normal grabbing for updating camera + + const projection = this.config.proj + if (projection && !this.constructor.includedProjections.includes(projection)) { + olProj4.register(proj4); + await olProj4.fromEPSGCode(projection) + } + proj.setUserProjection(projection); + + // Set basemap and camera + const map = new ol.Map({ + target: target, + view: new ol.View({ + constrainResolution: true, + center: this.config.center, + zoom: this.config.zoom, + }), + }); + + this.setControl(map, this.config) + this.setData(map, this.config) + + if (this.config.draw) { + setTimeout(() => { + const adapter = new TerraDrawOpenLayersAdapter({ + lib: { + Circle: geom.Circle, + Feature: ol.Feature, + GeoJSON: format.GeoJSON, + Style: style.Style, + VectorLayer: layer.Vector, + VectorSource: source.Vector, + Stroke: style.Stroke, + getUserProjection: proj.getUserProjection, + CircleStyle: style.Circle, + }, + map + }) + this.setDrawComponent(adapter) + }, 100) + } + + return map; + }; + + handleAliases(options) { + super.handleAliases(options) + if (options.STYLE) { + options.data.push({ + type: "style", + url: options.STYLE + }) + delete options.STYLE + } + } + + // Configure controls + setControl(map, config) { + if (config.control.fullscreen == true) { + map.addControl(new control.FullScreen()); + } + // TODO Add more options by config + if (config.control.scale == true) { + map.addControl(new control.ScaleLine({ + units: 'metric' + })) + } + }; + + // Configure extra stuff + setExtra(map, config) { + if (config.debug == true) { + map.addLayer( + new layer.Tile({ + source: new source.TileDebug(), + }) + ); + } + if (config.eval) { + const func = Function('map, config, ol', config.eval).bind(this) + func(map, config) + } + }; + + // Apply vector layer for markers onto map + addMarkers(map, markers) { + markers.forEach(config => { + let marker = document.createElement('div') + marker.innerHTML = this.defaultMarkerSvg() + marker.classList.add('marker') + marker.title = config.title + let overlay = new ol.Overlay({ + element: marker, + position: config.xy, + positioning: 'bottom-center', + anchor: [0.5, 1] + }) + map.addOverlay(overlay) + }) + } + + defaultMarkerSvg() { + return ` + + + `; + } + + addTileData(map, data) { + const styleDatum = data.filter(datum => datum.type == 'style')[0] + const tileData = data.filter(datum => datum.type == 'tile') + if (!styleDatum && tileData.length == 0) { + let baseLayer = new layer.Tile({ + source: new source.OSM(), + title: 'OSM Carto' + }) + map.addLayer(baseLayer) + } else { + tileData.forEach(datum => { + let tileLayer = new layer.Tile({ + source: new source.XYZ({ url: datum.url }), + title: datum.title ? datum.title : "Anonymous" + }) + map.addLayer(tileLayer) + }) + } + + // TODO Layers for WMTS + const wmtsData = data.filter(datum => datum.type == 'wmts')[0] + if (map, wmtsData) { + // this.addLayersInWMTS(map, wmtsData) + } + } + + addGPXFile(map, gpxUrl) { + const style = { + 'MultiLineString': new style.Style({ + stroke: new style.Stroke({ + color: 'red', + width: 3, + }) + }) + }; + + map.addLayer( + new layer.Vector({ + source: new source.Vector({ + url: gpxUrl, + format: new format.GPX(), + }), + style: function() { + return style['MultiLineString']; + }, + }) + ); + + if (Object.prototype.hasOwnProperty.call(this.config, 'center')) { + this.flyTo(map, { center: [10, 10], zoom: 10 }) + } + } + + updateCamera(map, options, useAnimation) { + const view = map.getView(); + if (useAnimation) { + flyTo(map, { center: options.center, zoom: options.zoom }) + } else { + view.animate({ + center: options.center, + zoom: options.zoom, + duration: 300 + }) + } + } +} + +// Pan map to a specific location +function flyTo(map, status, done) { + const duration = 2500; + const view = map.getView(); + const nextZoom = status.zoom ? status.zoom : view.getZoom(); + const nextCenter = status.center ? status.center : view.center; + + let parts = 2; + let called = false; + function callback(complete) { + --parts; + if (called) { + return; + } + if (parts === 0 || !complete) { + called = true; + done(complete); + } + } + + // Move view to the given location + view.animate( + { + center: nextCenter, + duration: duration, + }, + callback + ); + // At the same time, zoom out and zoom in + view.animate( + { + zoom: (view.getZoom() + nextZoom) / 2 - 1, + duration: duration / 2, + }, + { + zoom: nextZoom, + duration: duration / 2, + }, + callback + ); +} + + +const render = renderWith({ use: Renderer }) +const renderByTextContent = renderByTextContentWith({ use: Renderer }) +const renderByScriptTarget = renderByScriptTargetWith({ use: Renderer }) + +if (document.currentScript) { + globalThis.mapclay = { render, renderByTextContent } +} + +export { render, renderByTextContent, renderByScriptTarget } +export default Renderer diff --git a/src/mapclay.mjs b/src/mapclay.mjs new file mode 100644 index 0000000..fc2c680 --- /dev/null +++ b/src/mapclay.mjs @@ -0,0 +1,193 @@ +import { load as yamlLoad, loadAll as yamlLoadAll } from 'js-yaml'; + +// Renderer list for quick start {{{ +const dir = new URL('./', import.meta.url) +const defaultAliasesForRenderer = Object.freeze({ + "use": { + "Leaflet": { + value: dir + 'renderers/leaflet.mjs', + description: 'Leaflet is the leading open-source JavaScript library for mobile-friendly interactive maps. It has all the mapping features most developers ever need.', + }, + "Maplibre": { + value: dir + 'renderers/maplibre.mjs', + description: 'MapLibre GL JS is a TypeScript library that uses WebGL to render interactive maps from vector tiles in a browser. The customization of the map comply with the MapLibre Style Spec.', + }, + "Openlayers": { + value: dir + 'renderers/openlayers.mjs', + description: 'OpenLayers makes it easy to put a dynamic map in any web page. It can display map tiles, vector data and markers loaded from any source. OpenLayers has been developed to further the use of geographic information of all kinds.', + }, + } +}); +const applyDefaultAliases = (config) => { + config.aliases = { ...defaultAliasesForRenderer, ...(config.aliases ?? {}) } + return config +} +// }}} +// Parse yaml content with raw text {{{ +const parseConfigsFromText = (configText) => { + let configList = [] + yamlLoadAll( + configText, + (result) => { + if (typeof result === 'object' && !Array.isArray(result)) { + configList.push(result) + } else if (typeof result === 'string') { + if (configList.length > 0) { + configList.at(-1).eval = result + } + } + } + ) + + return configList.length > 0 ? configList : [{}] +} +// }}} +// Get config from other file by 'apply' {{{ +const appliedConfigs = {} + +const fetchConfig = (url) => fetch(url) + .then(res => res.text()) + .then(text => { + const config = yamlLoad(text) + appliedConfigs[url] = config + }) + .catch((err) => { throw Error(`Fail to fetch applied config ${url}`, err) }) + +const applyOtherConfig = (config) => { + if (!config.apply) return config + const appliedConfig = appliedConfigs[config.apply] + + return { ...(appliedConfig ?? {}), ...config } +} +// }}} +// Set option value by aliases {{{ +const setValueByAliases = (config) => { + if (!config.aliases) return config + + Object.entries(config) + .filter(([option, value]) => + option != 'aliases' && + typeof value === 'string' && + value.match(/^[A-Z]/) + ) + .forEach(([key, alias]) => { + const aliasResult = config.aliases?.[key]?.[alias] + const aliasValue = typeof aliasResult === 'object' && !Array.isArray(aliasResult) + ? aliasResult.value + : aliasResult + if (aliasValue) config[key] = aliasValue + }) + + return config +} +// }}} +// Render each map container by config {{{ +const renderMapContainer = async (target, config) => { + + const getRendererClass = async(c) => { + const rendererUrl = c.use ?? Object.values(c.aliases?.use)?.at(0)?.value + if (!rendererUrl) throw Error(`Renderer URL is not specified ${rendererUrl}`) + + return (await import(rendererUrl).catch((err) => { + throw Error(`Fail to import renderer by URL ${rendererUrl}`, err) + })).default + } + + const rendererClass = typeof config.use === 'function' + ? config.use + : await getRendererClass(config) + if (!rendererClass) throw Error(`Fail to get renderer class by module ${config.use}`) + + const renderer = new rendererClass(config) + + // Remove children from target container + Array.from(target.children).forEach(e => e.remove()) + target.innerHTML = '' + + const mapContainer = document.createElement('div') + target.appendChild(mapContainer) + mapContainer.id = config.id + mapContainer.title = config.id + mapContainer.style.setProperty('position', 'relative') + mapContainer.classList.add('map-container') + + mapContainer.renderer = renderer + const map = await renderer.createView(mapContainer) + mapContainer.renderer.map = map + + return mapContainer +} +// }}} +// Render target by config {{{ +/** + * @param {HTMLElement} target Element of map(s) container + * @param {Object[]|Object} configObj - Config(s) for each map. Scope into array if it is an Object + * @param {Object} options - Valid optoins: "rendererList" (list of renderer info) and "renderer" (Class for renderer) + * @returns {Promise} - Promise of rendering map(s) on target element + */ +const renderWith = (preset) => async (target, configObj) => { + // Return List of promises about map rendering + const configListArray = typeof configObj === 'object' + ? Array.isArray(configObj) + ? configObj + : [configObj] + : null + if (!configListArray) throw Error("Invalid configs", configListArray) + configListArray.forEach(config => Object.assign(config, preset)) + + // Fetch config files by option "apply" + configListArray.forEach(setValueByAliases) + const getAppliedConfigs = configListArray + .filter(config => config.apply) + .map(config => config.apply) + .map(fetchConfig) + await Promise.all(getAppliedConfigs) + + const renderEachConfig = configListArray + .map(applyOtherConfig) + .map(applyDefaultAliases) + .map(setValueByAliases) + .map(async config => renderMapContainer(target, config) + .catch(err => console.error('Fail to render map by config', config, err)) + ) + + return Promise.allSettled(renderEachConfig) +} +// }}} +// Render target element by textContent {{{ +const renderByTextContentWith = (preset) => async (target) => { + const configList = parseConfigsFromText(target.textContent) + return renderWith(preset)(target, configList) +} +// }}} +// Render target by