From ce3439f6b58da328748d9483ace3ecd2b9ff76e9 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 1 Jul 2024 12:14:32 +0200 Subject: [PATCH] feat: add new type of layer Categorized This is like Choropleth, but with categories instead of graduated. fix #1433 --- umap/static/umap/base.css | 4 +- umap/static/umap/js/modules/schema.js | 4 + umap/static/umap/js/umap.forms.js | 302 +++++++++--------- umap/static/umap/js/umap.layer.js | 287 ++++++++++++----- .../fixtures/categorized_highway.geojson | 1 + .../integration/test_categorized_layer.py | 141 ++++++++ 6 files changed, 512 insertions(+), 227 deletions(-) create mode 100644 umap/tests/fixtures/categorized_highway.geojson create mode 100644 umap/tests/integration/test_categorized_layer.py diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index a2322286d..4894acf79 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -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, diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index a818aec94..21b595103 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -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'], diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 0d698624e..4b0171527 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -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]) }, diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 857befe06..a2068365c 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -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 ? - }, - - getValues: function () { - const values = [] - this.datalayer.eachLayer((layer) => { - const value = this._getValue(layer) - if (!Number.isNaN(value)) values.push(value) - }) - return values + const value = +feature.properties[key] + if (!Number.isNaN(value)) return value }, - 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 - - 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)}` + 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}`] + }) + }, +}) + +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) { diff --git a/umap/tests/fixtures/categorized_highway.geojson b/umap/tests/fixtures/categorized_highway.geojson new file mode 100644 index 000000000..743897bae --- /dev/null +++ b/umap/tests/fixtures/categorized_highway.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"highway":"tertiary","ref":"D 59a","id":"way/89506879"},"geometry":{"type":"LineString","coordinates":[[3.329911,48.442931],[3.340596,48.445685],[3.34569,48.447015],[3.348342,48.447707],[3.349625,48.448419]]},"id":"IxNjM"},{"type":"Feature","properties":{"highway":"tertiary","maxspeed":"50","ref":"D 59a","id":"way/89506883"},"geometry":{"type":"LineString","coordinates":[[3.316497,48.430407],[3.31648,48.43046],[3.316321,48.4308],[3.316171,48.431138],[3.315748,48.432219],[3.315563,48.432569],[3.315468,48.432988]]},"id":"AyNzg"},{"type":"Feature","properties":{"access":"no","bicycle":"yes","foot":"yes","highway":"unclassified","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/157803746"},"geometry":{"type":"LineString","coordinates":[[3.322811,48.445454],[3.322736,48.445251],[3.322636,48.445022],[3.322419,48.444642],[3.322057,48.444034],[3.321798,48.443691],[3.321447,48.443351],[3.321133,48.443096],[3.320785,48.442891],[3.320473,48.442753],[3.320178,48.442622],[3.319891,48.442415],[3.31883,48.44189]]},"id":"Q4NDQ"},{"type":"Feature","properties":{"highway":"track","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/157803748"},"geometry":{"type":"LineString","coordinates":[[3.318787,48.441834],[3.318546,48.441838],[3.318203,48.441806],[3.317738,48.44169],[3.317154,48.441519],[3.316836,48.441428],[3.316252,48.441317],[3.315526,48.441174]]},"id":"QwMjU"},{"type":"Feature","properties":{"access":"no","bicycle":"yes","bridge":"yes","foot":"yes","highway":"unclassified","layer":"1","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/157803749"},"geometry":{"type":"LineString","coordinates":[[3.322864,48.44574],[3.322811,48.445454]]},"id":"gyNzI"},{"type":"Feature","properties":{"access":"no","bicycle":"yes","foot":"yes","highway":"unclassified","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/157803750"},"geometry":{"type":"LineString","coordinates":[[3.333991,48.458017],[3.333441,48.458133],[3.332761,48.458267],[3.331904,48.458387],[3.331375,48.458422],[3.33056,48.458452],[3.329931,48.458456],[3.329558,48.458437],[3.329085,48.458374],[3.328322,48.458218],[3.32774,48.458094],[3.327159,48.457845],[3.326669,48.457605],[3.326137,48.4573],[3.325277,48.45666],[3.325102,48.456451],[3.324821,48.455988],[3.324534,48.455626],[3.324353,48.455379],[3.324342,48.455183],[3.324286,48.454686],[3.324155,48.453592],[3.324008,48.452772],[3.323833,48.452002],[3.323671,48.451541],[3.32349,48.451061],[3.323382,48.450657],[3.32329,48.450269],[3.323134,48.449399],[3.323056,48.449001],[3.322964,48.448774],[3.322928,48.448397],[3.322925,48.448091],[3.322992,48.447758],[3.322989,48.447443],[3.322922,48.446089],[3.322889,48.445857],[3.322864,48.44574]]},"id":"I3NDA"},{"type":"Feature","properties":{"highway":"unclassified","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/176285336"},"geometry":{"type":"LineString","coordinates":[[3.324318,48.433358],[3.326175,48.432166]]},"id":"E3MTA"},{"type":"Feature","properties":{"highway":"track","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/176985481"},"geometry":{"type":"LineString","coordinates":[[3.311888,48.439224],[3.311992,48.439127],[3.31201,48.438896],[3.312117,48.438331],[3.312466,48.437644],[3.312782,48.43733],[3.313319,48.436907],[3.313855,48.436142],[3.314322,48.435359],[3.314665,48.434828],[3.314756,48.434127],[3.314563,48.433583],[3.314064,48.4327],[3.313635,48.432049],[3.313104,48.4316],[3.312423,48.431255],[3.311688,48.431034],[3.310974,48.430956],[3.310545,48.430974],[3.310014,48.430906],[3.308888,48.430746],[3.307026,48.430636],[3.305878,48.430536],[3.304896,48.430369],[3.30385,48.430194],[3.303443,48.430112],[3.302912,48.430052],[3.302037,48.429991],[3.301463,48.429952],[3.300262,48.429756],[3.299929,48.429707],[3.298835,48.429564],[3.298169,48.429522],[3.296759,48.429411],[3.295541,48.429297],[3.295241,48.429258],[3.294693,48.429215],[3.294017,48.429205],[3.29354,48.429251],[3.292886,48.429279],[3.292376,48.429294],[3.292097,48.429279],[3.290863,48.429141],[3.290514,48.429091],[3.289425,48.428831],[3.288868,48.428699],[3.288422,48.42861],[3.287585,48.428457],[3.287269,48.428361],[3.286947,48.428297],[3.285896,48.42819],[3.284689,48.428155],[3.283374,48.428151],[3.282639,48.42814],[3.282119,48.428115],[3.281604,48.428062],[3.281041,48.427948],[3.280531,48.427841],[3.280107,48.427674],[3.279635,48.427525],[3.279061,48.42735],[3.278557,48.427112],[3.278257,48.426941],[3.277881,48.426692],[3.275145,48.425104],[3.274577,48.424773],[3.274137,48.424534],[3.273879,48.424403],[3.273531,48.424342],[3.272898,48.424253],[3.272318,48.424093],[3.271884,48.423915],[3.271063,48.423431],[3.270714,48.423185],[3.270489,48.423021],[3.270097,48.422762],[3.269727,48.42243],[3.269384,48.422],[3.269143,48.421615],[3.269122,48.421552],[3.269009,48.421206],[3.26894,48.420862],[3.268666,48.419811],[3.268592,48.419541],[3.268387,48.419139],[3.268284,48.419007],[3.268237,48.418946],[3.267943,48.418533],[3.267649,48.418259],[3.267375,48.418082],[3.267159,48.417941],[3.266757,48.417718],[3.266374,48.417559],[3.265992,48.417459],[3.265501,48.417392],[3.265157,48.417369],[3.264396,48.417359],[3.263963,48.417385],[3.263314,48.417418],[3.262437,48.417494],[3.261787,48.417518],[3.260853,48.417549],[3.260234,48.417546],[3.260029,48.417662],[3.259319,48.417556],[3.25855,48.417505],[3.25785,48.417279],[3.257406,48.417115],[3.257054,48.416915]]},"id":"c1MDg"},{"type":"Feature","properties":{"highway":"track","id":"way/204826086"},"geometry":{"type":"LineString","coordinates":[[3.282474,48.432896],[3.282699,48.432763],[3.283359,48.432419],[3.284419,48.431863],[3.285123,48.431589],[3.285648,48.431481],[3.286658,48.431336],[3.28693,48.431226],[3.287275,48.431028],[3.287506,48.430896],[3.287859,48.430734],[3.288232,48.430642]]},"id":"MyMjY"},{"type":"Feature","properties":{"highway":"unclassified","id":"way/204826117"},"geometry":{"type":"LineString","coordinates":[[3.319747,48.438464],[3.319869,48.438336],[3.320458,48.437703],[3.32229,48.43564],[3.323735,48.433961],[3.324218,48.43345],[3.324318,48.433358]]},"id":"YwNDM"},{"type":"Feature","properties":{"highway":"unclassified","id":"way/241679911"},"geometry":{"type":"LineString","coordinates":[[3.333439,48.436813],[3.333139,48.437132],[3.332689,48.43774],[3.332299,48.43841],[3.331963,48.439003],[3.331573,48.439691],[3.330788,48.441166],[3.329911,48.442931],[3.329409,48.443578],[3.328879,48.4442]]},"id":"E3ODg"},{"type":"Feature","properties":{"highway":"service","id":"way/241679912"},"geometry":{"type":"LineString","coordinates":[[3.330818,48.438632],[3.331963,48.439003]]},"id":"UyMzU"},{"type":"Feature","properties":{"highway":"tertiary","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2014","id":"way/268144125"},"geometry":{"type":"LineString","coordinates":[[3.287003,48.435101],[3.288441,48.434837],[3.290892,48.434381],[3.292744,48.434047],[3.294245,48.433794],[3.294873,48.433733],[3.295694,48.433822],[3.296503,48.433925],[3.296906,48.434036],[3.297346,48.434222],[3.298196,48.434617],[3.300036,48.435473],[3.300413,48.435607],[3.300853,48.435765],[3.30369,48.436519],[3.30413,48.43661],[3.304591,48.436658],[3.307256,48.436777],[3.308427,48.43692],[3.308562,48.436937],[3.309842,48.437124],[3.310257,48.437238],[3.310362,48.437314],[3.310392,48.437432],[3.310369,48.437851],[3.310448,48.438072],[3.310738,48.438329],[3.311215,48.438543],[3.31157,48.438751],[3.311804,48.439],[3.311888,48.439224]]},"id":"A3MDI"},{"type":"Feature","properties":{"grande_circulation":"yes","highway":"secondary","lanes":"2","lit":"no","maxspeed":"90","oneway":"no","ref":"D 411","source:maxspeed":"sign","id":"way/323986800"},"geometry":{"type":"LineString","coordinates":[[3.316497,48.430407],[3.316729,48.430502],[3.320189,48.431808],[3.321147,48.43217],[3.323495,48.433056],[3.324318,48.433358],[3.333439,48.436813],[3.348216,48.44241],[3.351505,48.44364]]},"id":"E5NzQ"},{"type":"Feature","properties":{"highway":"track","id":"way/486799707"},"geometry":{"type":"LineString","coordinates":[[3.306142,48.450149],[3.306711,48.449253],[3.307124,48.448231],[3.307354,48.447626],[3.307655,48.444976],[3.307998,48.444694],[3.30854,48.44363],[3.308685,48.44315],[3.30883,48.44252],[3.309098,48.441883],[3.309742,48.440018],[3.309779,48.439225],[3.310117,48.438111],[3.310369,48.437851]]},"id":"Q0Njc"},{"type":"Feature","properties":{"highway":"track","id":"way/486799708"},"geometry":{"type":"LineString","coordinates":[[3.302872,48.440398],[3.302103,48.440901],[3.301158,48.441292],[3.300708,48.441403],[3.300037,48.441417],[3.29912,48.441456],[3.29574,48.441575],[3.294316,48.44162],[3.293999,48.441599],[3.293573,48.441443]]},"id":"Q0NTk"},{"type":"Feature","properties":{"highway":"track","id":"way/486799709"},"geometry":{"type":"LineString","coordinates":[[3.308427,48.43692],[3.308025,48.437068],[3.306686,48.437132],[3.305361,48.437476],[3.304613,48.437931],[3.304079,48.438495],[3.303124,48.44002],[3.302872,48.440398],[3.302382,48.441132],[3.300665,48.443616],[3.300359,48.444057]]},"id":"IwNDY"},{"type":"Feature","properties":{"highway":"track","id":"way/486799710"},"geometry":{"type":"LineString","coordinates":[[3.320189,48.431808],[3.319082,48.433929],[3.318355,48.435437],[3.318259,48.435636]]},"id":"IzMTQ"},{"type":"Feature","properties":{"highway":"track","tracktype":"grade5","id":"way/500368729"},"geometry":{"type":"LineString","coordinates":[[3.292744,48.434047],[3.29338,48.434203],[3.294332,48.435848],[3.294755,48.436133],[3.294926,48.436656],[3.294955,48.437167],[3.295002,48.437572],[3.294791,48.438805],[3.295137,48.439058],[3.293609,48.441277],[3.293573,48.441443],[3.293268,48.442833],[3.294338,48.444194],[3.294267,48.444346],[3.292445,48.44479]]},"id":"I4NTc"},{"type":"Feature","properties":{"highway":"tertiary","ref":"D 59a","id":"way/642268287"},"geometry":{"type":"LineString","coordinates":[[3.32006,48.439401],[3.320767,48.439803],[3.321423,48.44017],[3.321721,48.440302],[3.322576,48.440483],[3.323881,48.440756],[3.325771,48.441501]]},"id":"AxNTI"},{"type":"Feature","properties":{"highway":"track","id":"way/696965030"},"geometry":{"type":"LineString","coordinates":[[3.321229,48.432107],[3.325698,48.429201]]},"id":"Q2NTc"},{"type":"Feature","properties":{"highway":"track","id":"way/696965031"},"geometry":{"type":"LineString","coordinates":[[3.321147,48.43217],[3.321229,48.432107],[3.318019,48.430863],[3.317002,48.430429],[3.316664,48.430366],[3.316553,48.430279],[3.316195,48.430172],[3.316543,48.430005],[3.317442,48.429489],[3.322388,48.426709],[3.321626,48.426453],[3.323568,48.425378],[3.32521,48.424434],[3.327954,48.422909],[3.328385,48.422629],[3.328679,48.42214],[3.328817,48.422036],[3.328959,48.421991],[3.329222,48.421987],[3.329461,48.422035],[3.329944,48.422211],[3.330247,48.422394],[3.329673,48.422761],[3.329276,48.422998],[3.329201,48.423195]]},"id":"gwMTA"},{"type":"Feature","properties":{"highway":"unclassified","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/1102515500"},"geometry":{"type":"LineString","coordinates":[[3.31883,48.44189],[3.318787,48.441834],[3.318802,48.441544],[3.318843,48.441286]]},"id":"YxMDE"},{"type":"Feature","properties":{"highway":"service","name":"Chemin des Pâtures","id":"way/486799705"},"geometry":{"type":"LineString","coordinates":[[3.319559,48.441297],[3.320371,48.441362],[3.321178,48.44146]]},"id":"MwMDk"},{"type":"Feature","properties":{"highway":"track","name":"Chemin du Bord de Seine","id":"way/156280075"},"geometry":{"type":"LineString","coordinates":[[3.322529,48.458698],[3.322536,48.45859],[3.322485,48.458054],[3.322449,48.4578],[3.322509,48.456769],[3.322697,48.455891],[3.323012,48.455006],[3.323064,48.454708],[3.323018,48.454219],[3.322891,48.453738],[3.322757,48.453191],[3.322551,48.45238],[3.322375,48.451853],[3.32206,48.451346],[3.321738,48.450893],[3.321581,48.450725],[3.321596,48.450592],[3.321399,48.450299],[3.321318,48.450245],[3.32125,48.450108],[3.321006,48.44971],[3.320781,48.449165],[3.320821,48.448622],[3.321127,48.447952],[3.32147,48.447189],[3.321544,48.446906],[3.32163,48.446443],[3.321606,48.44605],[3.321538,48.445738],[3.321414,48.445541],[3.321241,48.445004],[3.320932,48.4446],[3.320583,48.444125],[3.320314,48.44386],[3.31991,48.443591],[3.319189,48.443139],[3.318877,48.442977],[3.318183,48.44253],[3.31779,48.44237],[3.315977,48.441985],[3.314204,48.441883],[3.313497,48.441782],[3.31301,48.441592],[3.312678,48.441382],[3.31243,48.441356]]},"id":"I0MTA"},{"type":"Feature","properties":{"highway":"tertiary","maxspeed":"50","name":"Grande Rue","ref":"D 59a","id":"way/366050260"},"geometry":{"type":"LineString","coordinates":[[3.315468,48.432988],[3.315486,48.43315],[3.315572,48.433443],[3.315842,48.434111],[3.316294,48.435411],[3.31651,48.436015],[3.316641,48.436237],[3.316917,48.436554],[3.317196,48.436836],[3.317327,48.437165],[3.317374,48.437384],[3.317411,48.437728],[3.317336,48.438207],[3.317296,48.438516],[3.317291,48.438644],[3.317372,48.438761],[3.317754,48.438975],[3.318361,48.439276],[3.318541,48.439309],[3.319384,48.439309],[3.32006,48.439401]]},"id":"M4ODI"},{"type":"Feature","properties":{"highway":"tertiary","name":"Route de Noyen","ref":"D 59a","id":"way/642268286"},"geometry":{"type":"LineString","coordinates":[[3.325771,48.441501],[3.327689,48.442227],[3.328709,48.442568],[3.329911,48.442931]]},"id":"EwMTk"},{"type":"Feature","properties":{"highway":"residential","name":"Rue de Bombelles","id":"way/204826103"},"geometry":{"type":"LineString","coordinates":[[3.31836,48.438256],[3.318111,48.438262],[3.31777,48.438243],[3.317336,48.438207]]},"id":"YxNjQ"},{"type":"Feature","properties":{"highway":"residential","name":"Rue de Bombelles","id":"way/204826116"},"geometry":{"type":"LineString","coordinates":[[3.318381,48.438204],[3.318584,48.438228],[3.319747,48.438464],[3.320278,48.438561],[3.320366,48.438647],[3.320304,48.438808],[3.32006,48.439401]]},"id":"A5NDQ"},{"type":"Feature","properties":{"highway":"residential","name":"Rue de l'Est","id":"way/204826115"},"geometry":{"type":"LineString","coordinates":[[3.32006,48.439401],[3.320009,48.439588],[3.319715,48.440199],[3.319601,48.440519],[3.319562,48.44085],[3.319559,48.441297],[3.318843,48.441286]]},"id":"AwNjM"},{"type":"Feature","properties":{"highway":"residential","name":"Rue du Bac","source":"cadastre-dgi-fr source : Direction Générale des Impôts - Cadastre. Mise à jour : 2012","id":"way/157803747"},"geometry":{"type":"LineString","coordinates":[[3.318843,48.441286],[3.318869,48.441111],[3.318948,48.440362],[3.318931,48.440234],[3.318631,48.439937],[3.318625,48.439737],[3.318591,48.439531],[3.318541,48.439309]]},"id":"QyNDk"},{"type":"Feature","properties":{"highway":"residential","name":"Rue du Tertre","id":"way/204826120"},"geometry":{"type":"LineString","coordinates":[[3.317754,48.438975],[3.317848,48.438879],[3.31836,48.438256],[3.318381,48.438204],[3.318454,48.438021],[3.318623,48.437701],[3.318759,48.437439],[3.318778,48.437295],[3.318759,48.437176],[3.318353,48.435991],[3.318259,48.435636]]},"id":"M4NjA"}],"_umap_options":{"displayOnLoad":true,"inCaption":true,"browsable":true,"editMode":"advanced","name":"Grisy-sur-Seine","remoteData":{},"id":"769b2bb0-920d-4531-8055-dd198a33456a","permissions":{"edit_status":0},"type":"Categorized","weight":3,"opacity":0.9,"categorized":{"property":"highway"},"popupContentTemplate":"# {name}\n{highway}"}} diff --git a/umap/tests/integration/test_categorized_layer.py b/umap/tests/integration/test_categorized_layer.py new file mode 100644 index 000000000..c3c226416 --- /dev/null +++ b/umap/tests/integration/test_categorized_layer.py @@ -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)