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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue