diff --git a/Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction.ts b/Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction.ts index 007c549db..86d5cd0e7 100644 --- a/Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction.ts +++ b/Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction.ts @@ -1,13 +1,15 @@ -import { OsmCreateAction } from "./OsmChangeAction" -import { Tag } from "../../Tags/Tag" -import { Changes } from "../Changes" -import { ChangeDescription } from "./ChangeDescription" +import {OsmCreateAction} from "./OsmChangeAction" +import {Tag} from "../../Tags/Tag" +import {Changes} from "../Changes" +import {ChangeDescription} from "./ChangeDescription" import CreateNewWayAction from "./CreateNewWayAction" -import CreateWayWithPointReuseAction, { MergePointConfig } from "./CreateWayWithPointReuseAction" -import { And } from "../../Tags/And" -import { TagUtils } from "../../Tags/TagUtils" -import { SpecialVisualizationState } from "../../../UI/SpecialVisualization" -import { FeatureSource } from "../../FeatureSource/FeatureSource" +import CreateWayWithPointReuseAction, {MergePointConfig} from "./CreateWayWithPointReuseAction" +import {And} from "../../Tags/And" +import {TagUtils} from "../../Tags/TagUtils" +import {IndexedFeatureSource} from "../../FeatureSource/FeatureSource" +import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"; +import {Position} from "geojson"; +import FullNodeDatabaseSource from "../../FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; /** * More or less the same as 'CreateNewWay', except that it'll try to reuse already existing points @@ -24,9 +26,14 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct constructor( tags: Tag[], - outerRingCoordinates: [number, number][], - innerRingsCoordinates: [number, number][][], - state: SpecialVisualizationState, + outerRingCoordinates: Position[], + innerRingsCoordinates: Position[][], + state: { + layout: LayoutConfig; + changes: Changes; + indexedFeatures: IndexedFeatureSource, + fullNodeDatabase?: FullNodeDatabaseSource + }, config: MergePointConfig[], changeType: "import" | "create" | string ) { @@ -36,7 +43,7 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct this.theme = state?.layout?.id ?? "" this.createOuterWay = new CreateWayWithPointReuseAction( [], - outerRingCoordinates, + <[number,number][]> outerRingCoordinates, state, config ) @@ -59,10 +66,6 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct } } - public async getPreview(): Promise { - return undefined - } - protected async CreateChangeDescriptions(changes: Changes): Promise { console.log("Running CMPWPRA") const descriptions: ChangeDescription[] = [] diff --git a/Logic/Osm/Actions/CreateWayWithPointReuseAction.ts b/Logic/Osm/Actions/CreateWayWithPointReuseAction.ts index d8190da4d..15a483697 100644 --- a/Logic/Osm/Actions/CreateWayWithPointReuseAction.ts +++ b/Logic/Osm/Actions/CreateWayWithPointReuseAction.ts @@ -1,15 +1,17 @@ -import { OsmCreateAction } from "./OsmChangeAction" -import { Tag } from "../../Tags/Tag" -import { Changes } from "../Changes" -import { ChangeDescription } from "./ChangeDescription" -import { BBox } from "../../BBox" -import { TagsFilter } from "../../Tags/TagsFilter" -import { GeoOperations } from "../../GeoOperations" -import { FeatureSource } from "../../FeatureSource/FeatureSource" +import {OsmCreateAction} from "./OsmChangeAction" +import {Tag} from "../../Tags/Tag" +import {Changes} from "../Changes" +import {ChangeDescription} from "./ChangeDescription" +import {BBox} from "../../BBox" +import {TagsFilter} from "../../Tags/TagsFilter" +import {GeoOperations} from "../../GeoOperations" +import {FeatureSource, IndexedFeatureSource} from "../../FeatureSource/FeatureSource" import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource" import CreateNewNodeAction from "./CreateNewNodeAction" import CreateNewWayAction from "./CreateNewWayAction" -import { SpecialVisualizationState } from "../../../UI/SpecialVisualization" +import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"; +import FullNodeDatabaseSource from "../../FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; +import {Position} from "geojson"; export interface MergePointConfig { withinRangeOfM: number @@ -63,13 +65,23 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { * @private */ private readonly _coordinateInfo: CoordinateInfo[] - private readonly _state: SpecialVisualizationState + private readonly _state: { + layout: LayoutConfig; + changes: Changes; + indexedFeatures: IndexedFeatureSource, + fullNodeDatabase?: FullNodeDatabaseSource + } private readonly _config: MergePointConfig[] constructor( tags: Tag[], - coordinates: [number, number][], - state: SpecialVisualizationState, + coordinates: Position[], + state: { + layout: LayoutConfig; + changes: Changes; + indexedFeatures: IndexedFeatureSource, + fullNodeDatabase?: FullNodeDatabaseSource + }, config: MergePointConfig[] ) { super(null, true) @@ -78,7 +90,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { this._config = config // The main logic of this class: the coordinateInfo contains all the changes - this._coordinateInfo = this.CalculateClosebyNodes(coordinates) + this._coordinateInfo = this.CalculateClosebyNodes(<[number,number][]> coordinates) } public async getPreview(): Promise { @@ -233,7 +245,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { }, }) } - nodeIdsToUse.push({ lat, lon, nodeId: id }) + nodeIdsToUse.push({lat, lon, nodeId: id}) } const newWay = new CreateNewWayAction(this._tags, nodeIdsToUse, { @@ -252,7 +264,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { private CalculateClosebyNodes(coordinates: [number, number][]): CoordinateInfo[] { const bbox = new BBox(coordinates) const state = this._state - const allNodes =state.fullNodeDatabase?.getNodesWithin(bbox.pad(1.2)) + const allNodes = state.fullNodeDatabase?.getNodesWithin(bbox.pad(1.2)) ?? [] const maxDistance = Math.max(...this._config.map((c) => c.withinRangeOfM)) // Init coordianteinfo with undefined but the same length as coordinates @@ -309,7 +321,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction { if (!config.ifMatches.matchesProperties(node.properties)) { continue } - closebyNodes.push({ node, d, config }) + closebyNodes.push({node, d, config}) } } diff --git a/UI/Base/BackButton.svelte b/UI/Base/BackButton.svelte index 88ee19934..e39baa243 100644 --- a/UI/Base/BackButton.svelte +++ b/UI/Base/BackButton.svelte @@ -1,6 +1,7 @@ - diff --git a/UI/Base/NextButton.svelte b/UI/Base/NextButton.svelte index 4feb7fc18..1068559a3 100644 --- a/UI/Base/NextButton.svelte +++ b/UI/Base/NextButton.svelte @@ -1,6 +1,7 @@ + + + + + {#if currentFlowStep === "start"} + currentFlowStep = "confirm"}> + + {#if importFlow?.args?.icon} + + {/if} + {importFlow.args.text} + + + {:else if $canBeImported !== true && $canBeImported !== undefined} + + {#if $canBeImported.extraHelp} + + {/if} + {:else if $isLoading} + + + + {:else if currentFlowStep === "confirm"} +
+
+ +
+
+ currentFlowStep = "start"}> + + + { + currentFlowStep = "imported" + dispatch("confirm") + }}> + + + {#if importFlow.args.icon} + + {/if} + + + {importFlow.args.text} + + +
+ +
+ Translations.t.general.add.import.importTags.Subs({tags: str})} {state} + tags={$tags}/> +
+ +
+ {:else if currentFlowStep === "importing"} + + {:else if currentFlowStep === "imported"} +
+ +
+ {/if} + +
diff --git a/UI/Popup/ImportButtons/ImportFlow.ts b/UI/Popup/ImportButtons/ImportFlow.ts new file mode 100644 index 000000000..bfcba1cce --- /dev/null +++ b/UI/Popup/ImportButtons/ImportFlow.ts @@ -0,0 +1,199 @@ +import {SpecialVisualizationState} from "../../SpecialVisualization"; +import {Utils} from "../../../Utils"; +import {Store, UIEventSource} from "../../../Logic/UIEventSource"; +import {Tag} from "../../../Logic/Tags/Tag"; +import TagApplyButton from "../TagApplyButton"; +import {PointImportFlowArguments} from "./PointImportFlowState"; +import {Translation} from "../../i18n/Translation"; +import Translations from "../../i18n/Translations"; +import {OsmConnection} from "../../../Logic/Osm/OsmConnection"; +import FilteredLayer from "../../../Models/FilteredLayer"; +import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; +import {LayerConfigJson} from "../../../Models/ThemeConfig/Json/LayerConfigJson"; +import conflation_json from "../../../assets/layers/conflation/conflation.json"; + +export interface ImportFlowArguments { + readonly text: string + readonly tags: string + readonly targetLayer: string + readonly icon?: string +} + +export class ImportFlowUtils { + public static importedIds = new Set() + + public static readonly conflationLayer = new LayerConfig( + conflation_json, + "all_known_layers", + true + ) + + public static readonly documentationGeneral = `\n\n\nNote that the contributor must zoom to at least zoomlevel 18 to be able to use this functionality. + It is only functional in official themes, but can be tested in unoffical themes. + +#### Specifying which tags to copy or add + + The argument \`tags\` of the import button takes a \`;\`-seperated list of tags to add (or the name of a property which contains a JSON-list of properties). + +${Utils.Special_visualizations_tagsToApplyHelpText} +${Utils.special_visualizations_importRequirementDocs} +` + + public static generalArguments = [{ + 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", + required: true, + }, + { + name: "tags", + doc: "The tags to add onto the new object - see specification above. If this is a key (a single word occuring in the properties of the object), the corresponding value is taken and expanded instead", + required: true, + }, + { + 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", + },] + + /** + * Given the tagsstore of the point which represents the challenge, creates a new store with tags that should be applied onto the newly created point, + */ + public static getTagsToApply( + originalFeatureTags: UIEventSource, + args: { tags: string } + ): Store { + if (originalFeatureTags === undefined) { + return undefined + } + let newTags: Store + const tags = args.tags + if ( + tags.indexOf(" ") < 0 && + tags.indexOf(";") < 0 && + originalFeatureTags.data[tags] !== undefined + ) { + // This is a property to expand... + const items: string = originalFeatureTags.data[tags] + console.debug( + "The import button is using tags from properties[" + + tags + + "] of this object, namely ", + items + ) + newTags = TagApplyButton.generateTagsToApply(items, originalFeatureTags) + } else { + newTags = TagApplyButton.generateTagsToApply(tags, originalFeatureTags) + } + return newTags + } + + /** + * Lists: + * - targetLayer + * + * Others (e.g.: snapOnto-layers) are not to be handled here + * @param argsRaw + */ + public static getLayerDependencies(argsRaw: string[]) { + const args: ImportFlowArguments = Utils.ParseVisArgs(ImportFlowUtils.generalArguments, argsRaw) + return [args.targetLayer] + } + + public static getLayerDependenciesWithSnapOnto(argSpec: { + name: string, + defaultValue?: string + }[], argsRaw: string[]): string[] { + const deps = ImportFlowUtils.getLayerDependencies(argsRaw) + const argsParsed: PointImportFlowArguments = Utils.ParseVisArgs(argSpec, argsRaw) + const snapOntoLayers = argsParsed.snap_onto_layers?.split(";")?.map(l => l.trim()) ?? [] + deps.push(...snapOntoLayers) + return deps + } + + public static buildTagSpec(args: ImportFlowArguments, tagSource: Store>): Store { + let tagSpec = args.tags + return tagSource.mapD(tags => { + if ( + tagSpec.indexOf(" ") < 0 && + tagSpec.indexOf(";") < 0 && + tags[args.tags] !== undefined + ) { + // This is probably a key + tagSpec = tags[args.tags] + console.debug( + "The import button is using tags from properties[" + + args.tags + + "] of this object, namely ", + tagSpec + ) + } + return tagSpec + }) + } +} + + +/** + * The ImportFlow dictates some aspects of the import flow, e.g. what type of map should be shown and, in the case of a preview map, what layers that should be added. + * + * This class works together closely with ImportFlow.svelte + */ +export default abstract class ImportFlow { + + public readonly state: SpecialVisualizationState; + public readonly args: ArgT; + public readonly targetLayer: FilteredLayer; + public readonly tagsToApply: Store + + constructor(state: SpecialVisualizationState, args: ArgT, tagsToApply: Store) { + this.state = state; + this.args = args; + this.tagsToApply = tagsToApply; + this.targetLayer = state.layerState.filteredLayers.get(args.targetLayer) + + + } + + /** + * Constructs a store that contains either 'true' or gives a translation with the reason why it cannot be imported + */ + public canBeImported(): Store { + const state = this.state + return state.featureSwitchIsTesting.map(isTesting => { + const t = Translations.t.general.add.import + const usesTestUrl = this.state.osmConnection._oauth_config.url === OsmConnection.oauth_configs["osm-test"].url + if (!state.layout.official && !(isTesting || usesTestUrl)) { + // Unofficial theme - imports not allowed + return { + error: t.officialThemesOnly, + extraHelp: t.howToTest + } + } + + if (this.targetLayer === undefined) { + const e = `Target layer not defined: error in import button for theme: ${this.state.layout.id}: layer ${this.args.targetLayer} not found` + console.error(e) + return {error: new Translation({"*": e})} + } + + if (state.mapProperties.zoom.data < 18) { + return {error: t.zoomInMore} + } + + + if(state.dataIsLoading.data){ + return {error: Translations.t.general.add.stillLoading} + } + + return undefined + }, [state.mapProperties.zoom, state.dataIsLoading]) + + + } + +} diff --git a/UI/Popup/ImportButtons/ImportPointButtonViz.ts b/UI/Popup/ImportButtons/ImportPointButtonViz.ts new file mode 100644 index 000000000..68f84c068 --- /dev/null +++ b/UI/Popup/ImportButtons/ImportPointButtonViz.ts @@ -0,0 +1,66 @@ +import {Feature, Point} from "geojson"; +import {UIEventSource} from "../../../Logic/UIEventSource"; +import {SpecialVisualization, SpecialVisualizationState} from "../../SpecialVisualization"; +import BaseUIElement from "../../BaseUIElement"; +import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; +import SvelteUIElement from "../../Base/SvelteUIElement"; +import PointImportFlow from "./PointImportFlow.svelte"; +import {PointImportFlowArguments, PointImportFlowState} from "./PointImportFlowState"; +import {Utils} from "../../../Utils"; +import {ImportFlowUtils} from "./ImportFlow"; +import Translations from "../../i18n/Translations"; + +/** + * The wrapper to make the special visualisation for the PointImportFlow + */ +export class ImportPointButtonViz implements SpecialVisualization { + + public readonly funcName: string + public readonly docs: string | BaseUIElement + public readonly example?: string + public readonly args: { name: string; defaultValue?: string; doc: string }[] + + constructor() { + this.funcName = "import_button" + this.docs = "This button will copy the point from an external dataset into OpenStreetMap" + ImportFlowUtils.documentationGeneral + this.args = + [...ImportFlowUtils.generalArguments, + { + name: "snap_onto_layers", + doc: "If a way of the given layer(s) 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: "max_snap_distance", + doc: "The maximum distance that the imported point will be moved to snap onto a way in an already existing layer (in meters). This is previewed to the contributor, similar to the 'add new point'-action of MapComplete", + defaultValue: "5", + }, + { + name: "note_id", + doc: "If given, this key will be read. The corresponding note on OSM will be closed, stating 'imported'", + }, + { + name: "maproulette_id", + doc: "The property name of the maproulette_id - this is probably `mr_taskId`. If given, the maproulette challenge will be marked as fixed. Only use this if part of a maproulette-layer.", + }, + ] + } + + constr(state: SpecialVisualizationState, tagSource: UIEventSource>, argument: string[], feature: Feature, layer: LayerConfig): BaseUIElement { + if (feature.geometry.type !== "Point") { + return Translations.t.general.add.import.wrongType.SetClass("alert") + } + const baseArgs: PointImportFlowArguments = Utils.ParseVisArgs(this.args, argument) + const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource , baseArgs) + const importFlow = new PointImportFlowState(state, > feature, baseArgs, tagsToApply) + + return new SvelteUIElement( + PointImportFlow, { + importFlow + } + ) + } + + getLayerDependencies(argsRaw: string[]): string[] { + return ImportFlowUtils.getLayerDependenciesWithSnapOnto(this.args, argsRaw) + } +} diff --git a/UI/Popup/ImportButtons/PointImportFlow.svelte b/UI/Popup/ImportButtons/PointImportFlow.svelte new file mode 100644 index 000000000..8c62ffcf8 --- /dev/null +++ b/UI/Popup/ImportButtons/PointImportFlow.svelte @@ -0,0 +1,62 @@ + + + + +
+
+ +
+ state.guistate.backgroundLayerSelectionIsOpened.setData(true)} cls="absolute bottom-0"> + + +
+ +
diff --git a/UI/Popup/ImportButtons/PointImportFlowState.ts b/UI/Popup/ImportButtons/PointImportFlowState.ts new file mode 100644 index 000000000..f42dc33ff --- /dev/null +++ b/UI/Popup/ImportButtons/PointImportFlowState.ts @@ -0,0 +1,98 @@ +import ImportFlow, {ImportFlowArguments} from "./ImportFlow"; +import {SpecialVisualizationState} from "../../SpecialVisualization"; +import {Store, UIEventSource} from "../../../Logic/UIEventSource"; +import {OsmObject, OsmWay} from "../../../Logic/Osm/OsmObject"; +import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction"; +import {Feature, Point} from "geojson"; +import Maproulette from "../../../Logic/Maproulette"; +import {GeoOperations} from "../../../Logic/GeoOperations"; +import {Tag} from "../../../Logic/Tags/Tag"; + +export interface PointImportFlowArguments extends ImportFlowArguments { + max_snap_distance?: string + snap_onto_layers?: string + icon?: string + targetLayer: string + note_id?: string + maproulette_id?: string +} + +export class PointImportFlowState extends ImportFlow { + public readonly startCoordinate: [number, number] + private readonly _originalFeature: Feature; + private readonly _originalFeatureTags: UIEventSource> + + constructor(state: SpecialVisualizationState, originalFeature: Feature, args: PointImportFlowArguments, tagsToApply: Store) { + super(state, args, tagsToApply); + this._originalFeature = originalFeature; + this._originalFeatureTags = state.featureProperties.getStore(originalFeature.properties.id) + this.startCoordinate = GeoOperations.centerpointCoordinates(originalFeature) + } + + /** + * Creates a new point on OSM, closes (if applicable) the OSM-note or the MapRoulette-challenge + * + * Gives back the id of the newly created element + */ + + async onConfirm( + location: { lat: number; lon: number }, + snapOntoWayId: string + ): Promise { + const tags = this.tagsToApply.data + const originalFeatureTags = this._originalFeatureTags + originalFeatureTags.data["_imported"] = "yes" + originalFeatureTags.ping() // will set isImported as per its definition + let snapOnto: OsmObject | "deleted" = undefined + if (snapOntoWayId !== undefined) { + snapOnto = await this.state.osmObjectDownloader.DownloadObjectAsync(snapOntoWayId) + } + if (snapOnto === "deleted") { + snapOnto = undefined + } + let specialMotivation = undefined + + let note_id = this.args.note_id + if (note_id !== undefined && isNaN(Number(note_id))) { + note_id = originalFeatureTags.data[this.args.note_id] + specialMotivation = "source: https://osm.org/note/" + note_id + } + + const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, { + theme: this.state.layout.id, + changeType: "import", + snapOnto: snapOnto, + specialMotivation: specialMotivation, + }) + + await this.state.changes.applyAction(newElementAction) + this.state.selectedElement.setData( + this.state.indexedFeatures.featuresById.data.get(newElementAction.newElementId) + ) + + if (note_id !== undefined) { + await this.state.osmConnection.closeNote(note_id, "imported") + originalFeatureTags.data["closed_at"] = new Date().toISOString() + originalFeatureTags.ping() + } + + let maproulette_id = originalFeatureTags.data[this.args.maproulette_id] + if (maproulette_id !== undefined) { + if (this.state.featureSwitchIsTesting.data) { + console.log( + "Not marking maproulette task " + + maproulette_id + + " as fixed, because we are in testing mode" + ) + } else { + console.log("Marking maproulette task as fixed") + await Maproulette.singleton.closeTask(Number(maproulette_id)) + originalFeatureTags.data["mr_taskStatus"] = "Fixed" + originalFeatureTags.ping() + } + } + this.state.mapProperties.location.setData(location) + return newElementAction.newElementId + } + +} diff --git a/UI/Popup/ImportButtons/WayImportButtonViz.ts b/UI/Popup/ImportButtons/WayImportButtonViz.ts new file mode 100644 index 000000000..0f4fc73c6 --- /dev/null +++ b/UI/Popup/ImportButtons/WayImportButtonViz.ts @@ -0,0 +1,111 @@ +import {SpecialVisualization, SpecialVisualizationState} from "../../SpecialVisualization"; +import {AutoAction} from "../AutoApplyButton"; +import {Feature, LineString, Polygon} from "geojson"; +import {UIEventSource} from "../../../Logic/UIEventSource"; +import BaseUIElement from "../../BaseUIElement"; +import {ImportFlowUtils} from "./ImportFlow"; +import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; +import SvelteUIElement from "../../Base/SvelteUIElement"; +import {FixedUiElement} from "../../Base/FixedUiElement"; +import WayImportFlow from "./WayImportFlow.svelte"; +import WayImportFlowState, {WayImportFlowArguments} from "./WayImportFlowState"; +import {Utils} from "../../../Utils"; +import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"; +import {Changes} from "../../../Logic/Osm/Changes"; +import {IndexedFeatureSource} from "../../../Logic/FeatureSource/FeatureSource"; +import FullNodeDatabaseSource from "../../../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; + + +/** + * Wrapper around 'WayImportFlow' to make it a special visualisation + */ +export default class WayImportButtonViz implements AutoAction, SpecialVisualization { + + + public readonly funcName: string + public readonly docs: string | BaseUIElement + public readonly example?: string + public readonly args: { name: string; defaultValue?: string; doc: string }[] + + public readonly supportsAutoAction = true + public readonly needsNodeDatabase = true + + constructor() { + this.funcName = "import_way_button" + this.docs = "This button will copy the data from an external dataset into OpenStreetMap, copying the geometry and adding it as a 'line'" + ImportFlowUtils.documentationGeneral + this.args = [ + ...ImportFlowUtils.generalArguments, + { + name: "snap_to_point_if", + doc: "Points with the given tags will be snapped to or moved", + }, + { + name: "max_snap_distance", + doc: "If the imported object is a LineString or (Multi)Polygon, already existing OSM-points will be reused to construct the geometry of the newly imported way", + defaultValue: "0.05", + }, + { + name: "move_osm_point_if", + doc: "Moves the OSM-point to the newly imported point if these conditions are met", + }, + { + name: "max_move_distance", + doc: "If an OSM-point is moved, the maximum amount of meters it is moved. Capped on 20m", + defaultValue: "0.05", + }, + { + name: "snap_onto_layers", + doc: "If no existing nearby point exists, but a line of a specified layer is closeby, snap to this layer instead", + }, + { + name: "snap_to_layer_max_distance", + doc: "Distance to distort the geometry to snap to this layer", + defaultValue: "0.1", + }, + ] + } + + constr(state: SpecialVisualizationState, tagSource: UIEventSource>, argument: string[], feature: Feature, layer: LayerConfig): BaseUIElement { + const geometry = feature.geometry + if (!(geometry.type == "LineString" || geometry.type === "Polygon")) { + console.error("Invalid type to import", geometry.type) + return new FixedUiElement("Invalid geometry type:" + geometry.type).SetClass("alert") + } + const args: WayImportFlowArguments = Utils.ParseVisArgs(this.args, argument) + const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource, args) + const importFlow = new WayImportFlowState(state, >feature, args, tagsToApply, tagSource) + return new SvelteUIElement(WayImportFlow, { + importFlow + }) + } + + public async applyActionOn(feature: Feature, state: { + layout: LayoutConfig; + changes: Changes; + indexedFeatures: IndexedFeatureSource, + fullNodeDatabase: FullNodeDatabaseSource + }, tagSource: UIEventSource, argument: string[]): Promise { + { + // Small safety check to prevent duplicate imports + const id = tagSource.data.id + if (ImportFlowUtils.importedIds.has(id)) { + return + } + ImportFlowUtils.importedIds.add(id) + } + + if(feature.geometry.type !== "LineString" && feature.geometry.type !== "Polygon"){ + return + } + + const args: WayImportFlowArguments = Utils.ParseVisArgs(this.args, argument) + const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource, args) + const mergeConfigs = WayImportFlowState.GetMergeConfig(args, tagsToApply) + const action = WayImportFlowState.CreateAction(>feature, args, state, tagsToApply, mergeConfigs) + await state.changes.applyAction(action) + } + + getLayerDependencies(args: string[]){ + return ImportFlowUtils.getLayerDependenciesWithSnapOnto(this.args, args) + } +} diff --git a/UI/Popup/ImportButtons/WayImportFlow.svelte b/UI/Popup/ImportButtons/WayImportFlow.svelte new file mode 100644 index 000000000..807d7be72 --- /dev/null +++ b/UI/Popup/ImportButtons/WayImportFlow.svelte @@ -0,0 +1,58 @@ + + + + importFlow.onConfirm()}> +
+ +
+ +
+ state.guistate.backgroundLayerSelectionIsOpened.setData(true)} cls="absolute bottom-0"> + + +
+
diff --git a/UI/Popup/ImportButtons/WayImportFlowState.ts b/UI/Popup/ImportButtons/WayImportFlowState.ts new file mode 100644 index 000000000..b4800183e --- /dev/null +++ b/UI/Popup/ImportButtons/WayImportFlowState.ts @@ -0,0 +1,129 @@ +import ImportFlow, {ImportFlowArguments} from "./ImportFlow"; +import {SpecialVisualizationState} from "../../SpecialVisualization"; +import {Feature, LineString, Polygon} from "geojson"; +import {Store, UIEventSource} from "../../../Logic/UIEventSource"; +import {Tag} from "../../../Logic/Tags/Tag"; +import {And} from "../../../Logic/Tags/And"; +import CreateWayWithPointReuseAction, { + MergePointConfig +} from "../../../Logic/Osm/Actions/CreateWayWithPointReuseAction"; +import {TagUtils} from "../../../Logic/Tags/TagUtils"; +import {OsmCreateAction} from "../../../Logic/Osm/Actions/OsmChangeAction"; +import {FeatureSource, IndexedFeatureSource} from "../../../Logic/FeatureSource/FeatureSource"; +import CreateMultiPolygonWithPointReuseAction from "../../../Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction"; +import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"; +import {Changes} from "../../../Logic/Osm/Changes"; +import FullNodeDatabaseSource from "../../../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; + +export interface WayImportFlowArguments extends ImportFlowArguments { + max_snap_distance: string + snap_onto_layers: string, + snap_to_layer_max_distance: string, + max_move_distance: string, + move_osm_point_if, + snap_to_point_if +} + +export default class WayImportFlowState extends ImportFlow { + public readonly originalFeature: Feature; + + private readonly action: OsmCreateAction & { getPreview?(): Promise; } + private readonly _originalFeatureTags: UIEventSource>; + + constructor(state: SpecialVisualizationState, originalFeature: Feature, args: WayImportFlowArguments, tagsToApply: Store, originalFeatureTags: UIEventSource>) { + super(state, args, tagsToApply); + this.originalFeature = originalFeature; + this._originalFeatureTags = originalFeatureTags; + const mergeConfigs = WayImportFlowState.GetMergeConfig(args, tagsToApply) + this.action = WayImportFlowState.CreateAction(originalFeature, args, state, tagsToApply, mergeConfigs) + } + + public static CreateAction( + feature: Feature, + args: WayImportFlowArguments, + state: { + layout: LayoutConfig; + changes: Changes; + indexedFeatures: IndexedFeatureSource, + fullNodeDatabase?: FullNodeDatabaseSource + }, + tagsToApply: Store, + mergeConfigs: MergePointConfig[] + ): OsmCreateAction & { getPreview?(): Promise; newElementId?: string } { + if (feature.geometry.type === "Polygon" && feature.geometry.coordinates.length > 1) { + const coors = (feature.geometry).coordinates + const outer = coors[0] + const inner = [...coors] + inner.splice(0, 1) + return new CreateMultiPolygonWithPointReuseAction( + tagsToApply.data, + outer, + inner, + state, + mergeConfigs, + "import" + ) + } else if (feature.geometry.type === "Polygon") { + const coors = feature.geometry.coordinates + + const outer = coors[0] + return new CreateWayWithPointReuseAction(tagsToApply.data, outer, state, mergeConfigs) + } else if (feature.geometry.type === "LineString") { + const coors = feature.geometry.coordinates + return new CreateWayWithPointReuseAction(tagsToApply.data, coors, state, mergeConfigs) + } else { + throw "Unsupported type" + } + } + + public static GetMergeConfig(args: WayImportFlowArguments, newTags: Store): MergePointConfig[] { + const nodesMustMatch = args.snap_to_point_if + ?.split(";") + ?.map((tag, i) => TagUtils.Tag(tag, "TagsSpec for import button " + i)) + + const mergeConfigs = [] + if (nodesMustMatch !== undefined && nodesMustMatch.length > 0) { + const mergeConfig: MergePointConfig = { + mode: "reuse_osm_point", + ifMatches: new And(nodesMustMatch), + withinRangeOfM: Number(args.max_snap_distance), + } + mergeConfigs.push(mergeConfig) + } + + const moveOsmPointIfTags = args["move_osm_point_if"] + ?.split(";") + ?.map((tag, i) => TagUtils.Tag(tag, "TagsSpec for import button " + i)) + + if (nodesMustMatch !== undefined && moveOsmPointIfTags.length > 0) { + const moveDistance = Math.min(20, Number(args["max_move_distance"])) + const mergeConfig: MergePointConfig = { + mode: "move_osm_point", + ifMatches: new And(moveOsmPointIfTags), + withinRangeOfM: moveDistance, + } + mergeConfigs.push(mergeConfig) + } + + return mergeConfigs + } + + public async onConfirm() { + const originalFeatureTags = this._originalFeatureTags + originalFeatureTags.data["_imported"] = "yes" + originalFeatureTags.ping() // will set isImported as per its definition + const action = this.action + await this.state.changes.applyAction(action) + const newId = action.newElementId ?? action.mainObjectId + this.state.selectedLayer.setData(this.targetLayer.layerDef) + this.state.selectedElement.setData(this.state.indexedFeatures.featuresById.data.get(newId)) + } + + public GetPreview(): undefined | Promise { + if (!this.action?.getPreview) { + return undefined + } + return this.action.getPreview() + } + +} diff --git a/UI/Popup/MoveWizard.ts b/UI/Popup/MoveWizard.ts index 62d933f88..e7179c0ae 100644 --- a/UI/Popup/MoveWizard.ts +++ b/UI/Popup/MoveWizard.ts @@ -130,6 +130,7 @@ export default class MoveWizard extends Toggle { zoom: new UIEventSource(reason?.startZoom ?? 16), location: new UIEventSource({ lon, lat }), bounds: new UIEventSource(undefined), + rasterLayer: state.mapProperties.rasterLayer } const value = new UIEventSource<{ lon: number; lat: number }>(undefined) const locationInput = new SvelteUIElement(LocationInput, { diff --git a/UI/Popup/TagHint.svelte b/UI/Popup/TagHint.svelte index cf7d51112..d4ba075bf 100644 --- a/UI/Popup/TagHint.svelte +++ b/UI/Popup/TagHint.svelte @@ -14,7 +14,7 @@ /** * If given, this function will be called to embed the given tags hint into this translation */ - export let embedIn: (() => Translation) | undefined = undefined; + export let embedIn: ((string: string) => Translation) | undefined = undefined; const userDetails = state.osmConnection.userDetails; let tagsExplanation = ""; $: tagsExplanation = tags?.asHumanString(true, false, {}); diff --git a/UI/SpecialVisualization.ts b/UI/SpecialVisualization.ts index 6291247d3..f26f5e489 100644 --- a/UI/SpecialVisualization.ts +++ b/UI/SpecialVisualization.ts @@ -80,7 +80,7 @@ export interface SpecialVisualization { readonly example?: string /** - * Indicates that this special visualsiation will make requests to the 'alLNodesDatabase' and that it thus should be included + * Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included */ readonly needsNodeDatabase?: boolean readonly args: { diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 55d65df11..0bde3ee77 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -11,7 +11,6 @@ import {UploadToOsmViz} from "./Popup/UploadToOsmViz" import {MultiApplyViz} from "./Popup/MultiApplyViz" import {AddNoteCommentViz} from "./Popup/AddNoteCommentViz" import {PlantNetDetectionViz} from "./Popup/PlantNetDetectionViz" -import {ConflateButton, ImportWayButton} from "./Popup/ImportButton" import TagApplyButton from "./Popup/TagApplyButton" import {CloseNoteButton} from "./Popup/CloseNoteButton" import {MapillaryLinkVis} from "./Popup/MapillaryLinkVis" @@ -72,7 +71,8 @@ import SplitRoadWizard from "./Popup/SplitRoadWizard" import {ExportAsGpxViz} from "./Popup/ExportAsGpxViz" import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte" import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"; -import {ImportPointButton} from "./Popup/ImportButtons/ImportPointButton"; +import {ImportPointButtonViz} from "./Popup/ImportButtons/ImportPointButtonViz"; +import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz"; class NearbyImageVis implements SpecialVisualization { // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests @@ -619,9 +619,9 @@ export default class SpecialVisualizations { new TagApplyButton(), - new ImportPointButton(), - new ImportWayButton(), - new ConflateButton(), + new ImportPointButtonViz(), + new WayImportButtonViz(), + // TODO new ConflateButton(), new NearbyImageVis(), diff --git a/Utils.ts b/Utils.ts index 58916c5b1..69bda2980 100644 --- a/Utils.ts +++ b/Utils.ts @@ -153,8 +153,8 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be public static ParseVisArgs( specs: { name: string; defaultValue?: string }[], args: string[] - ): any { - const parsed = {} + ): Record { + const parsed: Record = {} if (args.length > specs.length) { throw ( "To much arguments for special visualization: got " + diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index 591a49f7f..fc7330753 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -981,6 +981,14 @@ video { height: 100%; } +.h-screen { + height: 100vh; +} + +.h-32 { + height: 8rem; +} + .h-8 { height: 2rem; } @@ -989,10 +997,6 @@ video { height: 4rem; } -.h-32 { - height: 8rem; -} - .h-12 { height: 3rem; } @@ -1001,10 +1005,6 @@ video { height: 1.5rem; } -.h-screen { - height: 100vh; -} - .h-4 { height: 1rem; } @@ -1075,8 +1075,8 @@ video { width: 100%; } -.w-1\/2 { - width: 50%; +.w-32 { + width: 8rem; } .w-8 { @@ -1087,10 +1087,6 @@ video { width: 4rem; } -.w-32 { - width: 8rem; -} - .w-12 { width: 3rem; } @@ -1137,6 +1133,10 @@ video { width: 16rem; } +.w-1\/2 { + width: 50%; +} + .w-96 { width: 24rem; } @@ -1257,6 +1257,10 @@ video { flex-direction: column; } +.flex-col-reverse { + flex-direction: column-reverse; +} + .flex-wrap { flex-wrap: wrap; } @@ -1449,11 +1453,6 @@ video { border-style: dotted; } -.border-gray-500 { - --tw-border-opacity: 1; - border-color: rgb(107 114 128 / var(--tw-border-opacity)); -} - .border-black { --tw-border-opacity: 1; border-color: rgb(0 0 0 / var(--tw-border-opacity)); @@ -1484,6 +1483,11 @@ video { border-color: rgb(75 85 99 / var(--tw-border-opacity)); } +.border-gray-500 { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + .border-opacity-50 { --tw-border-opacity: 0.5; } @@ -1528,14 +1532,14 @@ video { background-color: rgb(254 202 202 / var(--tw-bg-opacity)); } -.p-1 { - padding: 0.25rem; -} - .p-8 { padding: 2rem; } +.p-1 { + padding: 0.25rem; +} + .p-2 { padding: 0.5rem; } diff --git a/test.html b/test.html index aece23274..2a5d2478c 100644 --- a/test.html +++ b/test.html @@ -17,7 +17,7 @@ -
'maindiv' not attached
+
'maindiv' not attached
'extradiv' not attached
diff --git a/test.ts b/test.ts index 87324a7ba..ceadd3186 100644 --- a/test.ts +++ b/test.ts @@ -1,18 +1,15 @@ import LayoutConfig from "./Models/ThemeConfig/LayoutConfig" -import * as theme from "./assets/generated/themes/shops.json" +import * as theme from "./assets/generated/themes/bookcases.json" import ThemeViewState from "./Models/ThemeViewState" import Combine from "./UI/Base/Combine" import SpecialVisualizations from "./UI/SpecialVisualizations" -import InputHelpers from "./UI/InputElement/InputHelpers" -import BaseUIElement from "./UI/BaseUIElement" -import { UIEventSource } from "./Logic/UIEventSource" -import { VariableUiElement } from "./UI/Base/VariableUIElement" -import { FixedUiElement } from "./UI/Base/FixedUiElement" -import Title from "./UI/Base/Title" +import {VariableUiElement} from "./UI/Base/VariableUIElement" import SvelteUIElement from "./UI/Base/SvelteUIElement" -import ValidatedInput from "./UI/InputElement/ValidatedInput.svelte" -import { SvgToPdf } from "./Utils/svgToPdf" -import { Utils } from "./Utils" +import {SvgToPdf} from "./Utils/svgToPdf" +import {Utils} from "./Utils" +import {PointImportFlowState} from "./UI/Popup/ImportButtons/PointImportFlowState"; +import PointImportFlow from "./UI/Popup/ImportButtons/PointImportFlow.svelte"; +import {Feature, Point} from "geojson"; function testspecial() { const layout = new LayoutConfig(theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data) @@ -24,30 +21,6 @@ function testspecial() { new Combine(all).AttachTo("maindiv") } -function testinput() { - const els: BaseUIElement[] = [] - for (const key in InputHelpers.AvailableInputHelpers) { - const value = new UIEventSource(undefined) - const helper = InputHelpers.AvailableInputHelpers[key](value, { - mapProperties: { - zoom: new UIEventSource(16), - location: new UIEventSource({ lat: 51.1, lon: 3.2 }), - }, - }) - - const feedback: UIEventSource = new UIEventSource(undefined) - els.push( - new Combine([ - new Title(key), - new SvelteUIElement(ValidatedInput, { value, type: key, feedback }), - helper, - new VariableUiElement(feedback), - new VariableUiElement(value.map((v) => new FixedUiElement(v))), - ]).SetClass("flex flex-col p-1 border-3 border-gray-500") - ) - } - new Combine(els).SetClass("flex flex-col").AttachTo("maindiv") -} async function testPdf() { const svgs = await Promise.all( @@ -59,8 +32,31 @@ async function testPdf() { await pdf.ConvertSvg("nl") } +function testImportButton() { + const layout = new LayoutConfig(theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data) + const state = new ThemeViewState(layout) + const originalFeature: Feature = { + type: "Feature", + properties: { + id: "note/-1" + }, + geometry: { + type: "Point", + coordinates: [3.2255, 51.2112] + } + } + const importFlow = new PointImportFlowState(state, originalFeature, { + text: "Import this point", + newTags: undefined, + targetLayer: "public_bookcase" + }, tagsToApply) + new SvelteUIElement(PointImportFlow, { + importFlow + }).SetClass("h-full").AttachTo("maindiv") +} + +testImportButton() // testPdf().then((_) => console.log("All done")) -//testinput() /*/ testspecial() //*/