diff --git a/.env.example b/.env.example index e09ead953..85fd1e8ec 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,45 @@ -# This file is used to build your local dev or offline Terrastories server -# in Docker Compose. +################################## +# Configure MapLibre [Recommended] +# +# By default, Terrastories is configured to serve maps using +# Maplibre GL JS with Protomap's free API Tileserver. +# +# If you configure Mapbox, the settings in this section are ignored. -# ==> CORS -# CORS, or Cross Origin Request Sharing, allows external requests from allowed -# sites to access the resources in this application. -# The Public Community feature provides an API. For now, this API should only be available from -# allowed origins. These can be configured via this ENV variable to be configurable -# on servers without needing a code change. -CORS_ORIGINS="localhost:1080,\Ahttps:\/\/[a-z0-9]{4}-[0-9]{2}-[0-9]{3}-[0-9]{3}-[0-9]{3}.ngrok.io\z" +# ==> Protomaps Hosted API +# +# Protomaps also maintains a Tiles API - get a free API key. +# It's free for non-commercial use, or commercial use paired with a GitHub sponsorship. +# +# Sign up at https://app.protomaps.com/signup and provide your API key: +# PROTOMAPS_API_KEY= + +# ==> Tileserver (hosted or local) +# +# Previously, we provided a hosted Tileserver GL server along side +# Terrastories to serve map tiles in offline mode. +# +# TILESERVER_URL=https://localhost:8080/styles/my-style/style.json -# If you are setting up your own online production instance, please see this page -# https://docs.terrastories.app/setting-up-a-terrastories-server/hosting-environments/hosting-terrastories-online -# If you have any additional questions, reach out to the Terrastories Stewards team -# for help. +################## +# Configure Mapbox +# +# For backwards compatibility, Terrastories still supports map rendering with +# Mapbox and Mapbox styles when configured. -# ==> Default Online Mapbox Configuration -# Mapbox access token and style are required to run Terrastories in an internet- -# connected environment, including local Development. These settings do not need -# to be set here; however, each community must configure them in their Theme -# settings for any mapping functionality to work. +# Set your Mapbox Access Token (DEFAULT_MAPBOX_TOKEN works, but is deprecated) +# MAPBOX_ACCESS_TOKEN=pk.ey -# Configure a Mapbox personal access token to use the mapping functionality -# of terrastories. This Access Token will be used by default across all -# onboarded communities to this instance. -# DEFAULT_MAPBOX_TOKEN=pk.set-your-key-here +# Set your Mapbox Style (DEFAULT_MAP_STYLE works, but is deprecated) +# If unset, Terrastories defaults to Mapbox's streets-v11 +# MAPBOX_STYLE=mapbox://styles/mapbox/streets-v11 -# Configure a custom Mapbox style. This can be any off-the-shelf style provided -# by Mapbox, or a custom style associated with your personal access token. This -# default map style will be used by default across all onboarded communities to -# this instance. -# DEFAULT_MAP_STYLE=mapbox://styles/mapbox/streets-v11 +################## +# Configure CORS +# +# CORS (Cross Origin Request Sharing) allows external requests from allowed +# sites to access resources in this application. +# +# Default origins are provide for local development and ngrok. +# Additional origins can be added with comma-separation. +CORS_ORIGINS="localhost:1080,\Ahttps:\/\/[a-z0-9]{4}-[0-9]{2}-[0-9]{3}-[0-9]{3}-[0-9]{3}.ngrok.io\z" diff --git a/rails/Gemfile b/rails/Gemfile index f5c7e96fb..56aa7896d 100644 --- a/rails/Gemfile +++ b/rails/Gemfile @@ -17,9 +17,6 @@ gem 'pg', '>= 0.18', '< 2.0' gem 'rgeo', '~> 2.4.0' gem 'rgeo-geojson', '~> 2.1.1' -# Geocoder for center calculation -gem 'geocoder', '~> 1.8.1' - # Use css_bundling for stylesheets gem 'cssbundling-rails', '~> 1.1.2' diff --git a/rails/Gemfile.lock b/rails/Gemfile.lock index 20d35ff8a..9e1b240c6 100644 --- a/rails/Gemfile.lock +++ b/rails/Gemfile.lock @@ -143,7 +143,6 @@ GEM activerecord (>= 4.2, < 8) flipper (~> 0.26.2) formatador (1.1.0) - geocoder (1.8.2) globalid (1.1.0) activesupport (>= 5.0) guard (2.18.0) @@ -356,7 +355,6 @@ DEPENDENCIES factory_bot_rails flipper (~> 0.26.0) flipper-active_record (~> 0.26.0) - geocoder (~> 1.8.1) guard-rspec image_processing (~> 1.12.2) jbuilder (~> 2.5) diff --git a/rails/app/controllers/application_controller.rb b/rails/app/controllers/application_controller.rb index c777fb812..e81278482 100644 --- a/rails/app/controllers/application_controller.rb +++ b/rails/app/controllers/application_controller.rb @@ -28,7 +28,7 @@ def current_community end def offline_community? - Rails.application.config.offline_mode + Map.offline? end def user_not_authorized diff --git a/rails/app/controllers/dashboard/themes_controller.rb b/rails/app/controllers/dashboard/themes_controller.rb index 281a69205..5b90607a7 100644 --- a/rails/app/controllers/dashboard/themes_controller.rb +++ b/rails/app/controllers/dashboard/themes_controller.rb @@ -36,6 +36,8 @@ def theme_params :mapbox_style_url, :mapbox_access_token, :mapbox_3d, + :protomaps_api_key, + :protomaps_basemap_style, :center_lat, :center_long, :sw_boundary_lat, diff --git a/rails/app/javascript/components/App.jsx b/rails/app/javascript/components/App.jsx index f0e0b5c29..dff503eea 100644 --- a/rails/app/javascript/components/App.jsx +++ b/rails/app/javascript/components/App.jsx @@ -26,9 +26,10 @@ class App extends Component { stories: PropTypes.array, use_local_map_server: PropTypes.bool, mapbox_access_token: PropTypes.string, - mapbox_style: PropTypes.string, + map_style: PropTypes.string, mapbox_3d: PropTypes.bool, map_projection: PropTypes.string, + basemap_style: PropTypes.string, logo_path: PropTypes.string, user: PropTypes.object, center_lat: PropTypes.string, @@ -347,7 +348,8 @@ class App extends Component { points={this.state.points} mapboxAccessToken={this.props.mapbox_access_token} useLocalMapServer={this.props.use_local_map_server} - mapStyle={this.props.mapbox_style} + basemapStyle={this.props.basemap_style} + mapStyle={this.props.map_style} mapbox3d={this.props.mapbox_3d} mapProjection={this.props.map_projection} clearFilteredStories={this.resetStoriesAndMap} diff --git a/rails/app/javascript/components/Map.jsx b/rails/app/javascript/components/Map.jsx index edbb39d26..9c54580c1 100644 --- a/rails/app/javascript/components/Map.jsx +++ b/rails/app/javascript/components/Map.jsx @@ -1,7 +1,9 @@ import ReactDOM from "react-dom"; import React, { Component } from "react"; import PropTypes from "prop-types"; +import Minimap from "../vendor/mapgl-minimap.js"; import Popup from "./Popup"; +import { mapStyleLayers } from '../global/protomaps'; import 'mapbox-gl/dist/mapbox-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -18,9 +20,7 @@ export default class Map extends Component { this.state = { activePopup: null, mapGL: null, - isMapLibre: false, mapModule: null, - minimapModule: null }; } @@ -39,34 +39,23 @@ export default class Map extends Component { }; componentDidMount() { + if (this.state.mapModule) { + this.initializeMap(this.state.mapModule); + return; + } + if (this.props.useLocalMapServer) { - if (!this.state.mapModule) { - import('!maplibre-gl').then(module => { - this.setState({ mapModule: module.default }, () => { - this.initializeMap(this.state.mapModule, true); - }); + import('!maplibre-gl').then(module => { + this.setState({ mapModule: module.default }, () => { + this.initializeMap(this.state.mapModule); }); - } else { - this.initializeMap(this.state.mapModule, true); - } + }); } else { - if (!this.state.mapModule || !this.state.minimapModule) { - Promise.all([ - import('!mapbox-gl'), - import('../vendor/mapboxgl-control-minimap.js') - ]).then(([mapboxGLModule, minimapModule]) => { - this.setState({ - mapModule: mapboxGLModule.default, - minimapModule: minimapModule.default - }, () => { - this.Minimap = this.state.minimapModule; - this.initializeMap(this.state.mapModule, false); - }); + import('!mapbox-gl').then(module => { + this.setState({ mapModule: module.default }, () => { + this.initializeMap(this.state.mapModule); }); - } else { - this.Minimap = this.state.minimapModule; - this.initializeMap(this.state.mapModule, false); - } + }); } } @@ -104,11 +93,14 @@ export default class Map extends Component { } } - initializeMap(mapGL, isMapLibre) { - mapGL.accessToken = this.props.useLocalMapServer ? 'pk.ey' : this.props.mapboxAccessToken; + initializeMap(mapGL) { + if (!this.props.useLocalMapServer) { + mapGL.accessToken = this.props.mapboxAccessToken; + } + this.map = new mapGL.Map({ container: this.mapContainer, - style: this.props.mapStyle, + style: mapStyleLayers(this.props.mapStyle, this.props.basemapStyle), center: [this.props.centerLong, this.props.centerLat], zoom: this.props.zoom, maxBounds: this.checkBounds(), // check for bounding box presence @@ -180,31 +172,21 @@ export default class Map extends Component { this.addClusterClickHandler(); }); - // Hide minimap and nav controls for offline Terrastories - if(!isMapLibre && this.Minimap) { - this.map.addControl(new this.Minimap( - { - style: this.props.mapStyle, - zoomLevels: [ - [18, 14, 16], - [16, 12, 14], - [14, 10, 12], - [12, 8, 10], - [10, 6, 8], - [8, 4, 6], - [6, 2, 4], - [3, 0, 2], - [1, 0, 0] - ], - lineColor: "#136a7e", - fillColor: "#d77a34", - }), "top-right"); - } + // Add MiniMap + this.map.addControl(new Minimap( + mapGL, + { + center: [this.props.centerLong, this.props.centerLat], + maxBounds: this.checkBounds(), + style: mapStyleLayers(this.props.mapStyle, "light"), + lineColor: "#136a7e", + fillColor: "#d77a34", + }), "top-right"); this.map.addControl(new mapGL.NavigationControl()); // Add Maplibre logo for offline Terrastories - if(isMapLibre) { + if(this.props.useLocalMapServer) { this.map.addControl(new mapGL.LogoControl(), 'bottom-right'); } @@ -225,8 +207,7 @@ export default class Map extends Component { }) this.setState({ - mapGL: mapGL, - isMapLibre: isMapLibre + mapGL: mapGL }); } @@ -287,7 +268,7 @@ export default class Map extends Component { type: "symbol", layout: { 'text-field': '{point_count_abbreviated}', - 'text-font': ['Open Sans Bold'], + 'text-font': this.props.useLocalMapServer ? ['Noto Sans Medium'] : ['Open Sans Bold'], 'text-size': 16, 'text-offset': [0.2, 0.1] }, diff --git a/rails/app/javascript/global/protomaps.js b/rails/app/javascript/global/protomaps.js new file mode 100644 index 000000000..9ec950258 --- /dev/null +++ b/rails/app/javascript/global/protomaps.js @@ -0,0 +1,120 @@ +import layers from "protomaps-themes-base" +import bbox from "@turf/bbox" + +export function mapStyleLayers(mapStyle, theme = "contrast") { + // For custom map styles from Mapbox, Tileserver, or PMtiles that + // aren't supplied from Protomaps directly, return as-is. + if (!mapStyle.includes("api.protomaps.com")) return mapStyle + + // Protomaps Free API + const style = { + version: 8, + sources: {}, + layers: [] + } + + style.sources = { + protomaps: { + type: "vector", + attribution: 'Protomaps © OpenStreetMap', + url: mapStyle + } + } + + style.layers = layers("protomaps", theme) + style.glyphs = "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf" + + return style +} + +// Export mapgl for use in Rails JS. This +// allows us to add features to the map such +// as markers, popups, and navigation controls. +export async function mapgl(useMaplibre) { + let lib + if (useMaplibre) { + await import('maplibre-gl').then(module => { + lib = "Map" in module ? module : module.default + }); + } else { + await import('mapbox-gl').then(module => { + lib = "Map" in module ? module : module.default + }); + } + return lib +} + +// Instantiates a minimal Map from *-gl library +// +// Purposefully flexible since interactive maps +// in Rails have extremely varying interactions. +export function interactiveMap(lib, config) { + const map = new lib.Map({ + ...config, + style: mapStyleLayers(config.style, config.basemapStyle), + }) + return map +} + +// Instantiates a zoomable Map from *-gl library +// with point "markers" from a feature or feature collection source +// These maps do NOT require additional gl manipulation, +// and so it loads mapgl rather than relying on it being provided. +export async function staticMap(useMaplibre, config, pointFeatures) { + const maplib = await mapgl(useMaplibre) + const isFeatureCollection = pointFeatures && pointFeatures.type === "FeatureCollection" + + const map = new maplib.Map({ + ...config, + style: mapStyleLayers(config.style, config.basemapStyle), + bounds: isFeatureCollection ? bbox(pointFeatures) : undefined, + fitBoundsOptions: { + padding: isFeatureCollection ? 50 : undefined, + maxZoom: config.zoom || 8, + }, + scrollZoom: config.allowDrag || false, + dragPan: config.allowDrag || false, + dragRotate: false, + pitchWithRotation: false, + boxZoom: false, + touchPitch: false, + touchZoomRotate: false, + }) + + map.addControl(new maplib.NavigationControl({showCompass: false})) + + map.on("load", () => { + if (pointFeatures) { + map.addSource("points", { + "type": "geojson", + "data": pointFeatures + }) + + map.addLayer({ + type: "circle", + id: "circle-point", + source: "points", + paint: { + "circle-color": "#09697e", + "circle-radius": 12 + } + }) + + map.addLayer({ + type: "symbol", + id: "points", + source: "points", + layout: { + "icon-text-fit": "height", + "icon-text-fit-padding": [1,2,1,2], + "text-field": "{marker-symbol}", + "text-transform": "uppercase", + "text-font": useMaplibre ? ["Noto Sans Medium"] : ["Open Sans Bold"] + }, + paint: { + "text-color": "#FFFFFF" + } + }) + } + }) +} diff --git a/rails/app/javascript/packs/map_utils.js b/rails/app/javascript/packs/map_utils.js new file mode 100644 index 000000000..43b35eee9 --- /dev/null +++ b/rails/app/javascript/packs/map_utils.js @@ -0,0 +1,8 @@ +import { mapgl, interactiveMap, staticMap, mapStyleLayers } from "../global/protomaps" + +export { + mapgl, + interactiveMap, + staticMap, + mapStyleLayers, +} \ No newline at end of file diff --git a/rails/app/javascript/vendor/mapboxgl-control-minimap.js b/rails/app/javascript/vendor/mapboxgl-control-minimap.js deleted file mode 100644 index 631861d74..000000000 --- a/rails/app/javascript/vendor/mapboxgl-control-minimap.js +++ /dev/null @@ -1,328 +0,0 @@ -// Mapbox GL Minimap Control (https://github.com/aesqe/mapboxgl-minimap) by aesque -import mapboxgl from '!mapbox-gl'; - -let defaultOptions = { - id: "mapboxgl-minimap", - width: "320px", - height: "180px", - style: "mapbox://styles/mapbox/streets-v8", - center: [0, 0], - zoom: 6, - - // should be a function; will be bound to Minimap - zoomAdjust: null, - - // if parent map zoom >= 18 and minimap zoom >= 14, set minimap zoom to 16 - zoomLevels: [ - [18, 14, 16], - [16, 12, 14], - [14, 10, 12], - [12, 8, 10], - [10, 6, 8] - ], - - lineColor: "#08F", - lineWidth: 1, - lineOpacity: 1, - - fillColor: "#F80", - fillOpacity: 0.25, - - dragPan: false, - scrollZoom: false, - boxZoom: false, - dragRotate: false, - keyboard: false, - doubleClickZoom: false, - touchZoomRotate: false -}; - -//class Minimap extends mapboxgl.NavigationControl { -class Minimap { - constructor(_options){ - // super(); - this.options = defaultOptions; - Object.assign(this.options, _options); - - this._ticking = false; - this._lastMouseMoveEvent = null; - this._parentMap = null; - this._isDragging = false; - this._isCursorOverFeature = false; - this._previousPoint = [0, 0]; - this._currentPoint = [0, 0]; - this._trackingRectCoordinates = [[[], [], [], [], []]]; - } - - onAdd ( parentMap ) - { - this._parentMap = parentMap; - - var opts = this.options; - var container = this._container = this._createContainer(parentMap); - var miniMap = this._miniMap = new mapboxgl.Map({ - attributionControl: false, - container: container, - style: opts.style, - zoom: opts.zoom, - center: opts.center - }); - - if (opts.maxBounds) miniMap.setMaxBounds(opts.maxBounds); - - miniMap.on("load", this._load.bind(this)); - - return this._container; - } - - _load () - { - var opts = this.options; - var parentMap = this._parentMap; - var miniMap = this._miniMap; - var interactions = [ - "dragPan", "scrollZoom", "boxZoom", "dragRotate", - "keyboard", "doubleClickZoom", "touchZoomRotate" - ]; - - interactions.forEach(function(i){ - if( opts[i] !== true ) { - miniMap[i].disable(); - } - }); - - if( typeof opts.zoomAdjust === "function" ) { - this.options.zoomAdjust = opts.zoomAdjust.bind(this); - } else if( opts.zoomAdjust === null ) { - this.options.zoomAdjust = this._zoomAdjust.bind(this); - } - - var bounds = miniMap.getBounds(); - - this._convertBoundsToPoints(bounds); - - miniMap.addSource("trackingRect", { - "type": "geojson", - "data": { - "type": "Feature", - "properties": { - "name": "trackingRect" - }, - "geometry": { - "type": "Polygon", - "coordinates": this._trackingRectCoordinates - } - } - }); - - miniMap.addLayer({ - "id": "trackingRectOutline", - "type": "line", - "source": "trackingRect", - "layout": {}, - "paint": { - "line-color": opts.lineColor, - "line-width": opts.lineWidth, - "line-opacity": opts.lineOpacity - } - }); - - // needed for dragging - miniMap.addLayer({ - "id": "trackingRectFill", - "type": "fill", - "source": "trackingRect", - "layout": {}, - "paint": { - "fill-color": opts.fillColor, - "fill-opacity": opts.fillOpacity - } - }); - - this._trackingRect = this._miniMap.getSource("trackingRect"); - - this._update(); - - parentMap.on("move", this._update.bind(this)); - - miniMap.on("mousemove", this._mouseMove.bind(this)); - miniMap.on("mousedown", this._mouseDown.bind(this)); - miniMap.on("mouseup", this._mouseUp.bind(this)); - - miniMap.on("touchmove", this._mouseMove.bind(this)); - miniMap.on("touchstart", this._mouseDown.bind(this)); - miniMap.on("touchend", this._mouseUp.bind(this)); - - this._miniMapCanvas = miniMap.getCanvasContainer(); - this._miniMapCanvas.addEventListener("wheel", this._preventDefault); - this._miniMapCanvas.addEventListener("mousewheel", this._preventDefault); - } - - _mouseDown( e ) - { - if( this._isCursorOverFeature ) - { - this._isDragging = true; - this._previousPoint = this._currentPoint; - this._currentPoint = [e.lngLat.lng, e.lngLat.lat]; - } - } - - _mouseMove (e) - { - this._ticking = false; - - var miniMap = this._miniMap; - var features = miniMap.queryRenderedFeatures(e.point, { - layers: ["trackingRectFill"] - }); - - // don't update if we're still hovering the area - if( ! (this._isCursorOverFeature && features.length > 0) ) - { - this._isCursorOverFeature = features.length > 0; - this._miniMapCanvas.style.cursor = this._isCursorOverFeature ? "move" : ""; - } - - if( this._isDragging ) - { - this._previousPoint = this._currentPoint; - this._currentPoint = [e.lngLat.lng, e.lngLat.lat]; - - var offset = [ - this._previousPoint[0] - this._currentPoint[0], - this._previousPoint[1] - this._currentPoint[1] - ]; - - var newBounds = this._moveTrackingRect(offset); - - this._parentMap.fitBounds(newBounds, { - duration: 80, - noMoveStart: true - }); - } - } - - _mouseUp() - { - this._isDragging = false; - this._ticking = false; - } - - _moveTrackingRect( offset ) - { - var source = this._trackingRect; - var data = source._data; - var bounds = data.properties.bounds; - - bounds._ne.lat -= offset[1]; - bounds._ne.lng -= offset[0]; - bounds._sw.lat -= offset[1]; - bounds._sw.lng -= offset[0]; - - this._convertBoundsToPoints(bounds); - source.setData(data); - - return bounds; - } - - _setTrackingRectBounds( bounds ) - { - var source = this._trackingRect; - var data = source._data; - - data.properties.bounds = bounds; - this._convertBoundsToPoints(bounds); - source.setData(data); - } - - _convertBoundsToPoints( bounds ) - { - var ne = bounds._ne; - var sw = bounds._sw; - var trc = this._trackingRectCoordinates; - - trc[0][0][0] = ne.lng; - trc[0][0][1] = ne.lat; - trc[0][1][0] = sw.lng; - trc[0][1][1] = ne.lat; - trc[0][2][0] = sw.lng; - trc[0][2][1] = sw.lat; - trc[0][3][0] = ne.lng; - trc[0][3][1] = sw.lat; - trc[0][4][0] = ne.lng; - trc[0][4][1] = ne.lat; - } - - _update() - { - if( this._isDragging ) { - return; - } - - var parentBounds = this._parentMap.getBounds(); - - this._setTrackingRectBounds(parentBounds); - - if( typeof this.options.zoomAdjust === "function" ) { - this.options.zoomAdjust(); - } - } - - _zoomAdjust() - { - var miniMap = this._miniMap; - var parentMap = this._parentMap; - var miniZoom = parseInt(miniMap.getZoom(), 10); - var parentZoom = parseInt(parentMap.getZoom(), 10); - var levels = this.options.zoomLevels; - var found = false; - - levels.forEach(function(zoom) - { - if( ! found && parentZoom >= zoom[0] ) - { - if( miniZoom >= zoom[1] ) { - miniMap.setZoom(zoom[2]); - } - - miniMap.setCenter(parentMap.getCenter()); - found = true; - } - }); - - if( ! found && miniZoom !== this.options.zoom ) - { - if( typeof this.options.bounds === "object" ) { - miniMap.fitBounds(this.options.bounds, {duration: 50}); - } - - miniMap.setZoom(this.options.zoom) - } - } - - _createContainer ( parentMap ) - { - var opts = this.options; - var container = document.createElement("div"); - - container.className = "mapboxgl-ctrl-minimap mapboxgl-ctrl"; - var containerBorder = "border: 3px solid #136a7e"; - container.setAttribute('style', `width: ${opts.width}; height: ${opts.height}; ${containerBorder}`); - container.addEventListener("contextmenu", this._preventDefault); - - parentMap.getContainer().appendChild(container); - - if( opts.id !== "" ) { - container.id = opts.id; - } - - return container; - } - - _preventDefault( e ) { - e.preventDefault(); - } -} - -export default Minimap; \ No newline at end of file diff --git a/rails/app/javascript/vendor/mapgl-minimap.js b/rails/app/javascript/vendor/mapgl-minimap.js new file mode 100644 index 000000000..523e4ed15 --- /dev/null +++ b/rails/app/javascript/vendor/mapgl-minimap.js @@ -0,0 +1,326 @@ +class Minimap { + constructor(mapgl, config) { + this.mapGL = mapgl + this.parentMap = null + this.minimap = null + this.isDragging = false + this.isCursorOverFeature = false + this.currentPoint = [0, 0] + this.previousPoint = [0, 0] + this.trackingRectCoordinates = [[[], [], [], [], []]] + + this.options = { + id: "mapgl-minimap", + width: "320px", + height: "180px", + style: { + version: 8, + sources: {}, + layers: [] + }, + center: [0, 0], + + zoomLevelOffset: -4, + + lineColor: "#136a7e", + lineWidth: 1, + lineOpacity: 1, + + fillColor: "#d77a34", + fillOpacity: 0.25, + + borderColor: "#136a7e", + borderStyle: "solid", + borderWidth: 3, + + dragPan: false, + scrollZoom: false, + boxZoom: false, + dragRotate: false, + keyboard: false, + doubleClickZoom: false, + touchZoomRotate: false + } + + if (config) { + Object.assign(this.options, config) + } + } + + onAdd(parentMap) { + this.parentMap = parentMap + + const opts = this.options + const container = this.container = this.createContainer(parentMap) + + const minimap = this.minimap = new this.mapGL.Map({ + attributionControl: false, + container: container, + style: opts.style, + center: opts.center + }) + + this.zoomAdjust() + + if (opts.maxBounds) minimap.setMaxBounds(opts.maxBounds) + + minimap.getCanvas().removeAttribute("tabindex") + + this.onLoad = this.load.bind(this) + minimap.on("load", this.onLoad) + + return this.container + } + + + onRemove() { + this.parentMap.off("move", this.onMainMapMove) + + this.minimap.off("mousemove", this.onMouseMove) + this.minimap.off("mousedown", this.onMouseDown) + this.minimap.off("mouseup", this.onMouseUp) + + this.minimap.off("touchmove", this.onMouseMove) + this.minimap.off("touchstart", this.onMouseDown) + this.minimap.off("touchend", this.onMouseUp) + + this.minimapCanvas.removeEventListener("wheel", this.preventDefault) + this.minimapCanvas.removeEventListener("mousewheel", this.preventDefault) + + this.container.removeEventListener("contextmenu", this.preventDefault) + this.container.parentNode.removeChild(this.container) + this.minimap = null + } + + load() { + const opts = this.options + const parentMap = this.parentMap + const minimap = this.minimap + const interactions = [ + "dragPan", "scrollZoom", "boxZoom", "dragRotate", + "keyboard", "doubleClickZoom", "touchZoomRotate" + ] + + for(const interaction of interactions) { + if (!opts[interaction]) { + minimap[interaction].disable() + } + } + + // remove any trackingRect already loaded layers or sources + if (minimap.getLayer('trackingRectOutline')) { + minimap.removeLayer('trackingRectOutline'); + } + + if (minimap.getLayer('trackingRectFill')) { + minimap.removeLayer('trackingRectFill'); + } + + if (minimap.getSource('trackingRect')) { + minimap.removeSource('trackingRect'); + } + + // Add trackingRect sources and layers + minimap.addSource("trackingRect", { + "type": "geojson", + "data": { + "type": "Feature", + "properties": { + "name": "trackingRect" + }, + "geometry": { + "type": "Polygon", + "coordinates": this.trackingRectCoordinates + } + } + }) + + minimap.addLayer({ + "id": "trackingRectOutline", + "type": "line", + "source": "trackingRect", + "layout": {}, + "paint": { + "line-color": opts.lineColor, + "line-width": opts.lineWidth, + "line-opacity": opts.lineOpacity + } + }) + + // needed for dragging + minimap.addLayer({ + "id": "trackingRectFill", + "type": "fill", + "source": "trackingRect", + "layout": {}, + "paint": { + "fill-color": opts.fillColor, + "fill-opacity": opts.fillOpacity + } + }) + + this.trackingRect = this.minimap.getSource("trackingRect") + this.update() + + this.onMainMapMove = this.update.bind(this) + this.onMainMapMoveEnd = this.parentMapMoved.bind(this) + + this.onMouseMove = this.mouseMove.bind(this) + this.onMouseDown = this.mouseDown.bind(this) + this.onMouseUp = this.mouseUp.bind(this) + + parentMap.on("move", this.onMainMapMove) + parentMap.on("moveend", this.onMainMapMoveEnd) + + minimap.on("mousemove", this.onMouseMove) + minimap.on("mousedown", this.onMouseDown) + minimap.on("mouseup", this.onMouseUp) + + minimap.on("touchmove", this.onMouseMove) + minimap.on("touchstart", this.onMouseDown) + minimap.on("touchend", this.onMouseUp) + + this.minimapCanvas = minimap.getCanvasContainer() + this.minimapCanvas.addEventListener("wheel", this.preventDefault) + this.minimapCanvas.addEventListener("mousewheel", this.preventDefault) + } + + mouseDown(e) { + if (this.isCursorOverFeature) { + this.isDragging = true + this.previousPoint = this.currentPoint + this.currentPoint = [e.lngLat.lng, e.lngLat.lat] + } + } + + mouseMove (e) { + const minimap = this.minimap + const features = minimap.queryRenderedFeatures(e.point, { + layers: ["trackingRectFill"] + }) + + // don't update if we're still hovering the area + if (!(this.isCursorOverFeature && features.length > 0)) { + this.isCursorOverFeature = features.length > 0 + this.minimapCanvas.style.cursor = this.isCursorOverFeature ? "move" : "" + } + + if (this.isDragging) { + this.previousPoint = this.currentPoint + this.currentPoint = [e.lngLat.lng, e.lngLat.lat] + + const offset = [ + this.previousPoint[0] - this.currentPoint[0], + this.previousPoint[1] - this.currentPoint[1] + ] + + const newBounds = this.moveTrackingRect(offset) + + this.parentMap.fitBounds(newBounds, { + duration: 80 + }) + } + } + + mouseUp() { + this.isDragging = false + } + + moveTrackingRect(offset) { + const source = this.trackingRect + const data = source._data + const bounds = data.properties.bounds + + bounds._ne.lat -= offset[1] + bounds._ne.lng -= offset[0] + bounds._sw.lat -= offset[1] + bounds._sw.lng -= offset[0] + + // restrict bounds to max lat/lng before setting layer data + bounds._ne.lat = Math.min(bounds._ne.lat, 90) + bounds._ne.lng = Math.min(bounds._ne.lng, 180) + bounds._sw.lat = Math.max(bounds._sw.lat, -90) + bounds._sw.lng = Math.max(bounds._sw.lng, -180) + + // convert bounds to points for trackingRect + this.convertBoundsToPoints(bounds) + + source.setData(data) + + return bounds + } + + setTrackingRectBounds() { + const bounds = this.parentMap.getBounds() + const source = this.trackingRect + const data = source._data + + data.properties.bounds = bounds + this.convertBoundsToPoints(bounds) + source.setData(data) + } + + convertBoundsToPoints(bounds) { + const ne = bounds._ne + const sw = bounds._sw + const trc = this.trackingRectCoordinates + + trc[0][0][0] = ne.lng + trc[0][0][1] = ne.lat + trc[0][1][0] = sw.lng + trc[0][1][1] = ne.lat + trc[0][2][0] = sw.lng + trc[0][2][1] = sw.lat + trc[0][3][0] = ne.lng + trc[0][3][1] = sw.lat + trc[0][4][0] = ne.lng + trc[0][4][1] = ne.lat + } + + update() { + if (this.isDragging) return + + this.zoomAdjust() + this.setTrackingRectBounds() + } + + parentMapMoved() { + this.minimap.setCenter(this.parentMap.getCenter()) + this.zoomAdjust() + } + + zoomAdjust() { + this.minimap.setZoom( + Math.max(0, this.parentMap.getZoom() + this.options.zoomLevelOffset) + ) + } + + createContainer(parentMap) { + const opts = this.options + const container = document.createElement("div") + + container.className = "mapgl-minimap maplibregl-ctrl mapboxgl-ctrl" + if (opts.containerClass) container.classList.add(opts.containerClass) + container.setAttribute( + "style", + "width: " + opts.width + "; \ + height: " + opts.height + "; \ + border: " + opts.borderWidth + "px " + opts.borderStyle + " " + opts.borderColor + ";" + ) + container.addEventListener("contextmenu", this.preventDefault) + + parentMap.getContainer().appendChild(container) + + if( opts.id !== "" ) { + container.id = opts.id + } + + return container + } + + preventDefault(e) { + e.preventDefault() + } +} + +export default Minimap \ No newline at end of file diff --git a/rails/app/models/story.rb b/rails/app/models/story.rb index 44986874c..94f43f18c 100644 --- a/rails/app/models/story.rb +++ b/rails/app/models/story.rb @@ -46,10 +46,6 @@ def self.export_sample_csv end end - def geo_center - Geocoder::Calculations.geographic_center(places.map { |p| [p.lat, p.long] }).reverse - end - def static_map_markers RGeo::GeoJSON.encode( RGeo::GeoJSON::FeatureCollection.new( diff --git a/rails/app/models/theme.rb b/rails/app/models/theme.rb index 0f1e9a248..f7419dcf7 100644 --- a/rails/app/models/theme.rb +++ b/rails/app/models/theme.rb @@ -26,28 +26,44 @@ class Theme < ApplicationRecord validates :pitch, numericality: true, allow_nil: true, inclusion: {in: 0..85, message: :invalid_pitch} - def static_map_pitch - pitch.to_i > 60 ? 60 : pitch.to_i - end - def mapbox_token - if mapbox_access_token.present? && !offline_mode? + if mapbox_access_token.present? && !Map.offline? mapbox_access_token else - Rails.application.config.default_mapbox_token + Map.mapbox_access_token + end + end + + def basemap_style + return "default" if Map.offline? || !use_maplibre? + + if protomaps_basemap_style.present? + protomaps_basemap_style + else + "contrast" + end + end + + def map_style_url + return Map.default_style if Map.offline? + + if protomaps_api_key.present? + "https://api.protomaps.com/tiles/v3.json?key=#{protomaps_api_key}" + else + mapbox_style end end def mapbox_style - if mapbox_style_url.present? && !offline_mode? + if mapbox_style_url.present? && !Map.offline? mapbox_style_url else - Rails.application.config.default_map_style + Map.default_style end end - def offline_mode? - Rails.application.config.offline_mode + def use_maplibre? + Map.offline? || protomaps_api_key.present? || !(Map.use_mapbox? || mapbox_access_token.present?) end def all_boundaries_nil? @@ -76,22 +92,24 @@ def map_bounds # # Table name: themes # -# id :bigint not null, primary key -# active :boolean default(FALSE), not null -# bearing :decimal(10, 6) -# center_lat :decimal(10, 6) -# center_long :decimal(10, 6) -# map_projection :integer default("mercator") -# mapbox_3d :boolean default(FALSE) -# mapbox_access_token :string -# mapbox_style_url :string -# ne_boundary_lat :decimal(10, 6) -# ne_boundary_long :decimal(10, 6) -# pitch :decimal(10, 6) -# sw_boundary_lat :decimal(10, 6) -# sw_boundary_long :decimal(10, 6) -# zoom :decimal(10, 6) -# created_at :datetime not null -# updated_at :datetime not null -# community_id :bigint not null +# id :bigint not null, primary key +# active :boolean default(FALSE), not null +# bearing :decimal(10, 6) +# center_lat :decimal(10, 6) +# center_long :decimal(10, 6) +# map_projection :integer default("mercator") +# mapbox_3d :boolean default(FALSE) +# mapbox_access_token :string +# mapbox_style_url :string +# ne_boundary_lat :decimal(10, 6) +# ne_boundary_long :decimal(10, 6) +# pitch :decimal(10, 6) +# protomaps_api_key :text +# protomaps_basemap_style :text +# sw_boundary_lat :decimal(10, 6) +# sw_boundary_long :decimal(10, 6) +# zoom :decimal(10, 6) +# created_at :datetime not null +# updated_at :datetime not null +# community_id :bigint not null # diff --git a/rails/app/services/map.rb b/rails/app/services/map.rb new file mode 100644 index 000000000..a26506a17 --- /dev/null +++ b/rails/app/services/map.rb @@ -0,0 +1,90 @@ +class Map + class << self + # Offline Configuration + # + # When true, Map configuration is forced into offline compatible maps. + # - Mapbox configuration is ignored + # - Protomaps API key is ignored + # - Locally accessible map packages are used (default, or configured) + def offline? + return @_offline if defined? @_offline + @_offline = Rails.env.offline? || ["yes", "y", "true", "t"].include?(ENV["OFFLINE_MODE"]&.downcase) + end + + # Configure Mapbox + # + # By default, Terrastories uses MapLibre with a free Protomaps API key. + # + # In order to utilize Mapbox as your default map fallback renderer, + # you must: + # - Run in an "online" configuration + # - MAPBOX_ACCESS_TOKEN must have a valid access token from Mapbox + def use_mapbox? + return false if offline? + + return @_use_mapbox if defined? @_use_mapbox + @_use_mapbox = !!mapbox_access_token + end + + def mapbox_access_token + return @_mapbox_access_token if defined? @_mapbox_access_token + + if (token = ENV["DEFAULT_MAPBOX_TOKEN"]) + ActiveSupport::Deprecation.warn( + "Setting DEFAULT_MAPBOX_TOKEN to configure Mapbox is deprecated. " \ + "Use MAPBOX_ACCESS_TOKEN instead." + ) + end + + @_mapbox_access_token = token || ENV["MAPBOX_ACCESS_TOKEN"] + end + + def default_style + return @_default_style if defined? @_default_style + @_default_style = new(use_mapbox?, offline?).style + end + + # Test Helper to reset memoization in the same thread. + # In other environments, rebooting will remove cache. + def reload + return unless Rails.env.test? + + self.send(:remove_instance_variable, :@_offline) if defined? @_offline + self.send(:remove_instance_variable, :@_use_mapbox) if defined? @_use_mapbox + self.send(:remove_instance_variable, :@_mapbox_access_token) if defined? @_mapbox_access_token + self.send(:remove_instance_variable, :@_default_style) if defined? @_default_style + end + end + + def initialize(use_mapbox, offline) + @use_mapbox = use_mapbox + @offline = offline + @protocol, @host, @port = Rails.application.routes.default_url_options.values_at(:protocol, :host, :port) + end + + def style + configured_style + end + + private + + def configured_style + return @_configured_style if defined? @_configured_style + + @_configured_style = if @use_mapbox + ActiveSupport::Deprecation.warn( + "Setting DEFAULT_MAP_STYLE to configure Mapbox is deprecated. " \ + "Use MAPBOX_STYLE instead." + ) if ENV["DEFAULT_MAP_STYLE"] + ENV["DEFAULT_MAP_STYLE"] || ENV["MAPBOX_STYLE"] || "mapbox://styles/mapbox/streets-v11" + elsif !@offline && (protomaps_api_key = ENV["PROTOMAPS_API_KEY"]) + "https://api.protomaps.com/tiles/v3.json?key=#{protomaps_api_key}" + else + ActiveSupport::Deprecation.warn( + "Setting OFFLINE_MAP_STYLE to configure Tileserver is deprecated. " \ + "Use TILESERVER_URL instead." + ) if ENV["OFFLINE_MAP_STYLE"] + ENV["OFFLINE_MAP_STYLE"] || ENV["TILESERVER_URL"] + end + end +end \ No newline at end of file diff --git a/rails/app/views/dashboard/places/_form.html.erb b/rails/app/views/dashboard/places/_form.html.erb index 60b6e9586..0322c67e7 100644 --- a/rails/app/views/dashboard/places/_form.html.erb +++ b/rails/app/views/dashboard/places/_form.html.erb @@ -55,36 +55,57 @@ const placeForm = document.querySelector("#place_form"); const placeLatInput = placeForm.querySelector("#place_lat"); const placeLongInput = placeForm.querySelector("#place_long"); const coordinates = [placeLongInput.value, placeLatInput.value] -const placeMap = new mapboxgl.Map({ - accessToken: "<%= current_community.theme.mapbox_token %>", - container: "placeMap", // container ID - center: coordinates, // starting position [lng, lat] - hash: false, - zoom: <%= current_community.theme.zoom || 5 %>, // starting zoom - style: "<%= current_community.theme.mapbox_style %>", // style URL or style object - cooperativeGestures: true -}); - -const marker = new mapboxgl.Marker() - .setLngLat(coordinates) - .addTo(placeMap); - -// sync values when the map moves -placeMap.on('move', () => { - let { lng, lat } = placeMap.getCenter(); - placeLongInput.value = lng.toFixed(5); - placeLatInput.value = lat.toFixed(5); - marker.setLngLat({lng, lat}); -}); - -// sync values when input is edited -placeLongInput.addEventListener("change", (e) => { - let { lat } = placeMap.getCenter(); - placeMap.setCenter({lng: e.target.value, lat: lat}); -}); - -placeLatInput.addEventListener("change", (e) => { - let { lng } = placeMap.getCenter(); - placeMap.setCenter({lng: lng, lat: e.target.value}); -}); + +Terrastories.map_utils.mapgl(<%= current_community.theme.use_maplibre? %>).then(lib => { + const placeMap = Terrastories.map_utils.interactiveMap( + lib, + { + accessToken: "<%= current_community.theme.mapbox_token %>", + container: "placeMap", // container ID + center: coordinates, // starting position [lng, lat] + hash: false, + zoom: <%= current_community.theme.zoom || 5 %>, // starting zoom + style: "<%= current_community.theme.map_style_url %>", // style URL or style object + basemapStyle: "<%= current_community.theme.basemap_style %>", + cooperativeGestures: true, + dragRotate: false, + pitchWithRotation: false, + boxZoom: false, + touchPitch: false, + touchZoomRotate: false, + } + ); + + const marker = new lib.Marker({draggable: true}).setLngLat(coordinates) + + placeMap.on("load", () => { + marker.addTo(placeMap); + }) + + marker.on("dragend", (e) => { + const { lng, lat } = marker.getLngLat() + + placeMap.setCenter([lng, lat]) + }) + + // sync values when the map moves + placeMap.on('move', () => { + let { lng, lat } = placeMap.getCenter(); + placeLongInput.value = lng.toFixed(5); + placeLatInput.value = lat.toFixed(5); + marker.setLngLat({lng, lat}); + }); + + // sync values when input is edited + placeLongInput.addEventListener("change", (e) => { + let { lat } = placeMap.getCenter(); + placeMap.setCenter({lng: e.target.value, lat: lat}); + }); + + placeLatInput.addEventListener("change", (e) => { + let { lng } = placeMap.getCenter(); + placeMap.setCenter({lng: lng, lat: e.target.value}); + }); + +}) diff --git a/rails/app/views/dashboard/places/show.html.erb b/rails/app/views/dashboard/places/show.html.erb index 178a975a4..faeeb8783 100644 --- a/rails/app/views/dashboard/places/show.html.erb +++ b/rails/app/views/dashboard/places/show.html.erb @@ -103,36 +103,16 @@ if (nameAudio !== null) { }); } -const staticMap = new mapboxgl.Map({ - accessToken: "<%= current_community.theme.mapbox_token %>", - container: "static-map", // container ID - center: [<%= @place.long %>, <%= @place.lat %>], // starting position [lng, lat] - zoom: 8, // starting zoom - style: "<%= current_community.theme.mapbox_style %>", // style URL or style object - interactive: false -}); - -staticMap.on("load", () => { - staticMap.addSource("points", { - "type": "geojson", - "data": <%= raw @place.static_map_markers %> - }); - - staticMap.addLayer({ - type: "symbol", - id: "points", - source: "points", - layout: { - "icon-image": "rectangle-blue-2", - "icon-text-fit": "height", - "icon-text-fit-padding": [1,2,1,2], - "text-field": "{marker-symbol}", - "text-transform": "uppercase", - "text-font": ["Open Sans Bold"] - }, - paint: { - "text-color": "#FFFFFF" - } - }) -}); +Terrastories.map_utils.staticMap( + <%= current_community.theme.use_maplibre? %>, + { + accessToken: "<%= current_community.theme.mapbox_token %>", + container: "static-map", // container ID + center: [<%= @place.long %>, <%= @place.lat %>], // starting position [lng, lat] + zoom: <%= current_community.theme.zoom %>, // starting zoom + style: "<%= current_community.theme.map_style_url %>", // style URL or style object + basemapStyle: "<%= current_community.theme.basemap_style %>", + }, + <%= raw @place.static_map_markers %> +) \ No newline at end of file diff --git a/rails/app/views/dashboard/stories/show.html.erb b/rails/app/views/dashboard/stories/show.html.erb index 461cf47d3..55af07c56 100644 --- a/rails/app/views/dashboard/stories/show.html.erb +++ b/rails/app/views/dashboard/stories/show.html.erb @@ -94,36 +94,16 @@ diff --git a/rails/app/views/dashboard/themes/edit.html.erb b/rails/app/views/dashboard/themes/edit.html.erb index 0d4aa4928..7ee3cc109 100644 --- a/rails/app/views/dashboard/themes/edit.html.erb +++ b/rails/app/views/dashboard/themes/edit.html.erb @@ -139,20 +139,38 @@ - <% unless @theme.offline_mode? %> -
-
<%= t("map.online") %>
-
- <%= f.label :mapbox_style_url %> - <%= f.text_field :mapbox_style_url %> -
-
- <%= f.label :mapbox_access_token %> - <%= f.text_field :mapbox_access_token %> + <% unless Map.offline? %> +
<%= t("map.online") %>
+
+
+
+ <%= t("map.protomaps_configuration") %> +
+ <%= f.label :protomaps_api_key %> + <%= f.text_field :protomaps_api_key %> +
+
+ <%= f.label :protomaps_basemap_style %> + <%= f.select :protomaps_basemap_style, t("protomaps_basemap_styles").map { |k, v| [v, k] }, include_blank: "Default" %> +
+
-
- <%= f.check_box :mapbox_3d %> - <%= f.label :mapbox_3d %> +
+
+ <%= t("map.mapbox_configuration") %> +
+ <%= f.label :mapbox_style_url %> + <%= f.text_field :mapbox_style_url %> +
+
+ <%= f.label :mapbox_access_token %> + <%= f.text_field :mapbox_access_token %> +
+
+ <%= f.check_box :mapbox_3d %> + <%= f.label :mapbox_3d %> +
+
<% end %> @@ -171,84 +189,104 @@ const pitchValue = themeForm.querySelector("#pitchValue"); const bearingInput = themeForm.querySelector("#theme_bearing"); const bearingValue = themeForm.querySelector("#bearingValue"); -const themeMap = new mapboxgl.Map({ - accessToken: "<%= @theme.mapbox_token %>", - container: "themeMap", // container ID - center: <%= [@theme.center_long, @theme.center_lat] %>, // starting position [lng, lat] - zoom: <%= @theme.zoom || 0 %>, // starting zoom - pitch: <%= @theme.pitch || 0 %>, // starting pitch - bearing: <%= @theme.bearing || 0 %>, // starting bearing - style: "<%= @theme.mapbox_style %>", // style URL or style object - projection: "<%= @theme.map_projection %>", - hash: false, - cooperativeGestures: true -}); - -themeMap.on("moveend", (e) => { - let themeForm = document.querySelector("#themeForm"); - let { lng, lat } = e.target.getCenter(); - themeCenterLongInput.value = lng.toFixed(5); - themeCenterLatInput.value = lat.toFixed(5); - - if (!themeForm.querySelector("input#unrestricted_bounds").checked) { - let { _ne, _sw } = themeMap.getBounds(); - themeForm.querySelector("input#theme_ne_boundary_long").value = _ne.lng.toFixed(5); - themeForm.querySelector("input#theme_ne_boundary_lat").value = _ne.lat.toFixed(5); - themeForm.querySelector("input#theme_sw_boundary_long").value = _sw.lng.toFixed(5); - themeForm.querySelector("input#theme_sw_boundary_lat").value = _sw.lat.toFixed(5); - } +Terrastories.map_utils.mapgl(<%= @theme.use_maplibre? %>).then((lib) => { + const themeMap = Terrastories.map_utils.interactiveMap( + lib, + { + accessToken: "<%= @theme.mapbox_token %>", + container: "themeMap", // container ID + center: <%= [@theme.center_long, @theme.center_lat] %>, // starting position [lng, lat] + zoom: <%= @theme.zoom || 0 %>, // starting zoom + pitch: <%= @theme.pitch || 0 %>, // starting pitch + bearing: <%= @theme.bearing || 0 %>, // starting bearing + style: "<%= @theme.map_style_url %>", // style URL or style object + projection: "<%= @theme.map_projection %>", + hash: false, + cooperativeGestures: true, + basemapStyle: "<%= @theme.basemap_style %>", + } + ); + + const marker = new lib.Marker({draggable: true, color: "<%= t("protomaps_basemap_markers.#{@theme.basemap_style}") %>"}).setLngLat(<%= [@theme.center_long, @theme.center_lat] %>) + + themeMap.on("load", () => { + marker.addTo(themeMap) + }) + + marker.on("dragend", (e) => { + const { lng, lat } = marker.getLngLat() + + themeMap.setCenter([lng, lat]) + }) + + themeMap.on("moveend", (e) => { + let themeForm = document.querySelector("#themeForm"); + let { lng, lat } = e.target.getCenter(); + + marker.setLngLat({ lng, lat }) + + themeCenterLongInput.value = lng.toFixed(5); + themeCenterLatInput.value = lat.toFixed(5); + + if (!themeForm.querySelector("input#unrestricted_bounds").checked) { + let { _ne, _sw } = themeMap.getBounds(); + themeForm.querySelector("input#theme_ne_boundary_long").value = _ne.lng.toFixed(5); + themeForm.querySelector("input#theme_ne_boundary_lat").value = _ne.lat.toFixed(5); + themeForm.querySelector("input#theme_sw_boundary_long").value = _sw.lng.toFixed(5); + themeForm.querySelector("input#theme_sw_boundary_lat").value = _sw.lat.toFixed(5); + } + }); + + themeMap.on("zoomend", (e) => { + zoomValue.innerHTML = e.target.getZoom().toPrecision(5); + zoomInput.value = e.target.getZoom(); + }); + + themeMap.on("pitchend", (e) => { + pitchValue.innerHTML = e.target.getPitch().toPrecision(5); + pitchInput.value = e.target.getPitch(); + bearingValue.innerHTML = e.target.getBearing().toPrecision(5); + bearingInput.value = e.target.getBearing(); + }); + + const nav = new lib.NavigationControl({ + visualizePitch: true + }); + themeMap.addControl(nav, "top-right"); + + themeForm.querySelector("input#unrestricted_bounds").addEventListener("click", (e) => { + themeForm.querySelector(".unrestricted_bounds").classList.toggle("hidden", e.target.checked) + if (e.target.checked) { + themeForm.querySelectorAll(".unrestricted_bounds input").forEach((item)=>{item.removeAttribute("value")}) + } else { + var { _ne, _sw } = themeMap.getBounds(); + themeForm.querySelector("input#theme_ne_boundary_long").value = _ne.lng.toFixed(5); + themeForm.querySelector("input#theme_ne_boundary_lat").value = _ne.lat.toFixed(5); + themeForm.querySelector("input#theme_sw_boundary_long").value = _sw.lng.toFixed(5); + themeForm.querySelector("input#theme_sw_boundary_lat").value = _sw.lat.toFixed(5); + } + }) + + themeCenterLongInput.addEventListener("change", (e) => { + let { lat } = themeMap.getCenter(); + themeMap.setCenter({lng: e.target.value, lat: lat}); + }); + themeCenterLatInput.addEventListener("change", (e) => { + let { lng } = themeMap.getCenter(); + themeMap.setCenter({lng: lng, lat: e.target.value}); + }); + + zoomInput.addEventListener("input", (e) => { + zoomValue.innerHTML = e.target.value; + themeMap.setZoom(e.target.value); + }); + pitchInput.addEventListener("input", (e) => { + pitchValue.innerHTML = e.target.value; + themeMap.setPitch(e.target.value); + }); + bearingInput.addEventListener("input", (e) => { + bearingValue.innerHTML = e.target.value; + themeMap.setBearing(e.target.value); + }); }); - -themeMap.on("zoomend", (e) => { - zoomValue.innerHTML = e.target.getZoom().toPrecision(5); - zoomInput.value = e.target.getZoom(); -}); - -themeMap.on("pitchend", (e) => { - pitchValue.innerHTML = e.target.getPitch().toPrecision(5); - pitchInput.value = e.target.getPitch(); - bearingValue.innerHTML = e.target.getBearing().toPrecision(5); - bearingInput.value = e.target.getBearing(); -}); - -const nav = new mapboxgl.NavigationControl({ - visualizePitch: true -}); -themeMap.addControl(nav, "top-right"); - -themeForm.querySelector("input#unrestricted_bounds").addEventListener("click", (e) => { - themeForm.querySelector(".unrestricted_bounds").classList.toggle("hidden", e.target.checked) - if (e.target.checked) { - themeForm.querySelectorAll(".unrestricted_bounds input").forEach((item)=>{item.removeAttribute("value")}) - } else { - var { _ne, _sw } = themeMap.getBounds(); - themeForm.querySelector("input#theme_ne_boundary_long").value = _ne.lng.toFixed(5); - themeForm.querySelector("input#theme_ne_boundary_lat").value = _ne.lat.toFixed(5); - themeForm.querySelector("input#theme_sw_boundary_long").value = _sw.lng.toFixed(5); - themeForm.querySelector("input#theme_sw_boundary_lat").value = _sw.lat.toFixed(5); - } -}) - -themeCenterLongInput.addEventListener("change", (e) => { - let { lat } = themeMap.getCenter(); - themeMap.setCenter({lng: e.target.value, lat: lat}); -}); -themeCenterLatInput.addEventListener("change", (e) => { - let { lng } = themeMap.getCenter(); - themeMap.setCenter({lng: lng, lat: e.target.value}); -}); - -zoomInput.addEventListener("input", (e) => { - zoomValue.innerHTML = e.target.value; - themeMap.setZoom(e.target.value); -}); -pitchInput.addEventListener("input", (e) => { - pitchValue.innerHTML = e.target.value; - themeMap.setPitch(e.target.value); -}); -bearingInput.addEventListener("input", (e) => { - bearingValue.innerHTML = e.target.value; - themeMap.setBearing(e.target.value); -}); - - \ No newline at end of file + diff --git a/rails/app/views/home/_home.json.jbuilder b/rails/app/views/home/_home.json.jbuilder index 7db4d3e0d..e1f761d5f 100644 --- a/rails/app/views/home/_home.json.jbuilder +++ b/rails/app/views/home/_home.json.jbuilder @@ -30,10 +30,11 @@ end json.logo_path image_path("logocombo.svg") json.mapbox_access_token @community.theme.mapbox_token -json.mapbox_style @community.theme.mapbox_style +json.map_style @community.theme.map_style_url +json.basemap_style @community.theme.basemap_style json.mapbox_3d @community.theme.mapbox_3d json.map_projection @community.theme.map_projection -json.use_local_map_server @community.theme.offline_mode? +json.use_local_map_server @community.theme.use_maplibre? json.center_lat @community.theme.center_lat json.center_long @community.theme.center_long json.sw_boundary_lat @community.theme.sw_boundary_lat diff --git a/rails/app/views/layouts/dashboard.html.erb b/rails/app/views/layouts/dashboard.html.erb index 281aae014..0c49bc08a 100644 --- a/rails/app/views/layouts/dashboard.html.erb +++ b/rails/app/views/layouts/dashboard.html.erb @@ -9,6 +9,9 @@ <%= yield (:title) %> - Terrastories <%= csrf_meta_tags %> + + <%= javascript_pack_tag 'map_utils', 'data-turbolinks-track': 'reload', defer: false %> + <%= stylesheet_link_tag 'dashboard', media: 'all' %> <%= javascript_include_tag 'dashboard', media: 'all' %> diff --git a/rails/config/initializers/tileserver.rb b/rails/config/initializers/tileserver.rb deleted file mode 100644 index 2cb922cbb..000000000 --- a/rails/config/initializers/tileserver.rb +++ /dev/null @@ -1,38 +0,0 @@ -### Setup for Mapbox Tileservers ### -# This configures for both online (mapbox-gl) and offline (tileserver-gl) -# however only one configuration is used by the app. - -# By default, local tileserver / offline mode is turned off. -Rails.application.config.offline_mode = false - -####################### -## Online Configuration -# !!!! NOTE: If you are setting up your own online instance, -# please utilize your Community's settings page to configure -# your mapbox access token and preferred map style! These -# are only meant to be FALLBACK values. - -# == Default Mapbox Token -# The default mapbox token is used to provide a base mapbox token -# to render maps, even when one is not provided by the user. -# Mapbox tokens are set per-community in-app. -Rails.application.config.default_mapbox_token = ENV["DEFAULT_MAPBOX_TOKEN"] || ENV["MAPBOX_ACCESS_TOKEN"] || "pk.ey" - -# == Default Map Style -# The default map style is used to provide a default-style for map -# rendering. It's configurable per-community via settings to override -# what is defined here. -Rails.application.config.default_map_style = ENV["DEFAULT_MAP_STYLE"] || ENV["MAPBOX_STYLE"] || "mapbox://styles/mapbox/streets-v11" - -######################## -## Offline Configuration -# Running in offline mode requires a hosted tileserver -# While an access token is not required, Mapbox does require a formatted "token" -# in order to load the JS. -if Rails.env.offline? || ENV["OFFLINE_MODE"]&.downcase == "yes" - Rails.application.config.offline_mode = true -end - -if ENV["USE_LOCAL_MAP_SERVER"].present? || ENV["OFFLINE_MAP_STYLE"].present? - Rails.application.config.default_map_style = ENV["OFFLINE_MAP_STYLE"] -end diff --git a/rails/config/locales/en/dashboard.en.yml b/rails/config/locales/en/dashboard.en.yml index 91198804f..d6971cf02 100644 --- a/rails/config/locales/en/dashboard.en.yml +++ b/rails/config/locales/en/dashboard.en.yml @@ -2,6 +2,21 @@ en: profile: Profile interviewer: Interviewer import: Import + protomaps_basemap_styles: + contrast: Contrast + light: Light + dark: Dark + white: White + grayscale: Grayscale + black: Black + protomaps_basemap_markers: + default: "#3FB1CE" + contrast: "#B5C481" + light: "#3FB1CE" + dark: "#555" + white: "#bbb" + grayscale: "#999" + black: "#666" map: center: Map Center bounds: Map Bounds @@ -10,6 +25,8 @@ en: ne_corner: Northeast Corner unrestricted_bounds: Allow unrestricted bounds online: Online Map Configuration + mapbox_configuration: Mapbox Map Configuration (Optional) + protomaps_configuration: Protomaps API Configuration (Optional) settings: Map Settings settings_description: Customize your community's map settings. projection: diff --git a/rails/config/locales/en/models.en.yml b/rails/config/locales/en/models.en.yml index fc4eead32..8c6cf4738 100644 --- a/rails/config/locales/en/models.en.yml +++ b/rails/config/locales/en/models.en.yml @@ -73,8 +73,10 @@ en: sponsor_logos: Sponsor Logos mapbox_style_url: Mapbox Style URL mapbox_access_token: Mapbox access token associated with the style - mapbox_3d: Activate 3D Terrain view for online map + mapbox_3d: Activate 3D Terrain view map_projection: Set projection for map + protomaps_api_key: API Key (free at protomaps.com) + protomaps_basemap_style: Basemap Style center_lat: Map center, latitude center_long: Map center, longitude sw_boundary_lat: SW bounding box, latitude (optional) diff --git a/rails/config/webpack/webpack.config.js b/rails/config/webpack/webpack.config.js index cab47726e..5e3fcd7df 100644 --- a/rails/config/webpack/webpack.config.js +++ b/rails/config/webpack/webpack.config.js @@ -1,9 +1,14 @@ -// // See the shakacode/shakapacker README and docs directory for advice on customizing your webpackConfig. +// See the shakacode/shakapacker README and docs directory for advice on customizing your generateWebpackConfig. const { generateWebpackConfig } = require('shakapacker') +// Be sure to reload your server if you make changes to this config. const customConfig = { resolve: { extensions: ['.css'] + }, + output: { + library: ['Terrastories', '[name]'], + libraryTarget: 'var', } } diff --git a/rails/db/migrate/20240112163116_add_protomaps_config_to_theme.rb b/rails/db/migrate/20240112163116_add_protomaps_config_to_theme.rb new file mode 100644 index 000000000..579635b6e --- /dev/null +++ b/rails/db/migrate/20240112163116_add_protomaps_config_to_theme.rb @@ -0,0 +1,6 @@ +class AddProtomapsConfigToTheme < ActiveRecord::Migration[6.1] + def change + add_column :themes, :protomaps_api_key, :text + add_column :themes, :protomaps_basemap_style, :text + end +end diff --git a/rails/db/schema.rb b/rails/db/schema.rb index c30ae604e..7fcd63c24 100644 --- a/rails/db/schema.rb +++ b/rails/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_08_08_135800) do +ActiveRecord::Schema.define(version: 2024_01_12_163116) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -174,6 +174,8 @@ t.boolean "mapbox_3d", default: false t.integer "map_projection", default: 0 t.bigint "community_id", null: false + t.text "protomaps_api_key" + t.text "protomaps_basemap_style" end create_table "users", force: :cascade do |t| diff --git a/rails/package.json b/rails/package.json index 27140fb26..cce924a22 100644 --- a/rails/package.json +++ b/rails/package.json @@ -40,7 +40,7 @@ "i18next-http-backend": "^2.1.1", "jest": "24.0.0", "mapbox-gl": "^2.12.0", - "maplibre-gl": "^3.3.1", + "maplibre-gl": "^3.6.2", "mini-css-extract-plugin": "^2.7.6", "postcss": "^8.4.31", "postcss-flexbugs-fixes": "^5.0.2", @@ -48,6 +48,7 @@ "postcss-loader": "^7.3.3", "postcss-preset-env": "^9.2.0", "prop-types": "^15.8.1", + "protomaps-themes-base": "2.0.0-alpha.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^12.1.5", diff --git a/rails/spec/models/theme_spec.rb b/rails/spec/models/theme_spec.rb index efc62a7b9..44e24554f 100644 --- a/rails/spec/models/theme_spec.rb +++ b/rails/spec/models/theme_spec.rb @@ -26,4 +26,106 @@ expect(theme.sw_boundary).to eq nil # was [-180, -85] expect(theme.ne_boundary).to eq nil # was [180, 85] end + + describe "#basemap_style" do + it "returns 'contrast' with default configuration" do + expect(theme.basemap_style).to eq("contrast") + end + + it "returns configured protomaps_basemap_style even without protomaps API key" do + theme.protomaps_basemap_style = "light" + expect(theme.basemap_style).to eq("light") + end + + context "when offline" do + it "returns 'default'" do + expect(Map).to receive(:offline?).and_return(true) + expect(theme.basemap_style).to eq("default") + end + end + + context "when default map is mapbox" do + before { allow(Map).to receive(:use_mapbox?).and_return(true) } + + it "returns 'default'" do + # this value should be ignored + theme.protomaps_basemap_style = "light" + expect(theme.basemap_style).to eq("default") + end + + it "returns 'contrast' if protomaps is configured" do + theme.protomaps_api_key = "123123123" + expect(theme.basemap_style).to eq("contrast") + end + end + + context "when community has mapbox configured" do + it "returns 'default'" do + theme.mapbox_access_token = "pk.eyTest" + theme.protomaps_basemap_style = "light" + expect(theme.basemap_style).to eq("default") + end + end + end + + describe "#mapbox_token" do + it "returns nil by default" do + expect(theme.mapbox_token).to be_nil + end + + it "returns server configured token" do + theme.mapbox_access_token = "custom" + expect(theme.mapbox_token).to eq("custom") + end + + context "when server is configured for mapbox" do + before { allow(Map).to receive(:mapbox_access_token).and_return("pk.eyTest") } + + it "returns default server configured token" do + expect(theme.mapbox_token).to eq("pk.eyTest") + end + + it "returns community configured token when available" do + theme.mapbox_access_token = "custom" + expect(theme.mapbox_token).to eq("custom") + end + end + end + + describe "#map_style_url" do + context "when offline" do + it "returns default configured style" do + expect(Map).to receive(:offline?) { true } + expect(Map).to receive(:default_style) { "default_style" } + + expect(theme.map_style_url).to eq("default_style") + end + end + + it "returns community configured mapbox style" do + theme.mapbox_access_token = "pk.eyTest" + theme.mapbox_style_url = "mapbox://style" + expect(theme.map_style_url).to eq("mapbox://style") + end + + it "returns protomaps tile json url" do + theme.mapbox_access_token = "pk.eyTest" + theme.mapbox_style_url = "mapbox://style" + theme.protomaps_api_key = "123123123" + expect(theme.map_style_url).to eq("https://api.protomaps.com/tiles/v3.json?key=123123123") + end + + it "returns default protomaps tile json url" do + expect(ENV).to receive(:[]).with("PROTOMAPS_API_KEY").and_return("CUSTOM") + expect(theme.map_style_url).to eq("https://api.protomaps.com/tiles/v3.json?key=CUSTOM") + end + + context "when community has mapbox configured" do + it "returns default configured style" do + expect(Map).to receive(:default_style) { "default_style" } + + expect(theme.map_style_url).to eq("default_style") + end + end + end end diff --git a/rails/spec/requests/dashboard/manage_community_spec.rb b/rails/spec/requests/dashboard/manage_community_spec.rb index 8375281f8..1a762d818 100644 --- a/rails/spec/requests/dashboard/manage_community_spec.rb +++ b/rails/spec/requests/dashboard/manage_community_spec.rb @@ -17,31 +17,32 @@ allow(Flipper).to receive(:enabled?).with(:public_communities, community) { true } end - it "generates a static map when public is enabled" do - allow(URI).to receive(:open) { File.open("./spec/fixtures/media/terrastories.png") } + context "when Mapbox is configured" do + before { allow(Map).to receive(:mapbox_access_token).and_return("pk.eyTest") } - patch "/en/member/community", params: {community: {public: true}} + it "generates a static map when public is enabled & Mapbox is configured" do + allow(URI).to receive(:open) { File.open("./spec/fixtures/media/terrastories.png") } - expect(response).to redirect_to(community_settings_path) - expect(community.theme.static_map.attached?).to be true - end + patch "/en/member/community", params: {community: {public: true}} - it "skips generation if no access token is present (either self-added or not set in server eng)" do - # set default to nil (set in initializers/tileserver.rb) - expect(Rails.application.config).to receive(:default_mapbox_token) + expect(response).to redirect_to(community_settings_path) + expect(community.theme.static_map.attached?).to be true + end - patch "/en/member/community", params: {community: {public: true}} + it "continues even if the generated static map cannot be attached to community" do + expect(URI).to receive(:open).and_raise(OpenURI::HTTPError.new("401 Unauthorized", {})) - expect(community.theme.static_map.attached?).to be false - end + patch "/en/member/community", params: {community: {public: true}} - it "continues even if the generated static map cannot be attached to community" do - expect(URI).to receive(:open).and_raise(OpenURI::HTTPError.new("401 Unauthorized", {})) + expect(community.theme.static_map.attached?).to be false + end + end + it "skips generation if no access token is present (either self-added or not set in server eng)" do patch "/en/member/community", params: {community: {public: true}} expect(community.theme.static_map.attached?).to be false end end end -end \ No newline at end of file +end diff --git a/rails/spec/services/map_spec.rb b/rails/spec/services/map_spec.rb new file mode 100644 index 000000000..0d156f7e3 --- /dev/null +++ b/rails/spec/services/map_spec.rb @@ -0,0 +1,127 @@ +require "rails_helper" + +RSpec.describe Map do + before do + Map.reload + end + + describe "#offline?" do + it "returns true if Rails.env.offline?" do + allow(Rails).to receive(:env).and_return("offline".inquiry).once + expect(Map.offline?).to be true + end + + it "returns true if Rails.env.offline?" do + allow(ENV).to receive(:[]).with("OFFLINE_MODE").and_return("yes") + expect(Map.offline?).to be true + end + + it "returns false" do + expect(Map.offline?).to be false + end + + it "memoizes value" do + expect(Rails.env).to receive(:offline?).and_return(false).once + expect(ENV).to receive(:[]).with("OFFLINE_MODE").and_return("t").once + + Map.offline? + # subsequent calls should not trigger additional checks + Map.offline? + Map.offline? + end + end + + describe "#use_mapbox?" do + it "returns false if running in offline mode even if mapbox is configured" do + allow(Map).to receive(:offline?).and_return(true) + + expect(ENV).not_to receive(:[]).with("MAPBOX_ACCESS_TOKEN") + expect(ENV).not_to receive(:[]).with("DEFAULT_MAPBOX_TOKEN") + + expect(Map.use_mapbox?).to be false + end + + context "returns true" do + it "when MAPBOX_ACCESS_TOKEN is set" do + allow(ENV).to receive(:[]).with("MAPBOX_ACCESS_TOKEN").and_return("pk.ey") + expect(Map.use_mapbox?).to be true + end + + it "when [deprecated] DEFAULT_MAPBOX_TOKEN is set" do + allow(ENV).to receive(:[]).with("DEFAULT_MAPBOX_TOKEN").and_return("pk.ey") + + expect(ActiveSupport::Deprecation).to receive(:warn).with( + "Setting DEFAULT_MAPBOX_TOKEN to configure Mapbox is deprecated. " \ + "Use MAPBOX_ACCESS_TOKEN instead." + ) + expect(Map.use_mapbox?).to be true + end + end + + it "returns false when no mapbox configuration is set" do + allow(ENV).to receive(:[]).with("MAPBOX_ACCESS_TOKEN") + allow(ENV).to receive(:[]).with("DEFAULT_MAPBOX_TOKEN") + expect(Map.use_mapbox?).to be false + end + end + + describe "#default_style" do + context "when mapbox is configured" do + before { allow(Map).to receive(:use_mapbox?).and_return(true) } + + it "returns [deprecated] DEFAULT_MAP_STYLE" do + allow(ENV).to receive(:[]).with("DEFAULT_MAP_STYLE").and_return("mapbox://styles/terrastories/custom-v1") + # shouldn't use fallback + expect(ENV).not_to receive(:[]).with("MAPBOX_STYLE") + + expect(ActiveSupport::Deprecation).to receive(:warn).with( + "Setting DEFAULT_MAP_STYLE to configure Mapbox is deprecated. " \ + "Use MAPBOX_STYLE instead." + ) + + expect(Map.default_style).to eq("mapbox://styles/terrastories/custom-v1") + end + + it "returns MAPBOX_STYLE" do + expect(ENV).to receive(:[]).with("DEFAULT_MAP_STYLE") + expect(ENV).to receive(:[]).with("MAPBOX_STYLE").and_return("mapbox://styles/terrastories/custom-v2") + + expect(Map.default_style).to eq("mapbox://styles/terrastories/custom-v2") + end + + it "fallsback to a default mapbox managed style" do + expect(ENV).to receive(:[]).with("DEFAULT_MAP_STYLE") + expect(ENV).to receive(:[]).with("MAPBOX_STYLE") + + expect(Map.default_style).to eq("mapbox://styles/mapbox/streets-v11") + end + end + + context "when tileserver is configured" do + it "returns TILESERVER_URL" do + allow(ENV).to receive(:[]).with("TILESERVER_URL").and_return("https://tileserver.terrastories.app/style.json") + + expect(Map.default_style).to eq("https://tileserver.terrastories.app/style.json") + end + end + + context "when protomaps API key is configured" do + it "returns protomaps tilesjson w/ configured API key" do + allow(ENV).to receive(:[]).with("PROTOMAPS_API_KEY").and_return("1234567890") + + expect(Map.default_style).to eq("https://api.protomaps.com/tiles/v3.json?key=1234567890") + end + end + end + + describe "[deprecated] offline map style set to tileserver" do + it "returns offline map style url as-is if set as tileserver url" do + allow(ENV).to receive(:[]).with("OFFLINE_MAP_STYLE").and_return("http://tileserver.local/custom-map-style/style.json") + expect(ActiveSupport::Deprecation).to receive(:warn).with( + "Setting OFFLINE_MAP_STYLE to configure Tileserver is deprecated. " \ + "Use TILESERVER_URL instead." + ) + expect(Map.default_style).to eq("http://tileserver.local/custom-map-style/style.json") + end + end +end diff --git a/rails/spec/support/env_helpers.rb b/rails/spec/support/env_helpers.rb new file mode 100644 index 000000000..53f66f528 --- /dev/null +++ b/rails/spec/support/env_helpers.rb @@ -0,0 +1,6 @@ +RSpec.configure do |config| + config.before(:each) do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:fetch).and_call_original + end +end \ No newline at end of file diff --git a/rails/yarn.lock b/rails/yarn.lock index 586a4d8db..4a7588403 100644 --- a/rails/yarn.lock +++ b/rails/yarn.lock @@ -2065,11 +2065,16 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/geojson@*", "@types/geojson@^7946.0.12": +"@types/geojson@*": version "7946.0.12" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.12.tgz#0307536218d32e6b970bccd1d148b9c4e5b6f10d" integrity sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA== +"@types/geojson@^7946.0.13": + version "7946.0.13" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.13.tgz#e6e77ea9ecf36564980a861e24e62a095988775e" + integrity sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ== + "@types/http-errors@*": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.3.tgz#c54e61f79b3947d040f150abd58f71efb422ff62" @@ -2124,15 +2129,20 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.3": +"@types/mapbox__point-geometry@*": version "0.1.3" resolved "https://registry.yarnpkg.com/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.3.tgz#3050f58fcdcc9da96998655268d477ea266dbe76" integrity sha512-2W46IOXlu7vC8m3+M5rDqSnuY22GFxxx3xhkoyqyPWrD+eP2iAwNst0A1+umLYjCTJMJTSpiofphn9h9k+Kw+w== -"@types/mapbox__vector-tile@^1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.3.tgz#f8bf33d91f9951c6090dbd454d4e33626f5107cc" - integrity sha512-d263B3KCQtXKVZMHpMJrEW5EeLBsQ8jvAS9nhpUKC5hHIlQaACG9PWkW8qxEeNuceo9120AwPjeS91uNa4ltqA== +"@types/mapbox__point-geometry@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz#0ef017b75eedce02ff6243b4189210e2e6d5e56d" + integrity sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA== + +"@types/mapbox__vector-tile@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz#ad757441ef1d34628d9e098afd9c91423c1f8734" + integrity sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg== dependencies: "@types/geojson" "*" "@types/mapbox__point-geometry" "*" @@ -2170,11 +2180,16 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.1.tgz#27f7559836ad796cea31acb63163b203756a5b4e" integrity sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng== -"@types/pbf@*", "@types/pbf@^3.0.4": +"@types/pbf@*": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.4.tgz#32f8cd8bb2fb53e5870a5d751210d1dcdce23f85" integrity sha512-SOFlLGZkLbEXJRwcWCqeP/Koyaf/uAqLXHUsdo/nMfjLsNd8kqauwHe9GBOljSmpcHp/LC6kOjo3SidGjNirVA== +"@types/pbf@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404" + integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA== + "@types/prop-types@*": version "15.7.9" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d" @@ -2252,10 +2267,10 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== -"@types/supercluster@^7.1.2": - version "7.1.2" - resolved "https://registry.yarnpkg.com/@types/supercluster/-/supercluster-7.1.2.tgz#2391a1b22ef108e37d51d4c8bdea7dc04ebf4bcf" - integrity sha512-qMhofL945Z4njQUuntadexAgPtpiBC014WvVqU70Prj42LC77Xgmz04us7hSMmwjs7KbgAwGBmje+FSOvDbP0Q== +"@types/supercluster@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@types/supercluster/-/supercluster-7.1.3.tgz#1a1bc2401b09174d9c9e44124931ec7874a72b27" + integrity sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA== dependencies: "@types/geojson" "*" @@ -6709,10 +6724,10 @@ mapbox-gl@^2.12.0: tinyqueue "^2.0.3" vt-pbf "^3.1.3" -maplibre-gl@^3.3.1: - version "3.5.2" - resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-3.5.2.tgz#84a352f1845c6ccf6fe5d86aaa0d0e8b0f18923c" - integrity sha512-deqYA/RiEyXMGroZMDbOWNQTLnFsxREC+mDkQnuyCUNdBWm1KHafsXJYZP7rlLa5RLQNq05IAUAizY9aHTpIUw== +maplibre-gl@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-3.6.2.tgz#abc2f34bddecabef8c20028eff06d62e36d75ccc" + integrity sha512-krg2KFIdOpLPngONDhP6ixCoWl5kbdMINP0moMSJFVX7wX1Clm2M9hlNKXS8vBGlVWwR5R3ZfI6IPrYz7c+aCQ== dependencies: "@mapbox/geojson-rewind" "^0.5.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" @@ -6722,11 +6737,11 @@ maplibre-gl@^3.3.1: "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" "@maplibre/maplibre-gl-style-spec" "^19.3.3" - "@types/geojson" "^7946.0.12" - "@types/mapbox__point-geometry" "^0.1.3" - "@types/mapbox__vector-tile" "^1.3.3" - "@types/pbf" "^3.0.4" - "@types/supercluster" "^7.1.2" + "@types/geojson" "^7946.0.13" + "@types/mapbox__point-geometry" "^0.1.4" + "@types/mapbox__vector-tile" "^1.3.4" + "@types/pbf" "^3.0.5" + "@types/supercluster" "^7.1.3" earcut "^2.2.4" geojson-vt "^3.2.1" gl-matrix "^3.4.3" @@ -8079,6 +8094,11 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw== +protomaps-themes-base@2.0.0-alpha.4: + version "2.0.0-alpha.4" + resolved "https://registry.yarnpkg.com/protomaps-themes-base/-/protomaps-themes-base-2.0.0-alpha.4.tgz#277096362007bb4403b371297f066873bf66807a" + integrity sha512-XHwrN0yzXYsNCgdHTy6Kr2pFFTuOsoMHbpvoNzrAhBx11A/JV2rt9lKa3Dh7oTYVuarkHaFHQQ9bpqd7cLE8zg== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" diff --git a/tileserver/data/config.json b/tileserver/data/config.json index 822a2d494..d6445a275 100644 --- a/tileserver/data/config.json +++ b/tileserver/data/config.json @@ -3,7 +3,8 @@ "paths": { "fonts": "fonts", "sprites": "sprites" - } + }, + "serveAllFonts": true }, "data": { "terrastories-map": {