Refactoring: fix import buttons (WIP)

This commit is contained in:
Pieter Vander Vennet 2023-05-30 02:52:22 +02:00
parent 52b54d8b08
commit 8f942f0163
28 changed files with 970 additions and 779 deletions

View file

@ -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<FeatureSource> {
return undefined
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
console.log("Running CMPWPRA")
const descriptions: ChangeDescription[] = []

View file

@ -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<FeatureSource> {
@ -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})
}
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: NextButton
*/
import SubtleButton from "./SubtleButton.svelte";
import {ChevronLeftIcon} from "@rgossiaux/svelte-heroicons/solid";
@ -12,5 +13,5 @@
<SubtleButton on:click={() => dispatch("click")} options={{extraClasses:clss+ " flex items-center"}}>
<ChevronLeftIcon class="w-12 h-12" slot="image"/>
<slot name="message" slot="message"/>
<slot slot="message"/>
</SubtleButton>

View file

@ -5,9 +5,10 @@
* A round button with an icon and possible a small text, which hovers above the map
*/
const dispatch = createEventDispatcher()
export let cls = ""
</script>
<button on:click={e => dispatch("click", e)} class="rounded-full h-fit w-fit m-0.5 md:m-1 p-0.5 sm:p-1 pointer-events-auto">
<button on:click={e => dispatch("click", e)} class={"rounded-full h-fit w-fit m-0.5 md:m-1 p-0.5 sm:p-1 pointer-events-auto "+cls} >
<slot/>
</button>

View file

@ -1,6 +1,7 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: BackButton
*/
import SubtleButton from "./SubtleButton.svelte";
import {ChevronRightIcon} from "@rgossiaux/svelte-heroicons/solid";

View file

@ -283,7 +283,7 @@ export class DownloadPanel extends Toggle {
for (const neededLayer of neededLayers) {
const indexedFeatureSource = state.perLayer.get(neededLayer)
let features = indexedFeatureSource.GetFeaturesWithin(bbox, true)
let features = indexedFeatureSource.GetFeaturesWithin(bbox)
// The 'indexedFeatureSources' contains _all_ features, they are not filtered yet
const filter = state.layerState.filteredLayers.get(neededLayer)
features = features.filter((f) =>

View file

@ -18,13 +18,13 @@
* - Show more layers
* - Snap to layers
*
* This one is mostly used to insert new points
* This one is mostly used to insert new points, including when importing
*/
export let state: SpecialVisualizationState;
/**
* The start coordinate
*/
export let coordinate: { lon: number, lat: number };
export let coordinate: { lon: number, lat: number };
export let snapToLayers: string[] | undefined;
export let targetLayer: LayerConfig;
export let maxSnapDistance: number = undefined;
@ -105,4 +105,4 @@
<LocationInput {map} mapProperties={initialMapProperties}
value={preciseLocation} initialCoordinate={{...coordinate}} maxDistanceInMeters=50 />
value={preciseLocation} initialCoordinate={coordinate} maxDistanceInMeters=50 />

View file

@ -14,11 +14,11 @@
import {onDestroy} from "svelte";
/**
* A visualisation to pick a direction on a map background
* A visualisation to pick a location on a map background
*/
export let value: UIEventSource<{ lon: number, lat: number }>;
export let initialCoordinate : {lon: number, lat :number}
initialCoordinate = initialCoordinate ?? value .data
initialCoordinate = initialCoordinate ?? value.data
export let maxDistanceInMeters: number = undefined
export let mapProperties: Partial<MapProperties> & {
readonly location: UIEventSource<{ lon: number; lat: number }>

View file

@ -238,7 +238,7 @@
<div class="flex w-full flex-wrap-reverse md:flex-nowrap">
<BackButton on:click={() => selectedPreset = undefined} clss="w-full">
<Tr slot="message" t={t.backToSelect}/>
<Tr t={t.backToSelect}/>
</BackButton>
<NextButton on:click={() => confirmedCategory = true} clss="primary w-full">

View file

@ -25,11 +25,13 @@ import ShowDataLayer from "../Map/ShowDataLayer"
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import SpecialVisualizations from "../SpecialVisualizations"
import {Feature} from "geojson";
export interface AutoAction extends SpecialVisualization {
supportsAutoAction: boolean
applyActionOn(
feature: Feature,
state: {
layout: LayoutConfig
changes: Changes

View file

@ -57,7 +57,7 @@ export class CloseNoteButton implements SpecialVisualization {
comment: string
minZoom: string
zoomButton: string
} = Utils.ParseVisArgs(this.args, args)
} = <any> Utils.ParseVisArgs(this.args, args)
let icon = Svg.checkmark_svg()
if (params.icon !== "checkmark.svg" && (args[2] ?? "") !== "") {

View file

@ -1,56 +1,26 @@
import BaseUIElement from "../BaseUIElement"
import { SubtleButton } from "../Base/SubtleButton"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import { VariableUiElement } from "../Base/VariableUIElement"
import {Store, UIEventSource} from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Toggle from "../Input/Toggle"
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"
import Loading from "../Base/Loading"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Lazy from "../Base/Lazy"
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint"
import Img from "../Base/Img"
import FilteredLayer from "../../Models/FilteredLayer"
import { FixedUiElement } from "../Base/FixedUiElement"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import CreateWayWithPointReuseAction, {
MergePointConfig,
} from "../../Logic/Osm/Actions/CreateWayWithPointReuseAction"
import OsmChangeAction, { OsmCreateAction } from "../../Logic/Osm/Actions/OsmChangeAction"
import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { OsmObject, OsmWay } from "../../Logic/Osm/OsmObject"
import { PresetInfo } from "../BigComponents/SimpleAddUI"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { And } from "../../Logic/Tags/And"
import ReplaceGeometryAction from "../../Logic/Osm/Actions/ReplaceGeometryAction"
import CreateMultiPolygonWithPointReuseAction from "../../Logic/Osm/Actions/CreateMultiPolygonWithPointReuseAction"
import { Tag } from "../../Logic/Tags/Tag"
import TagApplyButton from "./TagApplyButton"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import conflation_json from "../../assets/layers/conflation/conflation.json"
import { GeoOperations } from "../../Logic/GeoOperations"
import { LoginToggle } from "./LoginButton"
import { AutoAction } from "./AutoApplyButton"
import Hash from "../../Logic/Web/Hash"
import { PreciseInput } from "../../Models/ThemeConfig/PresetConfig"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import Maproulette from "../../Logic/Maproulette"
import { Feature, Point } from "geojson"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import ShowDataLayer from "../Map/ShowDataLayer"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import {FixedUiElement} from "../Base/FixedUiElement"
import OsmChangeAction from "../../Logic/Osm/Actions/OsmChangeAction"
import {And} from "../../Logic/Tags/And"
import {Tag} from "../../Logic/Tags/Tag"
import {SpecialVisualization, SpecialVisualizationState} from "../SpecialVisualization"
import {Feature} from "geojson"
import {ImportFlowArguments, ImportFlowUtils} from "./ImportButtons/ImportFlow";
import {MergePointConfig} from "../../Logic/Osm/Actions/CreateWayWithPointReuseAction";
import {GeoOperations} from "../../Logic/GeoOperations";
import ReplaceGeometryAction from "../../Logic/Osm/Actions/ReplaceGeometryAction";
import {TagUtils} from "../../Logic/Tags/TagUtils";
/**
* @deprecated
* A helper class for the various import-flows.
* An import-flow always starts with a 'Import this'-button. Upon click, a custom confirmation panel is provided
*/
abstract class AbstractImportButton implements SpecialVisualization {
protected static importedIds = new Set<string>()
export abstract class AbstractImportButton implements SpecialVisualization {
public readonly funcName: string
public readonly docs: string
public readonly args: { name: string; defaultValue?: string; doc: string }[]
@ -66,18 +36,8 @@ abstract class AbstractImportButton implements SpecialVisualization {
this.funcName = funcName
this.showRemovedTags = options?.showRemovedTags ?? true
this.cannotBeImportedMessage = options?.cannotBeImportedMessage
this.docs = `${docsIntro}
this.docs = `${docsIntro}${ImportFlowUtils.documentationGeneral}`
Note 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}
`
this.args = [
{
name: "targetLayer",
@ -105,15 +65,8 @@ ${Utils.special_visualizations_importRequirementDocs}
abstract constructElement(
state: SpecialVisualizationState,
args: {
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
tags: string
newTags: UIEventSource<any>
targetLayer: string
},
args: ImportFlowArguments,
newTags: Store<Tag[]>,
tagSource: UIEventSource<Record<string, string>>,
feature: Feature,
onCancelClicked: () => void
@ -124,246 +77,14 @@ ${Utils.special_visualizations_importRequirementDocs}
tagSource: UIEventSource<Record<string, string>>,
argsRaw: string[]
) {
/**
* Some generic import button pre-validation is implemented here:
* - Are we logged in?
* - Did the user zoom in enough?
* ...
*
* The actual import flow (showing the conflation map, special cases) are handled in 'constructElement'
*/
const t = Translations.t.general.add.import
const t0 = Translations.t.general.add
const args = this.parseArgs(argsRaw, tagSource)
{
// Some initial validation
if (
!state.layout.official &&
!(
state.featureSwitchIsTesting.data ||
state.osmConnection._oauth_config.url ===
OsmConnection.oauth_configs["osm-test"].url
)
) {
return new Combine([t.officialThemesOnly.SetClass("alert"), t.howToTest])
}
const targetLayer: FilteredLayer = state.layerState.filteredLayers.get(args.targetLayer)
if (targetLayer === undefined) {
const e = `Target layer not defined: error in import button for theme: ${state.layout.id}: layer ${args.targetLayer} not found`
console.error(e)
return new FixedUiElement(e).SetClass("alert")
}
}
let img: BaseUIElement
if (args.icon !== undefined && args.icon !== "") {
img = new Img(args.icon)
} else {
img = Svg.add_svg()
}
const inviteToImportButton = new SubtleButton(img, args.text)
const id = tagSource.data.id
const feature = state.indexedFeatures.featuresById.data.get(id)
// Explanation of the tags that will be applied onto the imported/conflated object
let tagSpec = args.tags
if (
tagSpec.indexOf(" ") < 0 &&
tagSpec.indexOf(";") < 0 &&
tagSource.data[args.tags] !== undefined
) {
// This is probably a key
tagSpec = tagSource.data[args.tags]
console.debug(
"The import button is using tags from properties[" +
args.tags +
"] of this object, namely ",
tagSpec
)
}
const importClicked = new UIEventSource(false)
inviteToImportButton.onClick(() => {
importClicked.setData(true)
})
const pleaseLoginButton = new Toggle(
t0.pleaseLogin
.onClick(() => state.osmConnection.AttemptLogin())
.SetClass("login-button-friendly"),
undefined,
state.featureSwitchUserbadge
)
const isImported = tagSource.map((tags) => {
AbstractImportButton.importedIds.add(tags.id)
return tags._imported === "yes"
})
/**** The actual panel showing the import guiding map ****/
const importGuidingPanel = this.constructElement(state, args, tagSource, feature, () =>
importClicked.setData(false)
)
const importFlow = new Toggle(
new Toggle(new Loading(t0.stillLoading), importGuidingPanel, state.dataIsLoading),
inviteToImportButton,
importClicked
)
return new Toggle(
new LoginToggle(
new Toggle(
new Toggle(t.hasBeenImported, importFlow, isImported),
t.zoomInMore.SetClass("alert block"),
state.mapProperties.zoom.map((zoom) => zoom >= 18)
),
pleaseLoginButton,
state
),
this.cannotBeImportedMessage ?? t.wrongType,
new UIEventSource(this.canBeImported(feature))
)
return new FixedUiElement("Deprecated")
}
getLayerDependencies(argsRaw: string[]) {
const args = this.parseArgs(argsRaw, undefined)
const dependsOnLayers: string[] = []
// The target layer
dependsOnLayers.push(args.targetLayer)
const snapOntoLayers = args.snap_onto_layers?.trim() ?? ""
if (snapOntoLayers !== "") {
dependsOnLayers.push(...snapOntoLayers.split(";"))
}
return dependsOnLayers
}
protected abstract canBeImported(feature: any)
private static readonly conflationLayer = new LayerConfig(
<LayerConfigJson>conflation_json,
"all_known_layers",
true
)
protected createConfirmPanelForWay(
state: SpecialVisualizationState,
args: {
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
newTags: UIEventSource<Tag[]>
targetLayer: string
},
feature: any,
originalFeatureTags: UIEventSource<any>,
action: OsmChangeAction & { getPreview?(): Promise<FeatureSource>; newElementId?: string },
onCancel: () => void
): BaseUIElement {
const self = this
const map = new UIEventSource(undefined)
new MapLibreAdaptor(map, {
allowMoving: UIEventSource.feedFrom(state.featureSwitchIsTesting),
allowZooming: UIEventSource.feedFrom(state.featureSwitchIsTesting),
rasterLayer: state.mapProperties.rasterLayer,
})
const confirmationMap = new SvelteUIElement(MaplibreMap, { map })
confirmationMap.SetStyle("height: 20rem; overflow: hidden").SetClass("rounded-xl")
ShowDataLayer.showMultipleLayers(
map,
new StaticFeatureSource([feature]),
state.layout.layers,
{ zoomToFeatures: true }
)
// Show all relevant data - including (eventually) the way of which the geometry will be replaced
action.getPreview().then((changePreview) => {
new ShowDataLayer(map, {
zoomToFeatures: false,
features: changePreview,
layer: AbstractImportButton.conflationLayer,
})
})
const tagsExplanation = new VariableUiElement(
args.newTags.map((tagsToApply) => {
const filteredTags = tagsToApply.filter(
(t) => self.showRemovedTags || (t.value ?? "") !== ""
)
const tagsStr = new And(filteredTags).asHumanString(false, true, {})
return Translations.t.general.add.import.importTags.Subs({ tags: tagsStr })
})
).SetClass("subtle")
const confirmButton = new SubtleButton(
new Img(args.icon),
new Combine([args.text, tagsExplanation]).SetClass("flex flex-col")
)
confirmButton.onClick(async () => {
{
originalFeatureTags.data["_imported"] = "yes"
originalFeatureTags.ping() // will set isImported as per its definition
await state.changes.applyAction(action)
const newId = action.newElementId ?? action.mainObjectId
state.selectedElement.setData(state.indexedFeatures.featuresById.data.get(newId))
}
})
const cancel = new SubtleButton(Svg.close_svg(), Translations.t.general.cancel).onClick(
onCancel
)
return new Combine([confirmationMap, confirmButton, cancel]).SetClass("flex flex-col")
}
protected parseArgs(
argsRaw: string[],
originalFeatureTags: UIEventSource<any>
): {
minzoom: string
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
tags: string
targetLayer: string
newTags: UIEventSource<Tag[]>
} {
const baseArgs = Utils.ParseVisArgs(this.args, argsRaw)
if (originalFeatureTags !== undefined) {
const tags = baseArgs.tags
if (
tags.indexOf(" ") < 0 &&
tags.indexOf(";") < 0 &&
originalFeatureTags.data[tags] !== undefined
) {
// This might be 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
)
baseArgs["newTags"] = TagApplyButton.generateTagsToApply(items, originalFeatureTags)
} else {
baseArgs["newTags"] = TagApplyButton.generateTagsToApply(tags, originalFeatureTags)
}
}
return baseArgs
}
}
export class ConflateButton extends AbstractImportButton {
needsNodeDatabase = true
constructor() {
super(
"conflate_button",
@ -432,377 +153,10 @@ export class ConflateButton extends AbstractImportButton {
)
}
protected canBeImported(feature: any) {
protected canBeImported(feature: Feature) {
return (
feature.geometry.type === "LineString" ||
(feature.geometry.type === "Polygon" && feature.geometry.coordinates.length === 1)
)
}
}
export class ImportWayButton extends AbstractImportButton implements AutoAction {
public readonly supportsAutoAction = true
needsNodeDatabase = true
constructor() {
super(
"import_way_button",
"This button will copy the data from an external dataset into OpenStreetMap",
[
{
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",
},
],
{ showRemovedTags: false }
)
}
private static CreateAction(
feature,
args: {
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
tags: string
newTags: UIEventSource<any>
targetLayer: string
},
state: SpecialVisualizationState,
mergeConfigs: any[]
): OsmCreateAction & { getPreview(): Promise<FeatureSource>; newElementId?: string } {
const coors = feature.geometry.coordinates
if (feature.geometry.type === "Polygon" && coors.length > 1) {
const outer = coors[0]
const inner = [...coors]
inner.splice(0, 1)
return new CreateMultiPolygonWithPointReuseAction(
args.newTags.data,
outer,
inner,
state,
mergeConfigs,
"import"
)
} else if (feature.geometry.type === "Polygon") {
const outer = coors[0]
return new CreateWayWithPointReuseAction(args.newTags.data, outer, state, mergeConfigs)
} else if (feature.geometry.type === "LineString") {
return new CreateWayWithPointReuseAction(args.newTags.data, coors, state, mergeConfigs)
} else {
throw "Unsupported type"
}
}
async applyActionOn(
state: SpecialVisualizationState,
originalFeatureTags: UIEventSource<Record<string, string>>,
argument: string[]
): Promise<void> {
const id = originalFeatureTags.data.id
if (AbstractImportButton.importedIds.has(originalFeatureTags.data.id)) {
return
}
AbstractImportButton.importedIds.add(originalFeatureTags.data.id)
const args = this.parseArgs(argument, originalFeatureTags)
const feature = state.indexedFeatures.featuresById.data.get(id)
const mergeConfigs = this.GetMergeConfig(args)
const action = ImportWayButton.CreateAction(feature, args, state, mergeConfigs)
await state.changes.applyAction(action)
}
canBeImported(feature: any) {
const type = feature.geometry.type
return type === "LineString" || type === "Polygon"
}
constructElement(
state: SpecialVisualizationState,
args,
originalFeatureTags: UIEventSource<Record<string, string>>,
feature,
onCancel
): 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")
}
// Upload the way to OSM
const mergeConfigs = this.GetMergeConfig(args)
let action: OsmCreateAction & { getPreview?: any } = ImportWayButton.CreateAction(
feature,
args,
state,
mergeConfigs
)
return this.createConfirmPanelForWay(
state,
args,
feature,
originalFeatureTags,
action,
onCancel
)
}
private GetMergeConfig(args: {
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
tags: string
newTags: UIEventSource<any>
targetLayer: string
}): 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
}
}
export class ImportPointButton extends AbstractImportButton {
constructor() {
super(
"import_button",
"This button will copy the point from an external dataset into OpenStreetMap",
[
{
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: "location_picker",
defaultValue: "photo",
doc: "Chooses the background for the precise location picker, options are 'map', 'photo' or 'osmbasedmap' or 'none' if the precise input picker should be disabled",
},
{
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.",
},
],
{ showRemovedTags: false }
)
}
private static createConfirmPanelForPoint(
args: {
max_snap_distance: string
snap_onto_layers: string
icon: string
text: string
newTags: UIEventSource<any>
targetLayer: string
note_id: string
maproulette_id: string
},
state: SpecialVisualizationState,
originalFeatureTags: UIEventSource<any>,
feature: Feature<Point>,
onCancel: () => void,
close: () => void
): BaseUIElement {
async function confirm(
tags: any[],
location: { lat: number; lon: number },
snapOntoWayId: string
) {
originalFeatureTags.data["_imported"] = "yes"
originalFeatureTags.ping() // will set isImported as per its definition
let snapOnto: OsmObject | "deleted" = undefined
if (snapOntoWayId !== undefined) {
snapOnto = await state.osmObjectDownloader.DownloadObjectAsync(snapOntoWayId)
}
if(snapOnto === "deleted"){
return new FixedUiElement("Error - way is deleted. Refresh the page").SetClass("alert")
}
let specialMotivation = undefined
let note_id = args.note_id
if (args.note_id !== undefined && isNaN(Number(args.note_id))) {
note_id = originalFeatureTags.data[args.note_id]
specialMotivation = "source: https://osm.org/note/" + note_id
}
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {
theme: state.layout.id,
changeType: "import",
snapOnto: <OsmWay>snapOnto,
specialMotivation: specialMotivation,
})
await state.changes.applyAction(newElementAction)
state.selectedElement.setData(
state.indexedFeatures.featuresById.data.get(newElementAction.newElementId)
)
Hash.hash.setData(newElementAction.newElementId)
if (note_id !== undefined) {
await state.osmConnection.closeNote(note_id, "imported")
originalFeatureTags.data["closed_at"] = new Date().toISOString()
originalFeatureTags.ping()
}
let maproulette_id = originalFeatureTags.data[args.maproulette_id]
console.log(
"Checking if we need to mark a maproulette task as fixed (" + maproulette_id + ")"
)
if (maproulette_id !== undefined) {
if (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()
}
}
}
let preciseInputOption = args["location_picker"]
let preciseInputSpec: PreciseInput = undefined
console.log("Precise input location is ", preciseInputOption)
if (preciseInputOption !== "none") {
preciseInputSpec = {
snapToLayers: args.snap_onto_layers?.split(";"),
maxSnapDistance: Number(args.max_snap_distance),
preferredBackground: args["location_picker"] ?? ["photo", "map"],
}
}
const presetInfo = <PresetInfo>{
tags: args.newTags.data,
icon: () => new Img(args.icon),
layerToAddTo: state.layerState.filteredLayers.get(args.targetLayer),
name: args.text,
title: Translations.T(args.text),
preciseInput: preciseInputSpec, // must be explicitely assigned, if 'undefined' won't work otherwise
boundsFactor: 3,
}
const [lon, lat] = <[number, number]>feature.geometry.coordinates
return new ConfirmLocationOfPoint(
state,
state.guistate.themeIsOpened ,
presetInfo,
Translations.W(args.text),
{
lon,
lat,
},
confirm,
onCancel,
close
)
}
canBeImported(feature: any) {
return feature.geometry.type === "Point"
}
getLayerDependencies(argsRaw: string[]): string[] {
const deps = super.getLayerDependencies(argsRaw)
const layerSnap = argsRaw["snap_onto_layers"] ?? ""
if (layerSnap === "") {
return deps
}
deps.push(...layerSnap.split(";"))
return deps
}
constructElement(
state: SpecialVisualizationState,
args,
originalFeatureTags,
feature,
onCancel: () => void
): BaseUIElement {
const geometry = feature.geometry
if (geometry.type === "Point") {
return new Lazy(() =>
ImportPointButton.createConfirmPanelForPoint(
args,
state,
originalFeatureTags,
feature,
onCancel,
() => {
// Close the current popup
state.selectedElement.setData(undefined)
}
)
)
}
console.error("Invalid type to import", geometry.type)
return new FixedUiElement("Invalid geometry type:" + geometry.type).SetClass("alert")
}
}

View file

@ -0,0 +1,93 @@
<script lang="ts">
/**
* The 'importflow' does some basic setup, e.g. validate that imports are allowed, that the user is logged-in, ...
* They show some default components
*/
import ImportFlow from "./ImportFlow";
import LoginToggle from "../../Base/LoginToggle.svelte";
import BackButton from "../../Base/BackButton.svelte";
import Translations from "../../i18n/Translations";
import Tr from "../../Base/Tr.svelte";
import NextButton from "../../Base/NextButton.svelte";
import {createEventDispatcher} from "svelte";
import Loading from "../../Base/Loading.svelte";
import {And} from "../../../Logic/Tags/And";
import TagHint from "../TagHint.svelte";
import {TagsFilter} from "../../../Logic/Tags/TagsFilter";
import {Store} from "../../../Logic/UIEventSource";
export let importFlow: ImportFlow
let state = importFlow.state
export let currentFlowStep: "start" | "confirm" | "importing" | "imported" = "start"
const isLoading = state.dataIsLoading
const dispatch = createEventDispatcher<{ confirm }>()
const canBeImported = importFlow.canBeImported()
const tags : Store<TagsFilter> = importFlow.tagsToApply.map(tags => new And(tags))
const isDisplayed = importFlow.targetLayer.isDisplayed
const hasFilter = importFlow.targetLayer.appliedFilters
</script>
<LoginToggle {state}>
{#if currentFlowStep === "start"}
<NextButton clss="primary w-full" on:click={() => currentFlowStep = "confirm"}>
<slot name="start-flow-text">
{#if importFlow?.args?.icon}
<img class="w-8 h-8" src={importFlow.args.icon}/>
{/if}
{importFlow.args.text}
</slot>
</NextButton>
{:else if $canBeImported !== true && $canBeImported !== undefined}
<Tr cls="alert w-full flex justify-center" t={$canBeImported.error}/>
{#if $canBeImported.extraHelp}
<Tr t={$canBeImported.extraHelp}/>
{/if}
{:else if $isLoading}
<Loading>
<Tr t={Translations.t.general.add.stillLoading}/>
</Loading>
{:else if currentFlowStep === "confirm"}
<div class="h-full w-full flex flex-col">
<div class="w-full h-full">
<slot name="map"/>
</div>
<div class="flex flex-col-reverse md:flex-row">
<BackButton clss="w-full" on:click={() => currentFlowStep = "start"}>
<Tr t={Translations.t.general.back}/>
</BackButton>
<NextButton clss="primary w-full" on:click={() => {
currentFlowStep = "imported"
dispatch("confirm")
}}>
<span slot="image">
{#if importFlow.args.icon}
<img src={importFlow.args.icon}>
{/if}
</span>
<slot name="confirm-text">
{importFlow.args.text}
</slot>
</NextButton>
</div>
<div class="subtle">
<TagHint embedIn={str => Translations.t.general.add.import.importTags.Subs({tags: str})} {state}
tags={$tags}/>
</div>
</div>
{:else if currentFlowStep === "importing"}
<Loading/>
{:else if currentFlowStep === "imported"}
<div class="thanks w-full p-4">
<Tr t={Translations.t.general.add.import.hasBeenImported}/>
</div>
{/if}
</LoginToggle>

View file

@ -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<string>()
public static readonly conflationLayer = new LayerConfig(
<LayerConfigJson>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<any>,
args: { tags: string }
): Store<Tag[]> {
if (originalFeatureTags === undefined) {
return undefined
}
let newTags: Store<Tag[]>
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 = <any>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 = <any>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<Record<string, string>>): Store<string> {
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<ArgT extends ImportFlowArguments> {
public readonly state: SpecialVisualizationState;
public readonly args: ArgT;
public readonly targetLayer: FilteredLayer;
public readonly tagsToApply: Store<Tag[]>
constructor(state: SpecialVisualizationState, args: ArgT, tagsToApply: Store<Tag[]>) {
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<true | { error: Translation, extraHelp?: Translation }> {
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])
}
}

View file

@ -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<Record<string, string>>, argument: string[], feature: Feature, layer: LayerConfig): BaseUIElement {
if (feature.geometry.type !== "Point") {
return Translations.t.general.add.import.wrongType.SetClass("alert")
}
const baseArgs: PointImportFlowArguments = <any> Utils.ParseVisArgs(this.args, argument)
const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource , baseArgs)
const importFlow = new PointImportFlowState(state, <Feature<Point>> feature, baseArgs, tagsToApply)
return new SvelteUIElement(
PointImportFlow, {
importFlow
}
)
}
getLayerDependencies(argsRaw: string[]): string[] {
return ImportFlowUtils.getLayerDependenciesWithSnapOnto(this.args, argsRaw)
}
}

View file

@ -0,0 +1,62 @@
<script lang="ts">
import ImportFlow from "./ImportFlow.svelte";
import {PointImportFlowState} from "./PointImportFlowState";
import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import {UIEventSource} from "../../../Logic/UIEventSource";
import MapControlButton from "../../Base/MapControlButton.svelte";
import {Square3Stack3dIcon} from "@babeard/svelte-heroicons/solid";
export let importFlow: PointImportFlowState
const state = importFlow.state;
const args = importFlow.args
// The following variables are used for the map
const targetLayer: LayerConfig = state.layout.layers.find(l => l.id === args.targetLayer)
const snapToLayers: string[] | undefined = args.snap_onto_layers?.split(",")?.map(l => l.trim()) ?? []
const maxSnapDistance: number = Number(args.max_snap_distance ?? 25) ?? 25;
const snappedTo: UIEventSource<string | undefined> = new UIEventSource<string | undefined>(undefined);
const startCoordinate = {
lon: importFlow.startCoordinate[0],
lat: importFlow.startCoordinate[1]
}
const value: UIEventSource<{ lon: number, lat: number }> = new UIEventSource<{ lon: number; lat: number }>(
startCoordinate
);
async function onConfirm(): Promise<void> {
const importedId = await importFlow.onConfirm(
value.data,
snappedTo.data
)
state.selectedLayer.setData(targetLayer)
state.selectedElement.setData(state.indexedFeatures.featuresById.data.get(importedId))
}
</script>
<ImportFlow {importFlow} on:confirm={onConfirm }>
<div class="relative" slot="map">
<div class="h-32">
<NewPointLocationInput coordinate={startCoordinate}
{maxSnapDistance}
{snapToLayers}
{snappedTo}
{state}
{targetLayer}
{value}
/>
</div>
<MapControlButton on:click={() => state.guistate.backgroundLayerSelectionIsOpened.setData(true)} cls="absolute bottom-0">
<Square3Stack3dIcon class="w-6 h-6"/>
</MapControlButton>
</div>
</ImportFlow>

View file

@ -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<PointImportFlowArguments> {
public readonly startCoordinate: [number, number]
private readonly _originalFeature: Feature<Point>;
private readonly _originalFeatureTags: UIEventSource<Record<string, string>>
constructor(state: SpecialVisualizationState, originalFeature: Feature<Point>, args: PointImportFlowArguments, tagsToApply: Store<Tag[]>) {
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<string> {
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: <OsmWay>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
}
}

View file

@ -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<Record<string, string>>, 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 = <any>Utils.ParseVisArgs(this.args, argument)
const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource, args)
const importFlow = new WayImportFlowState(state, <Feature<LineString | Polygon>>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<any>, argument: string[]): Promise<void> {
{
// 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 = <any>Utils.ParseVisArgs(this.args, argument)
const tagsToApply = ImportFlowUtils.getTagsToApply(tagSource, args)
const mergeConfigs = WayImportFlowState.GetMergeConfig(args, tagsToApply)
const action = WayImportFlowState.CreateAction(<Feature<LineString | Polygon >>feature, args, state, tagsToApply, mergeConfigs)
await state.changes.applyAction(action)
}
getLayerDependencies(args: string[]){
return ImportFlowUtils.getLayerDependenciesWithSnapOnto(this.args, args)
}
}

View file

@ -0,0 +1,58 @@
<script lang="ts">
import WayImportFlowState from "./WayImportFlowState";
import ImportFlow from "./ImportFlow.svelte";
import MapControlButton from "../../Base/MapControlButton.svelte";
import {Square3Stack3dIcon} from "@babeard/svelte-heroicons/solid";
import {UIEventSource} from "../../../Logic/UIEventSource";
import {Map as MlMap} from "maplibre-gl"
import {MapLibreAdaptor} from "../../Map/MapLibreAdaptor";
import MaplibreMap from "../../Map/MaplibreMap.svelte";
import ShowDataLayer from "../../Map/ShowDataLayer";
import StaticFeatureSource from "../../../Logic/FeatureSource/Sources/StaticFeatureSource";
import {ImportFlowUtils} from "./ImportFlow";
import {GeoOperations} from "../../../Logic/GeoOperations";
export let importFlow: WayImportFlowState
const state = importFlow.state
const map = new UIEventSource<MlMap>(undefined)
const [lon, lat] = GeoOperations.centerpointCoordinates(importFlow.originalFeature)
const mla = new MapLibreAdaptor(map, {
allowMoving: UIEventSource.feedFrom(state.featureSwitchIsTesting),
allowZooming: UIEventSource.feedFrom(state.featureSwitchIsTesting),
rasterLayer : state.mapProperties.rasterLayer,
location: new UIEventSource<{lon: number; lat: number}>({lon, lat}),
zoom: new UIEventSource<number>(18)
})
// Show all relevant data - including (eventually) the way of which the geometry will be replaced
ShowDataLayer.showMultipleLayers(
map,
new StaticFeatureSource([importFlow.originalFeature]),
state.layout.layers,
{zoomToFeatures: false}
)
importFlow.GetPreview().then(features => {
new ShowDataLayer(map, {
zoomToFeatures: false,
features,
layer: ImportFlowUtils.conflationLayer,
})
})
</script>
<ImportFlow {importFlow} on:confirm={() => importFlow.onConfirm()}>
<div slot="map" class="relative">
<div class="h-32">
<MaplibreMap {map}/>
</div>
<MapControlButton on:click={() => state.guistate.backgroundLayerSelectionIsOpened.setData(true)} cls="absolute bottom-0">
<Square3Stack3dIcon class="w-6 h-6"/>
</MapControlButton>
</div>
</ImportFlow>

View file

@ -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<WayImportFlowArguments> {
public readonly originalFeature: Feature<LineString | Polygon>;
private readonly action: OsmCreateAction & { getPreview?(): Promise<FeatureSource>; }
private readonly _originalFeatureTags: UIEventSource<Record<string, string>>;
constructor(state: SpecialVisualizationState, originalFeature: Feature<LineString | Polygon>, args: WayImportFlowArguments, tagsToApply: Store<Tag[]>, originalFeatureTags: UIEventSource<Record<string, string>>) {
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<LineString | Polygon>,
args: WayImportFlowArguments,
state: {
layout: LayoutConfig;
changes: Changes;
indexedFeatures: IndexedFeatureSource,
fullNodeDatabase?: FullNodeDatabaseSource
},
tagsToApply: Store<Tag[]>,
mergeConfigs: MergePointConfig[]
): OsmCreateAction & { getPreview?(): Promise<FeatureSource>; newElementId?: string } {
if (feature.geometry.type === "Polygon" && feature.geometry.coordinates.length > 1) {
const coors = (<Polygon>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<Tag[]>): 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<FeatureSource> {
if (!this.action?.getPreview) {
return undefined
}
return this.action.getPreview()
}
}

View file

@ -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, {

View file

@ -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, {});

View file

@ -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: {

View file

@ -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(),

View file

@ -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<string, string> {
const parsed: Record<string, string> = {}
if (args.length > specs.length) {
throw (
"To much arguments for special visualization: got " +

View file

@ -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;
}

View file

@ -17,7 +17,7 @@
</head>
<body>
<div id="maindiv" class="w-full">'maindiv' not attached</div>
<div id="maindiv" class="w-full h-full">'maindiv' not attached</div>
<div id="extradiv">'extradiv' not attached</div>
<script type="module" src="./test.ts"></script>

66
test.ts
View file

@ -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(<any>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<string>(undefined)
const helper = InputHelpers.AvailableInputHelpers[key](value, {
mapProperties: {
zoom: new UIEventSource(16),
location: new UIEventSource({ lat: 51.1, lon: 3.2 }),
},
})
const feedback: UIEventSource<any> = new UIEventSource<any>(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(<any>theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data)
const state = new ThemeViewState(layout)
const originalFeature: Feature<Point> = {
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()
//*/