Merge pull request #2053 from umap-project/bubble-layer

Add "Proportional Circles" layer type
This commit is contained in:
Yohan Boniface 2024-08-15 11:04:47 +02:00 committed by GitHub
commit ab8bce985e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 511 additions and 60 deletions

View file

@ -118,6 +118,10 @@ class Feature {
this._ui = new klass(this, this.toLatLngs())
}
getUIClass() {
return this.getOption('UIClass')
}
getClassName() {
return this.staticOptions.className
}
@ -556,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)
@ -598,7 +602,7 @@ export class Point extends Feature {
}
getUIClass() {
return LeafletMarker
return super.getUIClass() || LeafletMarker
}
hasGeom() {
@ -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',
@ -721,16 +710,6 @@ class Path extends Feature {
]
}
getStyle() {
const options = {}
for (const option of this.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)
}
@ -817,18 +796,13 @@ export class LineString extends Path {
}
getUIClass() {
return LeafletPolyline
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'
@ -935,7 +909,7 @@ export class Polygon extends Path {
getUIClass() {
if (this.getOption('mask')) return MaskPolygon
return LeafletPolygon
return super.getUIClass() || LeafletPolygon
}
isSameClass(other) {
@ -967,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

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,132 @@ 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.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
return this._computeRadius(this._getValue(feature))
},
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'
},
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({
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 +379,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 +411,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,9 +3,11 @@ import {
Marker,
Polyline,
Polygon,
CircleMarker as BaseCircleMarker,
DomUtil,
LineUtil,
latLng,
LatLng,
LatLngBounds,
} from '../../../vendors/leaflet/leaflet-src.esm.js'
import { translate } from '../i18n.js'
@ -266,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 })
}
@ -295,8 +297,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 +310,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'
@ -333,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))
@ -396,6 +398,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({
@ -454,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({
@ -488,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]),
@ -523,3 +549,25 @@ export const MaskPolygon = LeafletPolygon.extend({
return this._latlngs[1]
},
})
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)
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

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

View file

@ -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 */

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("a31,31 0 1,0 -62,0 ")
)
assert (
page.locator("[data-feature=cap64]")
.get_attribute("d")
.endswith("a10,10 0 1,0 -20,0 ")
)
assert (
page.locator("[data-feature=cap27]")
.get_attribute("d")
.endswith("a6,6 0 1,0 -12,0 ")
)
assert (
page.locator("[data-feature=capa8]")
.get_attribute("d")
.endswith("a4,4 0 1,0 -8,0 ")
)
assert (
page.locator("[data-feature=capa6]")
.get_attribute("d")
.endswith("a3,3 0 1,0 -6,0 ")
)
assert (
page.locator("[data-feature=capa4]")
.get_attribute("d")
.endswith("a3,3 0 1,0 -6,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()