forked from MapComplete/MapComplete
		
	Feature: allow to move and snap to a layer, fix #2120
This commit is contained in:
		
							parent
							
								
									eb89427bfc
								
							
						
					
					
						commit
						fdedb75954
					
				
					 34 changed files with 824 additions and 301 deletions
				
			
		|  | @ -18,6 +18,7 @@ | |||
|   import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" | ||||
|   import { Tag } from "../../Logic/Tags/Tag" | ||||
|   import { TagUtils } from "../../Logic/Tags/TagUtils" | ||||
|   import type { WayId } from "../../Models/OsmFeature" | ||||
| 
 | ||||
|   /** | ||||
|    * An advanced location input, which has support to: | ||||
|  | @ -45,11 +46,16 @@ | |||
|   } | ||||
|   export let snapToLayers: string[] | undefined = undefined | ||||
|   export let targetLayer: LayerConfig | undefined = undefined | ||||
|   /** | ||||
|    * If a 'targetLayer' is given, objects of this layer will be shown as well to avoid duplicates | ||||
|    * If you want to hide some of them, blacklist them here | ||||
|    */ | ||||
|   export let dontShow: string[] = [] | ||||
|   export let maxSnapDistance: number = undefined | ||||
|   export let presetProperties: Tag[] = [] | ||||
|   let presetPropertiesUnpacked = TagUtils.KVtoProperties(presetProperties) | ||||
| 
 | ||||
|   export let snappedTo: UIEventSource<string | undefined> | ||||
|   export let snappedTo: UIEventSource<WayId | undefined> | ||||
| 
 | ||||
|   let preciseLocation: UIEventSource<{ lon: number; lat: number }> = new UIEventSource<{ | ||||
|     lon: number | ||||
|  | @ -57,7 +63,7 @@ | |||
|   }>(undefined) | ||||
| 
 | ||||
|   const map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined) | ||||
|   let initialMapProperties: Partial<MapProperties> & { location } = { | ||||
|   export let mapProperties: Partial<MapProperties> & { location } = { | ||||
|     zoom: new UIEventSource<number>(19), | ||||
|     maxbounds: new UIEventSource(undefined), | ||||
|     /*If no snapping needed: the value is simply the map location; | ||||
|  | @ -77,8 +83,11 @@ | |||
| 
 | ||||
|   if (targetLayer) { | ||||
|     // Show already existing items | ||||
|     const featuresForLayer = state.perLayer.get(targetLayer.id) | ||||
|     let featuresForLayer: FeatureSource = state.perLayer.get(targetLayer.id) | ||||
|     if (featuresForLayer) { | ||||
|       if (dontShow) { | ||||
|         featuresForLayer = new StaticFeatureSource(featuresForLayer.features.map(feats => feats.filter(f => dontShow.indexOf(f.properties.id) < 0))) | ||||
|       } | ||||
|       new ShowDataLayer(map, { | ||||
|         layer: targetLayer, | ||||
|         features: featuresForLayer, | ||||
|  | @ -104,13 +113,13 @@ | |||
|     const snappedLocation = new SnappingFeatureSource( | ||||
|       new FeatureSourceMerger(...Utils.NoNull(snapSources)), | ||||
|       // We snap to the (constantly updating) map location | ||||
|       initialMapProperties.location, | ||||
|       mapProperties.location, | ||||
|       { | ||||
|         maxDistance: maxSnapDistance ?? 15, | ||||
|         allowUnsnapped: true, | ||||
|         snappedTo, | ||||
|         snapLocation: value, | ||||
|       } | ||||
|       }, | ||||
|     ) | ||||
|     const withCorrectedAttributes = new StaticFeatureSource( | ||||
|       snappedLocation.features.mapD((feats) => | ||||
|  | @ -124,8 +133,8 @@ | |||
|             ...f, | ||||
|             properties, | ||||
|           } | ||||
|         }) | ||||
|       ) | ||||
|         }), | ||||
|       ), | ||||
|     ) | ||||
|     // The actual point to be created, snapped at the new location | ||||
|     new ShowDataLayer(map, { | ||||
|  | @ -139,7 +148,7 @@ | |||
| <LocationInput | ||||
|   {map} | ||||
|   on:click | ||||
|   mapProperties={initialMapProperties} | ||||
|   {mapProperties} | ||||
|   value={preciseLocation} | ||||
|   initialCoordinate={coordinate} | ||||
|   maxDistanceInMeters={50} | ||||
|  |  | |||
|  | @ -41,6 +41,7 @@ | |||
|   import Relocation from "../../assets/svg/Relocation.svelte" | ||||
|   import LockClosed from "@babeard/svelte-heroicons/solid/LockClosed" | ||||
|   import Key from "@babeard/svelte-heroicons/solid/Key" | ||||
|   import Snap from "../../assets/svg/Snap.svelte" | ||||
| 
 | ||||
|   /** | ||||
|    * Renders a single icon. | ||||
|  | @ -152,6 +153,8 @@ | |||
|     <LockClosed class={clss} {color} /> | ||||
|   {:else if icon === "key"} | ||||
|     <Key class={clss} {color} /> | ||||
|   {:else if icon === "snap"} | ||||
|     <Snap class={clss} /> | ||||
|   {:else if Utils.isEmoji(icon)} | ||||
|     <span style={`font-size: ${emojiHeight}; line-height: ${emojiHeight}`}> | ||||
|       {icon} | ||||
|  |  | |||
|  | @ -10,7 +10,6 @@ | |||
|   import type { MapProperties } from "../../Models/MapProperties" | ||||
|   import type { Feature, Point } from "geojson" | ||||
|   import { GeoOperations } from "../../Logic/GeoOperations" | ||||
|   import LocationInput from "../InputElement/Helpers/LocationInput.svelte" | ||||
|   import OpenBackgroundSelectorButton from "../BigComponents/OpenBackgroundSelectorButton.svelte" | ||||
|   import Geosearch from "../BigComponents/Geosearch.svelte" | ||||
|   import If from "../Base/If.svelte" | ||||
|  | @ -21,6 +20,8 @@ | |||
|   import ChevronLeft from "@babeard/svelte-heroicons/solid/ChevronLeft" | ||||
|   import ThemeViewState from "../../Models/ThemeViewState" | ||||
|   import Icon from "../Map/Icon.svelte" | ||||
|   import NewPointLocationInput from "../BigComponents/NewPointLocationInput.svelte" | ||||
|   import type { WayId } from "../../Models/OsmFeature" | ||||
| 
 | ||||
|   export let state: ThemeViewState | ||||
| 
 | ||||
|  | @ -36,20 +37,22 @@ | |||
| 
 | ||||
|   let newLocation = new UIEventSource<{ lon: number; lat: number }>(undefined) | ||||
| 
 | ||||
|   function initMapProperties() { | ||||
|   let snappedTo = new UIEventSource<WayId | undefined>(undefined) | ||||
| 
 | ||||
|   function initMapProperties(reason: MoveReason) { | ||||
|     return <any>{ | ||||
|       allowMoving: new UIEventSource(true), | ||||
|       allowRotating: new UIEventSource(false), | ||||
|       allowZooming: new UIEventSource(true), | ||||
|       bounds: new UIEventSource(undefined), | ||||
|       location: new UIEventSource({ lon, lat }), | ||||
|       minzoom: new UIEventSource($reason.minZoom), | ||||
|       minzoom: new UIEventSource(reason.minZoom), | ||||
|       rasterLayer: state.mapProperties.rasterLayer, | ||||
|       zoom: new UIEventSource($reason?.startZoom ?? 16), | ||||
|       zoom: new UIEventSource(reason?.startZoom ?? 16), | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   let moveWizardState = new MoveWizardState(id, layer.allowMove, state) | ||||
|   let moveWizardState = new MoveWizardState(id, layer.allowMove, layer, state) | ||||
|   if (moveWizardState.reasons.length === 1) { | ||||
|     reason.setData(moveWizardState.reasons[0]) | ||||
|   } | ||||
|  | @ -57,8 +60,8 @@ | |||
|   let currentMapProperties: MapProperties = undefined | ||||
| </script> | ||||
| 
 | ||||
| <LoginToggle {state}> | ||||
|   {#if moveWizardState.reasons.length > 0} | ||||
| {#if moveWizardState.reasons.length > 0} | ||||
|   <LoginToggle {state}> | ||||
|     {#if $notAllowed} | ||||
|       <div class="m-2 flex rounded-lg bg-gray-200 p-2"> | ||||
|         <Move_not_allowed class="m-2 h-8 w-8" /> | ||||
|  | @ -81,7 +84,7 @@ | |||
|         <span class="flex flex-col p-2"> | ||||
|           {#if currentStep === "reason" && moveWizardState.reasons.length > 1} | ||||
|             {#each moveWizardState.reasons as reasonSpec} | ||||
|               <button | ||||
|               <button class="flex justify-start" | ||||
|                 on:click={() => { | ||||
|                   reason.setData(reasonSpec) | ||||
|                   currentStep = "pick_location" | ||||
|  | @ -93,10 +96,16 @@ | |||
|             {/each} | ||||
|           {:else if currentStep === "pick_location" || currentStep === "reason"} | ||||
|             <div class="relative h-64 w-full"> | ||||
|               <LocationInput | ||||
|                 mapProperties={(currentMapProperties = initMapProperties())} | ||||
|               <NewPointLocationInput | ||||
|                 mapProperties={(currentMapProperties = initMapProperties($reason))} | ||||
|                 value={newLocation} | ||||
|                 initialCoordinate={{ lon, lat }} | ||||
|                 {state} | ||||
|                 coordinate={{ lon, lat }} | ||||
|                 {snappedTo} | ||||
|                 maxSnapDistance={$reason.maxSnapDistance ?? 5} | ||||
|                 snapToLayers={$reason.snapTo} | ||||
|                 targetLayer={layer} | ||||
|                 dontShow={[id]} | ||||
|               /> | ||||
|               <div class="absolute bottom-0 left-0"> | ||||
|                 <OpenBackgroundSelectorButton {state} /> | ||||
|  | @ -116,7 +125,7 @@ | |||
|                 <button | ||||
|                   class="primary w-full" | ||||
|                   on:click={() => { | ||||
|                     moveWizardState.moveFeature(newLocation.data, reason.data, featureToMove) | ||||
|                     moveWizardState.moveFeature(newLocation.data, snappedTo.data, reason.data, featureToMove) | ||||
|                     currentStep = "moved" | ||||
|                   }} | ||||
|                 > | ||||
|  | @ -155,5 +164,5 @@ | |||
|         </span> | ||||
|       </AccordionSingle> | ||||
|     {/if} | ||||
|   {/if} | ||||
| </LoginToggle> | ||||
|   </LoginToggle> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -12,6 +12,8 @@ import { Feature, Point } from "geojson" | |||
| import SvelteUIElement from "../Base/SvelteUIElement" | ||||
| import Relocation from "../../assets/svg/Relocation.svelte" | ||||
| import Location from "../../assets/svg/Location.svelte" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| import { WayId } from "../../Models/OsmFeature" | ||||
| 
 | ||||
| export interface MoveReason { | ||||
|     text: Translation | string | ||||
|  | @ -24,25 +26,40 @@ export interface MoveReason { | |||
|     startZoom: number | ||||
|     minZoom: number | ||||
|     eraseAddressFields: false | boolean | ||||
|     /** | ||||
|      * Snap to these layers | ||||
|      */ | ||||
|     snapTo?: string[] | ||||
|     maxSnapDistance?: number | ||||
| } | ||||
| 
 | ||||
| export class MoveWizardState { | ||||
|     public readonly reasons: ReadonlyArray<MoveReason> | ||||
| 
 | ||||
|     public readonly moveDisallowedReason = new UIEventSource<Translation>(undefined) | ||||
|     private readonly layer: LayerConfig | ||||
|     private readonly _state: SpecialVisualizationState | ||||
|     private readonly featureToMoveId: string | ||||
| 
 | ||||
|     constructor(id: string, options: MoveConfig, state: SpecialVisualizationState) { | ||||
|     /** | ||||
|      * Initialize the movestate for the feature of the given ID | ||||
|      * @param id of the feature that should be moved | ||||
|      * @param options | ||||
|      * @param layer | ||||
|      * @param state | ||||
|      */ | ||||
|     constructor(id: string, options: MoveConfig, layer: LayerConfig, state: SpecialVisualizationState) { | ||||
|         this.layer = layer | ||||
|         this._state = state | ||||
|         this.reasons = MoveWizardState.initReasons(options) | ||||
|         this.featureToMoveId = id | ||||
|         this.reasons = this.initReasons(options) | ||||
|         if (this.reasons.length > 0) { | ||||
|             this.checkIsAllowed(id) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static initReasons(options: MoveConfig): MoveReason[] { | ||||
|     private initReasons(options: MoveConfig): MoveReason[] { | ||||
|         const t = Translations.t.move | ||||
| 
 | ||||
|         const reasons: MoveReason[] = [] | ||||
|         if (options.enableRelocation) { | ||||
|             reasons.push({ | ||||
|  | @ -72,20 +89,52 @@ export class MoveWizardState { | |||
|                 eraseAddressFields: false, | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         const tags = this._state.featureProperties.getStore(this.featureToMoveId).data | ||||
|         const matchingPresets = this.layer.presets.filter(preset => preset.preciseInput.snapToLayers && new And(preset.tags).matchesProperties(tags)) | ||||
|         const matchingPreset = matchingPresets.flatMap(pr => pr.preciseInput?.snapToLayers) | ||||
|         for (const layerId of matchingPreset) { | ||||
|             const snapOntoLayer = this._state.layout.getLayer(layerId) | ||||
|             const text = <Translation> t.reasons.reasonSnapTo.PartialSubsTr("name", snapOntoLayer.snapName) | ||||
|             reasons.push({ | ||||
|                 text, | ||||
|                 invitingText: text, | ||||
|                 icon: "snap", | ||||
|                 changesetCommentValue: "snap", | ||||
|                 lockBounds: true, | ||||
|                 includeSearch: false, | ||||
|                 background: "photo", | ||||
|                 startZoom: 19, | ||||
|                 minZoom: 16, | ||||
|                 eraseAddressFields: false, | ||||
|                 snapTo: [snapOntoLayer.id], | ||||
|                 maxSnapDistance: 5, | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return reasons | ||||
|     } | ||||
| 
 | ||||
|     public async moveFeature( | ||||
|         loc: { lon: number; lat: number }, | ||||
|         snappedTo: WayId, | ||||
|         reason: MoveReason, | ||||
|         featureToMove: Feature<Point> | ||||
|         featureToMove: Feature<Point>, | ||||
|     ) { | ||||
|         const state = this._state | ||||
|         if(snappedTo !== undefined){ | ||||
|             this.moveDisallowedReason.set(Translations.t.move.partOfAWay) | ||||
|         } | ||||
|         await state.changes.applyAction( | ||||
|             new ChangeLocationAction(featureToMove.properties.id, [loc.lon, loc.lat], { | ||||
|                 reason: reason.changesetCommentValue, | ||||
|                 theme: state.layout.id, | ||||
|             }) | ||||
|             new ChangeLocationAction(state, | ||||
|                 featureToMove.properties.id, | ||||
|                 [loc.lon, loc.lat], | ||||
|                 snappedTo, | ||||
|                 { | ||||
|                     reason: reason.changesetCommentValue, | ||||
|                     theme: state.layout.id, | ||||
|                 }), | ||||
|         ) | ||||
|         featureToMove.properties._lat = loc.lat | ||||
|         featureToMove.properties._lon = loc.lon | ||||
|  | @ -104,8 +153,8 @@ export class MoveWizardState { | |||
|                     { | ||||
|                         changeType: "relocated", | ||||
|                         theme: state.layout.id, | ||||
|                     } | ||||
|                 ) | ||||
|                     }, | ||||
|                 ), | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1995,35 +1995,8 @@ export default class SpecialVisualizations { | |||
|                     layer: LayerConfig | ||||
|                 ): BaseUIElement { | ||||
|                     const translation = tagSource.map((tags) => { | ||||
|                         const presets = state.layout.getMatchingLayer(tags)?.presets | ||||
|                         if(!presets){ | ||||
|                             return undefined | ||||
|                         } | ||||
|                         const matchingPresets = presets | ||||
|                             .filter((pr) => pr.description !== undefined) | ||||
|                             .filter((pr) => new And(pr.tags).matchesProperties(tags)) | ||||
|                         let mostShadowed = matchingPresets[0] | ||||
|                         let mostShadowedTags = new And(mostShadowed.tags) | ||||
|                         for (let i = 1; i < matchingPresets.length; i++) { | ||||
|                             const pr = matchingPresets[i] | ||||
|                             const prTags = new And(pr.tags) | ||||
|                             if (mostShadowedTags.shadows(prTags)) { | ||||
|                                 if (!prTags.shadows(mostShadowedTags)) { | ||||
|                                     // We have a new most shadowed item
 | ||||
|                                     mostShadowed = pr | ||||
|                                     mostShadowedTags = prTags | ||||
|                                 } else { | ||||
|                                     // Both shadow each other: abort
 | ||||
|                                     mostShadowed = undefined | ||||
|                                     break | ||||
|                                 } | ||||
|                             } else if (!prTags.shadows(mostShadowedTags)) { | ||||
|                                 // The new contender does not win, but it might defeat the current contender
 | ||||
|                                 mostShadowed = undefined | ||||
|                                 break | ||||
|                             } | ||||
|                         } | ||||
|                         return mostShadowed?.description ?? matchingPresets[0]?.description | ||||
|                         const layer = state.layout.getMatchingLayer(tags) | ||||
|                         return layer?.getMostMatchingPreset(tags)?.description | ||||
|                     }) | ||||
|                     return new VariableUiElement(translation) | ||||
|                 } | ||||
|  |  | |||
|  | @ -417,6 +417,9 @@ export class TypedTranslation<T extends Record<string, any>> extends Translation | |||
|         key: string, | ||||
|         replaceWith: Translation | ||||
|     ): TypedTranslation<Omit<T, K>> { | ||||
|         if(replaceWith === undefined){ | ||||
|             return this | ||||
|         } | ||||
|         const newTranslations: Record<string, string> = {} | ||||
|         const toSearch = "{" + key + "}" | ||||
|         const missingLanguages = new Set<string>(Object.keys(this.translations)) | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue