forked from MapComplete/MapComplete
		
	Feature: show geocoded images on the map when hovered, show interactive minimap on nearbyImages element
This commit is contained in:
		
							parent
							
								
									d079ba91aa
								
							
						
					
					
						commit
						f3fdc95bd0
					
				
					 23 changed files with 404 additions and 182 deletions
				
			
		|  | @ -132,6 +132,7 @@ | |||
|         <div class="flex h-32 w-max gap-x-2"> | ||||
|           {#each $unknownImages as image (image)} | ||||
|             <AttributedImage | ||||
|               {state} | ||||
|               imgClass="h-32 w-max shrink-0" | ||||
|               image={{ url: image }} | ||||
|               previewedImage={state.previewedImage} | ||||
|  |  | |||
|  | @ -7,10 +7,12 @@ | |||
|   import { Mapillary } from "../../Logic/ImageProviders/Mapillary" | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import { MagnifyingGlassPlusIcon } from "@babeard/svelte-heroicons/outline" | ||||
|   import { CloseButton, Modal } from "flowbite-svelte" | ||||
|   import { CloseButton } from "flowbite-svelte" | ||||
|   import ImageOperations from "./ImageOperations.svelte" | ||||
|   import Popup from "../Base/Popup.svelte" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import type { Feature, Point } from "geojson" | ||||
| 
 | ||||
|   export let image: Partial<ProvidedImage> | ||||
|   let fallbackImage: string = undefined | ||||
|  | @ -20,19 +22,43 @@ | |||
| 
 | ||||
|   let imgEl: HTMLImageElement | ||||
|   export let imgClass: string = undefined | ||||
|   export let state: SpecialVisualizationState = undefined | ||||
|   export let attributionFormat: "minimal" | "medium" | "large" = "medium" | ||||
|   export let previewedImage: UIEventSource<ProvidedImage> | ||||
|   export let canZoom = previewedImage !== undefined | ||||
|   let loaded = false | ||||
|   let showBigPreview =  new UIEventSource(false) | ||||
|   onDestroy(showBigPreview.addCallbackAndRun(shown=>{ | ||||
|     if(!shown){ | ||||
|   let showBigPreview = new UIEventSource(false) | ||||
|   onDestroy(showBigPreview.addCallbackAndRun(shown => { | ||||
|     if (!shown) { | ||||
|       previewedImage.set(false) | ||||
|     } | ||||
|   })) | ||||
|   onDestroy(previewedImage.addCallbackAndRun(previewedImage => { | ||||
|     showBigPreview.set(previewedImage?.id === image.id) | ||||
|   })) | ||||
| 
 | ||||
|   function highlight(entered: boolean = true) { | ||||
|     if (!entered) { | ||||
|       state?.geocodedImages.set([]) | ||||
|       return | ||||
|     } | ||||
|     if (isNaN(image.lon) || isNaN(image.lat)) { | ||||
|       return | ||||
|     } | ||||
|     const f: Feature<Point> = { | ||||
|       type: "Feature", | ||||
|       properties: { | ||||
|         id: image.id, | ||||
|         rotation: image.rotation | ||||
|       }, | ||||
|       geometry: { | ||||
|         type: "Point", | ||||
|         coordinates: [image.lon, image.lat] | ||||
|       } | ||||
|     } | ||||
|     console.log(f) | ||||
|     state?.geocodedImages.set([f]) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <Popup shown={showBigPreview} bodyPadding="p-0" dismissable={true}> | ||||
|  | @ -48,7 +74,10 @@ | |||
|   </div> | ||||
| </Popup> | ||||
| <div class="relative shrink-0"> | ||||
|   <div class="relative w-fit"> | ||||
|   <div class="relative w-fit" | ||||
|        on:mouseenter={() => highlight()} | ||||
|        on:mouseleave={() => highlight(false)} | ||||
|   > | ||||
|     <img | ||||
|       bind:this={imgEl} | ||||
|       on:load={() => (loaded = true)} | ||||
|  | @ -68,7 +97,7 @@ | |||
|     {#if canZoom && loaded} | ||||
|       <div | ||||
|         class="bg-black-transparent absolute right-0 top-0 rounded-bl-full" | ||||
|            on:click={() => previewedImage.set(image)}> | ||||
|         on:click={() => previewedImage.set(image)}> | ||||
|         <MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" /> | ||||
|       </div> | ||||
|     {/if} | ||||
|  |  | |||
|  | @ -8,7 +8,6 @@ import ImageProvider, { ProvidedImage } from "../../Logic/ImageProviders/ImagePr | |||
| import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
| import { Changes } from "../../Logic/Osm/Changes" | ||||
| import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { Feature } from "geojson" | ||||
| import SvelteUIElement from "../Base/SvelteUIElement" | ||||
| import AttributedImage from "./AttributedImage.svelte" | ||||
| 
 | ||||
|  | @ -30,6 +29,7 @@ export class ImageCarousel extends Toggle { | |||
|                     try { | ||||
|                         let image: BaseUIElement = new SvelteUIElement(AttributedImage, { | ||||
|                             image: url, | ||||
|                             state, | ||||
|                             previewedImage: state?.previewedImage, | ||||
|                         }) | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,8 +14,8 @@ | |||
|   import AttributedImage from "./AttributedImage.svelte" | ||||
|   import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte" | ||||
|   import LoginToggle from "../Base/LoginToggle.svelte" | ||||
|   import ImagePreview from "./ImagePreview.svelte" | ||||
|   import FloatOver from "../Base/FloatOver.svelte" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import { Utils } from "../../Utils" | ||||
| 
 | ||||
|   export let tags: UIEventSource<OsmTags> | ||||
|   export let state: SpecialVisualizationState | ||||
|  | @ -23,6 +23,8 @@ | |||
|   export let feature: Feature | ||||
|   export let layer: LayerConfig | ||||
| 
 | ||||
|   export let highlighted: UIEventSource<string> = undefined | ||||
| 
 | ||||
|   export let linkable = true | ||||
|   let targetValue = Object.values(image.osmTags)[0] | ||||
|   let isLinked = new UIEventSource(Object.values(tags.data).some((v) => targetValue === v)) | ||||
|  | @ -33,7 +35,7 @@ | |||
|     key: undefined, | ||||
|     provider: AllImageProviders.byName(image.provider), | ||||
|     date: new Date(image.date), | ||||
|     id: Object.values(image.osmTags)[0], | ||||
|     id: Object.values(image.osmTags)[0] | ||||
|   } | ||||
| 
 | ||||
|   async function applyLink(isLinked: boolean) { | ||||
|  | @ -44,7 +46,7 @@ | |||
|     if (isLinked) { | ||||
|       const action = new LinkImageAction(currentTags.id, key, url, tags, { | ||||
|         theme: tags.data._orig_theme ?? state.layout.id, | ||||
|         changeType: "link-image", | ||||
|         changeType: "link-image" | ||||
|       }) | ||||
|       await state.changes.applyAction(action) | ||||
|     } else { | ||||
|  | @ -53,7 +55,7 @@ | |||
|         if (v === url) { | ||||
|           const action = new ChangeTagAction(currentTags.id, new Tag(k, ""), currentTags, { | ||||
|             theme: tags.data._orig_theme ?? state.layout.id, | ||||
|             changeType: "remove-image", | ||||
|             changeType: "remove-image" | ||||
|           }) | ||||
|           state.changes.applyAction(action) | ||||
|         } | ||||
|  | @ -62,16 +64,30 @@ | |||
|   } | ||||
| 
 | ||||
|   isLinked.addCallback((isLinked) => applyLink(isLinked)) | ||||
| 
 | ||||
|   let element: HTMLDivElement | ||||
|   if (highlighted) { | ||||
| 
 | ||||
|     onDestroy( | ||||
|       highlighted.addCallbackD(highlightedUrl => { | ||||
|         if (highlightedUrl === image.pictureUrl) { | ||||
|           Utils.scrollIntoView(element) | ||||
|         } | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <div | ||||
|   class="flex w-fit shrink-0 flex-col overflow-hidden rounded-lg" | ||||
|   class:border-interactive={$isLinked} | ||||
|   class:border-interactive={$isLinked || $highlighted === image.pictureUrl} | ||||
|   style="border-width: 2px" | ||||
|   bind:this={element} | ||||
| > | ||||
|   <AttributedImage | ||||
|     {state} | ||||
|     image={providedImage} | ||||
|     imgClass="max-h-64 w-auto" | ||||
|     imgClass="max-h-64 w-auto sm:h-32 md:h-64" | ||||
|     previewedImage={state.previewedImage} | ||||
|     attributionFormat="minimal" | ||||
|   > | ||||
|  |  | |||
|  | @ -7,13 +7,23 @@ | |||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch" | ||||
|   import LinkableImage from "./LinkableImage.svelte" | ||||
|   import type { Feature } from "geojson" | ||||
|   import type { Feature, Point } from "geojson" | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import MapillaryLink from "../BigComponents/MapillaryLink.svelte" | ||||
|   import MaplibreMap from "../Map/MaplibreMap.svelte" | ||||
|   import { Map as MlMap } from "maplibre-gl" | ||||
|   import { MapLibreAdaptor } from "../Map/MapLibreAdaptor" | ||||
|   import ShowDataLayer from "../Map/ShowDataLayer" | ||||
|   import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" | ||||
|   import * as geocoded_image from "../../assets/generated/layers/geocoded_image.json" | ||||
|   import type { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" | ||||
|   import { onDestroy } from "svelte" | ||||
|   import { BBox } from "../../Logic/BBox" | ||||
| 
 | ||||
| 
 | ||||
|   export let tags: UIEventSource<OsmTags> | ||||
|   export let state: SpecialVisualizationState | ||||
|  | @ -42,12 +52,100 @@ | |||
|     [loadedImages] | ||||
|   ) | ||||
| 
 | ||||
|   let asFeatures = result.map(p4cs => p4cs.map(p4c => (<Feature<Point>>{ | ||||
|     type: "Feature", | ||||
|     geometry: { | ||||
|       type: "Point", | ||||
|       coordinates: [p4c.coordinates.lng, p4c.coordinates.lat] | ||||
|     }, | ||||
|     properties: { | ||||
|       id: p4c.pictureUrl, | ||||
|       rotation: p4c.direction | ||||
|     } | ||||
|   }))) | ||||
| 
 | ||||
|   let selected = new UIEventSource<P4CPicture>(undefined) | ||||
|   let selectedAsFeature = selected.mapD(s => { | ||||
|     return [<Feature<Point>>{ | ||||
|       type: "Feature", | ||||
|       geometry: { | ||||
|         type: "Point", | ||||
|         coordinates: [s.coordinates.lng, s.coordinates.lat] | ||||
|       }, | ||||
|       properties: { | ||||
|         id: s.pictureUrl, | ||||
|         selected: "yes", | ||||
|         rotation: s.direction | ||||
|       } | ||||
|     }] | ||||
|   }) | ||||
| 
 | ||||
|   let someLoading = imageState.state.mapD((stateRecord) => | ||||
|     Object.values(stateRecord).some((v) => v === "loading") | ||||
|   ) | ||||
|   let errors = imageState.state.mapD((stateRecord) => | ||||
|     Object.keys(stateRecord).filter((k) => stateRecord[k] === "error") | ||||
|   ) | ||||
|   let highlighted = new UIEventSource<string>(undefined) | ||||
| 
 | ||||
|   onDestroy(highlighted.addCallbackD(hl => { | ||||
|       const p4c = result.data?.find(i => i.pictureUrl === hl) | ||||
|       selected.set(p4c) | ||||
|     } | ||||
|   )) | ||||
| 
 | ||||
|   let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined) | ||||
|   let mapProperties = new MapLibreAdaptor(map, { | ||||
|     rasterLayer: state.mapProperties.rasterLayer, | ||||
|     rotation: state.mapProperties.rotation, | ||||
|     pitch: state.mapProperties.pitch, | ||||
|     zoom: new UIEventSource<number>(16), | ||||
|     location: new UIEventSource({ lon, lat }), | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
|   const geocodedImageLayer = new LayerConfig(<LayerConfigJson>geocoded_image) | ||||
|   new ShowDataLayer(map, { | ||||
|     features: new StaticFeatureSource(asFeatures), | ||||
|     layer: geocodedImageLayer, | ||||
|     zoomToFeatures: true, | ||||
|     onClick: (feature) => { | ||||
|       highlighted.set(feature.properties.id) | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
|   ShowDataLayer.showMultipleLayers( | ||||
|     map, | ||||
|     new StaticFeatureSource([feature]), | ||||
|     state.layout.layers | ||||
|   ) | ||||
| 
 | ||||
|   onDestroy( | ||||
|   asFeatures.addCallbackAndRunD(features => { | ||||
|     if(features.length == 0){ | ||||
|       return | ||||
|     } | ||||
|     let bbox = BBox.get(features[0]) | ||||
|     for (const f of features) { | ||||
|       bbox = bbox.unionWith(BBox.get(f)) | ||||
|     } | ||||
|     mapProperties.maxbounds.set(bbox.pad(1.1)) | ||||
|   }) | ||||
| 
 | ||||
|   ) | ||||
| 
 | ||||
|   new ShowDataLayer(map, { | ||||
|     features: new StaticFeatureSource(selectedAsFeature), | ||||
|     layer: geocodedImageLayer, | ||||
|     onClick: (feature) => { | ||||
|       highlighted.set(feature.properties.id) | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex flex-col"> | ||||
|  | @ -62,12 +160,24 @@ | |||
|   {:else} | ||||
|     <div class="flex w-full space-x-4 overflow-x-auto" style="scroll-snap-type: x proximity"> | ||||
|       {#each $result as image (image.pictureUrl)} | ||||
|         <span class="w-fit shrink-0" style="scroll-snap-align: start"> | ||||
|           <LinkableImage {tags} {image} {state} {feature} {layer} {linkable} /> | ||||
|         <span class="w-fit shrink-0" style="scroll-snap-align: start" | ||||
|               on:mouseenter={() => {highlighted.set(image.pictureUrl)}} | ||||
|               on:mouseleave={() =>{ highlighted.set(undefined); selected.set(undefined)}} | ||||
|         > | ||||
|           <LinkableImage {tags} {image} {state} {feature} {layer} {linkable} {highlighted} /> | ||||
|         </span> | ||||
|       {/each} | ||||
|     </div> | ||||
|   {/if} | ||||
|   <span class="self-end pt-2"> | ||||
| 
 | ||||
|   <MapillaryLink | ||||
|     large={false} | ||||
|     mapProperties={{ zoom: new ImmutableStore(16), location: new ImmutableStore({ lon, lat }) }} | ||||
|   /> | ||||
|   </span> | ||||
| 
 | ||||
| 
 | ||||
|   <div class="my-2 flex justify-between"> | ||||
|     <div> | ||||
|       {#if $someLoading && $result.length > 0} | ||||
|  | @ -80,9 +190,10 @@ | |||
|         /> | ||||
|       {/if} | ||||
|     </div> | ||||
|     <MapillaryLink | ||||
|       large={false} | ||||
|       mapProperties={{ zoom: new ImmutableStore(16), location: new ImmutableStore({ lon, lat }) }} | ||||
|     /> | ||||
|   </div> | ||||
| 
 | ||||
| 
 | ||||
|   <div class="h-48"> | ||||
|     <MaplibreMap interactive={false} {map} {mapProperties} /> | ||||
|   </div> | ||||
| </div> | ||||
|  |  | |||
|  | @ -11,8 +11,9 @@ | |||
|   import Camera_plus from "../../assets/svg/Camera_plus.svelte" | ||||
|   import LoginToggle from "../Base/LoginToggle.svelte" | ||||
|   import { ariaLabel } from "../../Utils/ariaLabel" | ||||
|   import { Accordion, AccordionItem } from "flowbite-svelte" | ||||
|   import { Accordion, AccordionItem, Modal } from "flowbite-svelte" | ||||
|   import AccordionSingle from "../Flowbite/AccordionSingle.svelte" | ||||
|   import Popup from "../Base/Popup.svelte" | ||||
| 
 | ||||
|   export let tags: UIEventSource<OsmTags> | ||||
|   export let state: SpecialVisualizationState | ||||
|  | @ -24,15 +25,16 @@ | |||
|   export let layer: LayerConfig | ||||
|   const t = Translations.t.image.nearby | ||||
| 
 | ||||
|   let expanded = false | ||||
|   let enableLogin = state.featureSwitches.featureSwitchEnableLogin | ||||
|   export let shown = new UIEventSource(false) | ||||
| </script> | ||||
| 
 | ||||
| {#if enableLogin.data} | ||||
|   <AccordionSingle> | ||||
|     <span slot="header" class="p-2 text-base"> | ||||
|   <button on:click={() => {shown.set(!shown.data)}}><Tr t={t.seeNearby}/> </button> | ||||
|   <Popup {shown} bodyPadding="p-4"> | ||||
|     <span slot="header"> | ||||
|       <Tr t={t.seeNearby} /> | ||||
|     </span> | ||||
|     <NearbyImages {tags} {state} {lon} {lat} {feature} {linkable} {layer} /> | ||||
|   </AccordionSingle> | ||||
|   </Popup> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -121,7 +121,7 @@ | |||
|     <HeartOutlineIcon style="--svg-color: {color}" class={twMerge(clss, "apply-fill")} /> | ||||
|   {:else if icon === "confirm"} | ||||
|     <Confirm class={clss} {color} /> | ||||
|   {:else if icon === "direction"} | ||||
|   {:else if icon === "direction" || icon === "direction_gradient"} | ||||
|     <Direction_gradient class={clss} {color} /> | ||||
|   {:else if icon === "not_found"} | ||||
|     <Not_found class={twMerge(clss, "no-image-background")} {color} /> | ||||
|  |  | |||
|  | @ -159,10 +159,9 @@ class PointRenderingLayer { | |||
|         }) | ||||
| 
 | ||||
|         if (this._onClick) { | ||||
|             const self = this | ||||
|             el.addEventListener("click", function (ev) { | ||||
|             el.addEventListener("click",  (ev)=> { | ||||
|                 ev.preventDefault() | ||||
|                 self._onClick(feature) | ||||
|                 this._onClick(feature) | ||||
|                 // Workaround to signal the MapLibreAdaptor to ignore this click
 | ||||
|                 ev["consumed"] = true | ||||
|             }) | ||||
|  |  | |||
|  | @ -93,6 +93,7 @@ export interface SpecialVisualizationState { | |||
|     readonly previewedImage: UIEventSource<ProvidedImage> | ||||
|     readonly nearbyImageSearcher: CombinedFetcher | ||||
|     readonly geolocation: GeoLocationHandler | ||||
|     readonly geocodedImages : UIEventSource<Feature[]> | ||||
| 
 | ||||
|     showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer | ||||
|     reportError(message: string): Promise<void> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue