diff --git a/package.json b/package.json index aae95f12..363f9865 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@tmcw/togeojson": "^5.8.0", "chroma-js": "^2.4.2", + "colorbrewer": "^1.5.6", "csv2geojson": "5.1.1", "dompurify": "^3.0.3", "georsstogeojson": "^0.1.0", @@ -56,6 +57,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 b12bd27b..793fbeb5 100755 --- a/scripts/vendorsjs.sh +++ b/scripts/vendorsjs.sh @@ -26,6 +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/chroma/ && cp -r node_modules/chroma-js/chroma.min.js umap/static/umap/vendors/chroma/ +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/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 5266c848..ff806eba 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -117,6 +117,12 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ fillOpacity: 0.7, weight: 2, }, + MODES: { + kmeans: L._('K-means'), + equidistant: L._('Equidistant'), + jenks: L._('Jenks-Fisher'), + quantiles: L._('Quantiles'), + }, initialize: function (datalayer) { this.datalayer = datalayer @@ -132,7 +138,7 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ }, redraw: function () { - this.computeLimits() + this.computeBreaks() if (this._map) this.eachLayer(this._map.addLayer, this._map) }, @@ -141,26 +147,44 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ return +feature.properties[key] // TODO: should we catch values non castable to int ? }, - computeLimits: function () { + computeBreaks: function () { const values = [] - this.datalayer.eachLayer((layer) => values.push(this._getValue(layer))) - this.options.limits = chroma.limits( - values, - this.datalayer.options.choropleth.mode || 'q', - this.datalayer.options.choropleth.steps || 5 - ) + 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, + steps = +this.datalayer.options.choropleth.steps || 5, + breaks + if (mode === 'equidistant') { + breaks = ss.equalIntervalBreaks(values, steps) + } else if (mode === 'jenks') { + breaks = ss.jenks(values, steps) + } else if (mode === 'quantiles') { + const quantiles = [...Array(steps)].map((e, i) => i/steps).concat(1) + breaks = ss.quantile(values, quantiles) + } else { + breaks = ss.ckmeans(values, steps).map((cluster) => cluster[0]) + breaks.push(ss.max(values)) // Needed for computing the legend + } + this.options.breaks = breaks const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor - this.options.colors = chroma - .scale(this.datalayer.options.choropleth.brewer || ['#f7f7f7', fillColor]) - .colors(this.options.limits.length - 1) + 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.limits.length; i++) { - if (featureValue <= this.options.limits[i]) { + for (let i = 1; i < this.options.breaks.length; i++) { + if (featureValue <= this.options.breaks[i]) { return this.options.colors[i - 1] } } @@ -172,24 +196,22 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ addLayer: function (layer) { // Do not add yet the layer to the map - // wait for datachanged event, so we want compute limits once - var id = this.getLayerId(layer); - this._layers[id] = layer; - return this; + // 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.computeLimits() + this.computeBreaks() L.FeatureGroup.prototype.onAdd.call(this, map) }, getEditableOptions: function () { - // chroma expose each palette both in title mode and in lowercase - // TODO: PR to chroma to get a accessor to the palettes names list - const brewerPalettes = Object.keys(chroma.brewer) - .filter((s) => s[0] == s[0].toUpperCase()) + const brewerSchemes = Object.keys(colorbrewer) + .filter((k) => k !== 'schemeGroups') .sort() - .map((k) => [k, k]) + return [ [ 'options.choropleth.property', @@ -205,7 +227,7 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ { handler: 'Select', label: L._('Choropleth color palette'), - selectOptions: brewerPalettes, + selectOptions: brewerSchemes, }, ], [ @@ -223,13 +245,8 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ 'options.choropleth.mode', { handler: 'MultiChoice', - default: 'q', - choices: [ - ['q', L._('quantile')], - ['e', L._('equidistant')], - ['l', L._('logarithmic')], - ['k', L._('k-mean')], - ], + default: 'kmeans', + choices: Object.entries(this.MODES), label: L._('Choropleth mode'), }, ], @@ -240,14 +257,14 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ const parent = L.DomUtil.create('ul', '', container) let li, color, label - this.options.limits.slice(0, -1).forEach((limit, index) => { + 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.limits[index].toFixed( + label.textContent = `${+this.options.breaks[index].toFixed( 1 - )} - ${+this.options.limits[index + 1].toFixed(1)}` + )} - ${+this.options.breaks[index + 1].toFixed(1)}` }) }, }) diff --git a/umap/static/umap/test/Choropleth.js b/umap/static/umap/test/Choropleth.js index cb8566e7..faf2daac 100644 --- a/umap/static/umap/test/Choropleth.js +++ b/umap/static/umap/test/Choropleth.js @@ -216,26 +216,28 @@ describe('L.U.Choropleth', function () { describe('#compute()', function () { it('choropleth should compute default colors', function () { this.datalayer.resetLayer(true) - // Does not pass because chroma-js seems to have rounding issues - //assert.deepEqual(this.datalayer.layer.options.limits, [45, 438.6, 707.0, 3231.0, 4935.2, 9898]) - assert.equal(poly1._path.attributes.fill.value, '#ffffff') - assert.equal(poly4._path.attributes.fill.value, '#ffbfbf') - assert.equal(poly9._path.attributes.fill.value, '#ff0000') + 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('choropleth should compute brewer colors', function () { - this.datalayer.options.choropleth.brewer = 'Blues' + it('can change brewer scheme', function () { + this.datalayer.options.choropleth.brewer = 'Reds' this.datalayer.resetLayer(true) - assert.equal(poly1._path.attributes.fill.value, '#f7fbff') - assert.equal(poly4._path.attributes.fill.value, '#c6dbef') - assert.equal(poly9._path.attributes.fill.value, '#08306b') + 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.steps = 6 this.datalayer.resetLayer(true) - assert.equal(poly1._path.attributes.fill.value, '#f7fbff') - assert.equal(poly4._path.attributes.fill.value, '#94c4df') - assert.equal(poly9._path.attributes.fill.value, '#08306b') + 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 5d922418..e56ff9b5 100644 --- a/umap/static/umap/test/index.html +++ b/umap/static/umap/test/index.html @@ -25,7 +25,8 @@ - + + diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index 609eff2d..11693494 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -24,7 +24,8 @@ - + + {% endcompress %} {% if locale %}{% endif %} {% compress js %} diff --git a/umap/tests/fixtures/choropleth_region_chomage.geojson b/umap/tests/fixtures/choropleth_region_chomage.geojson index 9f8ef29a..0cd46b93 100644 --- a/umap/tests/fixtures/choropleth_region_chomage.geojson +++ b/umap/tests/fixtures/choropleth_region_chomage.geojson @@ -12,4 +12,4 @@ {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[2.062908,44.976505],[2.09421,44.872012],[2.171637,44.790027],[2.169416,44.63807],[2.326791,44.669693],[2.435001,44.638875],[2.556123,44.721284],[2.602682,44.843163],[2.738258,44.941219],[2.849652,44.87149],[2.923267,44.728643],[2.998574,44.674443],[3.105495,44.886775],[3.182317,44.863735],[3.337942,44.955907],[3.412832,44.944842],[3.475771,44.815371],[3.740649,44.838697],[3.875462,44.740627],[3.905171,44.592709],[4.068445,44.405112],[4.051457,44.317322],[4.25885,44.264784],[4.336071,44.339519],[4.503539,44.340188],[4.649227,44.27036],[4.762255,44.325382],[4.81409,44.232315],[5.060561,44.308137],[5.1549,44.230941],[5.384527,44.201049],[5.454715,44.119226],[5.576192,44.188037],[5.686443,44.197158],[5.631598,44.328306],[5.49307,44.337174],[5.418533,44.424945],[5.597253,44.543274],[5.649631,44.617885],[5.790624,44.653293],[5.850394,44.750747],[6.136227,44.864072],[6.355363,44.854775],[6.318202,45.003859],[6.203923,45.012471],[6.229392,45.10875],[6.331295,45.118124],[6.393911,45.061818],[6.629992,45.109325],[6.767941,45.15974],[6.849855,45.127165],[6.968762,45.208058],[7.137593,45.255693],[7.110693,45.326509],[7.184271,45.407484],[7.000332,45.504414],[7.000692,45.6399],[6.829113,45.702831],[6.818078,45.834974],[6.939609,45.846733],[7.043891,45.922087],[7.018252,45.984185],[6.81473,46.129696],[6.864511,46.282986],[6.722865,46.40755],[6.545176,46.394725],[6.390033,46.340163],[6.279914,46.351093],[6.295651,46.226055],[6.126621,46.14046],[5.985317,46.143309],[5.971781,46.211519],[6.124246,46.251016],[6.169736,46.367935],[6.064006,46.416223],[5.908936,46.283951],[5.725182,46.260732],[5.649345,46.339495],[5.473052,46.265067],[5.310563,46.44677],[5.20114,46.508211],[5.052372,46.484874],[4.940022,46.517199],[4.780208,46.176676],[4.69311,46.302197],[4.618558,46.264794],[4.405814,46.296058],[4.389398,46.213601],[4.261025,46.178754],[4.104087,46.198391],[3.988788,46.169805],[3.890131,46.214487],[3.891239,46.285246],[3.986627,46.319196],[3.99804,46.465464],[3.890467,46.481246],[3.743289,46.567565],[3.696958,46.660583],[3.598001,46.723983],[3.215545,46.682893],[3.032063,46.794909],[2.959919,46.803872],[2.774489,46.718903],[2.70497,46.73939],[2.596648,46.637215],[2.614961,46.553276],[2.352004,46.512207],[2.281044,46.420404],[2.323023,46.329277],[2.478945,46.281146],[2.5598,46.173367],[2.59442,45.989441],[2.492228,45.86403],[2.388014,45.827373],[2.52836,45.681924],[2.465345,45.60082],[2.516327,45.553428],[2.487472,45.418842],[2.37825,45.414302],[2.350481,45.327561],[2.195364,45.220851],[2.171759,45.081497],[2.062908,44.976505]]]},"properties":{"code":"84","nom":"Auvergne-Rhône-Alpes","taux":"6.1"}}, {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[4.230281,43.460184],[4.554917,43.446213],[4.562798,43.372135],[4.855045,43.332619],[4.86685,43.404678],[4.96771,43.4261],[5.04104,43.327285],[5.36205,43.32196],[5.363649,43.207122],[5.53693,43.21449],[5.600895,43.162546],[5.812732,43.109367],[6.124052,43.079307],[6.387568,43.1449],[6.635535,43.172509],[6.665956,43.31822],[6.739809,43.412882],[6.917972,43.447739],[6.971833,43.545451],[7.040444,43.541583],[7.167666,43.657402],[7.320289,43.691329],[7.528519,43.790518],[7.495441,43.864356],[7.648598,43.97411],[7.716938,44.081763],[7.670853,44.153737],[7.426953,44.112875],[7.188913,44.197801],[7.008059,44.236435],[6.896505,44.374301],[6.854014,44.529125],[6.933509,44.575953],[6.987061,44.690138],[7.006773,44.839316],[6.859866,44.852903],[6.749751,44.907359],[6.740812,45.016733],[6.629992,45.109325],[6.393911,45.061818],[6.331295,45.118124],[6.229392,45.10875],[6.203923,45.012471],[6.318202,45.003859],[6.355363,44.854775],[6.136227,44.864072],[5.850394,44.750747],[5.790624,44.653293],[5.649631,44.617885],[5.597253,44.543274],[5.418533,44.424945],[5.49307,44.337174],[5.631598,44.328306],[5.686443,44.197158],[5.576192,44.188037],[5.454715,44.119226],[5.384527,44.201049],[5.1549,44.230941],[5.060561,44.308137],[4.81409,44.232315],[4.762255,44.325382],[4.649227,44.27036],[4.722071,44.187421],[4.70746,44.10367],[4.8421,43.986474],[4.690546,43.883899],[4.593035,43.68746],[4.487234,43.699241],[4.409353,43.561127],[4.230281,43.460184]]]},"properties":{"code":"93","nom":"Provence-Alpes-Côte d'Azur","taux":"7.8"}}, {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.402271,41.858702],[9.412569,41.952476],[9.54998,42.104166],[9.558829,42.285265],[9.533196,42.545947],[9.449194,42.66224],[9.492385,42.8051],[9.463558,42.986401],[9.340873,42.994465],[9.311015,42.834679],[9.344478,42.73781],[9.085764,42.714609],[9.020694,42.644273],[8.886527,42.628966],[8.666509,42.515224],[8.674792,42.476243],[8.555885,42.36475],[8.689105,42.263528],[8.570341,42.230301],[8.590174,42.163885],[8.741329,42.040912],[8.5977,41.953238],[8.64145,41.909889],[8.803133,41.891381],[8.717242,41.722775],[8.914508,41.689724],[8.793077,41.629554],[8.788535,41.557736],[9.082201,41.441974],[9.219679,41.368212],[9.327205,41.616357],[9.387491,41.657359],[9.402271,41.858702]]]},"properties":{"code":"94","nom":"Corse","taux":"6.2"}} -],"_umap_options": {"displayOnLoad": true,"browsable": true,"name": "Taux de chômage","labelKey": "{nom} ({taux})","type": "Choropleth","choropleth": {"property": "taux","steps": "5","brewer": "Blues"}}} +],"_umap_options": {"displayOnLoad": true,"browsable": true,"name": "Taux de chômage","labelKey": "{nom} ({taux})","type": "Choropleth","choropleth": {"property": "taux"}}} diff --git a/umap/tests/integration/test_map.py b/umap/tests/integration/test_map.py index 8f9b7a63..a40e55d6 100644 --- a/umap/tests/integration/test_map.py +++ b/umap/tests/integration/test_map.py @@ -65,18 +65,18 @@ def test_basic_choropleth_map(map, live_server, page): data = json.loads(path.read_text()) DataLayerFactory(data=data, map=map) page.goto(f"{live_server.url}{map.get_absolute_url()}") - # Hauts-de-France, PACA, Occitanie - paths = page.locator("path[fill='#08306b']") - expect(paths).to_have_count(3) - # Normandie, Grand-Est, Centre-Val-de-Loire, IdF - paths = page.locator("path[fill='#2171b5']") - expect(paths).to_have_count(4) - # Bourgogne-Franceh-Comté - paths = page.locator("path[fill='#6baed6']") + # Hauts-de-France + paths = page.locator("path[fill='#08519c']") expect(paths).to_have_count(1) - # Corse, Nouvelle-Aquitaine - paths = page.locator("path[fill='#c6dbef']") + # Occitanie + paths = page.locator("path[fill='#3182bd']") + expect(paths).to_have_count(1) + # Grand-Est, PACA + paths = page.locator("path[fill='#6baed6']") expect(paths).to_have_count(2) + # Bourgogne-Franche-Comté, Centre-Val-de-Loire, IdF, Normandie, Corse, Nouvelle-Aquitaine + paths = page.locator("path[fill='#bdd7e7']") + expect(paths).to_have_count(6) # Bretagne, Pays de la Loire, AURA - paths = page.locator("path[fill='#f7fbff']") + paths = page.locator("path[fill='#eff3ff']") expect(paths).to_have_count(3)