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