From ff27cdd04a93e2690504b5c1bd6f946385c6d90e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 27 Sep 2023 10:30:57 +0200 Subject: [PATCH 001/125] Hide attribution on small screen and add a small ? to display it fix #844 --- umap/static/umap/js/umap.controls.js | 13 +++++++++---- umap/static/umap/map.css | 25 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index a364b231..0b0ede80 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -1130,34 +1130,39 @@ L.U.AttributionControl = L.Control.Attribution.extend({ _update: function () { L.Control.Attribution.prototype._update.call(this) + // Use our how container, so we can hide/show on small screens + const credits = this._container.innerHTML + this._container.innerHTML = '' + const container = L.DomUtil.add('div', 'attribution-container', this._container, credits) if (this._map.options.shortCredit) { L.DomUtil.add( 'span', '', - this._container, + container, ` — ${L.Util.toHTML(this._map.options.shortCredit)}` ) } if (this._map.options.captionMenus) { - const link = L.DomUtil.add('a', '', this._container, ` — ${L._('About')}`) + const link = L.DomUtil.add('a', '', container, ` — ${L._('About')}`) L.DomEvent.on(link, 'click', L.DomEvent.stop) .on(link, 'click', this._map.displayCaption, this._map) .on(link, 'dblclick', L.DomEvent.stop) } if (window.top === window.self && this._map.options.captionMenus) { // We are not in iframe mode - const home = L.DomUtil.add('a', '', this._container, ` — ${L._('Home')}`) + const home = L.DomUtil.add('a', '', container, ` — ${L._('Home')}`) home.href = '/' } if (this._map.options.captionMenus) { const poweredBy = L.DomUtil.add( 'a', '', - this._container, + container, ` — ${L._('Powered by uMap')}` ) poweredBy.href = 'https://github.com/umap-project/umap/' } + L.DomUtil.create('a', 'attribution-toggle', this._container) }, }) diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index cef43e39..59a41a05 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -147,7 +147,14 @@ a.umap-control-less { margin-bottom: 5px!important; padding: 0.5rem; } - +.attribution-toggle { + display: none; + width: 24px; + height: 24px; + vertical-align: middle; + background-image: url('./img/16.svg'); + background-position: 0px 0px; +} @@ -1542,6 +1549,22 @@ a.add-datalayer:hover, .umap-popup-large .umap-popup-content { width: 300px; } + + .attribution-toggle { + display: inline-block; + } + .attribution-container { + display: none; + } + .leaflet-control-attribution:active .attribution-container, + .leaflet-control-attribution:hover .attribution-container { + display: inline; + } + .leaflet-control-attribution:hover .attribution-toggle { + display: none; + } + + } /* ****** */ From ff6a994f31b3bbbea338ac3a20a51a342faaa591 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 01:45:13 +0000 Subject: [PATCH 002/125] Bump pillow from 9.5.0 to 10.0.1 Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.5.0 to 10.0.1. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.5.0...10.0.1) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a0869ca9..7831d31a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "django-compressor==4.3.1", "django-environ==0.10.0", "django-probes==1.7.0", - "Pillow==9.5.0", + "Pillow==10.0.1", "psycopg2==2.9.6", "requests==2.31.0", "social-auth-core==4.4.2", From 20cdc837af04b8bbac602c98699c7f70cb9ec20b Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 5 Oct 2023 15:14:28 +0200 Subject: [PATCH 003/125] Use toFixed instead of toPrecision in Range input toPrecision returns an exponential notation for example for: const x = 100 x.toPrecision(2) --- umap/static/umap/js/umap.forms.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 0d983e67..bf9dcdf7 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -854,10 +854,10 @@ L.FormBuilder.Range = L.FormBuilder.Input.extend({ datalist.id = `range-${this.options.label || this.name}` this.input.setAttribute('list', datalist.id) let options = '' + const step = this.options.step || 1, + digits = step < 1 ? 2 : 0 for (let i = this.options.min; i <= this.options.max; i += this.options.step) { - options += `` + options += `` } datalist.innerHTML = options }, From a5c7214533494e5d7ebb9b303b47c7a852b8dcd7 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 5 Oct 2023 17:32:27 +0200 Subject: [PATCH 004/125] Add back helpText on Range input --- umap/static/umap/js/umap.forms.js | 1 + 1 file changed, 1 insertion(+) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index bf9dcdf7..0d522ff5 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -860,6 +860,7 @@ L.FormBuilder.Range = L.FormBuilder.Input.extend({ options += `` } datalist.innerHTML = options + L.FormBuilder.Input.prototype.buildHelpText.call(this) }, }) From 3e54310c8dd80d4aa02c2d563d0b23e21c15b4b8 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 5 Oct 2023 17:36:07 +0200 Subject: [PATCH 005/125] Add Range input for heatmap radius --- umap/static/umap/js/umap.layer.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 7f0b03fc..4f45b859 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -161,8 +161,11 @@ L.U.Layer.Heat = L.HeatLayer.extend({ [ 'options.heat.radius', { - handler: 'BlurIntInput', - placeholder: L._('Heatmap radius'), + handler: 'Range', + min: 10, + max: 100, + step: 5, + label: L._('Heatmap radius'), helpText: L._('Override heatmap radius (default 25)'), }, ], From ca7257658c87a01d32c647d76997382590e5e258 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 5 Oct 2023 18:15:21 +0200 Subject: [PATCH 006/125] Make that Range inherit from FloatInput Otherwise we store a string --- umap/static/umap/js/umap.forms.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 0d522ff5..6faf6531 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -836,13 +836,13 @@ L.FormBuilder.OutlinkTarget = L.FormBuilder.MultiChoice.extend({ ], }) -L.FormBuilder.Range = L.FormBuilder.Input.extend({ +L.FormBuilder.Range = L.FormBuilder.FloatInput.extend({ type: function () { return 'range' }, value: function () { - return L.DomUtil.hasClass(this.wrapper, 'undefined') ? undefined : this.input.value + return L.DomUtil.hasClass(this.wrapper, 'undefined') ? undefined : L.FormBuilder.FloatInput.prototype.value.call(this) }, buildHelpText: function () { From 5bfa2fce385bbcd05dcadb2e765c185ced4c452c Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 5 Oct 2023 18:15:59 +0200 Subject: [PATCH 007/125] Patch HeatLayer for better rendering Patch comes from https://github.com/Leaflet/Leaflet.heat/pull/78 We'll remove it when this PR get merged and released. --- umap/static/umap/js/umap.layer.js | 86 ++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 4f45b859..2a261c54 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -137,14 +137,6 @@ L.U.Layer.Heat = L.HeatLayer.extend({ this.setLatLngs([]) }, - redraw: function () { - // setlalngs call _redraw through setAnimFrame, thus async, so this - // can ends with race condition if we remove the layer very faslty after. - // Remove me when https://github.com/Leaflet/Leaflet.heat/pull/53 is released. - if (!this._map) return - L.HeatLayer.prototype.redraw.call(this) - }, - getFeatures: function () { return {} }, @@ -190,6 +182,84 @@ L.U.Layer.Heat = L.HeatLayer.extend({ } this._updateOptions() }, + + _redraw: function () { + // Import patch from https://github.com/Leaflet/Leaflet.heat/pull/78 + // Remove me when this get merged and released. + if (!this._map) { + return + } + var data = [], + r = this._heat._r, + size = this._map.getSize(), + bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])), + cellSize = r / 2, + grid = [], + panePos = this._map._getMapPanePos(), + offsetX = panePos.x % cellSize, + offsetY = panePos.y % cellSize, + i, + len, + p, + cell, + x, + y, + j, + len2 + + this._max = 1 + + for (i = 0, len = this._latlngs.length; i < len; i++) { + p = this._map.latLngToContainerPoint(this._latlngs[i]) + x = Math.floor((p.x - offsetX) / cellSize) + 2 + y = Math.floor((p.y - offsetY) / cellSize) + 2 + + var alt = + this._latlngs[i].alt !== undefined + ? this._latlngs[i].alt + : this._latlngs[i][2] !== undefined + ? +this._latlngs[i][2] + : 1 + + grid[y] = grid[y] || [] + cell = grid[y][x] + + if (!cell) { + cell = grid[y][x] = [p.x, p.y, alt] + cell.p = p + } else { + cell[0] = (cell[0] * cell[2] + p.x * alt) / (cell[2] + alt) // x + cell[1] = (cell[1] * cell[2] + p.y * alt) / (cell[2] + alt) // y + cell[2] += alt // cumulated intensity value + } + + // Set the max for the current zoom level + if (cell[2] > this._max) { + this._max = cell[2] + } + } + + this._heat.max(this._max) + + for (i = 0, len = grid.length; i < len; i++) { + if (grid[i]) { + for (j = 0, len2 = grid[i].length; j < len2; j++) { + cell = grid[i][j] + if (cell && bounds.contains(cell.p)) { + data.push([ + Math.round(cell[0]), + Math.round(cell[1]), + Math.min(cell[2], this._max), + ]) + } + } + } + } + + this._heat.data(data).draw(this.options.minOpacity) + + this._frame = null + }, }) L.U.DataLayer = L.Evented.extend({ From de6c9f3b849bf5641ac81d981086a4a0d4ef4cb8 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 6 Oct 2023 22:25:56 +0200 Subject: [PATCH 008/125] Make sure we don't use DataLayer.settings by reference --- umap/tests/base.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/umap/tests/base.py b/umap/tests/base.py index 04d7da47..1076275e 100644 --- a/umap/tests/base.py +++ b/umap/tests/base.py @@ -1,4 +1,5 @@ import json +import copy import factory from django.contrib.auth import get_user_model @@ -102,13 +103,16 @@ class DataLayerFactory(factory.django.DjangoModelFactory): name = "test datalayer" description = "test description" display_on_load = True - settings = {"displayOnLoad": True, "browsable": True, "name": name} + settings = factory.Dict({"displayOnLoad": True, "browsable": True, "name": name}) @classmethod def _adjust_kwargs(cls, **kwargs): - data = kwargs.pop("data", DATALAYER_DATA).copy() + data = kwargs.pop("data", copy.deepcopy(DATALAYER_DATA)) kwargs["settings"]["name"] = kwargs["name"] - data["_umap_options"] = kwargs["settings"] + data["_umap_options"] = { + **DataLayerFactory.settings._defaults, + **kwargs["settings"], + } kwargs["geojson"] = ContentFile(json.dumps(data), "foo.json") return kwargs From 547485e50ffd8932aa34418820491f3e924b107e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 6 Oct 2023 22:26:31 +0200 Subject: [PATCH 009/125] Allow to hide a datalayer from the caption list --- umap/static/umap/js/umap.controls.js | 1 + umap/static/umap/js/umap.layer.js | 8 ++++++++ umap/tests/integration/test_map.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index a364b231..66c8849d 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -749,6 +749,7 @@ L.U.Map.include({ } const datalayerContainer = L.DomUtil.create('div', 'datalayer-container', container) this.eachVisibleDataLayer((datalayer) => { + if (!datalayer.options.inCaption) return const p = L.DomUtil.create('p', 'datalayer-legend', datalayerContainer), legend = L.DomUtil.create('span', '', p), headline = L.DomUtil.create('strong', '', p), diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 7f0b03fc..21d41b52 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -192,6 +192,7 @@ L.U.Layer.Heat = L.HeatLayer.extend({ L.U.DataLayer = L.Evented.extend({ options: { displayOnLoad: true, + inCaption: true, browsable: true, editMode: 'advanced', }, @@ -863,6 +864,13 @@ L.U.DataLayer = L.Evented.extend({ helpEntries: 'browsable', }, ], + [ + 'options.inCaption', + { + label: L._('Show this layer in the caption'), + handler: 'Switch', + }, + ], ] const title = L.DomUtil.add('h3', '', container, L._('Layer properties')) let builder = new L.U.FormBuilder(this, metadataFields, { diff --git a/umap/tests/integration/test_map.py b/umap/tests/integration/test_map.py index a5dd0dbc..fdc8dc79 100644 --- a/umap/tests/integration/test_map.py +++ b/umap/tests/integration/test_map.py @@ -3,6 +3,8 @@ from playwright.sync_api import expect from umap.models import Map +from ..base import DataLayerFactory + pytestmark = pytest.mark.django_db @@ -35,3 +37,21 @@ def test_remote_layer_should_not_be_used_as_datalayer_for_created_features( # A new datalayer has been created to host this created feature # given the remote one cannot accept new features expect(layers).to_have_count(2) + + +def test_can_hide_datalayer_from_caption(map, live_server, datalayer, page): + # Faster than doing a login + map.edit_status = Map.ANONYMOUS + map.save() + # Add another DataLayer + other = DataLayerFactory(map=map, name="Hidden", settings={"inCaption": False}) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + toggle = page.get_by_text("About").first + expect(toggle).to_be_visible() + toggle.click() + layers = page.locator(".umap-caption .datalayer-legend") + expect(layers).to_have_count(1) + found = page.locator("#umap-ui-container").get_by_text(datalayer.name) + expect(found).to_be_visible() + hidden = page.locator("#umap-ui-container").get_by_text(other.name) + expect(hidden).to_be_hidden() From da019774140905d388a0ca87756959c89f0b9e61 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 9 Oct 2023 18:30:44 +0200 Subject: [PATCH 010/125] Fix permissions tests --- umap/static/umap/test/Permissions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/test/Permissions.js b/umap/static/umap/test/Permissions.js index 4fb2ae70..979dcd03 100644 --- a/umap/static/umap/test/Permissions.js +++ b/umap/static/umap/test/Permissions.js @@ -36,7 +36,7 @@ describe('L.Permissions', function () { it('should not allow share_status nor owner', function () { this.map.permissions.options.anonymous_edit_url = 'http://anonymous.url' - delete this.map.permissions.options.owner + delete this.map.options.permissions.owner button = qs('a.update-map-permissions') happen.click(button) expect(qs('select[name="share_status"]')).not.to.be.ok @@ -48,8 +48,8 @@ describe('L.Permissions', function () { var button it('should only allow editors', function () { - this.map.permissions.options.owner = { id: 1, url: '/url', name: 'jojo' } - delete this.map.permissions.options.anonymous_edit_url + this.map.options.permissions.owner = { id: 1, url: '/url', name: 'jojo' } + delete this.map.options.permissions.anonymous_edit_url delete this.map.options.user button = qs('a.update-map-permissions') happen.click(button) From 8e12e6cf2413dc1bec77d290020b23ebd56491ed Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Sat, 10 Jun 2023 19:57:54 +0200 Subject: [PATCH 011/125] POC of a choropleth layer --- package.json | 1 + scripts/vendorsjs.sh | 1 + umap/static/umap/js/umap.features.js | 2 +- umap/static/umap/js/umap.forms.js | 1 + umap/static/umap/js/umap.layer.js | 96 +++++++++++++++++++++++++++- umap/templates/umap/js.html | 1 + 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b2995e06..aae95f12 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "homepage": "http://wiki.openstreetmap.org/wiki/UMap", "dependencies": { "@tmcw/togeojson": "^5.8.0", + "chroma-js": "^2.4.2", "csv2geojson": "5.1.1", "dompurify": "^3.0.3", "georsstogeojson": "^0.1.0", diff --git a/scripts/vendorsjs.sh b/scripts/vendorsjs.sh index 751132fc..b12bd27b 100755 --- a/scripts/vendorsjs.sh +++ b/scripts/vendorsjs.sh @@ -26,5 +26,6 @@ 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/ echo 'Done!' diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 7de50be8..7e2428d9 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -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) } diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 0d983e67..f3f022b2 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')], ], }) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 21d41b52..16a3d75b 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -106,6 +106,94 @@ L.U.Layer.Cluster = L.MarkerClusterGroup.extend({ }, }) +L.U.Layer.Choropleth = L.FeatureGroup.extend({ + _type: 'Choropleth', + includes: [L.U.Layer], + canBrowse: true, + + 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 + ) + }, + + computeLimits: function () { + const values = [] + this.datalayer.eachLayer((layer) => values.push(layer.properties.density)) + this.options.limits = chroma.limits( + values, + this.datalayer.options.choropleth.mode || 'q', + this.datalayer.options.choropleth.steps || 5 + ) + const color = this.datalayer.getOption('color') + this.options.colors = chroma + .scale(['white', color]) + .colors(this.options.limits.length) + }, + + getColor: function (feature) { + if (!feature) return // FIXME shold not happen + const featureValue = feature.properties.density + // Find the bucket/step/limit that this value is less than and give it that color + for (let i = 0; i < this.options.limits.length; i++) { + if (featureValue <= this.options.limits[i]) { + return this.options.colors[i] + } + } + }, + + getOption: function (option, feature) { + if (option === 'fillColor' || option === 'color') return this.getColor(feature) + }, + + addLayer: function (layer) { + this.computeLimits() + L.FeatureGroup.prototype.addLayer.call(this, layer) + }, + + removeLayer: function (layer) { + this.computeLimits() + L.FeatureGroup.prototype.removeLayer.call(this, layer) + }, + + onAdd: function (map) { + this.computeLimits() + L.FeatureGroup.prototype.onAdd.call(this, map) + }, + + getEditableOptions: function () { + return [ + [ + 'options.choropleth.steps', + { + handler: 'IntInput', + placeholder: L._('Choropleth steps'), + helpText: L._('Choropleth steps (default 5)'), + }, + ], + [ + 'options.choropleth.mode', + { + handler: 'Select', + selectOptions: [ + ['q', L._('quantile')], + ['e', L._('equidistant')], + ['l', L._('logarithmic')], + ['k', L._('k-mean')], + ], + helpText: L._('Choropleth mode'), + }, + ], + ] + }, +}) + L.U.Layer.Heat = L.HeatLayer.extend({ _type: 'Heat', includes: [L.U.Layer], @@ -897,8 +985,6 @@ L.U.DataLayer = L.Evented.extend({ 'options.fillOpacity', ] - shapeOptions = shapeOptions.concat(this.layer.getEditableOptions()) - const redrawCallback = function (field) { this.hide() this.layer.postUpdate(field) @@ -1050,7 +1136,11 @@ L.U.DataLayer = L.Evented.extend({ this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) }, - getOption: function (option) { + getOption: function (option, feature) { + if (this.layer && this.layer.getOption) { + const value = this.layer.getOption(option, feature) + if (value) return value + } if (L.Util.usableOption(this.options, option)) return this.options[option] else return this.map.getOption(option) }, diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index e2da8669..609eff2d 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -24,6 +24,7 @@ + {% endcompress %} {% if locale %}{% endif %} {% compress js %} From 5d350a7cc987dd5ba370f0fd5482e671df6f2c56 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Sat, 10 Jun 2023 21:17:28 +0200 Subject: [PATCH 012/125] Control property used in choropleth --- umap/static/umap/js/umap.layer.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 16a3d75b..418dccfd 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -123,9 +123,14 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ ) }, + _getValue: function (feature) { + const key = this.datalayer.options.choropleth.property || 'value' + return feature.properties[key] + }, + computeLimits: function () { const values = [] - this.datalayer.eachLayer((layer) => values.push(layer.properties.density)) + this.datalayer.eachLayer((layer) => values.push(this._getValue(layer))) this.options.limits = chroma.limits( values, this.datalayer.options.choropleth.mode || 'q', @@ -139,7 +144,7 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ getColor: function (feature) { if (!feature) return // FIXME shold not happen - const featureValue = feature.properties.density + const featureValue = this._getValue(feature) // Find the bucket/step/limit that this value is less than and give it that color for (let i = 0; i < this.options.limits.length; i++) { if (featureValue <= this.options.limits[i]) { @@ -169,6 +174,14 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ getEditableOptions: function () { return [ + [ + 'options.choropleth.property', + { + handler: 'BlurInput', + placeholder: L._('Choropleth property value'), + helpText: L._('Choropleth property value'), + }, + ], [ 'options.choropleth.steps', { From bf116e8d93d3e57815f2c9697ca59c31e283ee66 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Sat, 10 Jun 2023 22:21:41 +0200 Subject: [PATCH 013/125] Use brewer palettes for choropleth colors cf https://gka.github.io/chroma.js/#chroma-brewer --- umap/static/umap/js/umap.layer.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 418dccfd..24e005c2 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -136,9 +136,8 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ this.datalayer.options.choropleth.mode || 'q', this.datalayer.options.choropleth.steps || 5 ) - const color = this.datalayer.getOption('color') this.options.colors = chroma - .scale(['white', color]) + .scale(this.datalayer.options.choropleth.brewer || 'Blues') .colors(this.options.limits.length) }, @@ -173,6 +172,12 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ }, 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()) + .sort() + .map((k) => [k, k]) return [ [ 'options.choropleth.property', @@ -182,6 +187,14 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ helpText: L._('Choropleth property value'), }, ], + [ + 'options.choropleth.brewer', + { + handler: 'Select', + label: L._('Choropleth color palette'), + selectOptions: brewerPalettes, + }, + ], [ 'options.choropleth.steps', { From 7a0dbd014a5e88aeeea58db26910779e28d7e111 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 12 Jun 2023 15:53:33 +0200 Subject: [PATCH 014/125] There is one more limit than the number of steps Limits are steps boundaries, and first limit is always the lower value, and latest limit always the bigger. --- umap/static/umap/js/umap.layer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 24e005c2..54cdbe23 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -138,16 +138,16 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ ) this.options.colors = chroma .scale(this.datalayer.options.choropleth.brewer || 'Blues') - .colors(this.options.limits.length) + .colors(this.options.limits.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 = 0; i < this.options.limits.length; i++) { + for (let i = 1; i < this.options.limits.length; i++) { if (featureValue <= this.options.limits[i]) { - return this.options.colors[i] + return this.options.colors[i-1] } } }, From 451eb8c0bfd0eba534807544a2c7a51f3cba6720 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 20 Jun 2023 20:32:40 +0200 Subject: [PATCH 015/125] Naive Choropleth legend, WIP --- umap/static/umap/js/umap.controls.js | 1 + umap/static/umap/js/umap.layer.js | 13 +++++++++++++ umap/static/umap/map.css | 11 +++++++++++ 3 files changed, 25 insertions(+) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 66c8849d..ec351921 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.layer.js b/umap/static/umap/js/umap.layer.js index 54cdbe23..66f3acfb 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -218,6 +218,19 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ ], ] }, + + renderLegend: function (container) { + const parent = L.DomUtil.create('ul', '', container) + let li, color, label + + this.options.limits.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(1)} - ${+this.options.limits[index+1].toFixed(1)}` + }) + }, }) L.U.Layer.Heat = L.HeatLayer.extend({ diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index cef43e39..a35008d1 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -1080,6 +1080,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 */ From 6c502c54b4108c3096d9befe89cd96ba1eec227e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 19 Jun 2023 10:02:48 +0200 Subject: [PATCH 016/125] Better defaults for choropleth layer --- umap/static/umap/js/umap.features.js | 5 +++- umap/static/umap/js/umap.layer.js | 35 +++++++++++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 7e2428d9..427a8756 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 @@ -1084,6 +1084,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.layer.js b/umap/static/umap/js/umap.layer.js index 66f3acfb..1adae247 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -110,6 +110,13 @@ 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, + }, initialize: function (datalayer) { this.datalayer = datalayer @@ -136,24 +143,25 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ this.datalayer.options.choropleth.mode || 'q', this.datalayer.options.choropleth.steps || 5 ) + const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor this.options.colors = chroma - .scale(this.datalayer.options.choropleth.brewer || 'Blues') - .colors(this.options.limits.length - 1) + .scale(this.datalayer.options.choropleth.brewer || ['white', fillColor]) + .colors(this.options.limits.length) }, 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++) { + for (let i = 0; i < this.options.limits.length; i++) { if (featureValue <= this.options.limits[i]) { - return this.options.colors[i-1] + return this.options.colors[i] } } }, getOption: function (option, feature) { - if (option === 'fillColor' || option === 'color') return this.getColor(feature) + if (feature && option === feature.staticOptions.mainColor) return this.getColor(feature) }, addLayer: function (layer) { @@ -228,7 +236,9 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ 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(1)} - ${+this.options.limits[index+1].toFixed(1)}` + label.textContent = `${+this.options.limits[index].toFixed( + 1 + )} - ${+this.options.limits[index + 1].toFixed(1)}` }) }, }) @@ -1175,13 +1185,22 @@ L.U.DataLayer = L.Evented.extend({ this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) }, + getOwnOption: function (option) { + if (L.Util.usableOption(this.options, option)) return this.options[option] + }, + getOption: function (option, feature) { if (this.layer && this.layer.getOption) { const value = this.layer.getOption(option, feature) if (value) return value } - if (L.Util.usableOption(this.options, option)) return this.options[option] - else return this.map.getOption(option) + if (this.getOwnOption(option)) { + 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) { From 7b68c52a156942ee0903bf0ab42a42bd7c7ae850 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 19 Jun 2023 13:20:12 +0200 Subject: [PATCH 017/125] Allow to select choropleth property from a list instead of gessing it --- umap/static/umap/js/umap.layer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 1adae247..eda199a6 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -190,7 +190,8 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ [ 'options.choropleth.property', { - handler: 'BlurInput', + handler: 'Select', + selectOptions: this.datalayer._propertiesIndex, placeholder: L._('Choropleth property value'), helpText: L._('Choropleth property value'), }, From 125aa72785457a79f7d84c7cd8e9e4fdc92861a1 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 19 Jun 2023 19:03:51 +0200 Subject: [PATCH 018/125] Make choropleth mode work with lines --- umap/static/umap/js/umap.features.js | 3 ++- umap/static/umap/js/umap.layer.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 427a8756..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: {mainColor: 'color'}, + staticOptions: { mainColor: 'color' }, initialize: function (map, latlng, options) { this.map = map @@ -948,6 +948,7 @@ L.U.Polyline = L.Polyline.extend({ staticOptions: { stroke: true, fill: false, + mainColor: 'color', }, isSameClass: function (other) { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index eda199a6..dd7e569a 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -132,7 +132,7 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ _getValue: function (feature) { const key = this.datalayer.options.choropleth.property || 'value' - return feature.properties[key] + return +feature.properties[key] // TODO: should we catch values non castable to int ? }, computeLimits: function () { From 82bb017b2352081310eb700f69681b186827323e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 3 Jul 2023 18:26:22 +0200 Subject: [PATCH 019/125] Basic Choropleth tests --- umap/static/umap/js/umap.layer.js | 6 +- umap/static/umap/test/Choropleth.js | 242 ++++++++++++++++++++++++++++ umap/static/umap/test/index.html | 2 + 3 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 umap/static/umap/test/Choropleth.js diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index dd7e569a..3b207142 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -146,16 +146,16 @@ L.U.Layer.Choropleth = L.FeatureGroup.extend({ const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor this.options.colors = chroma .scale(this.datalayer.options.choropleth.brewer || ['white', fillColor]) - .colors(this.options.limits.length) + .colors(this.options.limits.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 = 0; i < this.options.limits.length; i++) { + for (let i = 1; i < this.options.limits.length; i++) { if (featureValue <= this.options.limits[i]) { - return this.options.colors[i] + return this.options.colors[i - 1] } } }, diff --git a/umap/static/umap/test/Choropleth.js b/umap/static/umap/test/Choropleth.js new file mode 100644 index 00000000..3682b4ad --- /dev/null +++ b/umap/static/umap/test/Choropleth.js @@ -0,0 +1,242 @@ +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() + 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.options.type = 'Choropleth' + this.datalayer.options.choropleth = { + property: 'value', + } + this.datalayer.resetLayer() + DATALAYER = this.datalayer + // 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') + }) + it('choropleth should compute brewer colors', function () { + this.datalayer.options.choropleth.brewer = 'Blues' + this.datalayer.resetLayer(true) + DATALAYER = this.datalayer + assert.equal(poly1._path.attributes.fill.value, '#f7fbff') + assert.equal(poly4._path.attributes.fill.value, '#c6dbef') + assert.equal(poly9._path.attributes.fill.value, '#08306b') + }) + it('choropleth should allow to change steps', function () { + 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') + }) + }) +}) diff --git a/umap/static/umap/test/index.html b/umap/static/umap/test/index.html index 620d0274..5d922418 100644 --- a/umap/static/umap/test/index.html +++ b/umap/static/umap/test/index.html @@ -25,6 +25,7 @@ + @@ -82,6 +83,7 @@ +