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? %> -