Commit 796a6446 authored by S Anand's avatar S Anand
Browse files

ENH: implement MapViewer. Fixes #41 @tejesh.p

parent 4be6b8a3
Pipeline #49111 failed with stage
in 2 minutes and 53 seconds
......@@ -23,7 +23,7 @@ indent_size = 4
indent_style = tab
indent_size = 4
# Markdown files require trailing whitespace
# Markdown files allow trailing whitespace, but we don't use this
# http://robandlauren.com/2013/11/21/configuring-sublime-text-markdown/
[*.md]
trim_trailing_whitespace = false
# [*.md]
# trim_trailing_whitespace = false
src/package.js
# mapviewer.js uses async.
# We use an older version of eslint for build errors that does not support async
# So remove this after the CI docker instance is updated (1 Apr 2018)
src/mapviewer.js
......@@ -10,6 +10,8 @@ TODO
# Ignore node related items
node_modules/
npm-debug.log
# We commit yarn.lock, not package-lock.json
package-lock.json
# Ignore editor files
.vscode/
......@@ -5,7 +5,9 @@ cache:
validate:
script:
- eclint check '**/*.html' '**/*.js' '**/*.css' '**/*.yaml' '**/*.md'
- yarn install
- npm run lint
- npm run test
deploy:
......@@ -14,4 +16,4 @@ deploy:
SERVER: uat.gramener.com
URL: g1
VERSION: v1
SETUP: npm run build
SETUP: yarn install && npm run build
......@@ -357,7 +357,11 @@ Rules:
- Else -> `string`
## datafilter
g1.datafilter(data, {
'sales>': ['100'],
'city': ['London', 'NJ'],
'product': ['Fan']
})
`g1.datafilter(data, filters)` returns the filtered data based on the filters. While urlfilter on [$.formhandler](#formhandler) applies filtering on data server side, `datafilter` applies urlfilter on frontend loaded data.
......@@ -366,7 +370,7 @@ Rules:
For example:
```js
var data = [
var data1 = [
{"ID": "1", "product": "Fan", "sales": "100", "city": "NY"},
{"ID": "2", "product": "Fan", "sales": "80", "city": "London"},
{"ID": "3", "product": "Fan", "sales": "120", "city": "NJ"},
......@@ -381,8 +385,23 @@ g1.datafilter(data, {
'product': ['Fan']
})
// Returns [{"ID": "3", "product": "Fan", "sales": "120", "city": "NJ"}, {"ID": "4", "product": "Fan", "sales": "130", "city": "London"}]
```
g1.datafilter(data, {
'datsetname2:city': ['London', 'NJ'],
'sales>~': [100],
'datsetname1:product': ['Fan']
}, 'datsetname1'))
// ignores datsetname2:city: ['London', 'NJ']
// Returns [{"ID": "3", "product": "Fan", "sales": "120", "city": "NJ"}, {"ID": "4", "product": "Fan", "sales": "130", "city": "London"}, {"ID": "1", "product": "Fan", "sales": "100", "city": "NY"}]
var data2 = [
{"ID": "1", "city": "NY"},
{"ID": "2", "city": "London"},
{"ID": "3", "city": "NJ"},
{"ID": "4", "city": "London"},
{"ID": "5", "city": "NY"},
{"ID": "5", "city": "London"}
]
g1.datafilter with multiple datasets:
......@@ -439,9 +458,17 @@ g1.datafilter(data2, {
// ]
```
## datafilter options
g1.datafilter with multiple datasets:
datafilter() contains three parameters:
```js
var data1 = [
{"ID": "1", "product": "Fan", "sales": "100", "city": "NY"},
{"ID": "2", "product": "Fan", "sales": "80", "city": "London"},
{"ID": "3", "product": "Fan", "sales": "120", "city": "NJ"},
{"ID": "4", "product": "Fan", "sales": "130", "city": "London"},
{"ID": "5", "product": "Light", "sales": "500", "city": "NY"},
{"ID": "5", "product": "Light", "sales": "100", "city": "London"}
]
  • This should be closed by placing triple backticks ``` for the fenced code block. FormHandler section underneath got displaced.

Please register or sign in to reply
- `data`: a list of objects
- `filters`: [formhandler filters][formhandler-filters] extracted using
......@@ -641,7 +668,7 @@ using as `.template({var1: value, var2: value, ...})`. For example:
To re-render the template, run `.template(data)` again with different data.
## $.template options
### $.template options
To re-use the template, i.e. render the same template on a different DOM node,
run `.template(data, {target: selector})`. This allows you to declare templates
......@@ -654,7 +681,7 @@ $('script.chart')
.template({}, {target: '.no-heading'})
```
## $.template attributes
### $.template attributes
Template containers can have an `src=` attribute that loads the template from a file:
......@@ -979,6 +1006,187 @@ selection.call(
)
```
## g1.mapviewer
Mapviewer is an abstraction over [Leaflet](http://leafletjs.com/) that can
create common GIS applications using configurations.
Mapviewer requires `npm install leaflet d3 d3-scale-chromatic g1`.
```html
<link rel="stylesheet" href="node_modules/leaflet/dist/leaflet.css">
<script src="node_modules/leaflet/dist/leaflet.js"></script>
<script src="node_modules/d3/build/d3.js"></script>
<script src="node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.min.js"></script>
<script src="node_modules/g1/dist/mapviewer.min.js"></script>
```
This creates a simple base map:
```html
<div id="base-map" style="height:300px"></div>
<script>
var map = g1.mapviewer({
id: 'base-map',
layers: {
worldMap: { type: 'tile', url: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }
}
})
</script>
```
This creates a set of markers for each row in [cities.json](test/cities.json).
```html
<div id="marker-map" style="height:300px">
<script>
var map = g1.mapviewer({
id: 'marker-map',
layers: {
worldMap: { type: 'tile', url: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
cityMarkers: {
type: 'marker',
url: 'cities.json',
latitude: 'lat',
longitude: 'long',
options: {
// title: 'column-name' // TODO
}
// TODO: allow specifying styles (e.g. color, icon file, etc) from data
}
}
})
</script>
```
This creates a set of circle markers for each row in [cities.json](test/cities.json).
You can apply styles based on any attribute or function.
```html
<div id="circle-marker-map" style="height:300px">
<script>
var map = g1.mapviewer({
id: 'circle-marker-map',
layers: {
worldMap: { type: 'tile', url: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
cityMarkers: {
type: 'circleMarker',
url: 'cities.json',
latitude: 'lat',
longitude: 'long',
options: {
title: 'column-name' // TODO: implement as popup
},
attrs: {
fillColor: {
metric: 'pollution',
range: 'RdYlGn'
}
}
}
}
})
</script>
```
This loads a [GeoJSON file](test/india-states.geojson), links data from
[state_score.json](test/state_score.json), and sets the fill color from a merged
attribute.
```html
<div id="geojson-map" style="height:300px">
<script>
var map = g1.mapviewer({
id: 'geojson-map',
layers: {
worldMap: { type: 'tile', url: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
indiaGeojson: {
type: 'geojson',
url: 'india-states.geojson',
link: {
url: 'state_score.json', // Load data from this file
dataKey: 'name', // Join this column from the URL (data)
mapKey: 'ST_NM' // with this property in the GeoJSON
},
attrs: {
fillColor: { // Fill the regions
metric: 'score', // with the "score" column from state_score.json
range: 'RdYlGn' // using a RdYlGn gradient
},
tooltip: function(prop) { // On hover, show this HTML tooltip
return prop.ST_NM + ': ' + prop.TOT_P
}
}
}
}
})
</script>
```
**Note**: You can use `type: 'topojson'` when loading TopoJSON maps.
### g1.mapviewer options
- `id`: ID of the map DOM element (example: `mapid`), or the DOM element
- `map`:
- `options`: supports same options as [Map options](http://leafletjs.com/reference-1.3.0.html#map)
- `layers`: builds layers one on top of another in the specified order.
- `{layername: {...} }` dict with layer name as keys
- Each layer MUST have a type. Currently supported types are
- tile
- geojson
- topojson
- marker
- circleMarker
- `tile` layer MUST have a url: that has the URL template for the leaflet tile layer.
- `url`: A string of the form - `http://{s}.somedomain.com/blabla/{z}/{x}/{y}{r}.png`
- `options`: supports same options as [Tile options](http://leafletjs.com/reference-1.3.0.html#tilelayer-minzoom)
- `geojson` layer MUST have a data as an array of objects or else MUST have a url (string).
- `url`: String
- `data`: An array of objects. data: takes preference over url.
- `options`: supports same options as [GeoJSON options](http://leafletjs.com/reference-1.3.0.html#geojson-style)
- `link`: adds attributes from input dataset to geojson
- `url` is url (String) to fetch data
- `mapKey` is attribute name in geojson to match
- `dataKey` is column name in input dataset that matches with geojson `mapKey`
- `attrs` Data driven styles.
- For `color`, `weight`, `opacity`, `fillColor`, `fillOpacity` properties, the options are:.
- `metric` string / function
- If `metric`: is a string, can be any numeric property of geojson
- To have a metric that is formala based on multiple properties, use function. Example: `function(row) { return row['congress_votes'] - row['bjp_votes']}`
- `domain` An array of two numbers. Defaults to calculated values of given `metric`.
- `range`
- For `fillColor` and `color`, must be a [interpolate color scheme](https://github.com/d3/d3-scale-chromatic#diverging)
- For `weight`, `opacity`,`fillOpacity` must be an array with min and max values
- `tooltip`: string / function that returns formatted value.
- function(properties) must return a string. feature properties are passed as argument.
- TODO: the properties currently include only geoJSON properties. link properties must be added
- `topojson` - same as `Geojson`
- `marker` layer MUST have a data as an array of objects or else MUST have a url (string).
- `url`: String
- `data`: An array of objects. data: takes preference over url.
- `latitude`: String (mandatory). Must be column name that contains latitude of marker
- `longitude`: String (mandatory). Must be column name that contains longitude of marker
- `options`: supports same options as [marker options](http://leafletjs.com/reference-1.3.0.html#marker-icon)
- `circleMarker` layer MUST have a data as an array of objects or else MUST have a url (string).
- `url`: String
- `data`: An array of objects. data: takes preference over url.
- `latitude`: String (mandatory). Must be column name that contains latitude of marker
- `longitude`: String (mandatory). Must be column name that contains longitude of marker
- `options`: supports same options as [circleMarker options](http://leafletjs.com/reference-1.3.0.html#circlemarker-radius)
- `attrs` same as `attrs` for `geojson` type layer
- `zoomHandler`: <!-- TODO -->
- `zoomlevel`: must be a `geojson` layer name or function. If given a `geojson` layer name, shows this layer and hides all other `geojson` layers. Optimize this to load only layer that is visible in viewport.
### g1.mapviewer methods
`g1.mapviewer.fitToLayer(layerName, options)`
Zooms map view to fit the layer. Supports same options as [fitBounds options](http://leafletjs.com/reference-1.3.0.html#fitbounds-options)
### g1.mapviewer events
- `mapload` is fired when all the map layers are loaded.
- `layersload` is fired when all layers are saved in mapviewer.gLayers
- tooltip is rendered on each layer only after layers are loaded.
## Contributing
......
export { version } from './src/package.js'
export { url } from './index-urlfilter.js'
export { datafilter } from './src/datafilter.js'
import './index-highlight.js'
import './index-template.js'
// import './index-formhandler.js'
import './index-event.js'
export { MapViewer, createMapViewer as mapviewer } from './src/mapviewer.js'
......@@ -19,32 +19,44 @@
"prepublishOnly": "npm test"
},
"devDependencies": {
"bootstrap": "4.0.0-beta.3",
"babel-core": "6",
"babel-plugin-external-helpers": "6",
"babel-plugin-transform-runtime": "6",
"babel-preset-env": "1",
"babelrc-rollup": "3",
"bootstrap": "4",
"browserify": "14",
"component-emitter": "1",
"d3": "4",
"d3-scale-chromatic": "1",
"eslint": "^4",
"es6-promise": "4",
"eslint": "4",
"express": "4",
"faucet": "^0.0.1",
"faucet": "0.0",
"font-awesome": "4",
"glob": "^7.1.2",
"glob": "7.1",
"html-minifier": "3",
"jquery": "3",
"json2module": "0.0",
"leaflet": "1",
"leaflet": "1.3",
"moment": "2",
"numeral": "2",
"popper.js": "1",
"puppeteer": "0.13",
"regenerator-runtime": "0.11",
"rimraf": "2",
"rollup": "0.52",
"rollup": "0.56",
"rollup-plugin-babel": "3",
"rollup-plugin-commonjs": "9",
"rollup-plugin-node-resolve": "3",
"rollup-plugin-uglify": "2",
"rollup-pluginutils": "2",
"rollup-watch": "4",
"tap-merge": "0.3",
"tape": "4",
"topojson": "3",
"uglify-js": "3"
}
"uglify-js": "3",
"unfetch": "3"
},
"dependencies": {}
}
import uglify from 'rollup-plugin-uglify'
import htmlparts from './rollup-plugin-htmlparts.js'
import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
import babel from 'rollup-plugin-babel'
import babelrc from 'babelrc-rollup'
const babelConfig = {
'presets': ['env']
}
export default [
{
......@@ -47,6 +55,22 @@ export default [
plugins: [uglify()],
output: { file: "dist/leaflet.min.js", format: "umd", name: "g1" }
},
{
input: "index-mapviewer",
plugins: [resolve(),
commonjs(),
babel(babelrc({ config: babelConfig, exclude: 'node_modules/**' })),
process.env.npm_lifecycle_event == 'dev'? '': uglify()
],
output: {
file: "dist/mapviewer.min.js", format: "umd", name: "g1", globals: {
leaflet: 'L',
d3: 'd3'
}
},
// indicate which modules should be treated as external
external: ['leaflet', 'd3']
},
{
input: "index-sanddance",
plugins: [uglify()],
......
/* globals d3, L */
import "es6-promise/auto"; // Promise polyfill to support IE11
import fetch from 'unfetch' // To do Ajax requests with browser native API
import 'regenerator-runtime/runtime' // To support async polyfill by babel
import { scale } from './scale.js'
// To add to window
if (!window.Promise) {
window.Promise = Promise;
}
var defaults = {
map: {
center: [0, 0],
zoom: 1
}
}
/*
* @class MapViewer
* @aka g1.MapViewer
* @inherits Class
*
* The central class of the API
* It is used to create data reactive map layers from multiple kind of datasources
*
* @example
*
* ```js
* // initialize the map on the "map" div with a given center and zoom
* var mapviewer = g1.mapviewer(config)
* ```
*/
export var MapViewer = class MapViewer {
constructor(config) {
var self = this
self.gData = {}
self.gLayers = {}
self._dataLayerMap = {}
self.options = config
self.mapDiv = typeof (self.options.id) === 'string' ? document.getElementById(self.options.id) : self.options.id
// Apply defaults to the configuration
// TODO: allow deep merge of defaults
for (var key in defaults)
if (!(key in self.options))
self.options[key] = defaults[key]
self.map = L.map(self.options.id, self.options.map)
if (self.options.layers) {
for (let layerName in self.options.layers) {
// To set the order of gLayers to be same as mentioned in self.options by user
self.gLayers[layerName] = undefined
}
for (let layerName in self.options.layers) {
self.buildLayer(layerName, self.options.layers[layerName])
}
self.renderTooltip()
}
}
}
// * @method cacheData(<String> datasetName, 'String' URL || <Object> data): <Object> data
// * GETs data if not already there in this.gData
// *
// * TODO: If dataset itself is given, that is understood as data update.
// * layers rendered based on this data must be reRendered.
// * ?? Do a data diff and then load dependant layers ??
// *
// * @example
// *
// *```js
// mapviewer.cacheData('india_states', 'india-states.geojson')
// *```
MapViewer.prototype.cacheData = function (layerName, url) {
var self = this
switch (typeof (url)) {
// TODO: use enums instead of strings?
case 'string':
return async function () {
if (!(url in self.gData))
self.gData[url] = await (await fetch(url)).json()
// create _dataLayerMap (to update layers when a dataset is updated)
url in self._dataLayerMap ? self._dataLayerMap[url].push(layerName) : self._dataLayerMap[url] = [layerName]
return self.gData[url]
}()
case 'object':
this.gData[JSON.stringify(url).hashCode()] = url
// TODO: Reload all layers that use this data
// This part will make mapviewer data reactive
return async function () {
return url
}()
}
}
// * @method _saveLayer(<Layer> layer, <String> layerName ): this
// * Adds a layer to gLayers object, and
// *
// * Private method
MapViewer.prototype._saveLayer = function (layerName, layer) {
var self = this, allLayersLoaded = true
self.gLayers[layerName] = layer
// sort order of layers to same order as the order given in config
if ('layers' in self.options) {
for (var key in self.gLayers) {
if (self.gLayers[key]) {
self.map.removeLayer(self.gLayers[key])
self.gLayers[key].addTo(self.map)
} else {
allLayersLoaded = false
}
}
}
if (allLayersLoaded === true) {
self.mapDiv.dispatchEvent(new Event('layersload'))
}
}
MapViewer.prototype.renderTooltip = function () {
var self = this
self.on('layersload', function () {
for (let layerName in self.options.layers) {
if (self.options.layers[layerName].attrs && self.options.layers[layerName].attrs.tooltip) {
self.gLayers[layerName].eachLayer(function (sublayer) {
var tooltipContent = self.options.layers[layerName].attrs.tooltip
if (typeof (self.options.layers[layerName].attrs.tooltip) === 'function') {
tooltipContent = self.options.layers[layerName].attrs.tooltip(sublayer.feature.properties)
}
sublayer.bindTooltip(tooltipContent)
})
}
}
self.mapDiv.dispatchEvent(new Event('mapload'))
})
}
MapViewer.prototype.on = function (type, callback) {
this.mapDiv.addEventListener(type, callback)
}
MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
var dataTableIndex = {}
dataTable.forEach(function (row) {
dataTableIndex[row[dataKey].toLowerCase()] = row
})
switch (mapJSON.type) {
case 'Feature':
case 'FeatureCollection':
mapJSON.features.map(function (feature) {
var row = dataTableIndex[feature.properties[mapKey].toLowerCase()]
for (let key in row) {
feature.properties[key] = row[key]
}
})
return mapJSON
default:
mapJSON.map(function (json) {
var row = dataTableIndex[json[mapKey]]
for (let key in row) {
json[key] = row[key]
}
})
return mapJSON
}
}
MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
var self = this, gLayer
switch (layerConfig.type.toLowerCase()) {
case 'tile':
gLayer = L.tileLayer(layerConfig.url, layerConfig.options)
this._saveLayer(layerName, gLayer)
break
// TODO: remove duplicate code topojson and geojson
case 'topojson':
self.cacheData(layerName, layerConfig[dataOrURL(layerConfig)]).then(function (mapJSON) {
gLayer = new L.TopoJSON(mapJSON, layerConfig.options)
self._saveLayer(layerName, gLayer)
if ('link' in layerConfig) {
self.cacheData(layerName, layerConfig.link[dataOrURL(layerConfig.link)]).then(function (tableData) {
self.mergeData(mapJSON, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
self.fitToLayer(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName)
})
} else {
if ('attrs' in layerConfig) self._choropleth(layerName)
self.fitToLayer(layerName)
}
})
break
case 'geojson':
self.cacheData(layerName, layerConfig[dataOrURL(layerConfig)]).then(function (mapJSON) {
gLayer = L.geoJSON(mapJSON, layerConfig.options)
self._saveLayer(layerName, gLayer)
if ('link' in layerConfig) {