mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
Merge pull request #2053 from umap-project/bubble-layer
Add "Proportional Circles" layer type
This commit is contained in:
commit
ab8bce985e
10 changed files with 511 additions and 60 deletions
|
@ -118,6 +118,10 @@ class Feature {
|
||||||
this._ui = new klass(this, this.toLatLngs())
|
this._ui = new klass(this, this.toLatLngs())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUIClass() {
|
||||||
|
return this.getOption('UIClass')
|
||||||
|
}
|
||||||
|
|
||||||
getClassName() {
|
getClassName() {
|
||||||
return this.staticOptions.className
|
return this.staticOptions.className
|
||||||
}
|
}
|
||||||
|
@ -556,8 +560,8 @@ class Feature {
|
||||||
properties.lon = center.lng
|
properties.lon = center.lng
|
||||||
properties.lng = center.lng
|
properties.lng = center.lng
|
||||||
properties.alt = center?.alt
|
properties.alt = center?.alt
|
||||||
if (typeof this.getMeasure !== 'undefined') {
|
if (typeof this.ui.getMeasure !== 'undefined') {
|
||||||
properties.measure = this.getMeasure()
|
properties.measure = this.ui.getMeasure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return L.extend(properties, this.properties)
|
return L.extend(properties, this.properties)
|
||||||
|
@ -598,7 +602,7 @@ export class Point extends Feature {
|
||||||
}
|
}
|
||||||
|
|
||||||
getUIClass() {
|
getUIClass() {
|
||||||
return LeafletMarker
|
return super.getUIClass() || LeafletMarker
|
||||||
}
|
}
|
||||||
|
|
||||||
hasGeom() {
|
hasGeom() {
|
||||||
|
@ -690,21 +694,6 @@ class Path extends Feature {
|
||||||
L.DomEvent.stop(event)
|
L.DomEvent.stop(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
getStyleOptions() {
|
|
||||||
return [
|
|
||||||
'smoothFactor',
|
|
||||||
'color',
|
|
||||||
'opacity',
|
|
||||||
'stroke',
|
|
||||||
'weight',
|
|
||||||
'fill',
|
|
||||||
'fillColor',
|
|
||||||
'fillOpacity',
|
|
||||||
'dashArray',
|
|
||||||
'interactive',
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
getShapeOptions() {
|
getShapeOptions() {
|
||||||
return [
|
return [
|
||||||
'properties._umap_options.color',
|
'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() {
|
getBestZoom() {
|
||||||
return this.getOption('zoomTo') || this.map.getBoundsZoom(this.bounds, true)
|
return this.getOption('zoomTo') || this.map.getBoundsZoom(this.bounds, true)
|
||||||
}
|
}
|
||||||
|
@ -817,18 +796,13 @@ export class LineString extends Path {
|
||||||
}
|
}
|
||||||
|
|
||||||
getUIClass() {
|
getUIClass() {
|
||||||
return LeafletPolyline
|
return super.getUIClass() || LeafletPolyline
|
||||||
}
|
}
|
||||||
|
|
||||||
isSameClass(other) {
|
isSameClass(other) {
|
||||||
return other instanceof LineString
|
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() {
|
toPolygon() {
|
||||||
const geojson = this.toGeoJSON()
|
const geojson = this.toGeoJSON()
|
||||||
geojson.geometry.type = 'Polygon'
|
geojson.geometry.type = 'Polygon'
|
||||||
|
@ -935,7 +909,7 @@ export class Polygon extends Path {
|
||||||
|
|
||||||
getUIClass() {
|
getUIClass() {
|
||||||
if (this.getOption('mask')) return MaskPolygon
|
if (this.getOption('mask')) return MaskPolygon
|
||||||
return LeafletPolygon
|
return super.getUIClass() || LeafletPolygon
|
||||||
}
|
}
|
||||||
|
|
||||||
isSameClass(other) {
|
isSameClass(other) {
|
||||||
|
@ -967,11 +941,6 @@ export class Polygon extends Path {
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
getMeasure(shape) {
|
|
||||||
const area = L.GeoUtil.geodesicArea(shape || this.ui._defaultShape())
|
|
||||||
return L.GeoUtil.readableArea(area, this.map.measureTools.getMeasureUnit())
|
|
||||||
}
|
|
||||||
|
|
||||||
toLineString() {
|
toLineString() {
|
||||||
const geojson = this.toGeoJSON()
|
const geojson = this.toGeoJSON()
|
||||||
delete geojson.id
|
delete geojson.id
|
||||||
|
|
|
@ -11,7 +11,7 @@ import * as Utils from '../utils.js'
|
||||||
import { Default as DefaultLayer } from '../rendering/layers/base.js'
|
import { Default as DefaultLayer } from '../rendering/layers/base.js'
|
||||||
import { Cluster } from '../rendering/layers/cluster.js'
|
import { Cluster } from '../rendering/layers/cluster.js'
|
||||||
import { Heat } from '../rendering/layers/heat.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 {
|
import {
|
||||||
uMapAlert as Alert,
|
uMapAlert as Alert,
|
||||||
uMapAlertConflict as AlertConflict,
|
uMapAlertConflict as AlertConflict,
|
||||||
|
@ -21,7 +21,14 @@ import { DataLayerPermissions } from '../permissions.js'
|
||||||
import { Point, LineString, Polygon } from './features.js'
|
import { Point, LineString, Polygon } from './features.js'
|
||||||
import TableEditor from '../tableeditor.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) => {
|
const LAYER_MAP = LAYER_TYPES.reduce((acc, klass) => {
|
||||||
acc[klass.TYPE] = klass
|
acc[klass.TYPE] = klass
|
||||||
|
@ -190,6 +197,8 @@ export class DataLayer {
|
||||||
if (visible) this.map.removeLayer(this.layer)
|
if (visible) this.map.removeLayer(this.layer)
|
||||||
const Class = LAYER_MAP[this.options.type] || DefaultLayer
|
const Class = LAYER_MAP[this.options.type] || DefaultLayer
|
||||||
this.layer = new Class(this)
|
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)
|
this.eachFeature(this.showFeature)
|
||||||
if (visible) this.show()
|
if (visible) this.show()
|
||||||
this.propagateRemote()
|
this.propagateRemote()
|
||||||
|
|
|
@ -2,11 +2,12 @@ import { FeatureGroup, DomUtil } from '../../../../vendors/leaflet/leaflet-src.e
|
||||||
import { translate } from '../../i18n.js'
|
import { translate } from '../../i18n.js'
|
||||||
import { LayerMixin } from './base.js'
|
import { LayerMixin } from './base.js'
|
||||||
import * as Utils from '../../utils.js'
|
import * as Utils from '../../utils.js'
|
||||||
|
import { CircleMarker } from '../ui.js'
|
||||||
|
|
||||||
// Layer where each feature color is relative to the others,
|
// Layer where each feature color is relative to the others,
|
||||||
// so we need all features before behing able to set one
|
// so we need all features before behing able to set one
|
||||||
// feature layer
|
// feature layer
|
||||||
const RelativeColorLayerMixin = {
|
const ClassifiedMixin = {
|
||||||
initialize: function (datalayer) {
|
initialize: function (datalayer) {
|
||||||
this.datalayer = datalayer
|
this.datalayer = datalayer
|
||||||
this.colorSchemes = Object.keys(colorbrewer)
|
this.colorSchemes = Object.keys(colorbrewer)
|
||||||
|
@ -16,10 +17,13 @@ const RelativeColorLayerMixin = {
|
||||||
if (!Utils.isObject(this.datalayer.options[key])) {
|
if (!Utils.isObject(this.datalayer.options[key])) {
|
||||||
this.datalayer.options[key] = {}
|
this.datalayer.options[key] = {}
|
||||||
}
|
}
|
||||||
|
this.ensureOptions(this.datalayer.options[key])
|
||||||
FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key])
|
FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key])
|
||||||
LayerMixin.onInit.call(this, this.datalayer.map)
|
LayerMixin.onInit.call(this, this.datalayer.map)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ensureOptions: () => {},
|
||||||
|
|
||||||
dataChanged: function () {
|
dataChanged: function () {
|
||||||
this.redraw()
|
this.redraw()
|
||||||
},
|
},
|
||||||
|
@ -29,9 +33,15 @@ const RelativeColorLayerMixin = {
|
||||||
if (this._map) this.eachLayer(this._map.addLayer, this._map)
|
if (this._map) this.eachLayer(this._map.addLayer, this._map)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getStyleProperty: (feature) => {
|
||||||
|
return feature.staticOptions.mainColor
|
||||||
|
},
|
||||||
|
|
||||||
getOption: function (option, feature) {
|
getOption: function (option, feature) {
|
||||||
if (feature && option === feature.staticOptions.mainColor) {
|
if (!feature) return
|
||||||
return this.getColor(feature)
|
if (option === this.getStyleProperty(feature)) {
|
||||||
|
const value = this._getOption(feature)
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -85,11 +95,10 @@ export const Choropleth = FeatureGroup.extend({
|
||||||
NAME: translate('Choropleth'),
|
NAME: translate('Choropleth'),
|
||||||
TYPE: 'Choropleth',
|
TYPE: 'Choropleth',
|
||||||
},
|
},
|
||||||
includes: [LayerMixin, RelativeColorLayerMixin],
|
includes: [LayerMixin, ClassifiedMixin],
|
||||||
// Have defaults that better suit the choropleth mode.
|
// Have defaults that better suit the choropleth mode.
|
||||||
defaults: {
|
defaults: {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fillColor: 'red',
|
|
||||||
fillOpacity: 0.7,
|
fillOpacity: 0.7,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
},
|
},
|
||||||
|
@ -142,13 +151,12 @@ export const Choropleth = FeatureGroup.extend({
|
||||||
this.datalayer.options.choropleth.breaks = this.options.breaks
|
this.datalayer.options.choropleth.breaks = this.options.breaks
|
||||||
.map((b) => +b.toFixed(2))
|
.map((b) => +b.toFixed(2))
|
||||||
.join(',')
|
.join(',')
|
||||||
const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor
|
|
||||||
let colorScheme = this.datalayer.options.choropleth.brewer
|
let colorScheme = this.datalayer.options.choropleth.brewer
|
||||||
if (!colorbrewer[colorScheme]) colorScheme = 'Blues'
|
if (!colorbrewer[colorScheme]) colorScheme = 'Blues'
|
||||||
this.options.colors = colorbrewer[colorScheme][this.options.breaks.length - 1] || []
|
this.options.colors = colorbrewer[colorScheme][this.options.breaks.length - 1] || []
|
||||||
},
|
},
|
||||||
|
|
||||||
getColor: function (feature) {
|
_getOption: function (feature) {
|
||||||
if (!feature) return // FIXME should not happen
|
if (!feature) return // FIXME should not happen
|
||||||
const featureValue = this._getValue(feature)
|
const featureValue = this._getValue(feature)
|
||||||
// Find the bucket/step/limit that this value is less than and give it that color
|
// 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({
|
export const Categorized = FeatureGroup.extend({
|
||||||
statics: {
|
statics: {
|
||||||
NAME: translate('Categorized'),
|
NAME: translate('Categorized'),
|
||||||
TYPE: 'Categorized',
|
TYPE: 'Categorized',
|
||||||
},
|
},
|
||||||
includes: [LayerMixin, RelativeColorLayerMixin],
|
includes: [LayerMixin, ClassifiedMixin],
|
||||||
MODES: {
|
MODES: {
|
||||||
manual: translate('Manual'),
|
manual: translate('Manual'),
|
||||||
alpha: translate('Alphabetical'),
|
alpha: translate('Alphabetical'),
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fillColor: 'red',
|
// fillColor: 'red',
|
||||||
fillOpacity: 0.7,
|
fillOpacity: 0.7,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
},
|
},
|
||||||
|
@ -258,7 +379,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
return feature.properties[key]
|
return feature.properties[key]
|
||||||
},
|
},
|
||||||
|
|
||||||
getColor: function (feature) {
|
_getOption: function (feature) {
|
||||||
if (!feature) return // FIXME should not happen
|
if (!feature) return // FIXME should not happen
|
||||||
const featureValue = this._getValue(feature)
|
const featureValue = this._getValue(feature)
|
||||||
for (let i = 0; i < this.options.categories.length; i++) {
|
for (let i = 0; i < this.options.categories.length; i++) {
|
||||||
|
@ -290,7 +411,6 @@ export const Categorized = FeatureGroup.extend({
|
||||||
}
|
}
|
||||||
this.options.categories = categories
|
this.options.categories = categories
|
||||||
this.datalayer.options.categorized.categories = this.options.categories.join(',')
|
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
|
const colorScheme = this.datalayer.options.categorized.brewer
|
||||||
this._classes = this.options.categories.length
|
this._classes = this.options.categories.length
|
||||||
if (colorbrewer[colorScheme]?.[this._classes]) {
|
if (colorbrewer[colorScheme]?.[this._classes]) {
|
|
@ -3,9 +3,11 @@ import {
|
||||||
Marker,
|
Marker,
|
||||||
Polyline,
|
Polyline,
|
||||||
Polygon,
|
Polygon,
|
||||||
|
CircleMarker as BaseCircleMarker,
|
||||||
DomUtil,
|
DomUtil,
|
||||||
LineUtil,
|
LineUtil,
|
||||||
latLng,
|
latLng,
|
||||||
|
LatLng,
|
||||||
LatLngBounds,
|
LatLngBounds,
|
||||||
} from '../../../vendors/leaflet/leaflet-src.esm.js'
|
} from '../../../vendors/leaflet/leaflet-src.esm.js'
|
||||||
import { translate } from '../i18n.js'
|
import { translate } from '../i18n.js'
|
||||||
|
@ -266,7 +268,7 @@ export const LeafletMarker = Marker.extend({
|
||||||
const PathMixin = {
|
const PathMixin = {
|
||||||
_onMouseOver: function () {
|
_onMouseOver: function () {
|
||||||
if (this._map.measureTools?.enabled()) {
|
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) {
|
} else if (this._map.editEnabled && !this._map.editedFeature) {
|
||||||
this._map.tooltip.open({ content: translate('Click to edit'), anchor: this })
|
this._map.tooltip.open({ content: translate('Click to edit'), anchor: this })
|
||||||
}
|
}
|
||||||
|
@ -295,8 +297,8 @@ const PathMixin = {
|
||||||
|
|
||||||
onAdd: function (map) {
|
onAdd: function (map) {
|
||||||
this._container = null
|
this._container = null
|
||||||
this.setStyle()
|
|
||||||
FeatureMixin.onAdd.call(this, map)
|
FeatureMixin.onAdd.call(this, map)
|
||||||
|
this.setStyle()
|
||||||
if (this.editing?.enabled()) this.editing.addHooks()
|
if (this.editing?.enabled()) this.editing.addHooks()
|
||||||
this.resetTooltip()
|
this.resetTooltip()
|
||||||
this._path.dataset.feature = this.feature.id
|
this._path.dataset.feature = this.feature.id
|
||||||
|
@ -308,7 +310,7 @@ const PathMixin = {
|
||||||
},
|
},
|
||||||
|
|
||||||
setStyle: function (options = {}) {
|
setStyle: function (options = {}) {
|
||||||
for (const option of this.feature.getStyleOptions()) {
|
for (const option of this.getStyleOptions()) {
|
||||||
options[option] = this.feature.getDynamicOption(option)
|
options[option] = this.feature.getDynamicOption(option)
|
||||||
}
|
}
|
||||||
options.pointerEvents = options.interactive ? 'visiblePainted' : 'stroke'
|
options.pointerEvents = options.interactive ? 'visiblePainted' : 'stroke'
|
||||||
|
@ -333,7 +335,7 @@ const PathMixin = {
|
||||||
let items = FeatureMixin.getContextMenuItems.call(this, event)
|
let items = FeatureMixin.getContextMenuItems.call(this, event)
|
||||||
items.push({
|
items.push({
|
||||||
text: translate('Display measure'),
|
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()) {
|
if (this._map.editEnabled && !this.feature.isReadOnly() && this.feature.isMulti()) {
|
||||||
items = items.concat(this.getContextMenuMultiItems(event))
|
items = items.concat(this.getContextMenuMultiItems(event))
|
||||||
|
@ -396,6 +398,19 @@ const PathMixin = {
|
||||||
if (!shape) return
|
if (!shape) return
|
||||||
return this.feature.isolateShape(shape)
|
return this.feature.isolateShape(shape)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getStyleOptions: () => [
|
||||||
|
'smoothFactor',
|
||||||
|
'color',
|
||||||
|
'opacity',
|
||||||
|
'stroke',
|
||||||
|
'weight',
|
||||||
|
'fill',
|
||||||
|
'fillColor',
|
||||||
|
'fillOpacity',
|
||||||
|
'dashArray',
|
||||||
|
'interactive',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeafletPolyline = Polyline.extend({
|
export const LeafletPolyline = Polyline.extend({
|
||||||
|
@ -454,6 +469,12 @@ export const LeafletPolyline = Polyline.extend({
|
||||||
})
|
})
|
||||||
return items
|
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({
|
export const LeafletPolygon = Polygon.extend({
|
||||||
|
@ -488,6 +509,11 @@ export const LeafletPolygon = Polygon.extend({
|
||||||
startHole: function (event) {
|
startHole: function (event) {
|
||||||
this.enableEdit().newHole(event.latlng)
|
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 = [
|
const WORLD = [
|
||||||
latLng([90, 180]),
|
latLng([90, 180]),
|
||||||
|
@ -523,3 +549,25 @@ export const MaskPolygon = LeafletPolygon.extend({
|
||||||
return this._latlngs[1]
|
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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
@ -57,6 +57,10 @@ export const SCHEMA = {
|
||||||
type: Object,
|
type: Object,
|
||||||
impacts: ['data'],
|
impacts: ['data'],
|
||||||
},
|
},
|
||||||
|
circles: {
|
||||||
|
type: Object,
|
||||||
|
impacts: ['data'],
|
||||||
|
},
|
||||||
cluster: {
|
cluster: {
|
||||||
type: Object,
|
type: Object,
|
||||||
impacts: ['data'],
|
impacts: ['data'],
|
||||||
|
|
|
@ -1261,7 +1261,7 @@ U.Editable = L.Editable.extend({
|
||||||
} else {
|
} else {
|
||||||
const tmpLatLngs = e.layer.editor._drawnLatLngs.slice()
|
const tmpLatLngs = e.layer.editor._drawnLatLngs.slice()
|
||||||
tmpLatLngs.push(e.latlng)
|
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) {
|
if (e.layer.editor._drawnLatLngs.length < e.layer.editor.MIN_VERTEX) {
|
||||||
// when drawing second point
|
// when drawing second point
|
||||||
|
@ -1273,7 +1273,7 @@ U.Editable = L.Editable.extend({
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// when moving an existing point
|
// when moving an existing point
|
||||||
measure = e.layer.feature.getMeasure()
|
measure = e.layer.getMeasure()
|
||||||
}
|
}
|
||||||
if (measure) {
|
if (measure) {
|
||||||
if (e.layer instanceof L.Polygon) {
|
if (e.layer instanceof L.Polygon) {
|
||||||
|
|
|
@ -994,6 +994,19 @@ a.umap-control-caption,
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 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 */
|
/* Popup */
|
||||||
|
|
219
umap/tests/fixtures/test_circles_layer.geojson
vendored
Normal file
219
umap/tests/fixtures/test_circles_layer.geojson
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
umap/tests/integration/test_circles_layer.py
Normal file
69
umap/tests/integration/test_circles_layer.py
Normal 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 ")
|
||||||
|
)
|
|
@ -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.locator(".panel.right").get_by_title("Edit", exact=True).click()
|
||||||
page.get_by_text("Shape properties").click()
|
page.get_by_text("Shape properties").click()
|
||||||
page.locator(".umap-field-iconClass a.define").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-circle-icon")).to_be_visible()
|
||||||
expect(page.locator(".umap-div-icon")).to_be_hidden()
|
expect(page.locator(".umap-div-icon")).to_be_hidden()
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue