Commit d13d85e0 authored by Tejesh's avatar Tejesh 🖖
Browse files

Merge branch 'mapviewer-dev' into 'dev'

ENH: add zoomhandler feature, refactor using deepmerge, deepclone

See merge request !24
parents 8b2aca10 b5577f19
Pipeline #56578 passed with stage
in 2 minutes and 6 seconds
......@@ -15,3 +15,4 @@ package-lock.json
# Ignore editor files
.vscode/
yarn-error.log
......@@ -1201,13 +1201,14 @@ Drilldown feature example:
- `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.
### g1.mapviewer methods
`g1.mapviewer.fitToLayer(layerName, options)`
`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)
`zoomHandler(layerName, minZoomLevel, maxZoomLevel(optional) )`
Shows the layer with `layerName` only between `minZoomLevel` and `maxZoomLevel`.
### g1.mapviewer events
- `layersloaded` is fired when all layers are saved in mapviewer.gLayers (used interally).
......
......@@ -29,16 +29,20 @@
"component-emitter": "1",
"d3": "4",
"d3-scale-chromatic": "1",
"deepmerge": "^2.1.1",
"es6-promise": "4",
"eslint": "4",
"events-polyfill": "^2.0.7",
"express": "4",
"faucet": "0.0",
"font-awesome": "4",
"glob": "7.1",
"html-minifier": "3",
"is-plain-object": "^2.0.4",
"jquery": "3",
"json2module": "0.0",
"leaflet": "1.3",
"lodash": "^4.17.10",
"moment": "2",
"numeral": "2",
"popper.js": "1",
......@@ -57,6 +61,5 @@
"topojson": "3",
"uglify-js": "3",
"unfetch": "3"
},
"dependencies": {}
}
}
/* globals d3, L */
import "es6-promise/auto"; // Promise polyfill to support IE11
import "es6-promise/auto" // Promise polyfill to support IE11
import 'events-polyfill'
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'
import deepmerge from 'deepmerge'
import deepclone from 'lodash/cloneDeep'
import isPlainObject from "is-plain-object"
// To add to window
if (!window.Promise) {
......@@ -14,7 +19,8 @@ var defaults = {
map: {
center: [0, 0],
zoom: 1
}
},
cache: true
}
/*
......@@ -42,10 +48,9 @@ export var MapViewer = class MapViewer {
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.options = deepmerge(defaults, self.options, {
isMergeableObject: isPlainObject
})
self.map = L.map(self.options.id, self.options.map)
......@@ -84,7 +89,7 @@ MapViewer.prototype.cacheData = function (layerName, url) {
// TODO: use enums instead of strings?
case 'string':
return async function () {
if (!(url in self.gData))
if (!(url in self.gData && self.options.cache))
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]
......@@ -125,13 +130,15 @@ MapViewer.prototype._saveLayer = function (layerName, layer) {
MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
var dataTableIndex = {}
dataTable.forEach(function (row) {
dataTableIndex[row[dataKey].toLowerCase()] = row
var prop = typeof (row[dataKey]) == 'string' ? row[dataKey].toLowerCase() : row[dataKey]
dataTableIndex[prop] = row
})
switch (mapJSON.type) {
case 'Feature':
case 'FeatureCollection':
mapJSON.features.map(function (feature) {
var row = dataTableIndex[feature.properties[mapKey].toLowerCase()]
var prop = typeof (feature.properties[mapKey]) == 'string' ? feature.properties[mapKey].toLowerCase() : feature.properties[mapKey]
var row = dataTableIndex[prop]
for (let key in row) {
feature.properties[key] = row[key]
}
......@@ -152,7 +159,7 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
}
MapViewer.prototype.fire = function (eventName) {
this.mapDiv.dispatchEvent(new Event(eventName))
this.mapDiv.dispatchEvent(new CustomEvent(eventName))
}
MapViewer.prototype.on = function (type, callback, options) {
......@@ -165,6 +172,9 @@ MapViewer.prototype.off = function (eventName, callback, options) {
MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
var self = this, gLayer
// Remove layer on map, if exists
if (self.map.hasLayer(self.gLayers[layerName])) self.map.removeLayer(self.gLayers[layerName])
switch (layerConfig.type.toLowerCase()) {
case 'tile':
gLayer = L.tileLayer(layerConfig.url, layerConfig.options)
......@@ -180,14 +190,14 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
self.mergeData(mapJSON, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
gLayer = new L.TopoJSON(mapJSON, layerConfig.options)
self._saveLayer(layerName, gLayer)
if ('attrs' in layerConfig) self._choropleth(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName)
self.fire(layerName + 'loaded')
})
} else {
gLayer = new L.TopoJSON(mapJSON, layerConfig.options)
self._saveLayer(layerName, gLayer)
if ('attrs' in layerConfig) self._choropleth(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName)
self.fire(layerName + 'loaded')
}
......@@ -227,12 +237,12 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
self.mergeData(pointjson, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
create_layer()
if ('attrs' in layerConfig) self._choropleth(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName)
})
} else {
create_layer()
if ('attrs' in layerConfig) self._choropleth(layerName)
if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName)
}
})
......@@ -242,26 +252,34 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
}
}
MapViewer.prototype._choropleth = function (layerName, layerConfig) {
MapViewer.prototype._choropleth = function (layerName, layerConfig, filter) {
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) {
// set defaults style
if (layerConfig.options && layerConfig.options.style) sublayer.setStyle(layerConfig.options.style)
// if filter true, skip attrs so that defaults remain applied
if (filter && typeof (filter) == 'function' && filter(sublayer.feature.properties) === false) {
return
}
for (prop in layerConfig.attrs) {
if (prop.toLowerCase() == 'tooltip') continue
if (typeof (self.options.layers[layerName].attrs[prop]) != 'object') {
style[prop] = self.options.layers[layerName].attrs[prop]
if (typeof (layerConfig.attrs[prop]) != 'object') {
style[prop] = layerConfig.attrs[prop]
continue
}
metric = self.options.layers[layerName].attrs[prop].metric
metric = layerConfig.attrs[prop].metric
if (typeof (metric) === 'string')
metricFormula = (row) => row[metric]
else
metricFormula = metric
if (self.options.layers[layerName].attrs[prop].domain)
domain = self.options.layers[layerName].attrs[prop].domain
if (layerConfig.attrs[prop].domain)
domain = layerConfig.attrs[prop].domain
else
domain = self._calculateMinMax(layer, metricFormula)
// TODO: ENH: cache _calculateMinMax for each property bcz its same for each sublayer
......@@ -271,9 +289,9 @@ MapViewer.prototype._choropleth = function (layerName, layerConfig) {
style[prop] = scale([], {
metric: metric,
domain: domain,
scheme: self.options.layers[layerName].attrs[prop].scheme,
scale: self.options.layers[layerName].attrs[prop].scale,
range: self.options.layers[layerName].attrs[prop].range
scheme: layerConfig.attrs[prop].scheme,
scale: layerConfig.attrs[prop].scale,
range: layerConfig.attrs[prop].range
})(sublayer.feature.properties)
}
......@@ -312,22 +330,35 @@ MapViewer.prototype.drilldown = function (drilldown) {
}
MapViewer.prototype.drilldown_recursive = function (currentLayer) {
var self = this
const levels = this.options.drilldown.levels
const levels = deepclone(self.options.drilldown.levels)
self.gLayers[currentLayer].eachLayer(function (sublayer) {
// TODO: use .once instead of .off and .on
sublayer.off('click')
sublayer.
on('click', function () {
sublayer.off('click')
if (levels.length == self.current_level) {
self.fitToLayer(sublayer)
} else {
var nextLayer = levels[self.current_level]
if (typeof(nextLayer.layerName) == 'function') {
var nextLayer = deepclone(levels[self.current_level])
if (typeof (nextLayer.layerName) == 'function') {
nextLayer.layerName = nextLayer.layerName(sublayer.feature.properties)
}
if (typeof (nextLayer.layerOptions.url) == 'function') {
nextLayer.layerOptions.url = nextLayer.layerOptions.url(sublayer.feature.properties)
}
if (typeof (nextLayer.layerOptions.data) == 'function') {
nextLayer.layerOptions.data = nextLayer.layerOptions.tata(sublayer.feature.properties)
}
if (nextLayer.layerOptions.link && typeof (nextLayer.layerOptions.link.data) == 'function') {
nextLayer.layerOptions.link.data = nextLayer.layerOptions.link.data(sublayer.feature.properties)
}
if (nextLayer.layerOptions.link && typeof (nextLayer.layerOptions.link.url) == 'function') {
nextLayer.layerOptions.link.url = nextLayer.layerOptions.link.url(sublayer.feature.properties)
}
self.options.layers[nextLayer.layerName] = nextLayer.layerOptions
self.buildLayer(nextLayer.layerName, nextLayer.layerOptions)
......@@ -348,11 +379,13 @@ MapViewer.prototype.drilldown_recursive = function (currentLayer) {
}
MapViewer.prototype.drillup = function () {
var self = this
if (self.current_level == 0) return
self.current_level -= 1
self.map.removeLayer(self.gLayers[self.drilldown_stack.pop()])
var current_level_layer = self.drilldown_stack.pop()
self.fitToLayer(self.gLayers[current_level_layer])
self.gLayers[current_level_layer].addTo(self.map)
self.drilldown_recursive(current_level_layer)
}
MapViewer.prototype.setupTooltip = function () {
var self = this
......@@ -374,6 +407,23 @@ MapViewer.prototype.renderTooltip = function (layerName, layerConfig) {
sublayer.bindTooltip(tooltipContent)
})
}
MapViewer.prototype.zoomHandler = function (layerName, minZoom, maxZoom) {
var self = this
self.map.on('zoom', function () {
maxZoom = maxZoom || self.map.getMaxZoom()
if (self.map.getZoom() < minZoom || self.map.getZoom() > maxZoom) {
if (self.map.hasLayer(self.gLayers[layerName])) {
self.map.removeLayer(self.gLayers[layerName])
}
}
else {
if (!self.map.hasLayer(self.gLayers[layerName])) {
self.map.addLayer(self.gLayers[layerName])
}
}
})
}
/*
* @method fitToLayer(<String> layerName, <Object> options ): this
* options are same options as fitBounds options
......@@ -414,7 +464,6 @@ String.prototype.slugify = function () {
.replace(/-+$/, ''); // Trim - from end of text
}
L.TopoJSON = L.GeoJSON.extend({
addData: function (jsonData) {
var key, geojson
......
This diff is collapsed.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>MapViewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="tape.js"></script>
<link rel="stylesheet" href="../node_modules/leaflet/dist/leaflet.css">
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<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="../dist/mapviewer.min.js"></script>
<style>
.map {
height: 300px;
}
</style>
</head>
<body>
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<div id="choropleth" class="map"></div>
<script>
var choro_map = g1.mapviewer({
id: 'choropleth',
layers: {
worldMap2: { type: 'tile', url: 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
indiaGeojson: {
type: 'geojson',
url: 'india-states.geojson',
link: {
url: 'state_score.json',
dataKey: 'name',
mapKey: 'ST_NM'
},
options: {
style: {
fillColor: '#ccc',
fillOpacity: 0.9
}
},
attrs: {
fillColor: {
metric: 'score', // same as function(d) { return d.age }
scale: 'linear',
domain: [10, 15, 30],
range: ['red', 'yellow', 'green'],
}
}
}
}
})
tape("g1.mapviewer test _choropleth without filter function", function (test) {
choro_map
.on('indiaGeojsonloaded', function () {
choro_map._choropleth('indiaGeojson', choro_map.options.layers.indiaGeojson)
choro_map.gLayers['indiaGeojson'].eachLayer(function (sublayer) {
if (sublayer.feature.properties['name'] == 'Kerala') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 43, 0)')
}
if (sublayer.feature.properties['name'] == 'Tamil Nadu') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 57, 0)')
}
if (sublayer.feature.properties['name'] == 'Maharashtra') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(177, 216, 0)')
}
if (sublayer.feature.properties['name'] == 'Odisha') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(154, 205, 0)')
}
})
choro_map._choropleth('indiaGeojson', {
options: {
style: {
fillColor: '#ccc',
fillOpacity: 0.9
}
},
attrs: {
fillColor: {
metric: 'unknown variable',
scale: 'linear',
domain: [10, 15, 30],
range: ['red', 'yellow', 'green'],
}
}
})
choro_map.gLayers['indiaGeojson'].eachLayer(function (sublayer) {
if (sublayer.feature.properties['name'] == 'Kerala') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
if (sublayer.feature.properties['name'] == 'Tamil Nadu') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
if (sublayer.feature.properties['name'] == 'Maharashtra') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
if (sublayer.feature.properties['name'] == 'Odisha') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
})
choro_map._choropleth('indiaGeojson', {
attrs: {
fillColor: {
metric: 'score',
scale: 'linear',
domain: [10, 15, 30],
range: ['red', 'yellow', 'green'],
}
}
})
choro_map.gLayers['indiaGeojson'].eachLayer(function (sublayer) {
if (sublayer.feature.properties['name'] == 'Kerala') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 43, 0)')
}
if (sublayer.feature.properties['name'] == 'Tamil Nadu') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 57, 0)')
}
if (sublayer.feature.properties['name'] == 'Maharashtra') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(177, 216, 0)')
}
if (sublayer.feature.properties['name'] == 'Odisha') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(154, 205, 0)')
}
})
test.end()
})
})
tape("g1.mapviewer test _choropleth with filter function", function (test) {
choro_map._choropleth('indiaGeojson', choro_map.options.layers.indiaGeojson)
choro_map.gLayers['indiaGeojson'].eachLayer(function (sublayer) {
if (sublayer.feature.properties['name'] == 'Kerala') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 43, 0)')
}
if (sublayer.feature.properties['name'] == 'Tamil Nadu') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 57, 0)')
}
if (sublayer.feature.properties['name'] == 'Maharashtra') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(177, 216, 0)')
}
if (sublayer.feature.properties['name'] == 'Odisha') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(154, 205, 0)')
}
})
choro_map._choropleth('indiaGeojson', choro_map.options.layers.indiaGeojson, (props) => props['name'] == 'Kerala')
choro_map.gLayers['indiaGeojson'].eachLayer(function (sublayer) {
if (sublayer.feature.properties['name'] == 'Kerala') {
test.equals(sublayer._path.attributes.fill.nodeValue, 'rgb(255, 43, 0)')
}
if (sublayer.feature.properties['name'] == 'Tamil Nadu') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
if (sublayer.feature.properties['name'] == 'Maharashtra') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
if (sublayer.feature.properties['name'] == 'Odisha') {
test.equals(sublayer._path.attributes.fill.nodeValue, '#ccc')
}
})
test.end()
})
</script>
</body>
</html>
......@@ -10,6 +10,7 @@
<link rel="stylesheet" href="../node_modules/leaflet/dist/leaflet.css">
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/leaflet/dist/leaflet.js"></script>
<script src="../node_modules/topojson-client/dist/topojson-client.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="../dist/mapviewer.min.js"></script>
......@@ -143,5 +144,13 @@
})
})
$(".leaflet-control-zoom-in").removeAttr('href').addClass('cursor-pointer')
$(".leaflet-control-zoom-out").removeAttr('href').addClass('cursor-pointer')
$(".leaflet-control-zoom").append('<a class="leaflet-control-zoom-reset" href="#" title="Zoom reset" role="button" aria-label="Zoom out"><i class="fa fa-undo fa-lg"></i></a>')
$(".leaflet-control-zoom-reset").on("click", function (evt) {
evt.preventDefault()
drilldown_map.drillup()
})
</script>
</body>
......@@ -81,6 +81,18 @@
}
}
},
cityMarkers: {
type: 'marker',
url: 'cities.json',
latitude: 'lat',
longitude: 'long',
options: {
title: 'column-name',
},
attrs: {
tooltip: 'just some tooltip text test'
}
},
cityCircleMarkers: {
type: 'circleMarker',
url: 'cities.json',
......@@ -106,10 +118,9 @@
}
}
})
tooltipFunction_map.on('mapload', function () {
tape("g1.mapviewer test if tooltip is added to the marker", function (test) {
tape("g1.mapviewer test if tooltip is added to the marker", function (test) {
tooltipFunction_map.on('mapviewerloaded', function () {
tooltipFunction_map.gLayers['cityMarkers'].eachLayer(function (sublayer) {
test.notOk(tooltipFunction_map.map.hasLayer(sublayer._tooltip))
})
tooltipFunction_map.gLayers['cityCircleMarkers'].eachLayer(function (sublayer) {
......@@ -119,6 +130,13 @@
// trigger hover event
// $('.leaflet-marker-icon' ,$('#tooltipFunction-map')).dispatch('mouseover')
$('.leaflet-interactive', $('#tooltipFunction-map')).dispatch('mouseover')
// $('.leaflet-interactive', $('#tooltipFunction-map')).dispatch(new MouseEvent('mouseover', {
// 'view': window,
// 'bubbles': true,
// 'cancelable': true,
// // 'screenX': 366,
// // 'screenY': 588
// }))
tooltipFunction_map.gLayers['cityMarkers'].eachLayer(function (sublayer) {
test.ok(tooltipFunction_map.map.hasLayer(sublayer._tooltip))
......@@ -127,7 +145,7 @@
test.ok(tooltipFunction_map.map.hasLayer(sublayer._tooltip))
})
$('.leaflet-interactive', $('#tooltipFunction-map')).dispatch('mouseout')
// $('.leaflet-interactive', $('#tooltipFunction-map')).dispatch('mouseout')
test.end()
})
......
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>MapViewer</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="tape.js"></script>
<link rel="stylesheet" href="../node_modules/leaflet/dist/leaflet.css">
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<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="../dist/mapviewer.min.js"></script>
<style>
.map {
height: 300px;
}
</style>
</head>
<body>
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<div id="zoomhandler" class="map"></div>