Choropleth: replace chromajs by colorbrewer + simple-statistics

simple-statistics has a few advantages:
- faster
- more accurate kmeans algo
- Jenks-Fisher algo

Also, I suspect will use it again for next step, which is Bubble
mode layer.
This commit is contained in:
Yohan Boniface 2023-10-11 19:36:12 +02:00
parent e97e566c42
commit 739626351c
8 changed files with 86 additions and 62 deletions

View file

@ -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"
}

View file

@ -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!'

View file

@ -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)}`
})
},
})

View file

@ -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')
})
})
})

View file

@ -25,7 +25,8 @@
<script src="../vendors/dompurify/purify.js"></script>
<script src="../vendors/togpx/togpx.js"></script>
<script src="../vendors/tokml/tokml.js"></script>
<script src="../vendors/chroma/chroma.min.js"></script>
<script src="../vendors/simple-statistics/simple-statistics.min.js"></script>
<script src="../vendors/colorbrewer/colorbrewer.js"></script>
<script src="../js/umap.core.js"></script>
<script src="../js/umap.autocomplete.js"></script>
<script src="../js/umap.popup.js"></script>

View file

@ -24,7 +24,8 @@
<script src="{{ STATIC_URL }}umap/vendors/tokml/tokml.js"></script>
<script src="{{ STATIC_URL }}umap/vendors/locatecontrol/L.Control.Locate.js"></script>
<script src="{{ STATIC_URL }}umap/vendors/dompurify/purify.js"></script>
<script src="{{ STATIC_URL }}umap/vendors/chroma/chroma.min.js"></script>
<script src="{{ STATIC_URL }}umap/vendors/colorbrewer/colorbrewer.js"></script>
<script src="{{ STATIC_URL }}umap/vendors/simple-statistics/simple-statistics.min.js"></script>
{% endcompress %}
{% if locale %}<script src="{{ STATIC_URL }}umap/locale/{{ locale }}.js"></script>{% endif %}
{% compress js %}

View file

@ -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"}}}

View file

@ -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)