diff --git a/InitUiElements.ts b/InitUiElements.ts index 19f1e3340..f3593556f 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -42,6 +42,8 @@ import {Tiles} from "./Models/TileRange"; import {TileHierarchyAggregator} from "./UI/ShowDataLayer/PerTileCountAggregator"; import {BBox} from "./Logic/GeoOperations"; import StaticFeatureSource from "./Logic/FeatureSource/Sources/StaticFeatureSource"; +import FilterConfig from "./Models/ThemeConfig/FilterConfig"; +import FilteredLayer from "./Models/FilteredLayer"; export class InitUiElements { static InitAll( @@ -406,8 +408,10 @@ export class InitUiElements { private static InitLayers(): void { const state = State.state; + const empty = [] + state.filteredLayers = state.layoutToUse.map((layoutToUse) => { - const flayers = []; + const flayers: FilteredLayer[] = []; for (const layer of layoutToUse.layers) { const isDisplayed = QueryParameters.GetQueryParameter( @@ -422,30 +426,47 @@ export class InitUiElements { const flayer = { isDisplayed: isDisplayed, layerDef: layer, - appliedFilters: new UIEventSource(undefined), + appliedFilters: new UIEventSource<{ filter: FilterConfig, selected: number }[]>([]), }; + + if (layer.filters.length > 0) { + const filtersPerName = new Map() + layer.filters.forEach(f => filtersPerName.set(f.id, f)) + const qp = QueryParameters.GetQueryParameter("filter-" + layer.id, "","Filtering state for a layer") + flayer.appliedFilters.map(filters => { + filters = filters ?? [] + return filters.map(f => f.filter.id + "." + f.selected).join(",") + }, [], textual => { + if(textual.length === 0){ + return empty + } + return textual.split(",").map(part => { + const [filterId, selected] = part.split("."); + return {filter: filtersPerName.get(filterId), selected: Number(selected)} + }).filter(f => f.filter !== undefined && !isNaN(f.selected)) + }).syncWith(qp, true) + } + flayers.push(flayer); } return flayers; }); + const layers = State.state.layoutToUse.data.layers - const clusterShow = Math.min(...layers.map(layer => layer.minzoom)) - - + const clusterCounter = TileHierarchyAggregator.createHierarchy() new ShowDataLayer({ features: clusterCounter.getCountsForZoom(State.state.locationControl, State.state.layoutToUse.data.clustering.minNeededElements), leafletMap: State.state.leafletMap, layerToShow: ShowTileInfo.styling, - doShowLayer: layers.length === 1 ? undefined : State.state.locationControl.map(l => l.zoom < clusterShow) }) State.state.featurePipeline = new FeaturePipeline( source => { clusterCounter.addTile(source) - + const clustering = State.state.layoutToUse.data.clustering const doShowFeatures = source.features.map( f => { @@ -489,7 +510,7 @@ export class InitUiElements { return true }, [State.state.locationControl, State.state.currentBounds] ) - + new ShowDataLayer( { features: source, diff --git a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts index 70d5a566c..65c6df0ec 100644 --- a/Logic/FeatureSource/Sources/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/Sources/FilteringFeatureSource.ts @@ -5,13 +5,14 @@ import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import Hash from "../../Web/Hash"; import {BBox} from "../../GeoOperations"; -export default class FilteringFeatureSource implements FeatureSourceForLayer , Tiled { +export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); public readonly name; public readonly layer: FilteredLayer; -public readonly tileIndex : number - public readonly bbox : BBox + public readonly tileIndex: number + public readonly bbox: BBox + constructor( state: { locationControl: UIEventSource<{ zoom: number }>, @@ -21,7 +22,7 @@ public readonly tileIndex : number upstream: FeatureSourceForLayer ) { const self = this; - this.name = "FilteringFeatureSource("+upstream.name+")" + this.name = "FilteringFeatureSource(" + upstream.name + ")" this.tileIndex = tileIndex this.bbox = BBox.fromTileIndex(tileIndex) @@ -50,12 +51,15 @@ public readonly tileIndex : number } const tagsFilter = layer.appliedFilters.data; - if (tagsFilter) { - if (!tagsFilter.matchesProperties(f.feature.properties)) { + for (const filter of tagsFilter ?? []) { + const neededTags = filter.filter.options[filter.selected].osmTags + if (!neededTags.matchesProperties(f.feature.properties)) { // Hidden by the filter on the layer itself - we want to hide it no matter wat return false; } } + + if (!layer.isDisplayed) { // The layer itself is either disabled or hidden due to zoom constraints // We should return true, but it might still match some other layer @@ -80,7 +84,7 @@ public readonly tileIndex : number }); layer.appliedFilters.addCallback(_ => { - if(!layer.isDisplayed.data){ + if (!layer.isDisplayed.data) { // Currently not shown. // Note that a change in 'isSHown' will trigger an update as well, so we don't have to watch it another time return; diff --git a/Logic/MetaTagging.ts b/Logic/MetaTagging.ts index c02ed04e4..2a85e6292 100644 --- a/Logic/MetaTagging.ts +++ b/Logic/MetaTagging.ts @@ -150,6 +150,7 @@ export default class MetaTagging { for (const f of functions) { f(params, feature); } + State.state.allElements.getEventSourceById(feature.properties.id).ping(); } catch (e) { console.error("While calculating a tag value: ", e) } diff --git a/Models/Constants.ts b/Models/Constants.ts index 7f1b0c8de..91c9e0015 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import {Utils} from "../Utils"; export default class Constants { - public static vNumber = "0.10.0-alpha-2"; + public static vNumber = "0.10.0-alpha-3"; public static ImgurApiKey = '7070e7167f0a25a' // The user journey states thresholds when a new feature gets unlocked diff --git a/Models/FilteredLayer.ts b/Models/FilteredLayer.ts index 6c387270f..68ffb4483 100644 --- a/Models/FilteredLayer.ts +++ b/Models/FilteredLayer.ts @@ -1,9 +1,10 @@ import {UIEventSource} from "../Logic/UIEventSource"; import LayerConfig from "./ThemeConfig/LayerConfig"; import {And} from "../Logic/Tags/And"; +import FilterConfig from "./ThemeConfig/FilterConfig"; export default interface FilteredLayer { readonly isDisplayed: UIEventSource; - readonly appliedFilters: UIEventSource; + readonly appliedFilters: UIEventSource<{filter: FilterConfig, selected: number}[]>; readonly layerDef: LayerConfig; } \ No newline at end of file diff --git a/Models/ThemeConfig/FilterConfig.ts b/Models/ThemeConfig/FilterConfig.ts index 464919d76..2dd9f69d6 100644 --- a/Models/ThemeConfig/FilterConfig.ts +++ b/Models/ThemeConfig/FilterConfig.ts @@ -5,7 +5,8 @@ import Translations from "../../UI/i18n/Translations"; import {TagUtils} from "../../Logic/Tags/TagUtils"; export default class FilterConfig { - readonly options: { + public readonly id: string + public readonly options: { question: Translation; osmTags: TagsFilter; }[]; @@ -14,11 +15,18 @@ export default class FilterConfig { if (json.options === undefined) { throw `A filter without options was given at ${context}` } + if (json.id === undefined) { + throw `A filter without id was found at ${context}` + } + if(json.id.match(/^[a-zA-Z0-9_-]*$/) === null){ + throw `A filter with invalid id was found at ${context}. Ids should only contain letters, numbers or - _` + + } if (json.options.map === undefined) { throw `A filter was given where the options aren't a list at ${context}` } - + this.id = json.id; this.options = json.options.map((option, i) => { const question = Translations.T( option.question, diff --git a/Models/ThemeConfig/Json/FilterConfigJson.ts b/Models/ThemeConfig/Json/FilterConfigJson.ts index c49f9f3eb..7151e3854 100644 --- a/Models/ThemeConfig/Json/FilterConfigJson.ts +++ b/Models/ThemeConfig/Json/FilterConfigJson.ts @@ -1,6 +1,10 @@ import {AndOrTagConfigJson} from "./TagConfigJson"; export default interface FilterConfigJson { + /** + * An id/name for this filter, used to set the URL parameters + */ + id: string, /** * The options for a filter * If there are multiple options these will be a list of radio buttons diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index 6e3fae5f7..249b3dacb 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -7,8 +7,6 @@ import Combine from "../Base/Combine"; import Translations from "../i18n/Translations"; import {Translation} from "../i18n/Translation"; import Svg from "../../Svg"; -import {TagsFilter} from "../../Logic/Tags/TagsFilter"; -import {And} from "../../Logic/Tags/And"; import {UIEventSource} from "../../Logic/UIEventSource"; import BaseUIElement from "../BaseUIElement"; import State from "../../State"; @@ -16,11 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer"; import BackgroundSelector from "./BackgroundSelector"; import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; - -/** - * Shows the filter - */ - export default class FilterView extends VariableUiElement { constructor(filteredLayer: UIEventSource) { const backgroundSelector = new Toggle( @@ -101,26 +94,52 @@ export default class FilterView extends VariableUiElement { return undefined; } - let listFilterElements: [BaseUIElement, UIEventSource][] = layer.filters.map( + const filterIndexes = new Map() + layer.filters.forEach((f, i) => filterIndexes.set(f.id, i)) + + let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map( FilterView.createFilter ); - const update = () => { - let listTagsFilters = Utils.NoNull( - listFilterElements.map((input) => input[1].data) - ); - flayer.appliedFilters.setData(new And(listTagsFilters)); - }; + listFilterElements.forEach((inputElement, i) => + inputElement[1].addCallback((changed) => { + const oldValue = flayer.appliedFilters.data + + if(changed === undefined){ + // Lets figure out which filter should be removed + // We know this inputElement corresponds with layer.filters[i] + // SO, if there is a value in 'oldValue' with this filter, we have to recalculated + if(!oldValue.some(f => f.filter === layer.filters[i])){ + // The filter to remove is already gone, we can stop + return; + } + }else if(oldValue.some(f => f.filter === changed.filter && f.selected === changed.selected)){ + // The changed value is already there + return; + } + const listTagsFilters = Utils.NoNull( + listFilterElements.map((input) => input[1].data) + ); - listFilterElements.forEach((inputElement) => - inputElement[1].addCallback((_) => update()) + console.log(listTagsFilters, oldValue) + flayer.appliedFilters.setData(listTagsFilters); + }) ); flayer.appliedFilters.addCallbackAndRun(appliedFilters => { - if (appliedFilters === undefined || appliedFilters.and.length === 0) { - listFilterElements.forEach(filter => filter[1].setData(undefined)) - return + for (let i = 0; i < layer.filters.length; i++){ + const filter = layer.filters[i]; + let foundMatch = undefined + for (const appliedFilter of appliedFilters) { + if(appliedFilter.filter === filter){ + foundMatch = appliedFilter + break; + } + } + + listFilterElements[i][1].setData(foundMatch) } + }) return new Combine(listFilterElements.map(input => input[0].SetClass("mt-3"))) @@ -128,7 +147,7 @@ export default class FilterView extends VariableUiElement { } - private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource] { + private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>] { if (filterConfig.options.length === 1) { let option = filterConfig.options[0]; @@ -142,20 +161,36 @@ export default class FilterView extends VariableUiElement { .ToggleOnClick() .SetClass("block m-1") - return [toggle, toggle.isEnabled.map(enabled => enabled ? option.osmTags : undefined, [], tags => tags !== undefined)] + const selected = { + filter: filterConfig, + selected: 0 + } + return [toggle, toggle.isEnabled.map(enabled => enabled ? selected : undefined, [], + f => f?.filter === filterConfig && f?.selected === 0) + ] } let options = filterConfig.options; + const values = options.map((f, i) => ({ + filter: filterConfig, selected: i + })) const radio = new RadioButton( options.map( - (option) => - new FixedInputElement(option.question.Clone(), option.osmTags) + (option, i) => + new FixedInputElement(option.question.Clone(), i) ), { dontStyle: true } ); - return [radio, radio.GetValue()] + return [radio, + radio.GetValue().map( + i => values[i], + [], + selected => { + return selected?.selected + } + )] } } diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index fb2c93a0a..5dca2cfc2 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -223,14 +223,14 @@ export default class SimpleAddUI extends Toggle { ] ).SetClass("flex flex-col") ).onClick(() => { - preset.layerToAddTo.appliedFilters.setData(new And([])) + preset.layerToAddTo.appliedFilters.setData([]) cancel() }) const disableFiltersOrConfirm = new Toggle( openLayerOrConfirm, disableFilter, - preset.layerToAddTo.appliedFilters.map(filters => filters === undefined || filters.normalize().and.length === 0) + preset.layerToAddTo.appliedFilters.map(filters => filters === undefined || filters.length === 0) ) diff --git a/assets/layers/birdhide/birdhide.json b/assets/layers/birdhide/birdhide.json index 01e2853f9..1a581b8a0 100644 --- a/assets/layers/birdhide/birdhide.json +++ b/assets/layers/birdhide/birdhide.json @@ -258,6 +258,7 @@ "wayHandling": 1, "filter": [ { + "id": "wheelchair", "options": [ { "question": { @@ -275,6 +276,7 @@ ] }, { + "id": "shelter", "options": [ { "question": { diff --git a/assets/layers/cafe_pub/cafe_pub.json b/assets/layers/cafe_pub/cafe_pub.json index 803d39ffa..e5977e8bb 100644 --- a/assets/layers/cafe_pub/cafe_pub.json +++ b/assets/layers/cafe_pub/cafe_pub.json @@ -170,6 +170,7 @@ ], "filter": [ { + "id": "opened-now", "options": [ { "question": { diff --git a/assets/layers/charging_station/charging_station.json b/assets/layers/charging_station/charging_station.json index ae3a12b1d..f3caa2321 100644 --- a/assets/layers/charging_station/charging_station.json +++ b/assets/layers/charging_station/charging_station.json @@ -2627,6 +2627,7 @@ "wayHandling": 1, "filter": [ { + "id": "vehicle-type", "options": [ { "question": { @@ -2656,6 +2657,7 @@ ] }, { + "id": "working", "options": [ { "question": { @@ -2671,6 +2673,7 @@ ] }, { + "id": "connection_type", "options": [ { "question": { diff --git a/assets/layers/charging_station/charging_station.protojson b/assets/layers/charging_station/charging_station.protojson index 111afe533..73aced6ad 100644 --- a/assets/layers/charging_station/charging_station.protojson +++ b/assets/layers/charging_station/charging_station.protojson @@ -648,6 +648,7 @@ "wayHandling": 1, "filter": [ { + "id": "vehicle-type", "options": [ { "question": { @@ -677,6 +678,7 @@ ] }, { + "id": "working", "options": [ { "question": { diff --git a/assets/layers/charging_station/csvToJson.ts b/assets/layers/charging_station/csvToJson.ts index 08e33a229..4a5b04f53 100644 --- a/assets/layers/charging_station/csvToJson.ts +++ b/assets/layers/charging_station/csvToJson.ts @@ -242,6 +242,7 @@ function run(file, protojson) { }) proto["filter"].push({ + id:"connection_type", options: filterOptions }) diff --git a/assets/layers/food/food.json b/assets/layers/food/food.json index c05bb6453..1bac9814d 100644 --- a/assets/layers/food/food.json +++ b/assets/layers/food/food.json @@ -560,6 +560,7 @@ ], "filter": [ { + "id": "opened-now", "options": [ { "question": { @@ -571,6 +572,7 @@ ] }, { + "id": "vegetarian", "options": [ { "question": { @@ -589,6 +591,7 @@ ] }, { + "id": "vegan", "options": [ { "question": { @@ -605,6 +608,7 @@ ] }, { + "id": "halal", "options": [ { "question": { diff --git a/assets/layers/nature_reserve/nature_reserve.json b/assets/layers/nature_reserve/nature_reserve.json index 5a3551d52..dab52299c 100644 --- a/assets/layers/nature_reserve/nature_reserve.json +++ b/assets/layers/nature_reserve/nature_reserve.json @@ -423,6 +423,7 @@ ], "filter": [ { + "id": "access", "options": [ { "question": { @@ -433,6 +434,7 @@ ] }, { + "id": "dogs", "options": [ { "question": { diff --git a/assets/layers/public_bookcase/public_bookcase.json b/assets/layers/public_bookcase/public_bookcase.json index d5576a1b8..cc21f23f8 100644 --- a/assets/layers/public_bookcase/public_bookcase.json +++ b/assets/layers/public_bookcase/public_bookcase.json @@ -444,6 +444,7 @@ }, "filter": [ { + "id": "kid-books", "options": [ { "question": "Kinderboeken aanwezig?", @@ -452,6 +453,7 @@ ] }, { + "id": "adult-books", "options": [ { "question": "Boeken voor volwassenen aanwezig?", @@ -460,6 +462,7 @@ ] }, { + "id": "inside", "options": [ { "question": "Binnen of buiten", diff --git a/assets/layers/toilet/toilet.json b/assets/layers/toilet/toilet.json index 9f7fedf32..c8ee81894 100644 --- a/assets/layers/toilet/toilet.json +++ b/assets/layers/toilet/toilet.json @@ -411,6 +411,7 @@ ], "filter": [ { + "id": "wheelchair", "options": [ { "question": { @@ -421,6 +422,7 @@ ] }, { + "id": "changing_table", "options": [ { "question": { @@ -431,6 +433,7 @@ ] }, { + "id": "free", "options": [ { "question": { diff --git a/assets/themes/cycle_highways/cycle_highways.json b/assets/themes/cycle_highways/cycle_highways.json index 9cea92fde..53dea9cc8 100644 --- a/assets/themes/cycle_highways/cycle_highways.json +++ b/assets/themes/cycle_highways/cycle_highways.json @@ -143,6 +143,7 @@ }, "filter": [ { + "id": "name-alt", "options": [ { "question": "Name contains 'alt'", @@ -151,6 +152,7 @@ ] }, { + "id": "name-wenslijn", "options": [ { "question": "Name contains 'wenslijn'", @@ -159,6 +161,7 @@ ] }, { + "id": "name-omleiding", "options": [ { "question": "Name contains 'omleiding'", @@ -167,6 +170,7 @@ ] }, { + "id":"ref-alt", "options": [ { "question": "Reference contains 'alt'", @@ -175,6 +179,7 @@ ] }, { + "id": "missing_link", "options": [ { "question": "No filter" @@ -194,6 +199,7 @@ ] }, { + "id": "proposed", "options": [ { "question": "No filter" diff --git a/assets/themes/uk_addresses/housenumber_unknown_small.svg b/assets/themes/uk_addresses/housenumber_unknown_small.svg new file mode 100644 index 000000000..398ce8f72 --- /dev/null +++ b/assets/themes/uk_addresses/housenumber_unknown_small.svg @@ -0,0 +1,60 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/themes/uk_addresses/license_info.json b/assets/themes/uk_addresses/license_info.json index 0c6fb0715..7d805cee4 100644 --- a/assets/themes/uk_addresses/license_info.json +++ b/assets/themes/uk_addresses/license_info.json @@ -39,5 +39,13 @@ "https://github.com/streetcomplete/StreetComplete/tree/master/res/graphics", "https://f-droid.org/packages/de.westnordost.streetcomplete/" ] + }, + { + "path": "housenumber_unknown_small.svg", + "license": "CC0", + "authors": [ + "Pieter Vander Vennet" + ], + "sources": [] } ] \ No newline at end of file diff --git a/assets/themes/uk_addresses/uk_addresses.json b/assets/themes/uk_addresses/uk_addresses.json index d4708deac..8dd07bf47 100644 --- a/assets/themes/uk_addresses/uk_addresses.json +++ b/assets/themes/uk_addresses/uk_addresses.json @@ -15,11 +15,15 @@ "maintainer": "Pieter Vander Vennet, Rob Nickerson, Russ Garrett", "icon": "./assets/themes/uk_addresses/housenumber_unknown.svg", "version": "2021-09-17", - "startLat": -0.08528530407, - "startLon": 51.52103754846, - "startZoom": 18, - "widenFactor": 1.5, + "startLat": -0.08706, + "startLon": 51.52224, + "startZoom": 17, + "widenFactor": 1.01, "socialImage": "", + "clustering": { + "minNeededFeatures": 25, + "maxZoom": 17 + }, "layers": [ { "id": "to_import", @@ -34,21 +38,21 @@ "minzoom": 12, "wayHandling": 1, "icon": { - "render": "./assets/themes/uk_addresses/housenumber_unknown.svg" - }, - "iconSize": { - "render": "40,40,center", + "render": "./assets/themes/uk_addresses/housenumber_unknown.svg", "mappings": [ { "if": "_embedding_object:id~*", - "then": "15,15,center" + "then": "./assets/themes/uk_addresses/housenumber_unknown_small.svg" }, { "if": "_imported=yes", - "then": "8,8,center" + "then": "./assets/themes/uk_addresses/housenumber_unknown_small.svg" } ] }, + "iconSize": { + "render": "40,40,center" + }, "title": { "render": "Address to be determined" }, @@ -73,6 +77,22 @@ "_embedding_object:addr:housenumber=JSON.parse(feat.properties._embedding_object)?.['addr:housenumber']", "_embedding_object:addr:street=JSON.parse(feat.properties._embedding_object)?.['addr:street']", "_embedding_object:id=JSON.parse(feat.properties._embedding_object)?.id" + ], + "filter": [ + { + "id": "to_handle", + "options": [ + { + "question": "Only show non-matched objects", + "osmTags": { + "and": [ + "_imported=", + "_embedding_object:id=" + ] + } + } + ] + } ] }, { diff --git a/test/GeoOperations.spec.ts b/test/GeoOperations.spec.ts index 49447afd5..372402648 100644 --- a/test/GeoOperations.spec.ts +++ b/test/GeoOperations.spec.ts @@ -181,10 +181,10 @@ export default class GeoOperationsSpec extends T { ["bbox bounds test", () => { const bbox = BBox.fromTile(16, 32754, 21785) - equal(-0.0714111328125, bbox.minLon) - equal(-0.076904296875, bbox.maxLon) - equal(51.53266860674158, bbox.minLat) - equal(51.5292513551899, bbox.maxLat) + equal(-0.076904296875, bbox.minLon) + equal(-0.0714111328125, bbox.maxLon) + equal(51.5292513551899, bbox.minLat) + equal(51.53266860674158, bbox.maxLat) } ] ]