Commit 36adaa41 authored by S Anand's avatar S Anand
Browse files

ENH: mapviewer drilldown. Fixes #48 @tejesh.p

parent 9b0c2faa
Pipeline #50162 passed with stage
in 3 minutes and 8 seconds
......@@ -1114,7 +1114,7 @@ attribute.
},
attrs: {
fillColor: { // Fill the regions
metric: 'score', // with the "score" column from state_score.json
metric: 'score', // with the "score" column state_score.json
range: 'RdYlGn' // using a RdYlGn gradient
},
tooltip: function(prop) { // On hover, show this HTML tooltip
......@@ -1127,6 +1127,59 @@ attribute.
</script>
```
Drilldown feature example:
```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 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
}
}
}
},
drilldown: {
rootLayer: 'indiaGeojson',
levels: [
{
layerName: function(properties) {return properties['STATE'] + '-layer'},
layerOptions: {
url: function(properties) {return properties['STATE'] + '-census.json'},
type: 'geojson',
attrs: {
fillColor: {
metric: 'DT_CEN_CD',
range: 'RdYlGn'
},
tooltip: function (properties) {
return 'DISTRICT: ' + properties['DISTRICT']
}
}
}
}
]
}
})
</script>
```
**Note**: You can use `type: 'topojson'` when loading TopoJSON maps.
### g1.mapviewer options
......@@ -1140,7 +1193,7 @@ attribute.
- tile
- geojson
- topojson
- marker
- marker (`link`: option is not yet supported )
- 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`
......@@ -1179,6 +1232,11 @@ attribute.
- `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
- `drilldown`:
- `rootLayer`: `geojson/topojson` layer that acts as root layer to drilldown further.
- `levels`: Array of objects that provides layer info
- `layerName`: Can be a string or function. Function takes argument as `properties` of parentLayer feature
- `layerOptions`: Same as layer options in `layers` option. If `url` is function, `url` takes argument as `properties` of parentLayer feature
- `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.
......@@ -1188,9 +1246,9 @@ Zooms map view to fit the layer. Supports same options as [fitBounds options](ht
### 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.
- `layersloaded` is fired when all layers are saved in mapviewer.gLayers (used interally).
- tooltip is rendered on each layer only after `layersload` is fired
- `layerName + 'loaded'` is fired for each layer with name as `layerName`
## Contributing
......
......@@ -6,5 +6,4 @@ url:
handler: FormHandler
kwargs:
url: test/formhandler.csv
xsrf_cookies: false # TODO: enable this and test
id: ID
......@@ -10,7 +10,7 @@
"url": "git@code.gramener.com:s.anand/g1.git"
},
"scripts": {
"lint": "eslint index.js src",
"lint": "eslint index.js src && eclint check '**/*.html' '**/*.js' '**/*.css' '**/*.yaml' '**/*.md'",
"build": "rimraf dist && json2module package.json > src/package.js && rollup -c",
"dev": "rimraf dist && json2module package.json > src/package.js && rollup -c -w",
"pretest": "npm run build && browserify -s tape -r tape -o test/tape.js",
......
......@@ -12,7 +12,7 @@ Each template receives these variables:
<!-- This is the root template that renders all other components on this page -->
<!-- var template_ -->
<div class="position-relative">
<div class="position-relative">
<div class="formhandler">
<div class="formhandler-table-header d-flex justify-content-between mb-2">
<div class="d-flex flex-wrap">
......@@ -33,8 +33,9 @@ Each template receives these variables:
<div class="fa fa-spinner fa-spin fa-3x fa-fw"></div>
<span class="sr-only">Loading...</span>
</div>
</div>
<div class="modal formhandler-table-modal" id="fh-modal-<%- idcount %>" tabindex="-1" role="dialog" aria-labelledby="fh-label-<%- idcount %>" aria-hidden="true">
</div>
<div class="modal formhandler-table-modal" id="fh-modal-<%- idcount %>" tabindex="-1" role="dialog" aria-labelledby="fh-label-<%- idcount %>"
aria-hidden="true">
<div class="modal-dialog modal-sm" role="document">
<div class="modal-content">
<form class="formhandler-table-modal-form modal-body">
......@@ -48,9 +49,12 @@ Each template receives these variables:
<a class="btn btn-sm btn-danger remove-action urlfilter" data-dismiss="modal" data-target="#" href="#">Remove filter</button>
</div>
</form>
</div><!-- .modal-content -->
</div><!-- .modal-dialog -->
</div><!-- .modal -->
</div>
<!-- .modal-content -->
</div>
<!-- .modal-dialog -->
</div>
<!-- .modal -->
<!-- end -->
<!-- var template_table -->
......@@ -71,7 +75,8 @@ Each template receives these variables:
var menu_item = false
var col_id = idcount++
%>
<th class="<%- args['_sort'] == colinfo.name ? 'table-primary' : args['_sort'] == '-' + colinfo.name ? 'table-danger' : '' %>" data-col="<%- colinfo.name %>">
<th class="<%- args['_sort'] == colinfo.name ? 'table-primary' : args['_sort'] == '-' + colinfo.name ? 'table-danger' : '' %>"
data-col="<%- colinfo.name %>">
<div class="dropdown">
<a href="#" class="dropdown-toggle text-nowrap" id="fh-dd-<%- col_id %>" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<%- colinfo.title || colinfo.name %>
......@@ -83,13 +88,18 @@ Each template receives these variables:
<%- title %>
</a>
<% }) %>
<% if (menu_item) { %><div class="dropdown-divider"></div><% menu_item = false } %>
<% if (menu_item) { %>
<div class="dropdown-divider"></div>
<% menu_item = false } %>
<% _.each(colinfo.filters, function(title, op) { menu_item = true %>
<a class="dropdown-item <%= colinfo.name + op in args ? 'active' : '' %>" href="#" data-op="<%- op %>" data-toggle="modal" data-target="#fh-modal-<%- form_id %>">
<a class="dropdown-item <%= colinfo.name + op in args ? 'active' : '' %>" href="#" data-op="<%- op %>" data-toggle="modal"
data-target="#fh-modal-<%- form_id %>">
<%- title %>
</a>
<% }) %>
<% if (menu_item) { %><div class="dropdown-divider"></div><% menu_item = false } %>
<% if (menu_item) { %>
<div class="dropdown-divider"></div>
<% menu_item = false } %>
<% if (colinfo.hideable) { %>
<a class="dropdown-item urlfilter" href="?_c=-<%- colinfo.name %>" data-mode="add">Hide</a>
<% } %>
......@@ -103,7 +113,9 @@ Each template receives these variables:
<tr class="new-row">
<% _.each(cols, function(colinfo) { %>
<% if (!colinfo.template) { %>
<td><input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value=""/></td>
<td>
<input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value="" />
</td>
<% } else { %>
<td></td>
<% } %>
......@@ -133,14 +145,23 @@ Each template receives these variables:
<% if (colinfo.template) { %>
<%= typeof colinfo.template == 'function' ? colinfo.template({value: val, format: disp, link: col_link, data: data}) : _.template(colinfo.template)(({value: val, format: disp, link: col_link, data: data})) %>
<% } else if (col_link) { %>
<td><a href="<%- col_link %>" target="_blank"><%= disp %></a></td>
<td>
<a href="<%- col_link %>" target="_blank">
<%= disp %>
</a>
</td>
<% } else { %>
<% if (isEdit && isEditable) { %>
<td><input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value="<%- val %>"/></td>
<td>
<input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value="<%- val %>"
/>
</td>
<% } else { %>
<td><a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<td>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a></td>
</a>
</td>
<% } %>
<% } %>
<% }) %>
......@@ -157,7 +178,7 @@ Each template receives these variables:
lo = Math.max(page - 2, 1),
hi = last_page !== null ? Math.min(last_page, page + 2) : page + 2
%>
<ul class="pagination pagination-sm">
<ul class="pagination pagination-sm">
<li class="page-item <%- page <= 1 ? 'disabled' : '' %>">
<a class="page-link" href="?_offset=<%- meta.offset - meta.limit %>">Previous</a>
</li>
......@@ -174,7 +195,9 @@ Each template receives these variables:
<% } %>
<% _.each(_.range(lo, hi + 1), function(pg) { %>
<li class="page-item <%- pg == page ? 'active' : '' %>">
<a class="page-link" href="?_offset=<%- meta.limit * (pg - 1) || '' %>"><%- pg %></a>
<a class="page-link" href="?_offset=<%- meta.limit * (pg - 1) || '' %>">
<%- pg %>
</a>
</li>
<% }) %>
<% if ('count' in meta) { %>
......@@ -185,7 +208,9 @@ Each template receives these variables:
<% } %>
<% if (hi < last_page || lo > hi) { %>
<li class="page-item">
<a class="page-link" href="?_offset=<%- meta.limit * (last_page - 1) %>"><%- last_page %></a>
<a class="page-link" href="?_offset=<%- meta.limit * (last_page - 1) %>">
<%- last_page %>
</a>
</li>
<% } %>
<% } %>
......@@ -193,11 +218,11 @@ Each template receives these variables:
<li class="page-item <%- (last_page === null) || (page < last_page) ? '' : 'disabled' %>">
<a class="page-link" href="?_offset=<%- meta.offset + meta.limit %>">Next</a>
</li>
</ul>
<!-- end -->
</ul>
<!-- end -->
<!-- var template_size -->
<% if (meta.limit) { %>
<!-- var template_size -->
<% if (meta.limit) { %>
<div class="btn-group btn-group-sm" role="group">
<button id="formhandler-size-<%- idcount++ %>" type="button" class="btn btn-light btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
......@@ -211,29 +236,30 @@ Each template receives these variables:
<% }) %>
</div>
</div>
<% } %>
<!-- end -->
<% } %>
<!-- end -->
<!-- var template_count -->
<% if ('count' in meta) { %>
<span class="btn btn-sm btn-light"><%- meta.count %> rows</span>
<% } %>
<!-- end -->
<!-- var template_count -->
<% if ('count' in meta) { %>
<span class="btn btn-sm btn-light">
<%- meta.count %> rows</span>
<% } %>
<!-- end -->
<!-- var template_edit -->
<button type="button" class="btn btn-primary mr-2 btn-sm edit-btn">
<!-- var template_edit -->
<button type="button" class="btn btn-primary mr-2 btn-sm edit-btn">
Edit
</button>
<!-- end -->
</button>
<!-- end -->
<!-- var template_add -->
<button type="button" class="btn btn-primary mr-2 btn-sm add-btn">
<!-- var template_add -->
<button type="button" class="btn btn-primary mr-2 btn-sm add-btn">
Add
</button>
<!-- end -->
</button>
<!-- end -->
<!-- var template_export -->
<div class="btn-group btn-group-sm" role="group">
<!-- var template_export -->
<div class="btn-group btn-group-sm" role="group">
<button id="formhandler-export-<%- idcount++ %>" type="button" class="btn btn-light btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
Export as
......@@ -245,19 +271,17 @@ Each template receives these variables:
</a>
<% }) %>
</div>
</div>
<!-- end -->
</div>
<!-- end -->
<!-- var template_filters -->
<div class="p-1">
<!-- var template_filters -->
<div class="p-1">
<% var qparts = parse('?') %>
<% _.each(args['_c'], function(col_name) { %>
<% qparts.update({_c: col_name}, 'add') %>
<% var hide_col = col_name[0] == '-' %>
<% var display_name = hide_col ? col_name.slice(1) : col_name %>
<a href="?_c=<%- col_name %>"
data-mode="del"
class="badge badge-pill <%- hide_col ? 'badge-dark' : 'badge-danger' %> urlfilter"
<a href="?_c=<%- col_name %>" data-mode="del" class="badge badge-pill <%- hide_col ? 'badge-dark' : 'badge-danger' %> urlfilter"
title="<%- hide_col ? 'Show' : 'Hide' %> column <%- display_name %>">
<%- display_name %>
</a>
......@@ -266,51 +290,50 @@ Each template receives these variables:
<% if (key.charAt(0) !== '_' && key !== 'c') { %>
<% _.each(args[key], function(col_name) { %>
<% var update = {}; update[key] = col_name; qparts.update(update) %>
<a href="?<%- key %>=<%- col_name %>" data-mode="del" class="badge badge-pill badge-dark urlfilter"
title="Clear <%- key %> filter">
<%- key %> = <%- col_name %>
<a href="?<%- key %>=<%- col_name %>" data-mode="del" class="badge badge-pill badge-dark urlfilter" title="Clear <%- key %> filter">
<%- key %> =
<%- col_name %>
</a>
<% }) %>
<% } %>
<% }) %>
<% qparts = qparts.toString() %>
<% if (qparts && qparts != '?') { %>
<a href="?<%- qparts.slice(1) %>"
class="badge badge-pill badge-danger urlfilter"
data-mode="del"
title="Clear all filters">×</a>
<a href="?<%- qparts.slice(1) %>" class="badge badge-pill badge-danger urlfilter" data-mode="del" title="Clear all filters">×</a>
<% } %>
</div>
<!-- end -->
</div>
<!-- end -->
<!-- var template_error -->
<div class="alert alert-warning" role="alert">
<p class="text-center"><%- message %> </p>
</div>
<!-- end -->
<!-- var template_error -->
<div class="alert alert-warning" role="alert">
<p class="text-center">
<%- message %>
</p>
</div>
<!-- end -->
<!-- var template_table_grid -->
<%
var filtered_cols = args['_c'] && args['_c'].length != options.columns.length ?
<!-- var template_table_grid -->
<%
var filtered_cols = args['_c'] && args['_c'].length != options.columns.length ?
options.columns.filter(function(col) { return args['_c'].indexOf('-' + col.name) < 0 }) :
options.columns
var cols = options.columns.length ? filtered_cols : meta.columns
var form_id = idcount
var img = (options.icon) ? options.icon : 'http://icons.iconarchive.com/icons/mazenl77/NX11/256/Folder-Default-icon.png'
var cols = options.columns.length ? filtered_cols : meta.columns
var form_id = idcount
var img = (options.icon) ? options.icon : 'http://icons.iconarchive.com/icons/mazenl77/NX11/256/Folder-Default-icon.png'
%>
<% if (options.rowTemplate) { %>
<% if (options.rowTemplate) { %>
<% _.each(data, function(row) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, data: data}) : _.template(options.rowTemplate)({row: row, data: data}) %>
<% }) %>
<% } else {%>
<div class="formhandler-grid row">
<% } else {%>
<div class="formhandler-grid row">
<% _.each(data, function(row) { %>
<div class="col-sm-3 <%= options.classes || 'formhandler-grid-cell d-inline-block p-3 box-shadow' %>">
<div class="thumbnail">
<% if (img.indexOf('fa ') >= 0) { %>
<i class="<%= img %>"></i>
<% } else { %>
<img class="img img-responsive" src="<%= img %>"/>
<img class="img img-responsive" src="<%= img %>" />
<% } %>
<div class="caption">
......@@ -325,11 +348,15 @@ Each template receives these variables:
moment(val).format(colinfo.format):
val %>
<div>
<strong><%= colinfo.name %></strong>:
<strong>
<%= colinfo.name %>
</strong>:
<% if ('link' in colinfo) {
var col_link = typeof colinfo.link == 'function' ? colinfo.link(val) : _.template(colinfo.link)(row)
%>
<a href="<%- col_link %>" target="_blank"><%= disp %></a>
<a href="<%- col_link %>" target="_blank">
<%= disp %>
</a>
<% } else { %>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
......@@ -341,6 +368,6 @@ Each template receives these variables:
</div>
</div>
<% }) %>
</div>
<% } %>
<!-- end -->
</div>
<% } %>
<!-- end -->
......@@ -57,7 +57,10 @@ export var MapViewer = class MapViewer {
for (let layerName in self.options.layers) {
self.buildLayer(layerName, self.options.layers[layerName])
}
self.renderTooltip()
self.setupTooltip()
self.drilldown()
self.current_level = 0
self.drilldown_stack = []
}
}
}
......@@ -105,42 +108,18 @@ MapViewer.prototype.cacheData = function (layerName, url) {
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
self.gLayers[layerName].addTo(self.map)
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 {
if (!self.gLayers[key]) {
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.fire('layersloaded')
}
}
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) {
......@@ -158,6 +137,7 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
}
})
return mapJSON
break
default:
mapJSON.map(function (json) {
var row = dataTableIndex[json[mapKey]]
......@@ -166,30 +146,45 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
}
})
return mapJSON
break
}
}
MapViewer.prototype.fire = function (eventName) {
this.mapDiv.dispatchEvent(new Event(eventName))
}
MapViewer.prototype.on = function (type, callback, options) {
this.mapDiv.addEventListener(type, callback, options)
}
MapViewer.prototype.off = function (eventName, callback, options) {
this.mapDiv.removeEventListener(eventName, callback, options)
}
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)
self.fire(layerName + 'loaded')
break
case 'geojson':
case 'topojson':
case 'geojson':
self.cacheData(layerName, layerConfig[dataOrURL(layerConfig)]).then(function (mapJSON) {
gLayer = new L.TopoJSON(mapJSON, layerConfig.options)
self.fitToLayer(gLayer)
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)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fire(layerName+'loaded')
})
} else {
if ('attrs' in layerConfig) self._choropleth(layerName)
self.fitToLayer(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fire(layerName + 'loaded')
}
})
break
......@@ -203,8 +198,9 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
mark.feature.properties = d
pointLayers.push(mark)
})
self.fitToLayer(L.featureGroup(pointLayers))
self._saveLayer(layerName, L.featureGroup(pointLayers))
self.fitToLayer(layerName)
self.fire(layerName + 'loaded')
})
break
case 'circle':
......@@ -218,16 +214,17 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
mark.feature.properties = d
pointLayers.push(mark)
})
self.fitToLayer(L.featureGroup(pointLayers))
self._saveLayer(layerName, L.featureGroup(pointLayers))
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)
if ('attrs' in layerConfig) self._choropleth(layerName)
self.fitToLayer(layerName)
self.mergeData(pointjson, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fire(layerName + 'loaded')
})
} else {
if ('attrs' in layerConfig) self._choropleth(layerName)
self.fitToLayer(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fire(layerName + 'loaded')
}
})
break
......@@ -236,21 +233,21 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
}
}
MapViewer.prototype._choropleth = function (layerName) {
MapViewer.prototype._choropleth = function (layerName, layerConfig) {
var layer = this.gLayers[layerName], self = this
layer.eachLayer(function (sublayer) {
var style = {}, prop, metricFormula, metric, domain
for (prop in self.options.layers[layerName].attrs) {
if (prop.toLowerCase() == 'tooltip') continue