Merge pull request #1953 from umap-project/categorized-layer

feat: add new type of layer Categorized
This commit is contained in:
Yohan Boniface 2024-07-05 18:18:36 +02:00 committed by GitHub
commit 257d205690
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 511 additions and 226 deletions

View file

@ -496,8 +496,10 @@ input.switch:checked ~ label:after {
}
.button-bar.by3,
.button-bar.by5,
.button-bar.by6,
.umap-multiplechoice.by3,
.umap-multiplechoice.by5 {
.umap-multiplechoice.by5,
.umap-multiplechoice.by6 {
grid-template-columns: 1fr 1fr 1fr;
}
.button-bar.by4,

View file

@ -40,6 +40,10 @@ export const SCHEMA = {
label: translate('Do you want to display caption menus?'),
default: true,
},
categorized: {
type: Object,
impacts: ['data'],
},
color: {
type: String,
impacts: ['data'],

View file

@ -1,3 +1,153 @@
U.COLORS = [
'Black',
'Navy',
'DarkBlue',
'MediumBlue',
'Blue',
'DarkGreen',
'Green',
'Teal',
'DarkCyan',
'DeepSkyBlue',
'DarkTurquoise',
'MediumSpringGreen',
'Lime',
'SpringGreen',
'Aqua',
'Cyan',
'MidnightBlue',
'DodgerBlue',
'LightSeaGreen',
'ForestGreen',
'SeaGreen',
'DarkSlateGray',
'DarkSlateGrey',
'LimeGreen',
'MediumSeaGreen',
'Turquoise',
'RoyalBlue',
'SteelBlue',
'DarkSlateBlue',
'MediumTurquoise',
'Indigo',
'DarkOliveGreen',
'CadetBlue',
'CornflowerBlue',
'MediumAquaMarine',
'DimGray',
'DimGrey',
'SlateBlue',
'OliveDrab',
'SlateGray',
'SlateGrey',
'LightSlateGray',
'LightSlateGrey',
'MediumSlateBlue',
'LawnGreen',
'Chartreuse',
'Aquamarine',
'Maroon',
'Purple',
'Olive',
'Gray',
'Grey',
'SkyBlue',
'LightSkyBlue',
'BlueViolet',
'DarkRed',
'DarkMagenta',
'SaddleBrown',
'DarkSeaGreen',
'LightGreen',
'MediumPurple',
'DarkViolet',
'PaleGreen',
'DarkOrchid',
'YellowGreen',
'Sienna',
'Brown',
'DarkGray',
'DarkGrey',
'LightBlue',
'GreenYellow',
'PaleTurquoise',
'LightSteelBlue',
'PowderBlue',
'FireBrick',
'DarkGoldenRod',
'MediumOrchid',
'RosyBrown',
'DarkKhaki',
'Silver',
'MediumVioletRed',
'IndianRed',
'Peru',
'Chocolate',
'Tan',
'LightGray',
'LightGrey',
'Thistle',
'Orchid',
'GoldenRod',
'PaleVioletRed',
'Crimson',
'Gainsboro',
'Plum',
'BurlyWood',
'LightCyan',
'Lavender',
'DarkSalmon',
'Violet',
'PaleGoldenRod',
'LightCoral',
'Khaki',
'AliceBlue',
'HoneyDew',
'Azure',
'SandyBrown',
'Wheat',
'Beige',
'WhiteSmoke',
'MintCream',
'GhostWhite',
'Salmon',
'AntiqueWhite',
'Linen',
'LightGoldenRodYellow',
'OldLace',
'Red',
'Fuchsia',
'Magenta',
'DeepPink',
'OrangeRed',
'Tomato',
'HotPink',
'Coral',
'DarkOrange',
'LightSalmon',
'Orange',
'LightPink',
'Pink',
'Gold',
'PeachPuff',
'NavajoWhite',
'Moccasin',
'Bisque',
'MistyRose',
'BlanchedAlmond',
'PapayaWhip',
'LavenderBlush',
'SeaShell',
'Cornsilk',
'LemonChiffon',
'FloralWhite',
'Snow',
'Yellow',
'LightYellow',
'Ivory',
'White',
]
L.FormBuilder.Element.include({
undefine: function () {
L.DomUtil.addClass(this.wrapper, 'undefined')
@ -115,156 +265,7 @@ L.FormBuilder.CheckBox.include({
})
L.FormBuilder.ColorPicker = L.FormBuilder.Input.extend({
colors: [
'Black',
'Navy',
'DarkBlue',
'MediumBlue',
'Blue',
'DarkGreen',
'Green',
'Teal',
'DarkCyan',
'DeepSkyBlue',
'DarkTurquoise',
'MediumSpringGreen',
'Lime',
'SpringGreen',
'Aqua',
'Cyan',
'MidnightBlue',
'DodgerBlue',
'LightSeaGreen',
'ForestGreen',
'SeaGreen',
'DarkSlateGray',
'DarkSlateGrey',
'LimeGreen',
'MediumSeaGreen',
'Turquoise',
'RoyalBlue',
'SteelBlue',
'DarkSlateBlue',
'MediumTurquoise',
'Indigo',
'DarkOliveGreen',
'CadetBlue',
'CornflowerBlue',
'MediumAquaMarine',
'DimGray',
'DimGrey',
'SlateBlue',
'OliveDrab',
'SlateGray',
'SlateGrey',
'LightSlateGray',
'LightSlateGrey',
'MediumSlateBlue',
'LawnGreen',
'Chartreuse',
'Aquamarine',
'Maroon',
'Purple',
'Olive',
'Gray',
'Grey',
'SkyBlue',
'LightSkyBlue',
'BlueViolet',
'DarkRed',
'DarkMagenta',
'SaddleBrown',
'DarkSeaGreen',
'LightGreen',
'MediumPurple',
'DarkViolet',
'PaleGreen',
'DarkOrchid',
'YellowGreen',
'Sienna',
'Brown',
'DarkGray',
'DarkGrey',
'LightBlue',
'GreenYellow',
'PaleTurquoise',
'LightSteelBlue',
'PowderBlue',
'FireBrick',
'DarkGoldenRod',
'MediumOrchid',
'RosyBrown',
'DarkKhaki',
'Silver',
'MediumVioletRed',
'IndianRed',
'Peru',
'Chocolate',
'Tan',
'LightGray',
'LightGrey',
'Thistle',
'Orchid',
'GoldenRod',
'PaleVioletRed',
'Crimson',
'Gainsboro',
'Plum',
'BurlyWood',
'LightCyan',
'Lavender',
'DarkSalmon',
'Violet',
'PaleGoldenRod',
'LightCoral',
'Khaki',
'AliceBlue',
'HoneyDew',
'Azure',
'SandyBrown',
'Wheat',
'Beige',
'WhiteSmoke',
'MintCream',
'GhostWhite',
'Salmon',
'AntiqueWhite',
'Linen',
'LightGoldenRodYellow',
'OldLace',
'Red',
'Fuchsia',
'Magenta',
'DeepPink',
'OrangeRed',
'Tomato',
'HotPink',
'Coral',
'DarkOrange',
'LightSalmon',
'Orange',
'LightPink',
'Pink',
'Gold',
'PeachPuff',
'NavajoWhite',
'Moccasin',
'Bisque',
'MistyRose',
'BlanchedAlmond',
'PapayaWhip',
'LavenderBlush',
'SeaShell',
'Cornsilk',
'LemonChiffon',
'FloralWhite',
'Snow',
'Yellow',
'LightYellow',
'Ivory',
'White',
],
colors: U.COLORS,
getParentNode: function () {
L.FormBuilder.CheckBox.prototype.getParentNode.call(this)
return this.quickContainer
@ -346,6 +347,7 @@ L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({
U.Layer.Cluster,
U.Layer.Heat,
U.Layer.Choropleth,
U.Layer.Categorized,
]
return layer_classes.map((class_) => [class_.TYPE, class_.NAME])
},

View file

@ -126,7 +126,77 @@ U.Layer.Cluster = L.MarkerClusterGroup.extend({
},
})
U.Layer.Choropleth = L.FeatureGroup.extend({
// Layer where each feature color is relative to the others,
// so we need all features before behing able to set one
// feature layer
U.RelativeColorLayer = L.FeatureGroup.extend({
initialize: function (datalayer) {
this.datalayer = datalayer
this.colorSchemes = Object.keys(colorbrewer)
.filter((k) => k !== 'schemeGroups')
.sort()
const key = this.getType().toLowerCase()
if (!U.Utils.isObject(this.datalayer.options[key])) {
this.datalayer.options[key] = {}
}
L.FeatureGroup.prototype.initialize.call(this, [], this.datalayer.options[key])
this.datalayer.onceDataLoaded(() => {
this.redraw()
this.datalayer.on('datachanged', this.redraw, this)
})
},
redraw: function () {
this.compute()
if (this._map) this.eachLayer(this._map.addLayer, this._map)
},
getOption: function (option, feature) {
if (feature && option === feature.staticOptions.mainColor) {
return this.getColor(feature)
}
},
addLayer: function (layer) {
// Do not add yet the layer to the map
// wait for datachanged event, so we can compute breaks only once
const id = this.getLayerId(layer)
this._layers[id] = layer
return this
},
onAdd: function (map) {
this.compute()
L.FeatureGroup.prototype.onAdd.call(this, map)
},
getValues: function () {
const values = []
this.datalayer.eachLayer((layer) => {
const value = this._getValue(layer)
if (value !== undefined) values.push(value)
})
return values
},
renderLegend: function (container) {
const parent = L.DomUtil.create('ul', '', container)
const items = this.getLegendItems()
for (const [color, label] of items) {
const li = L.DomUtil.create('li', '', parent)
const colorEl = L.DomUtil.create('span', 'datalayer-color', li)
colorEl.style.backgroundColor = color
const labelEl = L.DomUtil.create('span', '', li)
labelEl.textContent = label
}
},
getColorSchemes: function (classes) {
return this.colorSchemes.filter((scheme) => Boolean(colorbrewer[scheme][classes]))
},
})
U.Layer.Choropleth = U.RelativeColorLayer.extend({
statics: {
NAME: L._('Choropleth'),
TYPE: 'Choropleth',
@ -147,42 +217,13 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
manual: L._('Manual'),
},
initialize: function (datalayer) {
this.datalayer = datalayer
if (!U.Utils.isObject(this.datalayer.options.choropleth)) {
this.datalayer.options.choropleth = {}
}
L.FeatureGroup.prototype.initialize.call(
this,
[],
this.datalayer.options.choropleth
)
this.datalayer.onceDataLoaded(() => {
this.redraw()
this.datalayer.on('datachanged', this.redraw, this)
})
},
redraw: function () {
this.computeBreaks()
if (this._map) this.eachLayer(this._map.addLayer, this._map)
},
_getValue: function (feature) {
const key = this.datalayer.options.choropleth.property || 'value'
return +feature.properties[key] // TODO: should we catch values non castable to int ?
const value = +feature.properties[key]
if (!Number.isNaN(value)) return value
},
getValues: function () {
const values = []
this.datalayer.eachLayer((layer) => {
const value = this._getValue(layer)
if (!Number.isNaN(value)) values.push(value)
})
return values
},
computeBreaks: function () {
compute: function () {
const values = this.getValues()
if (!values.length) {
@ -224,7 +265,7 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
},
getColor: function (feature) {
if (!feature) return // FIXME shold not happen
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
for (let i = 1; i < this.options.breaks.length; i++) {
@ -234,25 +275,6 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
}
},
getOption: function (option, feature) {
if (feature && option === feature.staticOptions.mainColor) {
return this.getColor(feature)
}
},
addLayer: function (layer) {
// Do not add yet the layer to the map
// wait for datachanged event, so we want compute breaks once
const id = this.getLayerId(layer)
this._layers[id] = layer
return this
},
onAdd: function (map) {
this.computeBreaks()
L.FeatureGroup.prototype.onAdd.call(this, map)
},
onEdit: function (field, builder) {
// Only compute the breaks if we're dealing with choropleth
if (!field.startsWith('options.choropleth')) return
@ -261,7 +283,7 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
this.datalayer.options.choropleth.mode = 'manual'
if (builder) builder.helpers['options.choropleth.mode'].fetch()
}
this.computeBreaks()
this.compute()
// If user changes the mode or the number of classes,
// then update the breaks input value
if (field === 'options.choropleth.mode' || field === 'options.choropleth.classes') {
@ -270,10 +292,6 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
},
getEditableOptions: function () {
const brewerSchemes = Object.keys(colorbrewer)
.filter((k) => k !== 'schemeGroups')
.sort()
return [
[
'options.choropleth.property',
@ -288,7 +306,7 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
{
handler: 'Select',
label: L._('Choropleth color palette'),
selectOptions: brewerSchemes,
selectOptions: this.colorSchemes,
},
],
[
@ -324,20 +342,137 @@ U.Layer.Choropleth = L.FeatureGroup.extend({
]
},
renderLegend: function (container) {
const parent = L.DomUtil.create('ul', '', container)
let li
let color
let label
getLegendItems: function () {
return this.options.breaks.slice(0, -1).map((el, index) => {
const from = +this.options.breaks[index].toFixed(1)
const to = +this.options.breaks[index + 1].toFixed(1)
return [this.options.colors[index], `${from} - ${to}`]
})
},
})
this.options.breaks.slice(0, -1).forEach((limit, index) => {
li = L.DomUtil.create('li', '', parent)
color = L.DomUtil.create('span', 'datalayer-color', li)
color.style.backgroundColor = this.options.colors[index]
label = L.DomUtil.create('span', '', li)
label.textContent = `${+this.options.breaks[index].toFixed(
1
)} - ${+this.options.breaks[index + 1].toFixed(1)}`
U.Layer.Categorized = U.RelativeColorLayer.extend({
statics: {
NAME: L._('Categorized'),
TYPE: 'Categorized',
},
includes: [U.Layer],
MODES: {
manual: L._('Manual'),
alpha: L._('Alphabetical'),
},
defaults: {
color: 'white',
fillColor: 'red',
fillOpacity: 0.7,
weight: 2,
},
_getValue: function (feature) {
const key =
this.datalayer.options.categorized.property || this.datalayer._propertiesIndex[0]
return feature.properties[key]
},
getColor: function (feature) {
if (!feature) return // FIXME should not happen
const featureValue = this._getValue(feature)
for (let i = 0; i < this.options.categories.length; i++) {
if (featureValue === this.options.categories[i]) {
return this.options.colors[i]
}
}
},
compute: function () {
const values = this.getValues()
if (!values.length) {
this.options.categories = []
this.options.colors = []
return
}
const mode = this.datalayer.options.categorized.mode
let categories = []
if (mode === 'manual') {
const manualCategories = this.datalayer.options.categorized.categories
if (manualCategories) {
categories = manualCategories.split(',')
}
} else {
categories = values
.filter((val, idx, arr) => arr.indexOf(val) === idx)
.sort(U.Utils.naturalSort)
}
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]) {
this.options.colors = colorbrewer[colorScheme][this._classes]
} else {
this.options.colors = colorbrewer?.Accent[this._classes] ? colorbrewer?.Accent[this._classes] : U.COLORS
}
},
getEditableOptions: function () {
return [
[
'options.categorized.property',
{
handler: 'Select',
selectOptions: this.datalayer._propertiesIndex,
label: L._('Category property'),
},
],
[
'options.categorized.brewer',
{
handler: 'Select',
label: L._('Color palette'),
selectOptions: this.getColorSchemes(this._classes),
},
],
[
'options.categorized.categories',
{
handler: 'BlurInput',
label: L._('Categories'),
helpText: L._('Comma separated list of categories.'),
},
],
[
'options.categorized.mode',
{
handler: 'MultiChoice',
default: 'alpha',
choices: Object.entries(this.MODES),
label: L._('Categories mode'),
},
],
]
},
onEdit: function (field, builder) {
// Only compute the categories if we're dealing with categorized
if (!field.startsWith('options.categorized')) return
// If user touches the categories, then force manual mode
if (field === 'options.categorized.categories') {
this.datalayer.options.categorized.mode = 'manual'
if (builder) builder.helpers['options.categorized.mode'].fetch()
}
this.compute()
// If user changes the mode
// then update the categories input value
if (field === 'options.categorized.mode') {
if (builder) builder.helpers['options.categorized.categories'].fetch()
}
},
getLegendItems: function () {
return this.options.categories.map((limit, index) => {
return [this.options.colors[index], this.options.categories[index]]
})
},
})
@ -614,9 +749,9 @@ U.DataLayer = L.Evented.extend({
this.resetLayer()
}
this.hide()
fields.forEach((field) => {
for (const field of fields) {
this.layer.onEdit(field, builder)
})
}
this.redraw()
this.show()
break
@ -733,8 +868,8 @@ U.DataLayer = L.Evented.extend({
this.addData(geojson, sync)
this._geojson = geojson
this._dataloaded = true
this.fire('dataloaded')
this.fire('datachanged')
this.fire('dataloaded')
},
fromUmapGeoJSON: async function (geojson) {

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,141 @@
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_categorized_map_with_default_color(map, live_server, page):
path = Path(__file__).parent.parent / "fixtures/categorized_highway.geojson"
data = json.loads(path.read_text())
DataLayerFactory(data=data, map=map)
page.goto(f"{live_server.url}{map.get_absolute_url()}#13/48.4378/3.3043")
# residential
expect(page.locator("path[stroke='#7fc97f']")).to_have_count(5)
# secondary
expect(page.locator("path[stroke='#beaed4']")).to_have_count(1)
# service
expect(page.locator("path[stroke='#fdc086']")).to_have_count(2)
# tertiary
expect(page.locator("path[stroke='#ffff99']")).to_have_count(6)
# track
expect(page.locator("path[stroke='#386cb0']")).to_have_count(11)
# unclassified
expect(page.locator("path[stroke='#f0027f']")).to_have_count(7)
def test_basic_categorized_map_with_custom_brewer(openmap, live_server, page):
path = Path(__file__).parent.parent / "fixtures/categorized_highway.geojson"
data = json.loads(path.read_text())
# Change brewer at load
data["_umap_options"]["categorized"]["brewer"] = "Spectral"
DataLayerFactory(data=data, map=openmap)
page.goto(f"{live_server.url}{openmap.get_absolute_url()}#13/48.4378/3.3043")
# residential
expect(page.locator("path[stroke='#d53e4f']")).to_have_count(5)
# secondary
expect(page.locator("path[stroke='#fc8d59']")).to_have_count(1)
# service
expect(page.locator("path[stroke='#fee08b']")).to_have_count(2)
# tertiary
expect(page.locator("path[stroke='#e6f598']")).to_have_count(6)
# track
expect(page.locator("path[stroke='#99d594']")).to_have_count(11)
# unclassified
expect(page.locator("path[stroke='#3288bd']")).to_have_count(7)
# Now change brewer from UI
page.get_by_role("button", name="Edit").click()
page.get_by_role("link", name="Manage layers").click()
page.locator(".panel").get_by_title("Edit", exact=True).click()
page.get_by_text("Categorized: settings").click()
page.locator('select[name="brewer"]').select_option("Paired")
# residential
expect(page.locator("path[stroke='#a6cee3']")).to_have_count(5)
# secondary
expect(page.locator("path[stroke='#1f78b4']")).to_have_count(1)
# service
expect(page.locator("path[stroke='#b2df8a']")).to_have_count(2)
# tertiary
expect(page.locator("path[stroke='#33a02c']")).to_have_count(6)
# track
expect(page.locator("path[stroke='#fb9a99']")).to_have_count(11)
# unclassified
expect(page.locator("path[stroke='#e31a1c']")).to_have_count(7)
def test_basic_categorized_map_with_custom_categories(openmap, live_server, page):
path = Path(__file__).parent.parent / "fixtures/categorized_highway.geojson"
data = json.loads(path.read_text())
# Change categories at load
data["_umap_options"]["categorized"]["categories"] = (
"unclassified,track,service,residential,tertiary,secondary"
)
data["_umap_options"]["categorized"]["mode"] = "manual"
DataLayerFactory(data=data, map=openmap)
page.goto(f"{live_server.url}{openmap.get_absolute_url()}#13/48.4378/3.3043")
# unclassified
expect(page.locator("path[stroke='#7fc97f']")).to_have_count(7)
# track
expect(page.locator("path[stroke='#beaed4']")).to_have_count(11)
# service
expect(page.locator("path[stroke='#fdc086']")).to_have_count(2)
# residential
expect(page.locator("path[stroke='#ffff99']")).to_have_count(5)
# tertiary
expect(page.locator("path[stroke='#386cb0']")).to_have_count(6)
# secondary
expect(page.locator("path[stroke='#f0027f']")).to_have_count(1)
# Now change categories from UI
page.get_by_role("button", name="Edit").click()
page.get_by_role("link", name="Manage layers").click()
page.locator(".panel").get_by_title("Edit", exact=True).click()
page.get_by_text("Categorized: settings").click()
page.locator('input[name="categories"]').fill(
"secondary,tertiary,residential,service,track,unclassified"
)
page.locator('input[name="categories"]').blur()
# secondary
expect(page.locator("path[stroke='#7fc97f']")).to_have_count(1)
# tertiary
expect(page.locator("path[stroke='#beaed4']")).to_have_count(6)
# residential
expect(page.locator("path[stroke='#fdc086']")).to_have_count(5)
# service
expect(page.locator("path[stroke='#ffff99']")).to_have_count(2)
# track
expect(page.locator("path[stroke='#386cb0']")).to_have_count(11)
# unclassified
expect(page.locator("path[stroke='#f0027f']")).to_have_count(7)
# Now go back to automatic categories
page.get_by_role("button", name="Edit").click()
page.get_by_role("link", name="Manage layers").click()
page.locator(".panel").get_by_title("Edit", exact=True).click()
page.get_by_text("Categorized: settings").click()
page.get_by_text("Alphabetical").click()
# residential
expect(page.locator("path[stroke='#7fc97f']")).to_have_count(5)
# secondary
expect(page.locator("path[stroke='#beaed4']")).to_have_count(1)
# service
expect(page.locator("path[stroke='#fdc086']")).to_have_count(2)
# tertiary
expect(page.locator("path[stroke='#ffff99']")).to_have_count(6)
# track
expect(page.locator("path[stroke='#386cb0']")).to_have_count(11)
# unclassified
expect(page.locator("path[stroke='#f0027f']")).to_have_count(7)