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. ...@@ -1114,7 +1114,7 @@ attribute.
}, },
attrs: { attrs: {
fillColor: { // Fill the regions 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 range: 'RdYlGn' // using a RdYlGn gradient
}, },
tooltip: function(prop) { // On hover, show this HTML tooltip tooltip: function(prop) { // On hover, show this HTML tooltip
...@@ -1127,6 +1127,59 @@ attribute. ...@@ -1127,6 +1127,59 @@ attribute.
</script> </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. **Note**: You can use `type: 'topojson'` when loading TopoJSON maps.
### g1.mapviewer options ### g1.mapviewer options
...@@ -1140,7 +1193,7 @@ attribute. ...@@ -1140,7 +1193,7 @@ attribute.
- tile - tile
- geojson - geojson
- topojson - topojson
- marker - marker (`link`: option is not yet supported )
- circleMarker - circleMarker
- `tile` layer MUST have a url: that has the URL template for the leaflet tile layer. - `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` - `url`: A string of the form - `http://{s}.somedomain.com/blabla/{z}/{x}/{y}{r}.png`
...@@ -1179,6 +1232,11 @@ attribute. ...@@ -1179,6 +1232,11 @@ attribute.
- `longitude`: String (mandatory). Must be column name that contains longitude 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) - `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 - `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 --> - `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. - `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 ...@@ -1188,9 +1246,9 @@ Zooms map view to fit the layer. Supports same options as [fitBounds options](ht
### g1.mapviewer events ### g1.mapviewer events
- `mapload` is fired when all the map layers are loaded. - `layersloaded` is fired when all layers are saved in mapviewer.gLayers (used interally).
- `layersload` is fired when all layers are saved in mapviewer.gLayers - tooltip is rendered on each layer only after `layersload` is fired
- tooltip is rendered on each layer only after layers are loaded. - `layerName + 'loaded'` is fired for each layer with name as `layerName`
## Contributing ## Contributing
......
...@@ -6,5 +6,4 @@ url: ...@@ -6,5 +6,4 @@ url:
handler: FormHandler handler: FormHandler
kwargs: kwargs:
url: test/formhandler.csv url: test/formhandler.csv
xsrf_cookies: false # TODO: enable this and test
id: ID id: ID
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
"url": "git@code.gramener.com:s.anand/g1.git" "url": "git@code.gramener.com:s.anand/g1.git"
}, },
"scripts": { "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", "build": "rimraf dist && json2module package.json > src/package.js && rollup -c",
"dev": "rimraf dist && json2module package.json > src/package.js && rollup -c -w", "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", "pretest": "npm run build && browserify -s tape -r tape -o test/tape.js",
......
This diff is collapsed.
...@@ -57,7 +57,10 @@ export var MapViewer = class MapViewer { ...@@ -57,7 +57,10 @@ export var MapViewer = class MapViewer {
for (let layerName in self.options.layers) { for (let layerName in self.options.layers) {
self.buildLayer(layerName, self.options.layers[layerName]) self.buildLayer(layerName, self.options.layers[layerName])
} }
self.renderTooltip() self.setupTooltip()
self.drilldown()
self.current_level = 0
self.drilldown_stack = []
} }
} }
} }
...@@ -105,44 +108,20 @@ MapViewer.prototype.cacheData = function (layerName, url) { ...@@ -105,44 +108,20 @@ MapViewer.prototype.cacheData = function (layerName, url) {
MapViewer.prototype._saveLayer = function (layerName, layer) { MapViewer.prototype._saveLayer = function (layerName, layer) {
var self = this, allLayersLoaded = true var self = this, allLayersLoaded = true
self.gLayers[layerName] = layer 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) { if ('layers' in self.options) {
for (var key in self.gLayers) { for (var key in self.gLayers) {
if (self.gLayers[key]) { if (!self.gLayers[key]) {
self.map.removeLayer(self.gLayers[key])
self.gLayers[key].addTo(self.map)
} else {
allLayersLoaded = false allLayersLoaded = false
} }
} }
} }
if (allLayersLoaded === true) { if (allLayersLoaded === true) {
self.mapDiv.dispatchEvent(new Event('layersload')) self.fire('layersloaded')
} }
} }
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) { MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
var dataTableIndex = {} var dataTableIndex = {}
dataTable.forEach(function (row) { dataTable.forEach(function (row) {
...@@ -158,6 +137,7 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) { ...@@ -158,6 +137,7 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
} }
}) })
return mapJSON return mapJSON
break
default: default:
mapJSON.map(function (json) { mapJSON.map(function (json) {
var row = dataTableIndex[json[mapKey]] var row = dataTableIndex[json[mapKey]]
...@@ -166,30 +146,45 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) { ...@@ -166,30 +146,45 @@ MapViewer.prototype.mergeData = function (mapJSON, dataTable, mapKey, dataKey) {
} }
}) })
return mapJSON 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) { MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
var self = this, gLayer var self = this, gLayer
switch (layerConfig.type.toLowerCase()) { switch (layerConfig.type.toLowerCase()) {
case 'tile': case 'tile':
gLayer = L.tileLayer(layerConfig.url, layerConfig.options) gLayer = L.tileLayer(layerConfig.url, layerConfig.options)
this._saveLayer(layerName, gLayer) this._saveLayer(layerName, gLayer)
self.fire(layerName + 'loaded')
break break
case 'geojson':
case 'topojson': case 'topojson':
case 'geojson':
self.cacheData(layerName, layerConfig[dataOrURL(layerConfig)]).then(function (mapJSON) { self.cacheData(layerName, layerConfig[dataOrURL(layerConfig)]).then(function (mapJSON) {
gLayer = new L.TopoJSON(mapJSON, layerConfig.options) gLayer = new L.TopoJSON(mapJSON, layerConfig.options)
self.fitToLayer(gLayer)
self._saveLayer(layerName, gLayer) self._saveLayer(layerName, gLayer)
if ('link' in layerConfig) { if ('link' in layerConfig) {
self.cacheData(layerName, layerConfig.link[dataOrURL(layerConfig.link)]).then(function (tableData) { self.cacheData(layerName, layerConfig.link[dataOrURL(layerConfig.link)]).then(function (tableData) {
self.mergeData(mapJSON, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey) self.mergeData(mapJSON, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
self.fitToLayer(layerName) if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
if ('attrs' in layerConfig) self._choropleth(layerName) self.fire(layerName+'loaded')
}) })
} else { } else {
if ('attrs' in layerConfig) self._choropleth(layerName) if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName) self.fire(layerName + 'loaded')
} }
}) })
break break
...@@ -203,8 +198,9 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) { ...@@ -203,8 +198,9 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
mark.feature.properties = d mark.feature.properties = d
pointLayers.push(mark) pointLayers.push(mark)
}) })
self.fitToLayer(L.featureGroup(pointLayers))
self._saveLayer(layerName, L.featureGroup(pointLayers)) self._saveLayer(layerName, L.featureGroup(pointLayers))
self.fitToLayer(layerName) self.fire(layerName + 'loaded')
}) })
break break
case 'circle': case 'circle':
...@@ -218,16 +214,17 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) { ...@@ -218,16 +214,17 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) {
mark.feature.properties = d mark.feature.properties = d
pointLayers.push(mark) pointLayers.push(mark)
}) })
self.fitToLayer(L.featureGroup(pointLayers))
self._saveLayer(layerName, L.featureGroup(pointLayers)) self._saveLayer(layerName, L.featureGroup(pointLayers))
if ('link' in layerConfig) { if ('link' in layerConfig) {
self.cacheData(layerName, layerConfig.link[dataOrURL(layerConfig.link)]).then(function (tableData) { self.cacheData(layerName, layerConfig.link[dataOrURL(layerConfig.link)]).then(function (tableData) {
self.mergeData(mapJSON, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey) self.mergeData(pointjson, tableData, layerConfig.link.mapKey, layerConfig.link.dataKey)
if ('attrs' in layerConfig) self._choropleth(layerName) if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName) self.fire(layerName + 'loaded')
}) })
} else { } else {
if ('attrs' in layerConfig) self._choropleth(layerName) if ('attrs' in layerConfig) self._choropleth(layerName, layerConfig)
self.fitToLayer(layerName) self.fire(layerName + 'loaded')
} }
}) })
break break
...@@ -236,21 +233,21 @@ MapViewer.prototype.buildLayer = function (layerName, layerConfig) { ...@@ -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 var layer = this.gLayers[layerName], self = this
layer.eachLayer(function (sublayer) { layer.eachLayer(function (sublayer) {
var style = {}, prop, metricFormula, metric, domain var style = {}, prop, metricFormula, metric, domain
for (prop in self.options.layers[layerName].attrs) { for (prop in layerConfig.attrs) {
if (prop.toLowerCase() == 'tooltip') continue if(prop.toLowerCase() == 'tooltip') continue
metric = self.options.layers[layerName].attrs[prop].metric metric = layerConfig.attrs[prop].metric
if (typeof (metric) === 'string') if (typeof (metric) === 'string')
metricFormula = (row) => row[metric] metricFormula = (row) => row[metric]
else else
metricFormula = metric metricFormula = metric
if (self.options.layers[layerName].attrs[prop].domain) if (layerConfig.attrs[prop].domain)
domain = self.options.layers[layerName].attrs[prop].domain domain = layerConfig.attrs[prop].domain
else else
domain = self._calculateMinMax(layer, metricFormula) domain = self._calculateMinMax(layer, metricFormula)
...@@ -258,16 +255,18 @@ MapViewer.prototype._choropleth = function (layerName) { ...@@ -258,16 +255,18 @@ MapViewer.prototype._choropleth = function (layerName) {
style[prop] = scale([], { style[prop] = scale([], {
metric: metric, metric: metric,
domain: domain, domain: domain,
scheme: self.options.layers[layerName].attrs[prop].range scheme: layerConfig.attrs[prop].range
})(sublayer.feature.properties) })(sublayer.feature.properties)
} }
} }
sublayer.setStyle(style) sublayer.setStyle(style)
}) })
} }
// * @method _calculateMinMax(layer, <function> metricFormula ): <Array> /*
// * Analogous to d3.extent but for feature.properties * @method _calculateMinMax(layer, <function> metricFormula ): <Array>
// * Private/internal method * Analogous to d3.extent but for feature.properties
* Private/internal method
*/
MapViewer.prototype._calculateMinMax = function (layer, metricFormula) { MapViewer.prototype._calculateMinMax = function (layer, metricFormula) {
var minVal, maxVal var minVal, maxVal
layer.eachLayer(function (sublayer) { layer.eachLayer(function (sublayer) {
...@@ -283,8 +282,83 @@ MapViewer.prototype._calculateMinMax = function (layer, metricFormula) { ...@@ -283,8 +282,83 @@ MapViewer.prototype._calculateMinMax = function (layer, metricFormula) {
}) })
return [minVal, maxVal] return [minVal, maxVal]
} }
MapViewer.prototype.drilldown = function (drilldown) {
var self = this
self.on('layersloaded', function() {
if(self.options.drilldown) {
self.drilldown_recursive(self.options.drilldown.rootLayer)
}
}, {
once: true
})
}
MapViewer.prototype.drilldown_recursive = function (currentLayer) {
var self = this
const levels = this.options.drilldown.levels
self.gLayers[currentLayer].eachLayer(function (sublayer) {
// TODO: use .once instead of .off and .on
sublayer.off('click')
sublayer.
on('click', function () {
if (levels.length == self.current_level) {
self.fitToLayer(sublayer)
} else {
var nextLayer = 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)
}
self.options.layers[nextLayer.layerName] = nextLayer.layerOptions
self.buildLayer(nextLayer.layerName, nextLayer.layerOptions)
// remove this layer and store it in a stack
self.drilldown_stack.push(currentLayer)
self.drilldown_stack.push(nextLayer.layerName)
self.map.removeLayer(self.gLayers[currentLayer])
self.current_level += 1
self.on(nextLayer.layerName + 'loaded', function() {
if(nextLayer.layerOptions.attrs && nextLayer.layerOptions.attrs.tooltip)
self.renderTooltip(nextLayer.layerName, nextLayer.layerOptions)
// attach drilldown events for sublayers
self.drilldown_recursive(nextLayer.layerName)
}, {once: true})
}
})
})
}
MapViewer.prototype.drillup = function () {
var self = this
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)
}
MapViewer.prototype.setupTooltip = function () {
var self = this
self.on('layersloaded', function () {
for (let layerName in self.options.layers) {
if (self.options.layers[layerName].attrs && self.options.layers[layerName].attrs.tooltip) {
self.renderTooltip(layerName, self.options.layers[layerName])
}
}
self.fire('mapviewerloaded')
})
}
MapViewer.prototype.renderTooltip = function (layerName, layerConfig) {
this.gLayers[layerName].eachLayer(function (sublayer) {
var tooltipContent = layerConfig.attrs.tooltip
if (typeof (layerConfig.attrs.tooltip) === 'function') {
tooltipContent = layerConfig.attrs.tooltip(sublayer.feature.properties)
}
sublayer.bindTooltip(tooltipContent)
})
}
/* /*
* @method fitToLayer(<String> layerName ): this * @method fitToLayer(<String> layerName, <Object> options ): this
* options are same options as fitBounds options
* Zooms map view to fit the layer * Zooms map view to fit the layer
* *
* @example * @example
...@@ -293,8 +367,9 @@ MapViewer.prototype._calculateMinMax = function (layer, metricFormula) { ...@@ -293,8 +367,9 @@ MapViewer.prototype._calculateMinMax = function (layer, metricFormula) {
mapviewer.fitToLayer('indiaGeojson') mapviewer.fitToLayer('indiaGeojson')
*``` *```
*/ */
MapViewer.prototype.fitToLayer = function (layerName, options = {}) { MapViewer.prototype.fitToLayer = function (layerName, options = this.options.fitbounds) {
this.map.fitBounds(this.gLayers[layerName].getBounds(), options) var layer = typeof (layerName) == 'string' ? this.gLayers[layerName] : layerName
this.map.fitBounds(layer.getBounds(), options)
} }
function dataOrURL(conf) { function dataOrURL(conf) {
......
This diff is collapsed.
[{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"North America"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"South America"},{"Continent":"Europe"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"South America"},{"Continent":"South America"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"North America"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"South America"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"South America"},{"Continent":"North America"},{"Continent":"North America"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"North America"},{"Continent":"Africa"},{"Continent":"South America"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Oceania"},{"Continent":"Oceania"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"Africa"},{"Continent":"South America"},{"Continent":"North America"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Oceania"},{"Continent":"Africa"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"North America"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Oceania"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"North America"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Oceania"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"North America"},{"Continent":"South America"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Oceania"},{"Continent":"South America"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Oceania"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"South America"},{"Continent":"Africa"},{"Continent":"North America"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"North America"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Europe"},{"Continent":"Africa"},{"Continent":"North America"},{"Continent":"South America"},{"Continent":"Asia"},{"Continent":"Europe"},{"Continent":"North America"},{"Continent":"South America"},{"Continent":"Asia"},{"Continent":"Oceania"},{"Continent":"Oceania"},{"Continent":"Asia"},{"Continent":"Africa"},{"Continent":"Africa"},{"Continent":"Africa"}] [
{
"Continent": "Europe"
},
{
"Continent": "Asia"
},
{
"Continent": "Asia"
},
{
"Continent": "North America"
},
{
"Continent": "Europe"
},
{
"Continent": "Asia"
},
{
"Continent": "Africa"
},
{
"Continent": "South America"
},
{
"Continent": "Europe"
},
{
"Continent": "Oceania"
},
{
"Continent": "Asia"
},
{
"Continent": "Europe"
},
{
"Continent": "North America"
},
{
"Continent": "Asia"
},
{
"Continent": "Europe"
},
{
"Continent": "Africa"
},
{
"Continent": "Europe"
},
{
"Continent": "Asia"