Home

@wq/map

wq version: 1.1 1.2/1.3
Docs > wq.app: Modules

@wq/map

@wq/map

@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.

Overview

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:

For example, the content you are reading is a Doc instance with an identifier of map-js, rendered in a "detail" view for the docs 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.

Installation

wq.app for PyPI

@wq/app for npm

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

API

Initialization

The map plugin requires both a global configuration and a per-page configuration for pages that need maps.

wq.app for PyPI

@wq/app for npm

// 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.

Global Configuration

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()).

Customizing the Basemap

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).

Full Example

wq.app for PyPI

@wq/app for npm

// 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);

Individual Map Configuration

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).

Multiple Configurations

@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.

Configuration Options

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.

Example

/* 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>

Layer Configuration

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.

Example

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
            }
        }]
    }
];

Map Editing

@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:

  1. User navigates to /mymodel/1234/edit
  2. @wq/map loads /mymodel/1234/edit.geojson and displays it on a Leaflet.draw-enabled map
  3. User makes edits, which are serialized as a FeatureCollection to a hidden geometry field in the form (this can be customized with the geometryField layer configuration option).
  4. User posts form to server, which parses and stores the geometry field.

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.

Advanced Usage

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'

Example

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}}&check;{{/interactive}}
      {{^interactive}}&cross;{{/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

@wq/map:locate

@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.

API

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).

wq.app for PyPI

@wq/app for npm

// 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();
});

Usage

The Locator widget provides three modes for entering location information:

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:

Example

Latitude
Longitude
Accuracy (m)

JS

wq.app for PyPI

@wq/app for npm

// 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();
});

HTML

<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() than Geolocation.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 setting watch: true in the underlying call to L.Map.locate().

@wq/app
@wq/model