From 6b60b0de64051c8b61bdaf46f4acd5bea507fbab Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 12 Aug 2024 14:56:41 +0200 Subject: [PATCH 1/4] wip: add "Proportional Circles" layer --- umap/static/umap/js/modules/data/features.js | 27 +-- umap/static/umap/js/modules/data/layer.js | 13 +- .../layers/{relative.js => classified.js} | 111 ++++++++- umap/static/umap/js/modules/rendering/ui.js | 32 ++- umap/static/umap/js/modules/schema.js | 4 + .../tests/fixtures/test_circles_layer.geojson | 219 ++++++++++++++++++ umap/tests/integration/test_circles_layer.py | 69 ++++++ umap/tests/integration/test_edit_datalayer.py | 2 +- 8 files changed, 442 insertions(+), 35 deletions(-) rename umap/static/umap/js/modules/rendering/layers/{relative.js => classified.js} (80%) create mode 100644 umap/tests/fixtures/test_circles_layer.geojson create mode 100644 umap/tests/integration/test_circles_layer.py diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index e15b4924..be97494f 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -118,6 +118,10 @@ class Feature { this._ui = new klass(this, this.toLatLngs()) } + getUIClass() { + return this.getOption('UIClass') || this.getDefaultUIClass() + } + getClassName() { return this.staticOptions.className } @@ -597,7 +601,7 @@ export class Point extends Feature { return { coordinates: GeoJSON.latLngToCoords(latlng), type: 'Point' } } - getUIClass() { + getDefaultUIClass() { return LeafletMarker } @@ -690,21 +694,6 @@ class Path extends Feature { L.DomEvent.stop(event) } - getStyleOptions() { - return [ - 'smoothFactor', - 'color', - 'opacity', - 'stroke', - 'weight', - 'fill', - 'fillColor', - 'fillOpacity', - 'dashArray', - 'interactive', - ] - } - getShapeOptions() { return [ 'properties._umap_options.color', @@ -723,7 +712,7 @@ class Path extends Feature { getStyle() { const options = {} - for (const option of this.getStyleOptions()) { + for (const option of this.ui.getStyleOptions()) { options[option] = this.getDynamicOption(option) } if (options.interactive) options.pointerEvents = 'visiblePainted' @@ -816,7 +805,7 @@ export class LineString extends Path { return !this.coordinates.length } - getUIClass() { + getDefaultUIClass() { return LeafletPolyline } @@ -933,7 +922,7 @@ export class Polygon extends Path { return !this.coordinates.length || !this.coordinates[0].length } - getUIClass() { + getDefaultUIClass() { if (this.getOption('mask')) return MaskPolygon return LeafletPolygon } diff --git a/umap/static/umap/js/modules/data/layer.js b/umap/static/umap/js/modules/data/layer.js index 915fe5ba..151eee46 100644 --- a/umap/static/umap/js/modules/data/layer.js +++ b/umap/static/umap/js/modules/data/layer.js @@ -11,7 +11,7 @@ import * as Utils from '../utils.js' import { Default as DefaultLayer } from '../rendering/layers/base.js' import { Cluster } from '../rendering/layers/cluster.js' import { Heat } from '../rendering/layers/heat.js' -import { Categorized, Choropleth } from '../rendering/layers/relative.js' +import { Categorized, Choropleth, Circles } from '../rendering/layers/classified.js' import { uMapAlert as Alert, uMapAlertConflict as AlertConflict, @@ -21,7 +21,14 @@ import { DataLayerPermissions } from '../permissions.js' import { Point, LineString, Polygon } from './features.js' import TableEditor from '../tableeditor.js' -export const LAYER_TYPES = [DefaultLayer, Cluster, Heat, Choropleth, Categorized] +export const LAYER_TYPES = [ + DefaultLayer, + Cluster, + Heat, + Choropleth, + Categorized, + Circles, +] const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => { acc[klass.TYPE] = klass @@ -190,6 +197,8 @@ export class DataLayer { if (visible) this.map.removeLayer(this.layer) const Class = LAYER_MAP[this.options.type] || DefaultLayer this.layer = new Class(this) + // Rendering layer changed, so let's force reset the feature rendering too. + this.eachFeature((feature) => feature.makeUI()) this.eachFeature(this.showFeature) if (visible) this.show() this.propagateRemote() diff --git a/umap/static/umap/js/modules/rendering/layers/relative.js b/umap/static/umap/js/modules/rendering/layers/classified.js similarity index 80% rename from umap/static/umap/js/modules/rendering/layers/relative.js rename to umap/static/umap/js/modules/rendering/layers/classified.js index 010d76a1..b8b3ad73 100644 --- a/umap/static/umap/js/modules/rendering/layers/relative.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -2,11 +2,12 @@ import { FeatureGroup, DomUtil } from '../../../../vendors/leaflet/leaflet-src.e import { translate } from '../../i18n.js' import { LayerMixin } from './base.js' import * as Utils from '../../utils.js' +import { CircleMarker } from '../ui.js' // Layer where each feature color is relative to the others, // so we need all features before behing able to set one // feature layer -const RelativeColorLayerMixin = { +const ClassifiedMixin = { initialize: function (datalayer) { this.datalayer = datalayer this.colorSchemes = Object.keys(colorbrewer) @@ -16,10 +17,13 @@ const RelativeColorLayerMixin = { if (!Utils.isObject(this.datalayer.options[key])) { this.datalayer.options[key] = {} } + this.ensureOptions(this.datalayer.options[key]) FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key]) LayerMixin.onInit.call(this, this.datalayer.map) }, + ensureOptions: () => {}, + dataChanged: function () { this.redraw() }, @@ -29,9 +33,15 @@ const RelativeColorLayerMixin = { if (this._map) this.eachLayer(this._map.addLayer, this._map) }, + getStyleProperty: (feature) => { + return feature.staticOptions.mainColor + }, + getOption: function (option, feature) { - if (feature && option === feature.staticOptions.mainColor) { - return this.getColor(feature) + if (!feature) return + if (option === this.getStyleProperty(feature)) { + const value = this._getOption(feature) + return value } }, @@ -85,11 +95,10 @@ export const Choropleth = FeatureGroup.extend({ NAME: translate('Choropleth'), TYPE: 'Choropleth', }, - includes: [LayerMixin, RelativeColorLayerMixin], + includes: [LayerMixin, ClassifiedMixin], // Have defaults that better suit the choropleth mode. defaults: { color: 'white', - fillColor: 'red', fillOpacity: 0.7, weight: 2, }, @@ -142,13 +151,12 @@ export const Choropleth = FeatureGroup.extend({ 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][this.options.breaks.length - 1] || [] }, - getColor: function (feature) { + _getOption: function (feature) { if (!feature) return // FIXME should not happen const featureValue = this._getValue(feature) // Find the bucket/step/limit that this value is less than and give it that color @@ -235,19 +243,101 @@ export const Choropleth = FeatureGroup.extend({ }, }) +export const Circles = FeatureGroup.extend({ + statics: { + NAME: translate('Proportional circles'), + TYPE: 'Circles', + }, + includes: [LayerMixin, ClassifiedMixin], + defaults: { + weight: 1, + UIClass: CircleMarker, + }, + + ensureOptions: function (options) { + if (!Utils.isObject(this.datalayer.options.circles.radius)) { + this.datalayer.options.circles.radius = {} + } + }, + + _getValue: function (feature) { + const key = this.datalayer.options.circles.property || 'value' + const value = +feature.properties[key] + if (!Number.isNaN(value)) return value + }, + + compute: function () { + const values = this.getValues() + this.options.minValue = Math.min(...values) + this.options.maxValue = Math.max(...values) + }, + + onEdit: function (field, builder) { + this.compute() + }, + + _getOption: function (feature) { + if (!feature) return // FIXME should not happen + const current = this._getValue(feature) + const minPX = this.datalayer.options.circles.radius?.min || 2 + const maxPX = this.datalayer.options.circles.radius?.max || 50 + const valuesRange = this.options.maxValue - this.options.minValue + const pxRange = maxPX - minPX + const radius = minPX + ((current - this.options.minValue) / valuesRange) * pxRange + return radius || minPX + }, + + getEditableOptions: function () { + return [ + [ + 'options.circles.property', + { + handler: 'Select', + selectOptions: this.datalayer._propertiesIndex, + label: translate('Property name to compute circles'), + }, + ], + [ + 'options.circles.radius.min', + { + handler: 'Range', + label: translate('Min circle radius'), + min: 2, + max: 10, + step: 1, + }, + ], + [ + 'options.circles.radius.max', + { + handler: 'Range', + label: translate('Max circle radius'), + min: 12, + max: 50, + step: 2, + }, + ], + ] + }, + + getStyleProperty: (feature) => { + return 'radius' + }, +}) + export const Categorized = FeatureGroup.extend({ statics: { NAME: translate('Categorized'), TYPE: 'Categorized', }, - includes: [LayerMixin, RelativeColorLayerMixin], + includes: [LayerMixin, ClassifiedMixin], MODES: { manual: translate('Manual'), alpha: translate('Alphabetical'), }, defaults: { color: 'white', - fillColor: 'red', + // fillColor: 'red', fillOpacity: 0.7, weight: 2, }, @@ -258,7 +348,7 @@ export const Categorized = FeatureGroup.extend({ return feature.properties[key] }, - getColor: function (feature) { + _getOption: function (feature) { if (!feature) return // FIXME should not happen const featureValue = this._getValue(feature) for (let i = 0; i < this.options.categories.length; i++) { @@ -290,7 +380,6 @@ export const Categorized = FeatureGroup.extend({ } this.options.categories = categories this.datalayer.options.categorized.categories = this.options.categories.join(',') - const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor const colorScheme = this.datalayer.options.categorized.brewer this._classes = this.options.categories.length if (colorbrewer[colorScheme]?.[this._classes]) { diff --git a/umap/static/umap/js/modules/rendering/ui.js b/umap/static/umap/js/modules/rendering/ui.js index f86888c6..c6df741a 100644 --- a/umap/static/umap/js/modules/rendering/ui.js +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -3,6 +3,7 @@ import { Marker, Polyline, Polygon, + CircleMarker as BaseCircleMarker, DomUtil, LineUtil, latLng, @@ -295,8 +296,8 @@ const PathMixin = { onAdd: function (map) { this._container = null - this.setStyle() FeatureMixin.onAdd.call(this, map) + this.setStyle() if (this.editing?.enabled()) this.editing.addHooks() this.resetTooltip() this._path.dataset.feature = this.feature.id @@ -308,7 +309,7 @@ const PathMixin = { }, setStyle: function (options = {}) { - for (const option of this.feature.getStyleOptions()) { + for (const option of this.getStyleOptions()) { options[option] = this.feature.getDynamicOption(option) } options.pointerEvents = options.interactive ? 'visiblePainted' : 'stroke' @@ -396,6 +397,19 @@ const PathMixin = { if (!shape) return return this.feature.isolateShape(shape) }, + + getStyleOptions: () => [ + 'smoothFactor', + 'color', + 'opacity', + 'stroke', + 'weight', + 'fill', + 'fillColor', + 'fillOpacity', + 'dashArray', + 'interactive', + ], } export const LeafletPolyline = Polyline.extend({ @@ -523,3 +537,17 @@ export const MaskPolygon = LeafletPolygon.extend({ return this._latlngs[1] }, }) + +export const CircleMarker = BaseCircleMarker.extend({ + parentClass: BaseCircleMarker, + includes: [FeatureMixin, PathMixin], + getClass: () => CircleMarker, + getStyleOptions: function () { + const options = PathMixin.getStyleOptions.call(this) + options.push('radius') + return options + }, + getCenter: function () { + return this._latlng + }, +}) diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index 363b56c4..233c6742 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -57,6 +57,10 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, + circles: { + type: Object, + impacts: ['data'], + }, cluster: { type: Object, impacts: ['data'], diff --git a/umap/tests/fixtures/test_circles_layer.geojson b/umap/tests/fixtures/test_circles_layer.geojson new file mode 100644 index 00000000..d4ff092b --- /dev/null +++ b/umap/tests/fixtures/test_circles_layer.geojson @@ -0,0 +1,219 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5869228, + 47.1988448 + ] + }, + "properties": { + "@id": "node/8371195387", + "access": "private", + "amenity": "bicycle_parking", + "covered": "yes", + "fee": "no", + "name": "station with unknown capacity" + }, + "id": "capa0" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5567325, + 47.2223201 + ] + }, + "properties": { + "@id": "node/3750335624", + "amenity": "bicycle_parking", + "bicycle_parking": "stands", + "capacity": "2", + "check_date:capacity": "2021-05-12", + "covered": "no", + "material": "metal", + "name": "tiny station with 2" + }, + "id": "capa2" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5293571, + 47.2285598 + ] + }, + "properties": { + "@id": "node/7149702104", + "amenity": "bicycle_parking", + "bicycle_parking": "wall_loops", + "covered": "yes", + "capacity": "2", + "name": "tiny station with 3" + }, + "id": "capa3" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5613176, + 47.2223468 + ] + }, + "properties": { + "@id": "node/5206704322", + "amenity": "bicycle_parking", + "bicycle_parking": "stands", + "capacity": "4", + "name": "small station with 4" + }, + "id": "capa4" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5465724, + 47.2074831 + ] + }, + "properties": { + "@id": "node/10539987424", + "amenity": "bicycle_parking", + "bicycle_parking": "stands", + "capacity": "6", + "covered": "no", + "material": "metal", + "name": "small station with 6" + }, + "id": "capa6" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5877882, + 47.2179441 + ] + }, + "properties": { + "@id": "node/10239046793", + "amenity": "bicycle_parking", + "capacity": 8 + }, + "id": "capa8" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5406938, + 47.2312451 + ] + }, + "properties": { + "@id": "node/5176046494", + "amenity": "bicycle_parking", + "bicycle_parking": "rack", + "capacity": "27", + "name": "middle station with 27" + }, + "id": "cap27" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5344658, + 47.1988441 + ] + }, + "properties": { + "@id": "node/4126326089", + "access": "private", + "amenity": "bicycle_parking", + "bicycle_parking": "building", + "capacity": "64", + "covered": "yes", + "material": "metal", + "name": "middle station with 64" + }, + "id": "cap64" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5433176, + 47.2172633 + ] + }, + "properties": { + "@id": "way/1001063092", + "access": "yes", + "amenity": "bicycle_parking", + "architect": "Forma 6;Phytolab", + "bicycle_parking": "shed", + "building": "roof", + "building:architecture": "contemporary", + "capacity": "676", + "capacity:cargo_bike": "22", + "capacity:motorcycle": "33", + "covered": "yes", + "fee": "no", + "name": "big station with 676", + "start_date": "2021-11-15", + "@geometry": "center" + }, + "id": "ca676" + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -1.5291998, + 47.259166 + ] + }, + "properties": { + "@id": "node/11141117088", + "amenity": "bicycle_parking", + "bicycle_parking": "stands", + "capacity": "1160", + "temporary": "yes", + "temporary:date_off": "2023-10-28", + "temporary:date_on": "2023-09-08", + "name": "huge station with 1160" + }, + "id": "c1160" + } + ], + "_umap_options": { + "displayOnLoad": true, + "inCaption": true, + "browsable": true, + "editMode": "advanced", + "name": "Calque 1", + "remoteData": {}, + "type": "Circles", + "circles": { + "radius": {"min": 2, "max": 40}, + "property": "capacity" + } + } +} diff --git a/umap/tests/integration/test_circles_layer.py b/umap/tests/integration/test_circles_layer.py new file mode 100644 index 00000000..57512bce --- /dev/null +++ b/umap/tests/integration/test_circles_layer.py @@ -0,0 +1,69 @@ +import json +from pathlib import Path + +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +def test_basic_circles_layer(map, live_server, page): + path = Path(__file__).parent.parent / "fixtures/test_circles_layer.geojson" + data = json.loads(path.read_text()) + DataLayerFactory(data=data, map=map) + page.goto(f"{live_server.url}{map.get_absolute_url()}#12/47.2210/-1.5621") + paths = page.locator("path") + expect(paths).to_have_count(10) + # Last arc curve command + assert ( + page.locator("[data-feature=c1160]") + .get_attribute("d") + .endswith("a40,40 0 1,0 -80,0 ") + ) + assert ( + page.locator("[data-feature=ca676]") + .get_attribute("d") + .endswith("a24,24 0 1,0 -48,0 ") + ) + assert ( + page.locator("[data-feature=cap64]") + .get_attribute("d") + .endswith("a4,4 0 1,0 -8,0 ") + ) + assert ( + page.locator("[data-feature=cap27]") + .get_attribute("d") + .endswith("a3,3 0 1,0 -6,0 ") + ) + assert ( + page.locator("[data-feature=capa8]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) + assert ( + page.locator("[data-feature=capa6]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) + assert ( + page.locator("[data-feature=capa4]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) + assert ( + page.locator("[data-feature=capa3]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) + assert ( + page.locator("[data-feature=capa2]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) + assert ( + page.locator("[data-feature=capa0]") + .get_attribute("d") + .endswith("a2,2 0 1,0 -4,0 ") + ) diff --git a/umap/tests/integration/test_edit_datalayer.py b/umap/tests/integration/test_edit_datalayer.py index b511d385..0271bac9 100644 --- a/umap/tests/integration/test_edit_datalayer.py +++ b/umap/tests/integration/test_edit_datalayer.py @@ -107,7 +107,7 @@ def test_can_change_icon_class(live_server, openmap, page): page.locator(".panel.right").get_by_title("Edit", exact=True).click() page.get_by_text("Shape properties").click() page.locator(".umap-field-iconClass a.define").click() - page.get_by_text("Circle").click() + page.get_by_text("Circle", exact=True).click() expect(page.locator(".umap-circle-icon")).to_be_visible() expect(page.locator(".umap-div-icon")).to_be_hidden() From cbb022f80465caf7992ed7d580a89e1a8b972b5f Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 13 Aug 2024 10:01:19 +0200 Subject: [PATCH 2/4] wip: use sqrt for computing circles radius to smooth the sizes The theory is that the circles surface should be proportional, not the radius. --- .../umap/js/modules/rendering/layers/classified.js | 2 +- umap/tests/integration/test_circles_layer.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/umap/static/umap/js/modules/rendering/layers/classified.js b/umap/static/umap/js/modules/rendering/layers/classified.js index b8b3ad73..5711289c 100644 --- a/umap/static/umap/js/modules/rendering/layers/classified.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -263,7 +263,7 @@ export const Circles = FeatureGroup.extend({ _getValue: function (feature) { const key = this.datalayer.options.circles.property || 'value' const value = +feature.properties[key] - if (!Number.isNaN(value)) return value + if (!Number.isNaN(value)) return Math.sqrt(value) }, compute: function () { diff --git a/umap/tests/integration/test_circles_layer.py b/umap/tests/integration/test_circles_layer.py index 57512bce..a8663f2c 100644 --- a/umap/tests/integration/test_circles_layer.py +++ b/umap/tests/integration/test_circles_layer.py @@ -25,32 +25,32 @@ def test_basic_circles_layer(map, live_server, page): assert ( page.locator("[data-feature=ca676]") .get_attribute("d") - .endswith("a24,24 0 1,0 -48,0 ") + .endswith("a31,31 0 1,0 -62,0 ") ) assert ( page.locator("[data-feature=cap64]") .get_attribute("d") - .endswith("a4,4 0 1,0 -8,0 ") + .endswith("a10,10 0 1,0 -20,0 ") ) assert ( page.locator("[data-feature=cap27]") .get_attribute("d") - .endswith("a3,3 0 1,0 -6,0 ") + .endswith("a6,6 0 1,0 -12,0 ") ) assert ( page.locator("[data-feature=capa8]") .get_attribute("d") - .endswith("a2,2 0 1,0 -4,0 ") + .endswith("a4,4 0 1,0 -8,0 ") ) assert ( page.locator("[data-feature=capa6]") .get_attribute("d") - .endswith("a2,2 0 1,0 -4,0 ") + .endswith("a3,3 0 1,0 -6,0 ") ) assert ( page.locator("[data-feature=capa4]") .get_attribute("d") - .endswith("a2,2 0 1,0 -4,0 ") + .endswith("a3,3 0 1,0 -6,0 ") ) assert ( page.locator("[data-feature=capa3]") From 930463032b8076ce8ed273e362d068b22bff288e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 13 Aug 2024 12:30:39 +0200 Subject: [PATCH 3/4] wip: add minimal legend for circles layer --- .../js/modules/rendering/layers/classified.js | 51 +++++++++++++++---- umap/static/umap/map.css | 13 +++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/umap/static/umap/js/modules/rendering/layers/classified.js b/umap/static/umap/js/modules/rendering/layers/classified.js index 5711289c..42c3067a 100644 --- a/umap/static/umap/js/modules/rendering/layers/classified.js +++ b/umap/static/umap/js/modules/rendering/layers/classified.js @@ -263,28 +263,33 @@ export const Circles = FeatureGroup.extend({ _getValue: function (feature) { const key = this.datalayer.options.circles.property || 'value' const value = +feature.properties[key] - if (!Number.isNaN(value)) return Math.sqrt(value) + if (!Number.isNaN(value)) return value }, compute: function () { const values = this.getValues() - this.options.minValue = Math.min(...values) - this.options.maxValue = Math.max(...values) + this.options.minValue = Math.sqrt(Math.min(...values)) + this.options.maxValue = Math.sqrt(Math.max(...values)) + this.options.minPX = this.datalayer.options.circles.radius?.min || 2 + this.options.maxPX = this.datalayer.options.circles.radius?.max || 50 }, onEdit: function (field, builder) { this.compute() }, + _computeRadius: function (value) { + const valuesRange = this.options.maxValue - this.options.minValue + const pxRange = this.options.maxPX - this.options.minPX + const radius = + this.options.minPX + + ((Math.sqrt(value) - this.options.minValue) / valuesRange) * pxRange + return radius || this.options.minPX + }, + _getOption: function (feature) { if (!feature) return // FIXME should not happen - const current = this._getValue(feature) - const minPX = this.datalayer.options.circles.radius?.min || 2 - const maxPX = this.datalayer.options.circles.radius?.max || 50 - const valuesRange = this.options.maxValue - this.options.minValue - const pxRange = maxPX - minPX - const radius = minPX + ((current - this.options.minValue) / valuesRange) * pxRange - return radius || minPX + return this._computeRadius(this._getValue(feature)) }, getEditableOptions: function () { @@ -323,6 +328,32 @@ export const Circles = FeatureGroup.extend({ getStyleProperty: (feature) => { return 'radius' }, + + renderLegend: function (container) { + const parent = DomUtil.create('ul', 'circles-layer-legend', container) + const color = this.datalayer.getOption('color') + const values = this.getValues() + if (!values.length) return + values.sort((a, b) => a - b) + const minValue = values[0] + const maxValue = values[values.length - 1] + const medianValue = values[Math.round(values.length / 2)] + const items = [ + [this.options.minPX, minValue], + [this._computeRadius(medianValue), medianValue], + [this.options.maxPX, maxValue], + ] + for (const [size, label] of items) { + const li = DomUtil.create('li', '', parent) + const circleEl = DomUtil.create('span', 'circle', li) + circleEl.style.backgroundColor = color + circleEl.style.height = `${size * 2}px` + circleEl.style.width = `${size * 2}px` + circleEl.style.opacity = this.datalayer.getOption('opacity') + const labelEl = DomUtil.create('span', 'label', li) + labelEl.textContent = label + } + }, }) export const Categorized = FeatureGroup.extend({ diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index dbadbf09..9632b74d 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -994,6 +994,19 @@ a.umap-control-caption, padding: 0; margin: 0; } +.datalayer-legend .circles-layer-legend { + padding: var(--box-padding); +} +.circles-layer-legend li { + display: flex; + align-items: center; + justify-content: space-between; +} +.circles-layer-legend li .circle { + border-radius: 50%; + display: inline-block; + text-align: center; +} /* ********************************* */ /* Popup */ From e42ed4373f145697a8267f6b4d7b5eea27521fd0 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 14 Aug 2024 12:02:08 +0200 Subject: [PATCH 4/4] wip: do not fail when setting "Circles" layer with non markers data --- umap/static/umap/js/modules/data/features.js | 38 +++++--------------- umap/static/umap/js/modules/rendering/ui.js | 24 +++++++++++-- umap/static/umap/js/umap.controls.js | 4 +-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/umap/static/umap/js/modules/data/features.js b/umap/static/umap/js/modules/data/features.js index be97494f..4a1daf4c 100644 --- a/umap/static/umap/js/modules/data/features.js +++ b/umap/static/umap/js/modules/data/features.js @@ -119,7 +119,7 @@ class Feature { } getUIClass() { - return this.getOption('UIClass') || this.getDefaultUIClass() + return this.getOption('UIClass') } getClassName() { @@ -560,8 +560,8 @@ class Feature { properties.lon = center.lng properties.lng = center.lng properties.alt = center?.alt - if (typeof this.getMeasure !== 'undefined') { - properties.measure = this.getMeasure() + if (typeof this.ui.getMeasure !== 'undefined') { + properties.measure = this.ui.getMeasure() } } return L.extend(properties, this.properties) @@ -601,8 +601,8 @@ export class Point extends Feature { return { coordinates: GeoJSON.latLngToCoords(latlng), type: 'Point' } } - getDefaultUIClass() { - return LeafletMarker + getUIClass() { + return super.getUIClass() || LeafletMarker } hasGeom() { @@ -710,16 +710,6 @@ class Path extends Feature { ] } - getStyle() { - const options = {} - for (const option of this.ui.getStyleOptions()) { - options[option] = this.getDynamicOption(option) - } - if (options.interactive) options.pointerEvents = 'visiblePainted' - else options.pointerEvents = 'stroke' - return options - } - getBestZoom() { return this.getOption('zoomTo') || this.map.getBoundsZoom(this.bounds, true) } @@ -805,19 +795,14 @@ export class LineString extends Path { return !this.coordinates.length } - getDefaultUIClass() { - return LeafletPolyline + getUIClass() { + return super.getUIClass() || LeafletPolyline } isSameClass(other) { return other instanceof LineString } - getMeasure(shape) { - const length = L.GeoUtil.lineLength(this.map, shape || this.ui._defaultShape()) - return L.GeoUtil.readableDistance(length, this.map.measureTools.getMeasureUnit()) - } - toPolygon() { const geojson = this.toGeoJSON() geojson.geometry.type = 'Polygon' @@ -922,9 +907,9 @@ export class Polygon extends Path { return !this.coordinates.length || !this.coordinates[0].length } - getDefaultUIClass() { + getUIClass() { if (this.getOption('mask')) return MaskPolygon - return LeafletPolygon + return super.getUIClass() || LeafletPolygon } isSameClass(other) { @@ -956,11 +941,6 @@ export class Polygon extends Path { return options } - getMeasure(shape) { - const area = L.GeoUtil.geodesicArea(shape || this.ui._defaultShape()) - return L.GeoUtil.readableArea(area, this.map.measureTools.getMeasureUnit()) - } - toLineString() { const geojson = this.toGeoJSON() delete geojson.id diff --git a/umap/static/umap/js/modules/rendering/ui.js b/umap/static/umap/js/modules/rendering/ui.js index c6df741a..a1ce47c9 100644 --- a/umap/static/umap/js/modules/rendering/ui.js +++ b/umap/static/umap/js/modules/rendering/ui.js @@ -7,6 +7,7 @@ import { DomUtil, LineUtil, latLng, + LatLng, LatLngBounds, } from '../../../vendors/leaflet/leaflet-src.esm.js' import { translate } from '../i18n.js' @@ -267,7 +268,7 @@ export const LeafletMarker = Marker.extend({ const PathMixin = { _onMouseOver: function () { if (this._map.measureTools?.enabled()) { - this._map.tooltip.open({ content: this.feature.getMeasure(), anchor: this }) + this._map.tooltip.open({ content: this.getMeasure(), anchor: this }) } else if (this._map.editEnabled && !this._map.editedFeature) { this._map.tooltip.open({ content: translate('Click to edit'), anchor: this }) } @@ -334,7 +335,7 @@ const PathMixin = { let items = FeatureMixin.getContextMenuItems.call(this, event) items.push({ text: translate('Display measure'), - callback: () => Alert.info(this.feature.getMeasure()), + callback: () => Alert.info(this.getMeasure()), }) if (this._map.editEnabled && !this.feature.isReadOnly() && this.feature.isMulti()) { items = items.concat(this.getContextMenuMultiItems(event)) @@ -468,6 +469,12 @@ export const LeafletPolyline = Polyline.extend({ }) return items }, + + getMeasure: function (shape) { + // FIXME: compute from data in feature (with TurfJS) + const length = L.GeoUtil.lineLength(this._map, shape || this._defaultShape()) + return L.GeoUtil.readableDistance(length, this._map.measureTools.getMeasureUnit()) + }, }) export const LeafletPolygon = Polygon.extend({ @@ -502,6 +509,11 @@ export const LeafletPolygon = Polygon.extend({ startHole: function (event) { this.enableEdit().newHole(event.latlng) }, + + getMeasure: function (shape) { + const area = L.GeoUtil.geodesicArea(shape || this._defaultShape()) + return L.GeoUtil.readableArea(area, this._map.measureTools.getMeasureUnit()) + }, }) const WORLD = [ latLng([90, 180]), @@ -541,6 +553,14 @@ export const MaskPolygon = LeafletPolygon.extend({ export const CircleMarker = BaseCircleMarker.extend({ parentClass: BaseCircleMarker, includes: [FeatureMixin, PathMixin], + initialize: function (feature, latlng) { + if (Array.isArray(latlng) && !(latlng[0] instanceof Number)) { + // Must be a line or polygon + const bounds = new LatLngBounds(latlng) + latlng = bounds.getCenter() + } + FeatureMixin.initialize.call(this, feature, latlng) + }, getClass: () => CircleMarker, getStyleOptions: function () { const options = PathMixin.getStyleOptions.call(this) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 4fcd3fc1..6de1f61f 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -1261,7 +1261,7 @@ U.Editable = L.Editable.extend({ } else { const tmpLatLngs = e.layer.editor._drawnLatLngs.slice() tmpLatLngs.push(e.latlng) - measure = e.layer.feature.getMeasure(tmpLatLngs) + measure = e.layer.getMeasure(tmpLatLngs) if (e.layer.editor._drawnLatLngs.length < e.layer.editor.MIN_VERTEX) { // when drawing second point @@ -1273,7 +1273,7 @@ U.Editable = L.Editable.extend({ } } else { // when moving an existing point - measure = e.layer.feature.getMeasure() + measure = e.layer.getMeasure() } if (measure) { if (e.layer instanceof L.Polygon) {