UX: more work on a search function

This commit is contained in:
Pieter Vander Vennet 2024-08-21 14:06:42 +02:00
parent 3cd04df60b
commit 00ad21d5ef
30 changed files with 636 additions and 138 deletions

View file

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

View file

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

View file

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

View file

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

View file

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