diff --git a/package.json b/package.json
index b2995e06..33d3835c 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"homepage": "http://wiki.openstreetmap.org/wiki/UMap",
"dependencies": {
"@tmcw/togeojson": "^5.8.0",
+ "colorbrewer": "^1.5.6",
"csv2geojson": "5.1.1",
"dompurify": "^3.0.3",
"georsstogeojson": "^0.1.0",
@@ -55,6 +56,7 @@
"leaflet.path.drag": "0.0.6",
"leaflet.photon": "0.8.0",
"osmtogeojson": "^3.0.0-beta.3",
+ "simple-statistics": "^7.8.3",
"togpx": "^0.5.4",
"tokml": "0.4.0"
}
diff --git a/scripts/vendorsjs.sh b/scripts/vendorsjs.sh
index 751132fc..793fbeb5 100755
--- a/scripts/vendorsjs.sh
+++ b/scripts/vendorsjs.sh
@@ -26,5 +26,7 @@ mkdir -p umap/static/umap/vendors/tokml && cp -r node_modules/tokml/tokml.js uma
mkdir -p umap/static/umap/vendors/locatecontrol/ && cp -r node_modules/leaflet.locatecontrol/dist/L.Control.Locate.css umap/static/umap/vendors/locatecontrol/
mkdir -p umap/static/umap/vendors/locatecontrol/ && cp -r node_modules/leaflet.locatecontrol/src/L.Control.Locate.js umap/static/umap/vendors/locatecontrol/
mkdir -p umap/static/umap/vendors/dompurify/ && cp -r node_modules/dompurify/dist/purify.js umap/static/umap/vendors/dompurify/
+mkdir -p umap/static/umap/vendors/colorbrewer/ && cp node_modules/colorbrewer/index.js umap/static/umap/vendors/colorbrewer/colorbrewer.js
+mkdir -p umap/static/umap/vendors/simple-statistics/ && cp node_modules/simple-statistics/dist/simple-statistics.min.js umap/static/umap/vendors/simple-statistics/
echo 'Done!'
diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css
index b5f1ce9a..12cd7d39 100644
--- a/umap/static/umap/base.css
+++ b/umap/static/umap/base.css
@@ -354,7 +354,8 @@ input.switch:checked ~ label:after {
.button-bar.half {
grid-template-columns: 1fr 1fr;
}
-.umap-multiplechoice.by3 {
+.umap-multiplechoice.by3,
+.umap-multiplechoice.by5 {
grid-template-columns: 1fr 1fr 1fr;
}
.umap-multiplechoice.by4 {
diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js
index 78565975..62e0d83a 100644
--- a/umap/static/umap/js/umap.controls.js
+++ b/umap/static/umap/js/umap.controls.js
@@ -598,6 +598,7 @@ L.U.DataLayersControl = L.Control.extend({
L.U.DataLayer.include({
renderLegend: function (container) {
+ if (this.layer.renderLegend) return this.layer.renderLegend(container)
const color = L.DomUtil.create('span', 'datalayer-color', container)
color.style.backgroundColor = this.getColor()
},
diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js
index 7de50be8..5b3b8f7b 100644
--- a/umap/static/umap/js/umap.features.js
+++ b/umap/static/umap/js/umap.features.js
@@ -1,5 +1,5 @@
L.U.FeatureMixin = {
- staticOptions: {},
+ staticOptions: { mainColor: 'color' },
initialize: function (map, latlng, options) {
this.map = map
@@ -283,7 +283,7 @@ L.U.FeatureMixin = {
} else if (L.Util.usableOption(this.properties._umap_options, option)) {
value = this.properties._umap_options[option]
} else if (this.datalayer) {
- value = this.datalayer.getOption(option)
+ value = this.datalayer.getOption(option, this)
} else {
value = this.map.getOption(option)
}
@@ -948,6 +948,7 @@ L.U.Polyline = L.Polyline.extend({
staticOptions: {
stroke: true,
fill: false,
+ mainColor: 'color',
},
isSameClass: function (other) {
@@ -1084,6 +1085,9 @@ L.U.Polyline = L.Polyline.extend({
L.U.Polygon = L.Polygon.extend({
parentClass: L.Polygon,
includes: [L.U.FeatureMixin, L.U.PathMixin],
+ staticOptions: {
+ mainColor: 'fillColor',
+ },
isSameClass: function (other) {
return other instanceof L.U.Polygon
diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js
index 6faf6531..c75d01ec 100644
--- a/umap/static/umap/js/umap.forms.js
+++ b/umap/static/umap/js/umap.forms.js
@@ -379,6 +379,7 @@ L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({
['Default', L._('Default')],
['Cluster', L._('Clustered')],
['Heat', L._('Heatmap')],
+ ['Choropleth', L._('Choropleth')],
],
})
@@ -732,7 +733,7 @@ L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({
fetch: function () {
let value = (this.backup = this.toHTML())
if (!this.container.querySelector(`input[type="radio"][value="${value}"]`))
- value = this.default
+ value = typeof(this.options.default) !== 'undefined' ? this.options.default : this.default
this.container.querySelector(`input[type="radio"][value="${value}"]`).checked = true
},
diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js
index 790b0143..9525c217 100644
--- a/umap/static/umap/js/umap.layer.js
+++ b/umap/static/umap/js/umap.layer.js
@@ -106,6 +106,194 @@ L.U.Layer.Cluster = L.MarkerClusterGroup.extend({
},
})
+L.U.Layer.Choropleth = L.FeatureGroup.extend({
+ _type: 'Choropleth',
+ includes: [L.U.Layer],
+ canBrowse: true,
+ // Have defaults that better suit the choropleth mode.
+ defaults: {
+ color: 'white',
+ fillColor: 'red',
+ fillOpacity: 0.7,
+ weight: 2,
+ },
+ MODES: {
+ kmeans: L._('K-means'),
+ equidistant: L._('Equidistant'),
+ jenks: L._('Jenks-Fisher'),
+ quantiles: L._('Quantiles'),
+ manual: L._('Manual'),
+ },
+
+ initialize: function (datalayer) {
+ this.datalayer = datalayer
+ if (!L.Util.isObject(this.datalayer.options.choropleth)) {
+ this.datalayer.options.choropleth = {}
+ }
+ L.FeatureGroup.prototype.initialize.call(
+ this,
+ [],
+ this.datalayer.options.choropleth
+ )
+ this.datalayer.on('datachanged', this.redraw, this)
+ },
+
+ redraw: function () {
+ this.computeBreaks()
+ if (this._map) this.eachLayer(this._map.addLayer, this._map)
+ },
+
+ _getValue: function (feature) {
+ const key = this.datalayer.options.choropleth.property || 'value'
+ return +feature.properties[key] // TODO: should we catch values non castable to int ?
+ },
+
+ computeBreaks: function () {
+ const values = []
+ this.datalayer.eachLayer((layer) => {
+ let value = this._getValue(layer)
+ if (!isNaN(value)) values.push(value)
+ })
+ if (!values.length) {
+ this.options.breaks = []
+ this.options.colors = []
+ return
+ }
+ let mode = this.datalayer.options.choropleth.mode,
+ classes = +this.datalayer.options.choropleth.classes || 5,
+ breaks = []
+ if (mode === 'manual') {
+ const manualBreaks = this.datalayer.options.choropleth.breaks
+ if (manualBreaks) {
+ breaks = manualBreaks.split(",").map(b => +b).filter(b => !isNaN(b))
+ }
+ } else if (mode === 'equidistant') {
+ breaks = ss.equalIntervalBreaks(values, classes)
+ } else if (mode === 'jenks') {
+ breaks = ss.jenks(values, classes)
+ } else if (mode === 'quantiles') {
+ const quantiles = [...Array(classes)].map((e, i) => i/classes).concat(1)
+ breaks = ss.quantile(values, quantiles)
+ } else {
+ breaks = ss.ckmeans(values, classes).map((cluster) => cluster[0])
+ breaks.push(ss.max(values)) // Needed for computing the legend
+ }
+ this.options.breaks = breaks
+ this.datalayer.options.choropleth.breaks = this.options.breaks.map(b => +b.toFixed(2)).join(',')
+ const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor
+ let colorScheme = this.datalayer.options.choropleth.brewer
+ if (!colorbrewer[colorScheme]) colorScheme = 'Blues'
+ this.options.colors = colorbrewer[colorScheme][breaks.length - 1] || []
+ },
+
+ getColor: function (feature) {
+ if (!feature) return // FIXME shold not happen
+ const featureValue = this._getValue(feature)
+ // Find the bucket/step/limit that this value is less than and give it that color
+ for (let i = 1; i < this.options.breaks.length; i++) {
+ if (featureValue <= this.options.breaks[i]) {
+ return this.options.colors[i - 1]
+ }
+ }
+ },
+
+ getOption: function (option, feature) {
+ if (feature && option === feature.staticOptions.mainColor) return this.getColor(feature)
+ },
+
+ addLayer: function (layer) {
+ // Do not add yet the layer to the map
+ // wait for datachanged event, so we want compute breaks once
+ var id = this.getLayerId(layer)
+ this._layers[id] = layer
+ return this
+ },
+
+ onAdd: function (map) {
+ this.computeBreaks()
+ L.FeatureGroup.prototype.onAdd.call(this, map)
+ },
+
+ postUpdate: function (e) {
+ if (e.helper.field === 'options.choropleth.breaks') {
+ this.datalayer.options.choropleth.mode = 'manual'
+ e.helper.builder.helpers["options.choropleth.mode"].fetch()
+ }
+ this.computeBreaks()
+ if (e.helper.field !== 'options.choropleth.breaks') {
+ e.helper.builder.helpers["options.choropleth.breaks"].fetch()
+ }
+ },
+
+ getEditableOptions: function () {
+ const brewerSchemes = Object.keys(colorbrewer)
+ .filter((k) => k !== 'schemeGroups')
+ .sort()
+
+ return [
+ [
+ 'options.choropleth.property',
+ {
+ handler: 'Select',
+ selectOptions: this.datalayer._propertiesIndex,
+ label: L._('Choropleth property value'),
+ },
+ ],
+ [
+ 'options.choropleth.brewer',
+ {
+ handler: 'Select',
+ label: L._('Choropleth color palette'),
+ selectOptions: brewerSchemes,
+ },
+ ],
+ [
+ 'options.choropleth.classes',
+ {
+ handler: 'Range',
+ min: 3,
+ max: 9,
+ step: 1,
+ label: L._('Choropleth classes'),
+ helpText: L._('Number of desired classes (default 5)'),
+ },
+ ],
+ [
+ 'options.choropleth.breaks',
+ {
+ handler: 'BlurInput',
+ label: L._('Choropleth breakpoints'),
+ helpText: L._('Comma separated list of numbers, including min and max values.'),
+ },
+ ],
+ [
+ 'options.choropleth.mode',
+ {
+ handler: 'MultiChoice',
+ default: 'kmeans',
+ choices: Object.entries(this.MODES),
+ label: L._('Choropleth mode'),
+ },
+ ],
+ ]
+ },
+
+ renderLegend: function (container) {
+ const parent = L.DomUtil.create('ul', '', container)
+ let li, color, label
+
+ this.options.breaks.slice(0, -1).forEach((limit, index) => {
+ li = L.DomUtil.create('li', '', parent)
+ color = L.DomUtil.create('span', 'datalayer-color', li)
+ color.style.backgroundColor = this.options.colors[index]
+ label = L.DomUtil.create('span', '', li)
+ label.textContent = `${+this.options.breaks[index].toFixed(
+ 1
+ )} - ${+this.options.breaks[index + 1].toFixed(1)}`
+ })
+ },
+})
+
L.U.Layer.Heat = L.HeatLayer.extend({
_type: 'Heat',
includes: [L.U.Layer],
@@ -399,7 +587,7 @@ L.U.DataLayer = L.Evented.extend({
if (visible) this.map.removeLayer(this.layer)
const Class = L.U.Layer[this.options.type] || L.U.Layer.Default
this.layer = new Class(this)
- this.eachLayer((feature) => this.showFeature(feature))
+ this.eachLayer(this.showFeature)
if (visible) this.show()
this.propagateRemote()
},
@@ -970,11 +1158,9 @@ L.U.DataLayer = L.Evented.extend({
'options.fillOpacity',
]
- shapeOptions = shapeOptions.concat(this.layer.getEditableOptions())
-
- const redrawCallback = function (field) {
+ const redrawCallback = function (e) {
this.hide()
- this.layer.postUpdate(field)
+ this.layer.postUpdate(e)
this.show()
}
@@ -1123,9 +1309,22 @@ L.U.DataLayer = L.Evented.extend({
this.map.ui.openPanel({ data: { html: container }, className: 'dark' })
},
- getOption: function (option) {
+ getOwnOption: function (option) {
if (L.Util.usableOption(this.options, option)) return this.options[option]
- else return this.map.getOption(option)
+ },
+
+ getOption: function (option, feature) {
+ if (this.layer && this.layer.getOption) {
+ const value = this.layer.getOption(option, feature)
+ if (typeof value !== 'undefined') return value
+ }
+ if (typeof this.getOwnOption(option) !== 'undefined') {
+ return this.getOwnOption(option)
+ } else if (this.layer && this.layer.defaults && this.layer.defaults[option]) {
+ return this.layer.defaults[option]
+ } else {
+ return this.map.getOption(option)
+ }
},
buildVersionsFieldset: function (container) {
diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css
index 2f51dc01..483316e9 100644
--- a/umap/static/umap/map.css
+++ b/umap/static/umap/map.css
@@ -1087,6 +1087,17 @@ a.add-datalayer:hover,
vertical-align: middle;
}
+.datalayer-legend {
+ color: #555;
+ padding: 6px 8px;
+ box-shadow: 0 0 3px rgba(0,0,0,0.2);
+ border-radius: 1px;
+}
+.datalayer-legend ul {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+}
/* ********************************* */
/* Popup */
diff --git a/umap/static/umap/test/Choropleth.js b/umap/static/umap/test/Choropleth.js
new file mode 100644
index 00000000..f87e7cce
--- /dev/null
+++ b/umap/static/umap/test/Choropleth.js
@@ -0,0 +1,243 @@
+const POLYGONS = {
+ _umap_options: defaultDatalayerData(),
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 1',
+ value: 45,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [0, 49],
+ [-2, 47],
+ [1, 46],
+ [3, 47],
+ [0, 49],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 2',
+ value: 87,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [0, 49],
+ [2, 50],
+ [6, 49],
+ [4, 47],
+ [0, 49],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 3',
+ value: 673,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [4, 47],
+ [6, 49],
+ [11, 47],
+ [9, 45],
+ [4, 47],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 4',
+ value: 674,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [2, 46],
+ [4, 47],
+ [8, 45],
+ [6, 43],
+ [2, 46],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 5',
+ value: 839,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-2, 47],
+ [1, 46],
+ [0, 44],
+ [-4, 45],
+ [-2, 47],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 6',
+ value: 3829,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [1, 45],
+ [5, 43],
+ [4, 42],
+ [0, 44],
+ [1, 45],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 7',
+ value: 4900,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [9, 45],
+ [12, 47],
+ [15, 45],
+ [13, 43],
+ [9, 45],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 8',
+ value: 4988,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [7, 43],
+ [9, 45],
+ [12, 43],
+ [10, 42],
+ [7, 43],
+ ],
+ ],
+ },
+ },
+ {
+ type: 'Feature',
+ properties: {
+ name: 'number 9',
+ value: 9898,
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [4, 42],
+ [6, 43],
+ [9, 41],
+ [7, 40],
+ [4, 42],
+ ],
+ ],
+ },
+ },
+ ],
+}
+
+describe('L.U.Choropleth', function () {
+ let path = '/map/99/datalayer/edit/62/',
+ poly1,
+ poly4,
+ poly9
+
+ before(function () {
+ this.server = sinon.fakeServer.create()
+ this.server.respondWith(/\/datalayer\/62\/\?.*/, JSON.stringify(POLYGONS))
+ this.map = initMap({ umap_id: 99 })
+ this.datalayer = this.map.getDataLayerByUmapId(62)
+ this.server.respond()
+ this.datalayer.options.type = 'Choropleth'
+ this.datalayer.options.choropleth = {
+ property: 'value',
+ }
+ enableEdit()
+ this.datalayer.eachLayer(function (layer) {
+ if (layer.properties.name === 'number 1') {
+ poly1 = layer
+ } else if (layer.properties.name === 'number 4') {
+ poly4 = layer
+ } else if (layer.properties.name === 'number 9') {
+ poly9 = layer
+ }
+ })
+ })
+ after(function () {
+ this.server.restore()
+ //resetMap()
+ })
+
+ describe('#init()', function () {
+ it('datalayer should have 9 features', function () {
+ assert.equal(this.datalayer._index.length, 9)
+ })
+ })
+ describe('#compute()', function () {
+ it('choropleth should compute default colors', function () {
+ this.datalayer.resetLayer(true)
+ assert.deepEqual(
+ this.datalayer.layer.options.breaks,
+ [45, 673, 3829, 4900, 9898, 9898]
+ )
+ assert.equal(poly1._path.attributes.fill.value, '#eff3ff')
+ assert.equal(poly4._path.attributes.fill.value, '#bdd7e7')
+ assert.equal(poly9._path.attributes.fill.value, '#3182bd')
+ })
+ it('can change brewer scheme', function () {
+ this.datalayer.options.choropleth.brewer = 'Reds'
+ this.datalayer.resetLayer(true)
+ assert.equal(poly1._path.attributes.fill.value, '#fee5d9')
+ assert.equal(poly4._path.attributes.fill.value, '#fcae91')
+ assert.equal(poly9._path.attributes.fill.value, '#de2d26')
+ })
+ it('choropleth should allow to change steps', function () {
+ this.datalayer.options.choropleth.brewer = 'Blues'
+ this.datalayer.options.choropleth.classes = 6
+ this.datalayer.resetLayer(true)
+ assert.equal(poly1._path.attributes.fill.value, '#eff3ff')
+ assert.equal(poly4._path.attributes.fill.value, '#c6dbef')
+ assert.equal(poly9._path.attributes.fill.value, '#3182bd')
+ })
+ })
+})
diff --git a/umap/static/umap/test/index.html b/umap/static/umap/test/index.html
index 620d0274..e56ff9b5 100644
--- a/umap/static/umap/test/index.html
+++ b/umap/static/umap/test/index.html
@@ -25,6 +25,8 @@
+
+
@@ -82,6 +84,7 @@
+