@wq/map is a @wq/app plugin that adds mapping capabilities. @wq/map can leverage the wq configuration object to generate Leaflet maps for pages rendered via @wq/app. The generated maps can automatically download and display GeoJSON data rendered by wq.db's REST API.
As a plugin for @wq/app, @wq/map utilizes similar concepts and conventions, with corresponding constraints on how it can be used. In particular, each map generated by @wq/map always corresponds to a page defined in the wq configuration object with a "map"
property defined. For model-backed pages ("list": true
), @wq/map' maps can distinguish between "list" views, "detail" views, and "edit" views.
@wq/map leverages three configuration objects:
A default map configuration can be assigned by setting map: true
in the page configuration. Each map will automatically get a single overlay configured with the GeoJSON equivalent of the page:
/simple
) will be /simple.geojson
/list/
) will be /list.geojson
/list/itemid
) will be /list/itemid.geojson
/list/itemid/edit
) will be /list/itemid/edit.geojson
For example, the content you are reading is a
Doc
instance with an identifier ofmap-js
, rendered in a "detail" view for thedocs
list page.docs
is registered in the wq configuration for this website as"map": true
. Since @wq/map is loaded for this website, the map at the top of this document should have automatically loaded /docs/map-js.geojson when the page opened.
The default GeoJSON layers should work as long as your webserver is running wq.db or a service with a compatible URL Structure.
python3 -m venv venv # create virtual env (if needed)
. venv/bin/activate # activate virtual env
python3 -m pip install wq # install wq framework (wq.app, wq.db, etc.)
# pip install wq.app # install wq.app only
npm install @wq/map
The map plugin requires both a global configuration and a per-page configuration for pages that need maps.
// myapp/main.js
define(['wq/app', 'wq/map', './config'],
function(app, map, config) {
// In myapp/config.js or in wq.db.rest registration:
// config.map = { ... }
// config.pages[page].map = { ... }
app.use(map);
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
});
// src/index.js
import app from '@wq/app';
import map from '@wq/map';
import config from './config';
// In src/config.js or in wq.db.rest registration:
// config.map = { ... }
// config.pages[page].map = { ... }
app.use(map);
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
The map module provides the following configuration options.
map.init()
is called automatically by app.init()
with the contents of config.map
. The configuration object default settings for rendered maps including initial bounds.
name | default | purpose |
---|---|---|
bounds |
[[-4,-4],[4,4]] |
Default extent for initially rendered map. This is specified as a bounds rather than a center and zoom, to ensure the full intended extent is visible regardless of screen size. |
autoZoom |
Object | By default, rendered maps will automatically zoom (and pan) to the extent of their embedded GeoJSON feature layers using the following options. To disable auto-zooming entirely, set autoZoom to false . |
autoZoom.animate |
true |
Whether to animate the auto-zooming. Incorporating animation is valuable as it gives the user a chance to visually orient the rendered features in relation to the original zoom level. |
autoZoom.wait |
0.5 |
How long to wait before triggering auto-zooming, in seconds. Waiting gives the map a chance to settle and makes the animation more salient. |
autoZoom.sticky |
true |
Whether to save the last zoom and center (from auto-zooming and/or regular panning) for use in the next map (true ) or to always start out new maps from the default zoom and center (false ). Particularly useful in maintaining visual consistency for the user when they are quickly navigating between a series of list or detail pages in succession. |
autoZoom.maxZoom |
13 |
The maximum zoom level to use when auto-zooming. (Useful to avoid zooming in too far when the only feature is a single point) |
icon |
Object | Default icon settings for use with map.createIcon() . The "default" default icon settings correspond to the default icon created by Leaflet (L.Icon.Default ). |
basemaps |
Array | Basemap configuration to use on every generated map. The name attribute will be used as the basemap name in the layers control, while the type specifies the name of a layer creation function to use when creating the basemap. All other options will be passed to the layer creation function. One basemap type is preregistered: tile (which corresponds to L.tileLayer() ). |
The default basemap configuration uses the free Stamen Terrain layer:
config.map.basemaps = [
{
'name': "Stamen Terrain",
'type': 'tile',
'url': '//stamen-tiles-{s}.a.ssl.fastly.net/{layer}/{z}/{x}/{y}.jpg',
'layer': 'terrain',
'attribution': 'Map tiles by Stamen Design ...'
}
];
If you would like to change the basemap (for example, to incorporate aerial imagery), you will need to customize this configuration to incorporate map tiles from another source. There are a number of services available (some free for small projects), but nearly all now require an API key. For example, after obtaining an API key from MapBox, you might do something like this:
var attrib = 'Map data ...',
token = '(insert API key)',
cdn = 'https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}';
config.map = {
'basemaps': [{
'name': 'MapBox Streets',
'type': 'tile',
'url': cdn,
'id': 'mapbox.streets',
'accessToken': token,
'attribution': attrib
}, {
'name': 'MapBox Satellite',
'type': 'tile',
'url': cdn,
'id': 'mapbox.satellite',
'accessToken': token,
'attribution': attrib
}]
};
If you want to keep your API token out of your version control, you can put it in a separate unversioned JavaScript module and/or include it in your local_settings.py
and register it with your router (as in the code for this website).
If you have an ArcGIS license, you can integrate ESRI basemaps by loading @wq/map:mapserv
rather than @wq/map
. @wq/map:mapserv
is a variant of @wq/map
with additional basemap types corresponding to Esri Leaflet layer creation functions. For example, to register the ESRI Topograpic, Streets, and Imagery layers, you could do something like the following:
config.map = {
"basemaps": [
{
"name": "ESRI Topographic",
"type": "esri-basemap",
"layer": "Topographic",
},
{
"name": "ESRI Streets",
"type": "esri-basemap",
"layer": "Streets",
},
{
"name": "ESRI Imagery",
"type": "esri-basemap",
"layer": "Imagery",
"labels": true,
}
]
}
Additional basemap types (e.g. from other Leaflet plugins) can be incorporated with map.addBasemapType()
(see below).
// myapp/config.js
define(['data/config'], function(config) {
// (set template defaults, transitions, store)
// ...
// set map config defaults
config.map = {
'bounds': [
[44.78, -93.1],
[45.18, -93.5]
],
'basemaps': [
// custom basemap definition
]
});
return config;
});
// myapp/main.js
define(['wq/app', 'wq/map', './config'],
// to enable ESRI layers:
// define(['wq/app', 'wq/mapserv', './config'],
function(app, map, config) {
app.use(map);
app.init(config);
});
// src/config.js
import config from './data/config';
// (set template defaults, transitions, store)
// ...
// set map config defaults
config.map = {
'bounds': [
[44.78, -93.1],
[45.18, -93.5]
],
'basemaps': [
// custom basemap definition
]
});
export default config;
// src/index.js
import app from '@wq/app';
import map from '@wq/map';
// to enable ESRI layers:
// import {mapserv as map} from '@wq/map';
import config from './config';
app.use(map);
app.init(config);
After initialization, map.config.maps
will be populated with map configurations for each page in the wq configuration object with a "map"
property defined. If a page's map
property is defined an object, that object will be used as the map configuration. If it is an array of objects, multiple map configurations will be defined (see below). If the property is simply true
, a default set of map configurations will be created with URL-based GeoJSON overlays (as described above).
@wq/map supports defining separate map configurations for the "list", "detail", and "edit" modes of list pages. As of version 1.0, @wq/map also supports custom page modes, as well as placing multiple maps in different locations on the same screen.
To make it easier to manage all of these possible variations, the map
configuration is now defined as an array rather than as a deeply nested object. Each object in the array can have the following attributes:
name | default | purpose |
---|---|---|
mode |
defaults |
Template rendering mode for which this configuration applies to. Typically one of list , detail , or edit . If set to all or defaults , the defined configuration will be mixed together with any other applicable configuration when rendering mode-specific maps. You can also use all or defaults to define maps for simple (non-list) pages, which do not use rendering modes. |
map |
main |
Whether this configuration applies to the default (main) map or to a secondary map on the same screen. |
In addition to the mode and map attributes, each map configuration can have the following options:
name | default | purpose |
---|---|---|
layers |
See below | An array of layer configurations to use for this rendering mode. |
autoLayers |
true |
If true , the maps created for the page will automatically include a default "geojson" layer as discussed above, as well as any explicitly defined layers. |
autoZoom |
global setting | Set to false to disable auto-zooming on a per-map basis. |
minBounds |
none | Minimum bounds to set when auto-zooming. Should be a Leaflet LatLngBounds or compatible array. |
noLayerControl |
false |
Set to true to disable the default layer control. |
onshow |
none | Function to call after map is created with map.createMap() . The function will be passed the newly created L.Map object. |
div |
[page]-map |
The id of the <div> tag to place the Leaflet map into. The div should be present in the template in order for the automatic map creation to work. By default the expected div id will be [page]-map for list views, [page]-[itemid]-map for detail views, and [page]-[itemid]-edit-map for edit views. Like all Leaflet maps, the height of the div should be explicitly specified in an attribute or in CSS. |
/* main.css */
.my-map-class {
border: 1px solid black;
height: 300px;
}
<!-- model_list.html -->
<div id="model-map" class="my-map-class"></div>
<!-- model_detail.html -->
<div id="model-{{id}}-map" class="my-map-class"></div>
Each map configuration should have one or more layer configurations added to it. The layer configurations are defined as layers
, an array property on the map configuration. Layers can also be added programmatically via map.addLayerConf()
, which takes the name of an existing map and a layer configuration to add to it. However, it is recommended to define all layers as part of the initial layer configuration. With wq.db.rest, this can be done by specifying a map property when registering the model:
# myapp/rest.py
from .models import MyModel
app.router.register_model(
MyModel,
fields="__all__",
map=[{
'mode': 'all',
...
}, {
'mode': 'list',
'layers': [...],
}, {
'mode': 'detail',
'layers': [...]
}]
)
A layer configuration consists of the following options:
name | purpose |
---|---|
name |
The name of the layer to show in the layer list |
type |
The type of overlay to use. The default is "geojson" , which is the only built-in type. Other overlay types can be registered via map.addOverlayType() (see below). |
The built-in "geojson"
layer type recognizes the following options:
name | purpose |
---|---|
url |
The path to a geojson file to download (including the .geojson extension). This can be defined via a template syntax (as seen in the example below). |
icon |
The name of an icon to use, or a template that will compile to an icon name, or a function returning an icon name. If a template or a function, it will be called with the feature.properties for each feature in the dataset. Icon names should first be registered via map.createIcon() (see below). |
popup |
The name of a popup template to use when rendering marker popups. See map.renderPopup() below |
cluster |
Boolean indicating whether or not to cluster markers. The default for auto-generated layers is true as long as the Leaflet.markercluster plugin is present. A copy of the plugin is included with wq.app but is not imported by default. |
draw |
Options to use for layer editing via Leaflet.draw . See Map Editing below. |
geometryField |
The name of a hidden field to save edited geometry to. See Map Editing below. |
style |
A function to define styles based on the properties of each feature in the GeoJSON. The function should take a feature and return a style object. Available style options are listed in the documentation for L.Path. Equivalent to L.GeoJSON 's style option. |
oneach |
A function to call for each feature in the GeoJSON. The function should take a Leaflet layer object and a GeoJSON feature. map.renderPopup() can automatically create a compatible function that will attach a templated popup to each layer using the properties in the GeoJSON feature. Equivalent to L.GeoJSON 's onEachFeature option. |
clusterIcon |
CSS class to use when creating the cluster icon <div> . Can be a plain string, a template definition, or a function. If a template or a function, a context of the form {'count': count, [size]: true} will be provided, where [size] is one of large (> 100), medium (> 10), or small . |
config.pages[pagename].map = [
{
"mode": "list",
// "autoLayers": true,
"layers": [{
'name': pagename,
'type': 'geojson',
'url': '{{{url}}}.geojson',
'popup': pagename,
'cluster': true
}]
}, {
"mode": "detail":,
// "autoLayers": true,
"layers": [{
'name': pagename,
'type': 'geojson',
'url': pageurl + '/{{{id}}}.geojson',
'popup': page
}]
}, {
"mode": "edit",
// "autoLayers": true,
"layers": [{
'name': pagename,
'type': 'geojson',
'url': pageurl + '/{{{id}}}/edit.geojson',
'draw': {
'polygon': {},
'polyline': {},
'marker': {},
'rectangle': {},
'circle': false
}
}]
}
];
@wq/map supports editing layers via the Leaflet.draw plugin. This functionality can be enabled by setting a draw
attribute on the map's edit
mode configuration. The draw
configuration will be passed on to the Leaflet draw control to enable different drawing types.
The draw functionality is meant to work in close integration with wq.db.rest - particularly to edit models with geometry
fields. The basic workflow is like this:
FeatureCollection
to a hidden geometry
field in the form (this can be customized with the geometryField
layer configuration option).Note that there is an asymmetry between how the geographic data is initially loaded (edit.geojson) and how it is saved (form field). This is primarily to avoid needing to store the entire geographic dataset in offline storage. However, there are workarounds available if offline geographic data storage is needed.
It is often necessary to define additional layer or icon types for use with the map configuration above. These options need to be configured via JavaScript.
map.addBasemapType(name, function)
map.addBasemapType
can be used to add custom basemap types in addition to the built in "tile"
type. The provided function
should accept a basemap configuration and return a Leaflet layer instance. For example, the built-in "tile"
layer type is registered as follows:
map.addBasemapType('tile', function(layerConf) {
return L.tileLayer(layerConf.url, layerConf);
});
The @wq/map:mapserv module provides additional examples of custom basemap types.
map.addOverlayType(name, function)
map.addOverlayType
can be used to add custom overlay types in addition to the built in "geojson"
type. The provided function
should accept an overlay configuration and return a Leaflet layer instance. For example, to create a WMS overlay with leaflet.wms you could do the following:
map.addOverlayType('wms', function(layerConf) {
var wmsSource = wms.source("/url/to/wms", {
'format': 'image/png',
});
return wmsSource.getLayer(layerconf.layer);
});
The @wq/map:mapserv module provides additional examples of custom overlay types.
map.createIcon(name, options)
map.createIcon
defines and names an L.Icon for later use in layer configurations. The function accepts a string name and an object containing options for the icon. Options are the same as those for L.Icon, but with a number of built-in defaults. These defaults are optimized to make it trivial to define icons that have the same dimensions and shadow as Leaflet's default icon:
name | default |
---|---|
iconSize |
[25, 41] |
iconAnchor |
[12, 41] |
popupAnchor |
[1, -34] |
shadowSize |
[41, 41] |
shadowUrl |
L.Icon.Default.imagePath + '/marker-shadow.png' |
map.createIcon("green", {'iconUrl': "/images/green.png"});
map.createBasemaps()
map.createBasemaps()
gemerates the actual basemap instances for use in every map generated by @wq/map. The function returns an object where the keys are layer names and the values are layer objects (usually L.TileLayer
). The basemaps will show up in the layer control generated for each map. Generally, you shouldn't need to call or override this function directly - instead, customize the global basemaps
configuration and/or register custom basemap types with addBasemapType()
.
map.createLayerControl(basemaps, layers)
map.createLayerControl()
is a simple hook to allow customization of the default layer control added to every map generated by @wq/map. The function takes two arguments; the basemaps from map.createBasemaps()
, and the GeoJSON layers created by map.createMap()
(using information from map.getLayerConfs()
). The default implementation simply passes the arguments on to, and returns, a L.Control.Layers instance.
map.renderPopup(page)
map.renderPopup(page)
generates a callback function suitable for use as the oneach
argument to a layer configuration, or as the onEachFeature option in L.GeoJSON
. (Note that a configuration of oneach: map.renderPopup(page)
can be replaced with popup: page
.) The function will render a popup using the template with the name [page]_popup
. The template should be included among the templates provided to @wq/app' init()
function. The template context will be the GeoJSON properties on each feature. The example map at the top of the page uses the following template:
<!-- doc_popup.html -->
<h3>{{label}}</h3>
<table>
<tr><th>Chapter:</th><td>{{chapter_label}}</td></tr>
<tr><th>Last Updated:</th><td>{{updated_label}}</td></tr>
<tr>
<th>Interactive:</th>
<td>
{{#interactive}}✓{{/interactive}}
{{^interactive}}✗{{/interactive}}
</td>
</tr>
</table>
map.loadLayer(url, callback)
map.loadLayer()
is used retrieve the actual GeoJSON data for "geojson" overlay types. The default implementation caches each GeoJSON object, so you can call map.loadLayer()
to prefetch layers that you expect to appear in later maps. The url
argument is assumed to be relative to the webservice root used by @wq/store.
@wq/map:locate is a @wq/app plugin providing utilities for requesting the user's latitude and longitude, a common use case in many VGI, citizen science, and crowdsourcing applications. @wq/map/locate is designed to be used together with @wq/map.
Once registered, the locate plugin populates form <input>
s from a Leaflet map to facilitate multiple ways of providing location information (e.g. GPS or a map click).
// myapp/main.js
define(['wq/app', 'wq/map', 'wq/locate', './config'],
function(app, map, locate, config) {
// In myapp/config.js or in wq.db.rest registration:
// config.locate = { ... };
// config.pages[page].map = { ... };
// config.pages[page].locate = true;
app.use(map);
app.use(locate); // Should be registered after map
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
});
// src/index.js
import app from '@wq/app';
import map, { locate } from '@wq/map';
import config from './config';
// In src/config.js or in wq.db.rest registration:
// config.locate = { ... };
// config.pages[page].map = { ... };
// config.pages[page].locate = true;
app.use(map);
app.use(locate); // Should be registered after map
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
The Locator widget provides three modes for entering location information:
gps
: Request position using device GPS or network location, if available. Also returns GPS "accuracy" in meters. To get more accurate measurements, the gps
mode of the Locator widget continually polls location until the form is saved or a different mode is selected.interactive
: Allow user to click the map directly. Accuracy is computed based on zoom level.manual
: Allow user to manually enter their latitude and longitude.The entered coordinates are automatically displayed on a Leaflet map. "Accuracy" is represented as a circle with the radius of the accuracy.
Tip: Save Accuracy In Your Database!
The accuracy number is a critical part of the location information - so don't discard it and only save the latitude and longitude. Remember that accuracy is fundamentally different than precision: just because a GPS device returns latitude and longitude specified out to 9 decimal places, does not mean the user is actually at that exact location. In fact, the initial measurement returned by many consumer GPS devices will often be "precise" but inaccurate, perhaps by several kilometers. The accuracy measurement is thus an imperfect, but useful metric for evaluating the location information. Keeping the GPS on for as long as possible is one way to get more accurate measurements, but it is still important to save the information in the database for future reference.
Similarly, having a user tap the map to specify their location is practically guaranteed to provide inaccurate results unless they zoom in first. For this reason, the
interactive
mode approximates an accuracy measurement based on zoom level (accuracy = 2 pixels of screen space converted to meters based on the zoom level). Accuracy can serve as a reminder to the user to zoom in, or even to enforce a minimum level of accuracy in your form processing logic.
The Locator widget searches for the following fields in the form. All fields are technically optional, though you will probably want at least latitude
and longitude
and/or geometry
be present.
field name | purpose |
---|---|
latitude |
A text input that will receive (or provide) the latitude |
longitude |
A text input that will receive (or provide) the longitude |
geometry |
A hidden input that will receive booth coordinates as a simple GeoJSON point |
accuracy |
A text input that will receive the computed accuracy |
toggle |
A set of radio buttons (or a select menu) that will change the widget mode. The values for each option should be one or more of the modes listed above. |
mode |
A hidden input that will receive the selected mode (in the case where toggle field is not used or is not saved) |
source |
A text input that will recieve information about the source of the GPS coordinate, if known. (For use with "gps" mode in PhoneGap/Cordova applications with cordova-plugin-bluetooth-geolocation installed). |
If any of these fields are named differently in your application, define config.locate.fieldNames
as follows:
config.locate = {
"fieldNames": {
"toggle": "toggle-btn",
"latitude": "lat",
"longitude": "lng",
"accuracy": "accuracy"
}
};
config.locate
can also be used to register up to three callback functions that will be executed at various points in the process:
onSetMode(mode)
: Called whenever the locator mode changes, e.g. in response to the user clicking the toggle
button.onUpdate(location, accuracy)
: Called when ever a new location is determined.onError(event)
: Called when GPS lookup fails.// myapp/main.js
define(['wq/app', 'wq/map', 'wq/locate', './config'],
function(app, map, locate, config) {
app.use(map);
app.use(locate);
config.locate = {
// Custom handler for location updates
'onUpdate': function(loc, accuracy) {
if (accuracy > 1000) {
$('#message').html(
"Note: your location accuracy appears to be off by more than 1km."
);
} else {
$('#message').html("");
}
}
}
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
});
// src/index.js
import app from '@wq/app';
import map, { locate } from '@wq/map';
import config from './config';
app.use(map);
app.use(locate);
config.locate = {
// Custom handler for location updates
'onUpdate': function(loc, accuracy) {
if (accuracy > 1000) {
$('#message').html(
"Note: your location accuracy appears to be off by more than 1km."
);
} else {
$('#message').html("");
}
}
}
app.init(config).then(function() {
app.jqmInit();
app.prefetchAll();
});
<fieldset data-role="controlgroup" data-type="horizontal">
<input type='radio' value='gps' id='loc-gps' name='toggle'>
<label for='loc-gps'>GPS</label>
<input type='radio' value='interactive' id='loc-interactive' name='toggle'>
<label for='loc-interactive'>Interactive</label>
<input type='radio' value='manual' id='loc-manual' name='toggle'>
<label for='loc-manual'>Manual</label>
</fieldset>
<div id='map-div-id'></div>
<div class='ui-grid-b'>
<div class='ui-block-a ui-content'>
Latitude
<input name="latitude" type="number" step="0.0001">
</div>
<div class='ui-block-b ui-content'>
Longitude
<input name="longitude" type="number" step="0.0001">
</div>
<div class='ui-block-c'>
Accuracy (m)
<input name="accuracy" type="number" step="0.0001">
</div>
</div>
Tip: Keep GPS running for better results!
When requesting the user's location in a web app, it's generally better to use
Geolocation.watchPosition()
thanGeolocation.getCurrentPosition()
, even when you only need a single point and not a GPS trace. The reason for this is that the first result returned by the GPS may be inaccurate, and the longer the GPS is on, the more time it has to lock on to the satellites. For this reason, @wq/map/locate continues requesting the GPS location until the user saves the form and/or navigates to another page. This is accomplished by settingwatch: true
in the underlying call to L.Map.locate().
Last modified on 2019-07-10 10:42 AM
Edit this Page |
Suggest Improvement
© 2013-2019 by S. Andrew Sheppard