wip: add "Proportional Circles" layer

This commit is contained in:
Yohan Boniface 2024-08-12 14:56:41 +02:00
parent 5a33709cc9
commit 6b60b0de64
8 changed files with 442 additions and 35 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -57,6 +57,10 @@ export const SCHEMA = {
type: Object,
impacts: ['data'],
},
circles: {
type: Object,
impacts: ['data'],
},
cluster: {
type: Object,
impacts: ['data'],

View file

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

View file

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

View file

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