Add the possibility to snap onto another layer with imports, add location confirm on input, add metalayer exporting all nodes, various fixes

This commit is contained in:
Pieter Vander Vennet 2021-10-31 02:08:39 +01:00
parent f5d6441b70
commit 23ae9d39c8
24 changed files with 807 additions and 390 deletions

View file

@ -14,7 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer";
import BackgroundSelector from "./BackgroundSelector";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig";
import {TagUtils} from "../../Logic/Tags/TagUtils";
export default class FilterView extends VariableUiElement {
constructor(filteredLayer: UIEventSource<FilteredLayer[]>, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[]) {
@ -83,7 +82,7 @@ export default class FilterView extends VariableUiElement {
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle);
const layer = filteredLayer.layerDef
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(
iconStyle
);
@ -113,18 +112,8 @@ export default class FilterView extends VariableUiElement {
const style =
"display:flex;align-items:center;padding:0.5rem 0;";
const mapRendering = layer.mapRendering.filter(r => r.location.has("point"))[0]
let layerIcon = undefined
let layerIconUnchecked = undefined
try {
if (mapRendering !== undefined) {
const defaultTags = new UIEventSource( TagUtils.changeAsProperties(layer.source.osmTags.asChange({id: "node/-1"})))
layerIcon = mapRendering.GenerateLeafletStyle(defaultTags, false, {noSize: true}).html.SetClass("w-8 h-8 ml-2")
layerIconUnchecked = mapRendering.GenerateLeafletStyle(defaultTags, false, {noSize: true}).html.SetClass("opacity-50 w-8 h-8 ml-2")
}
} catch (e) {
console.error(e)
}
const layerIcon = layer.defaultIcon()?.SetClass("w-8 h-8 ml-2")
const layerIconUnchecked = layer.defaultIcon()?.SetClass("opacity-50 w-8 h-8 ml-2")
const layerChecked = new Combine([icon, layerIcon, styledNameChecked, zoomStatus])
.SetStyle(style)

View file

@ -16,29 +16,165 @@ import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Changes} from "../../Logic/Osm/Changes";
import {ElementStorage} from "../../Logic/ElementStorage";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import Lazy from "../Base/Lazy";
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint";
import {PresetInfo} from "./SimpleAddUI";
import Img from "../Base/Img";
import {Translation} from "../i18n/Translation";
import FilteredLayer from "../../Models/FilteredLayer";
import SpecialVisualizations, {SpecialVisualization} from "../SpecialVisualizations";
import {FixedUiElement} from "../Base/FixedUiElement";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
export interface ImportButtonState {
description?: Translation;
image: () => BaseUIElement,
message: string | BaseUIElement,
originalTags: UIEventSource<any>,
newTags: UIEventSource<Tag[]>,
targetLayer: FilteredLayer,
feature: any,
minZoom: number,
state: {
featureSwitchUserbadge: UIEventSource<boolean>;
featurePipeline: FeaturePipeline;
allElements: ElementStorage;
selectedElement: UIEventSource<any>;
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
changes: Changes,
locationControl: UIEventSource<{ zoom: number }>
},
guiState: { filterViewIsOpened: UIEventSource<boolean> },
snapToLayers?: string[],
snapToLayersMaxDist?: number
}
export class ImportButtonSpecialViz implements SpecialVisualization {
funcName = "import_button"
docs = `This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes.
#### Importing a dataset into OpenStreetMap: requirements
If you want to import a dataset, make sure that:
1. The dataset to import has a suitable license
2. The community has been informed of the import
3. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed
There are also some technicalities in your theme to keep in mind:
1. The new feature will be added and will flow through the program as any other new point as if it came from OSM.
This means that there should be a layer which will match the new tags and which will display it.
2. The original feature from your geojson layer will gain the tag '_imported=yes'.
This should be used to change the appearance or even to hide it (eg by changing the icon size to zero)
3. There should be a way for the theme to detect previously imported points, even after reloading.
A reference number to the original dataset is an excellent way to do this
4. When importing ways, the theme creator is also responsible of avoiding overlapping ways.
#### Disabled in unofficial themes
The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md).
The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console.
In the case that MapComplete is pointed to the testing grounds, the edit will be made on ${OsmConnection.oauth_configs["osm-test"].url}
#### Specifying which tags to copy or add
The first argument of the import button takes a \`;\`-seperated list of tags to add.
${Utils.Special_visualizations_tagsToApplyHelpText}
`
args = [
{
name: "targetLayer",
doc: "The id of the layer where this point should end up. This is not very strict, it will simply result in checking that this layer is shown preventing possible duplicate elements"
},
{
name: "tags",
doc: "The tags to add onto the new object - see specification above"
},
{
name: "text",
doc: "The text to show on the button",
defaultValue: "Import this data into OpenStreetMap"
},
{
name: "icon",
doc: "A nice icon to show in the button",
defaultValue: "./assets/svg/addSmall.svg"
},
{
name: "minzoom",
doc: "How far the contributor must zoom in before being able to import the point",
defaultValue: "18"
}, {
name: "Snap onto layer(s)",
doc: "If a way of the given layer is closeby, will snap the new point onto this way (similar as preset might snap). To show multiple layers to snap onto, use a `;`-seperated list",
}, {
name: "snap max distance",
doc: "The maximum distance that this point will move to snap onto a layer (in meters)",
defaultValue: "5"
}]
constr(state, tagSource, args, guiState) {
if (!state.layoutToUse.official && !(state.featureSwitchIsTesting.data || state.osmConnection._oauth_config.url === OsmConnection.oauth_configs["osm-test"].url)) {
return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"),
new FixedUiElement("To test, add <b>test=true</b> or <b>backend=osm-test</b> to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")])
}
const newTags = SpecialVisualizations.generateTagsToApply(args[1], tagSource)
const id = tagSource.data.id;
const feature = state.allElements.ContainingFeatures.get(id)
let minZoom = args[4] == "" ? 18 : Number(args[4])
if(isNaN(minZoom)){
console.warn("Invalid minzoom:", minZoom)
minZoom = 18
}
const message = args[2]
const imageUrl = args[3]
let img: () => BaseUIElement
const targetLayer: FilteredLayer = state.filteredLayers.data.filter(fl => fl.layerDef.id === args[0])[0]
if (imageUrl !== undefined && imageUrl !== "") {
img = () => new Img(imageUrl)
} else {
img = () => Svg.add_ui()
}
const snapToLayers = args[5]?.split(";").filter(s => s !== "")
const snapToLayersMaxDist = Number(args[6] ?? 6)
if (targetLayer === undefined) {
const e = "Target layer not defined: error in import button for theme: " + state.layoutToUse.id + ": layer " + args[0] + " not found"
console.error(e)
return new FixedUiElement(e).SetClass("alert")
}
return new ImportButton(
{
state, guiState, image: img,
feature, newTags, message, minZoom,
originalTags: tagSource,
targetLayer,
snapToLayers,
snapToLayersMaxDist
}
);
}
}
export default class ImportButton extends Toggle {
constructor(imageUrl: string | BaseUIElement,
message: string | BaseUIElement,
originalTags: UIEventSource<any>,
newTags: UIEventSource<Tag[]>,
feature: any,
minZoom: number,
state: {
featureSwitchUserbadge: UIEventSource<boolean>;
featurePipeline: FeaturePipeline;
allElements: ElementStorage;
selectedElement: UIEventSource<any>;
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
changes: Changes,
locationControl: UIEventSource<{ zoom: number }>
}) {
constructor(o: ImportButtonState) {
const t = Translations.t.general.add;
const isImported = originalTags.map(tags => tags._imported === "yes")
const isImported = o.originalTags.map(tags => tags._imported === "yes")
const appliedTags = new Toggle(
new VariableUiElement(
newTags.map(tgs => {
o.newTags.map(tgs => {
const parts = []
for (const tag of tgs) {
parts.push(tag.key + "=" + tag.value)
@ -46,63 +182,106 @@ export default class ImportButton extends Toggle {
const txt = parts.join(" & ")
return t.presetInfo.Subs({tags: txt}).SetClass("subtle")
})), undefined,
state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt)
o.state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt)
)
const button = new SubtleButton(imageUrl, message)
const button = new SubtleButton(o.image(), o.message)
minZoom = Math.max(16, minZoom ?? 19)
o.minZoom = Math.max(16, o.minZoom ?? 19)
button.onClick(async () => {
if (isImported.data) {
return
}
originalTags.data["_imported"] = "yes"
originalTags.ping() // will set isImported as per its definition
const newElementAction = ImportButton.createAddActionForFeature(newTags.data, feature, state.layoutToUse.id)
await state.changes.applyAction(newElementAction)
state.selectedElement.setData(state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
console.log("Did set selected element to", state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
})
const withLoadingCheck = new Toggle(new Toggle(
new Loading(t.stillLoading.Clone()),
new Combine([button, appliedTags]).SetClass("flex flex-col"),
state.featurePipeline.runningQuery
o.state.featurePipeline.runningQuery
), t.zoomInFurther.Clone(),
state.locationControl.map(l => l.zoom >= minZoom)
o.state.locationControl.map(l => l.zoom >= o.minZoom)
)
const importButton = new Toggle(t.hasBeenImported, withLoadingCheck, isImported)
const importClicked = new UIEventSource(false);
const importFlow = new Toggle(
new Lazy(() => ImportButton.createConfirmPanel(o, isImported, importClicked)),
importButton,
importClicked
)
button.onClick(() => {
importClicked.setData(true);
})
const pleaseLoginButton =
new Toggle(t.pleaseLogin.Clone()
.onClick(() => state.osmConnection.AttemptLogin())
.onClick(() => o.state.osmConnection.AttemptLogin())
.SetClass("login-button-friendly"),
undefined,
state.featureSwitchUserbadge)
o.state.featureSwitchUserbadge)
super(new Toggle(importButton,
super(new Toggle(importFlow,
pleaseLoginButton,
state.osmConnection.isLoggedIn
o.state.osmConnection.isLoggedIn
),
t.wrongType,
new UIEventSource(ImportButton.canBeImported(feature))
new UIEventSource(ImportButton.canBeImported(o.feature))
)
}
public static createConfirmPanel(
o: ImportButtonState,
isImported: UIEventSource<boolean>,
importClicked: UIEventSource<boolean>): BaseUIElement {
async function confirm() {
if (isImported.data) {
return
}
o.originalTags.data["_imported"] = "yes"
o.originalTags.ping() // will set isImported as per its definition
const newElementAction = ImportButton.createAddActionForFeature(o.newTags.data, o.feature, o.state.layoutToUse.id)
await o.state.changes.applyAction(newElementAction)
o.state.selectedElement.setData(o.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
console.log("Did set selected element to", o.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
}
function cancel() {
importClicked.setData(false)
}
if (o.feature.geometry.type === "Point") {
const presetInfo = <PresetInfo>{
tags: o.newTags.data,
icon: o.image,
description: o.description,
layerToAddTo: o.targetLayer,
name: o.message,
title: o.message,
preciseInput: { snapToLayers: o.snapToLayers,
maxSnapDistance: o.snapToLayersMaxDist}
}
const [lon, lat] = o.feature.geometry.coordinates
console.log("Creating an import dialog at location", lon, lat)
return new ConfirmLocationOfPoint(o.state, o.guiState.filterViewIsOpened, presetInfo, Translations.W(o.message), {
lon,
lat
}, confirm, cancel)
}
}
private static canBeImported(feature: any) {
const type = feature.geometry.type
return type === "Point" || type === "LineString" || (type === "Polygon" && feature.geometry.coordinates.length === 1)
}
private static createAddActionForFeature(newTags: Tag[], feature: any, theme: string): OsmChangeAction & { newElementId: string } {
private static createAddActionForFeature(newTags: Tag[], feature: any, theme: string):
OsmChangeAction & { newElementId: string } {
const geometry = feature.geometry
const type = geometry.type
if (type === "Point") {

View file

@ -12,18 +12,16 @@ import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import LocationInput from "../Input/LocationInput";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../../Logic/BBox";
import Loc from "../../Models/Loc";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "../../Logic/Osm/Changes";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {ElementStorage} from "../../Logic/ElementStorage";
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -33,8 +31,7 @@ import {ElementStorage} from "../../Logic/ElementStorage";
* - A 'read your unread messages before adding a point'
*/
/*private*/
interface PresetInfo extends PresetConfig {
export interface PresetInfo extends PresetConfig {
name: string | BaseUIElement,
icon: () => BaseUIElement,
layerToAddTo: FilteredLayer
@ -91,20 +88,29 @@ export default class SimpleAddUI extends Toggle {
if (preset === undefined) {
return presetsOverview
}
return SimpleAddUI.CreateConfirmButton(state, filterViewIsOpened, preset,
(tags, location, snapOntoWayId?: string) => {
if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined)
} else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => {
createNewPoint(tags, location, <OsmWay>way)
return true;
})
}
},
() => {
selectedPreset.setData(undefined)
})
function confirm(tags, location, snapOntoWayId?: string) {
if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined)
} else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => {
createNewPoint(tags, location, <OsmWay>way)
return true;
})
}
}
function cancel() {
selectedPreset.setData(undefined)
}
const message =Translations.t.general.add.addNew.Subs({category: preset.name});
return new ConfirmLocationOfPoint(state, filterViewIsOpened, preset,
message,
state.LastClickLocation.data,
confirm,
cancel)
}
))
@ -134,170 +140,7 @@ export default class SimpleAddUI extends Toggle {
}
private static CreateConfirmButton(
state: {
LastClickLocation: UIEventSource<{ lat: number, lon: number }>,
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline
},
filterViewIsOpened: UIEventSource<boolean>,
preset: PresetInfo,
confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void,
cancel: () => void): BaseUIElement {
let location = state.LastClickLocation;
let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) {
// We uncouple the event source
const locationSrc = new UIEventSource({
lat: location.data.lat,
lon: location.data.lon,
zoom: 19
});
let backgroundLayer = undefined;
if (preset.preciseInput.preferredBackground) {
backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined
let mapBounds: UIEventSource<BBox> = undefined
if (preset.preciseInput.snapToLayers) {
snapToFeatures = new UIEventSource<{ feature: any }[]>([])
mapBounds = new UIEventSource<BBox>(undefined)
}
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation: locationSrc,
snapTo: snapToFeatures,
snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds
})
preciseInput.installBounds(0.15, true)
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
if (preset.preciseInput.snapToLayers) {
// We have to snap to certain layers.
// Lets fetch them
let loadedBbox: BBox = undefined
mapBounds?.addCallbackAndRunD(bbox => {
if (loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)) {
// All is already there
// return;
}
bbox = bbox.pad(2);
loadedBbox = bbox;
const allFeatures: { feature: any }[] = []
preset.preciseInput.snapToLayers.forEach(layerId => {
state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature: f}))))
})
snapToFeatures.setData(allFeatures)
})
}
}
let confirmButton: BaseUIElement = new SubtleButton(preset.icon(),
new Combine([
Translations.t.general.add.addNew.Subs({category: preset.name}),
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col")
).SetClass("font-bold break-words")
.onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue() ?? location).data, preciseInput?.snappedOnto?.data?.properties?.id);
});
if (preciseInput !== undefined) {
confirmButton = new Combine([preciseInput, confirmButton])
}
const openLayerControl =
new SubtleButton(
Svg.layers_ui(),
new Combine([
Translations.t.general.add.layerNotEnabled
.Subs({layer: preset.layerToAddTo.layerDef.name})
.SetClass("alert"),
Translations.t.general.add.openLayerControl
])
)
.onClick(() => filterViewIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
preset.layerToAddTo.isDisplayed
)
const disableFilter = new SubtleButton(
new Combine([
Svg.filter_ui().SetClass("absolute w-full"),
Svg.cross_bottom_right_svg().SetClass("absolute red-svg")
]).SetClass("relative"),
new Combine(
[
Translations.t.general.add.disableFiltersExplanation.Clone(),
Translations.t.general.add.disableFilters.Clone().SetClass("text-xl")
]
).SetClass("flex flex-col")
).onClick(() => {
preset.layerToAddTo.appliedFilters.setData([])
cancel()
})
const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm,
disableFilter,
preset.layerToAddTo.appliedFilters.map(filters => {
if (filters === undefined || filters.length === 0) {
return true;
}
for (const filter of filters) {
if (filter.selected === 0 && filter.filter.options.length === 1) {
return false;
}
if (filter.selected !== undefined) {
const tags = filter.filter.options[filter.selected].osmTags
if (tags !== undefined && tags["and"]?.length !== 0) {
// This actually doesn't filter anything at all
return false;
}
}
}
return true
})
)
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection);
const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(cancel)
return new Combine([
state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined,
disableFiltersOrConfirm,
cancelButton,
preset.description,
tagInfo
]).SetClass("flex flex-col")
}
private static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) {
public static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) {
const csCount = osmConnection.userDetails.data.csCount;
return new Toggle(
Translations.t.general.add.presetInfo.Subs({
@ -329,7 +172,7 @@ export default class SimpleAddUI extends Toggle {
private static CreatePresetSelectButton(preset: PresetInfo, osmConnection: OsmConnection) {
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection ,false);
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection, false);
return new SubtleButton(
preset.icon(),
new Combine([
@ -368,7 +211,7 @@ export default class SimpleAddUI extends Toggle {
for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0]. GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("w-12 h-12 block relative");
const presetInfo: PresetInfo = {
tags: preset.tags,