forked from MapComplete/MapComplete
UX: more work on a search function
This commit is contained in:
parent
3cd04df60b
commit
00ad21d5ef
30 changed files with 636 additions and 138 deletions
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { Feature } from "geojson"
|
||||
import Translations from "../i18n/Translations"
|
||||
import Loading from "../Base/Loading.svelte"
|
||||
|
|
@ -24,9 +24,9 @@
|
|||
|
||||
export let geolocationState: GeoLocationState | undefined = undefined
|
||||
export let clearAfterView: boolean = true
|
||||
export let searcher : GeocodingProvider = new NominatimGeocoding()
|
||||
export let state : SpecialVisualizationState
|
||||
let searchContents: string = ""
|
||||
export let searcher: GeocodingProvider = new NominatimGeocoding()
|
||||
export let state: SpecialVisualizationState
|
||||
let searchContents: UIEventSource<string> = new UIEventSource<string>("")
|
||||
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
|
||||
onDestroy(
|
||||
triggerSearch.addCallback((_) => {
|
||||
|
|
@ -40,6 +40,8 @@
|
|||
|
||||
let feedback: string = undefined
|
||||
|
||||
let isFocused = new UIEventSource(false)
|
||||
|
||||
function focusOnSearch() {
|
||||
requestAnimationFrame(() => {
|
||||
inputElement?.focus()
|
||||
|
|
@ -54,7 +56,7 @@
|
|||
|
||||
const dispatch = createEventDispatcher<{ searchCompleted; searchIsValid: boolean }>()
|
||||
$: {
|
||||
if (!searchContents?.trim()) {
|
||||
if (!$searchContents?.trim()) {
|
||||
dispatch("searchIsValid", false)
|
||||
} else {
|
||||
dispatch("searchIsValid", true)
|
||||
|
|
@ -67,12 +69,12 @@
|
|||
isRunning = true
|
||||
geolocationState?.allowMoving.setData(true)
|
||||
geolocationState?.requestMoment.setData(undefined) // If the GPS is still searching for a fix, we say that we don't want tozoom to it anymore
|
||||
searchContents = searchContents?.trim() ?? ""
|
||||
const searchContentsData = $searchContents?.trim() ?? ""
|
||||
|
||||
if (searchContents === "") {
|
||||
if (searchContentsData === "") {
|
||||
return
|
||||
}
|
||||
const result = await searcher.search(searchContents, { bbox: bounds.data, limit: 10 })
|
||||
const result = await searcher.search(searchContentsData, { bbox: bounds.data, limit: 10 })
|
||||
console.log("Results are", result)
|
||||
if (result.length == 0) {
|
||||
feedback = Translations.t.general.search.nothing.txt
|
||||
|
|
@ -84,7 +86,7 @@
|
|||
bounds.set(
|
||||
new BBox([
|
||||
[lon0, lat0],
|
||||
[lon1, lat1],
|
||||
[lon1, lat1]
|
||||
]).pad(0.01)
|
||||
)
|
||||
if (perLayer !== undefined) {
|
||||
|
|
@ -101,7 +103,7 @@
|
|||
}
|
||||
}
|
||||
if (clearAfterView) {
|
||||
searchContents = ""
|
||||
searchContents.setData("")
|
||||
}
|
||||
dispatch("searchIsValid", false)
|
||||
dispatch("searchCompleted")
|
||||
|
|
@ -114,18 +116,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
let suggestions: GeoCodeResult[] = []
|
||||
|
||||
async function updateSuggestions(search){
|
||||
|
||||
suggestions = await searcher.suggest(search, {limit: 5})
|
||||
}
|
||||
|
||||
$: updateSuggestions(searchContents)
|
||||
let suggestions: Store<GeoCodeResult[]> = searchContents.stabilized(250).bindD(search =>
|
||||
UIEventSource.FromPromise(searcher.suggest(search), err => console.error(err))
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<div class="normal-background flex justify-between rounded-full pl-2">
|
||||
<div class="normal-background flex justify-between rounded-full pl-2 w-full">
|
||||
<form class="flex w-full flex-wrap">
|
||||
{#if isRunning}
|
||||
<Loading>{Translations.t.general.search.searching}</Loading>
|
||||
|
|
@ -138,7 +135,9 @@
|
|||
feedback = undefined
|
||||
return keypr.key === "Enter" ? performSearch() : undefined
|
||||
}}
|
||||
bind:value={searchContents}
|
||||
on:focus={() => {isFocused.setData(true)}}
|
||||
on:blur={() => {isFocused.setData(false)}}
|
||||
bind:value={$searchContents}
|
||||
use:placeholder={Translations.t.general.search.search}
|
||||
use:ariaLabel={Translations.t.general.search.search}
|
||||
/>
|
||||
|
|
@ -153,6 +152,9 @@
|
|||
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} />
|
||||
</div>
|
||||
|
||||
<div class="h-2/3 ">
|
||||
<SearchResults {state} results={suggestions}/>
|
||||
<div class="relative h-0" style="z-index: 10">
|
||||
|
||||
<div class="absolute right-0" style="width: 25rem; max-width: 98vw">
|
||||
<SearchResults {isFocused} {state} results={$suggestions} searchTerm={searchContents} on:select={() => {searchContents.set("")}}/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,84 @@
|
|||
<script lang="ts">
|
||||
import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider"
|
||||
import { GeocodingUtils } from "../../Logic/Geocoding/GeocodingProvider"
|
||||
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import Icon from "../Map/Icon.svelte"
|
||||
import { BBox } from "../../Logic/BBox"
|
||||
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import ArrowUp from "@babeard/svelte-heroicons/mini/ArrowUp"
|
||||
|
||||
export let entry: GeoCodeResult
|
||||
export let state: SpecialVisualizationState
|
||||
let layer: LayerConfig
|
||||
if (entry.feature) {
|
||||
let tags : UIEventSource<Record<string, string>>
|
||||
if (entry.feature?.properties?.id) {
|
||||
layer = state.layout.getMatchingLayer(entry.feature.properties)
|
||||
tags = state.featureProperties.getStore(entry.feature.properties.id)
|
||||
}
|
||||
|
||||
let dispatch = createEventDispatcher<{select}>()
|
||||
let dispatch = createEventDispatcher<{ select }>()
|
||||
let distance = state.mapProperties.location.mapD(l => GeoOperations.distanceBetween([l.lon, l.lat], [entry.lon, entry.lat]))
|
||||
let bearing = state.mapProperties.location.mapD(l => GeoOperations.bearing([l.lon, l.lat], [entry.lon, entry.lat]))
|
||||
let mapRotation = state.mapProperties.rotation
|
||||
let inView = state.mapProperties.bounds.mapD(bounds => bounds.contains([entry.lon, entry.lat]))
|
||||
|
||||
function select() {
|
||||
state.mapProperties.flyTo(entry.lon, entry.lat, 17)
|
||||
console.log("Selected search entry", entry)
|
||||
if (entry.boundingbox) {
|
||||
const [lat0, lat1, lon0, lon1] = entry.boundingbox
|
||||
state.mapProperties.bounds.set(
|
||||
new BBox([
|
||||
[lon0, lat0],
|
||||
[lon1, lat1]
|
||||
]).pad(0.01)
|
||||
)
|
||||
} else {
|
||||
state.mapProperties.flyTo(entry.lon, entry.lat, GeocodingUtils.categoryToZoomLevel[entry.category] ?? 17)
|
||||
}
|
||||
if (entry.feature) {
|
||||
state.selectedElement.set(entry.feature)
|
||||
}
|
||||
state.recentlySearched.addSelected(entry)
|
||||
dispatch("select")
|
||||
}
|
||||
</script>
|
||||
<button class="unstyled w-full link-no-underline"
|
||||
on:click={() => select()}>
|
||||
<div class="p-2 flex items-center w-full gap-y-2 ">
|
||||
<button class="unstyled w-full link-no-underline" on:click={() => select() }>
|
||||
<div class="p-2 flex items-center w-full gap-y-2 w-full">
|
||||
|
||||
{#if layer}
|
||||
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} />
|
||||
{:else if entry.category}
|
||||
<Icon icon={GeocodingUtils.categoryToIcon[entry.category]} clss="w-6 h-6 shrink-0" color="#aaa" />
|
||||
{/if}
|
||||
<div class="flex flex-col items-start pl-2">
|
||||
<div class="flex">
|
||||
|
||||
{entry.display_name ?? entry.osm_id}
|
||||
</div>
|
||||
<div class="subtle">
|
||||
{#if $distance}
|
||||
{GeoOperations.distanceToHuman($distance)}
|
||||
{/if}
|
||||
<div class="flex flex-col items-start pl-2 w-full">
|
||||
<div class="flex flex-wrap gap-x-2 justify-between w-full">
|
||||
<b class="nowrap">
|
||||
{#if layer && $tags?.id}
|
||||
<TagRenderingAnswer config={layer.title} selectedElement={entry.feature} {state} {tags} {layer} />
|
||||
{:else}
|
||||
{entry.display_name ?? entry.osm_id}
|
||||
{/if}
|
||||
</b>
|
||||
<div class="flex gap-x-1 items-center">
|
||||
{#if $bearing && !$inView}
|
||||
<ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} />
|
||||
{/if}
|
||||
{#if $distance}
|
||||
{GeoOperations.distanceToHuman($distance)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if entry.description}
|
||||
<div class="subtle flex justify-between w-full">
|
||||
{entry.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,25 +3,79 @@
|
|||
import SearchResult from "./SearchResult.svelte"
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import { XMarkIcon } from "@babeard/svelte-heroicons/solid"
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import Loading from "../Base/Loading.svelte"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let results: GeoCodeResult[]
|
||||
export let searchTerm: Store<string>
|
||||
export let isFocused: Store<boolean>
|
||||
|
||||
function close(){
|
||||
results = []
|
||||
}
|
||||
let recentlySeen: Store<GeoCodeResult[]> = state.recentlySearched.seenThisSession
|
||||
</script>
|
||||
|
||||
{#if results.length > 0}
|
||||
<div class="relative w-full">
|
||||
<div class="w-full collapsable" style="height: 50rem;" class:collapsed={!$isFocused}>
|
||||
{#if $searchTerm.length > 0 && results === undefined}
|
||||
<div class="searchbox normal-background items-center">
|
||||
<Loading />
|
||||
</div>
|
||||
{:else if results?.length > 0}
|
||||
<div class="relative w-full h-full">
|
||||
<div class="absolute top-0 right-0 searchbox normal-background"
|
||||
style="width: 25rem">
|
||||
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto">
|
||||
|
||||
<div class="absolute top-0 left-0 flex flex-col gap-y-2 normal-background p-2 rounded-xl border border-black w-full">
|
||||
{#each results as entry (entry)}
|
||||
<SearchResult on:select={() => close()} {entry} {state} />
|
||||
{/each}
|
||||
{#each results as entry (entry)}
|
||||
<SearchResult on:select {entry} {state} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="absolute top-2 right-2 cursor-pointer" on:click={() => close()}>
|
||||
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-2 right-2" on:click={() => close()}>
|
||||
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else }
|
||||
|
||||
<div class="searchbox normal-background ">
|
||||
{#if $searchTerm.length > 0}
|
||||
<!-- TODO add translation -->
|
||||
<b class="flex justify-center p-4">No results found for {$searchTerm}</b>
|
||||
{/if}
|
||||
|
||||
{#if $recentlySeen?.length > 0}
|
||||
<!-- TODO add translation -->
|
||||
<h4>Recent searches</h4>
|
||||
{#each $recentlySeen as entry}
|
||||
<SearchResult {entry} {state} on:select />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
.searchbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid black;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.collapsable {
|
||||
max-height: 50vh;
|
||||
transition: max-height 350ms ease-in-out;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
max-height: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"
|
||||
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"
|
||||
import { HeartIcon, PencilIcon, WifiIcon } from "@babeard/svelte-heroicons/solid"
|
||||
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"
|
||||
import { HeartIcon as HeartOutlineIcon, HomeIcon } from "@babeard/svelte-heroicons/outline"
|
||||
import Confirm from "../../assets/svg/Confirm.svelte"
|
||||
import Not_found from "../../assets/svg/Not_found.svelte"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
import Mastodon from "../../assets/svg/Mastodon.svelte"
|
||||
import Party from "../../assets/svg/Party.svelte"
|
||||
import AddSmall from "../../assets/svg/AddSmall.svelte"
|
||||
import { LinkIcon } from "@babeard/svelte-heroicons/mini"
|
||||
import { GlobeAltIcon, LinkIcon } from "@babeard/svelte-heroicons/mini"
|
||||
import Square_rounded from "../../assets/svg/Square_rounded.svelte"
|
||||
import Bug from "../../assets/svg/Bug.svelte"
|
||||
import Cross_bottom_right from "../../assets/svg/Cross_bottom_right.svelte"
|
||||
|
|
@ -39,6 +39,9 @@
|
|||
import Gear from "../../assets/svg/Gear.svelte"
|
||||
import { DesktopComputerIcon, UserCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import Relocation from "../../assets/svg/Relocation.svelte"
|
||||
import BuildingOffice2 from "@babeard/svelte-heroicons/outline/BuildingOffice2"
|
||||
import Train from "../../assets/svg/Train.svelte"
|
||||
import Airport from "../../assets/svg/Airport.svelte"
|
||||
|
||||
/**
|
||||
* Renders a single icon.
|
||||
|
|
@ -146,10 +149,21 @@
|
|||
<PencilIcon class={clss} {color} />
|
||||
{:else if icon === "user_circle"}
|
||||
<UserCircleIcon class={clss} {color} />
|
||||
{:else if icon==="globe_alt"}
|
||||
<GlobeAltIcon class={clss} {color} />
|
||||
{:else if icon === "building_office_2"}
|
||||
<BuildingOffice2 class={clss} {color} />
|
||||
{:else if icon === "house"}
|
||||
<HomeIcon class={clss} {color} />
|
||||
{:else if icon === "train"}
|
||||
<Train {color} class={clss}/>
|
||||
{:else if icon === "airport"}
|
||||
<Airport {color} class={clss}/>
|
||||
{:else if Utils.isEmoji(icon)}
|
||||
<span style={`font-size: ${emojiHeight}px; line-height: ${emojiHeight}px`}>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
{:else}
|
||||
<img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true" alt="" />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource"
|
|||
import { Map as MlMap } from "maplibre-gl"
|
||||
import ShowDataLayer from "./Map/ShowDataLayer"
|
||||
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
|
||||
import { RecentSearch } from "../Logic/Geocoding/RecentSearch"
|
||||
|
||||
/**
|
||||
* The state needed to render a special Visualisation.
|
||||
|
|
@ -95,6 +96,8 @@ export interface SpecialVisualizationState {
|
|||
readonly previewedImage: UIEventSource<ProvidedImage>
|
||||
readonly nearbyImageSearcher: CombinedFetcher
|
||||
readonly geolocation: GeoLocationHandler
|
||||
readonly recentlySearched: RecentSearch
|
||||
|
||||
|
||||
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
||||
reportError(message: string): Promise<void>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue