Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions src/sidebar/search/AddressInput.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { JSX, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { QueryPoint, QueryPointType } from '@/stores/QueryStore'
import { Bbox, GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper'
import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem } from '@/sidebar/search/AddressInputAutocomplete'
import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem, RecentLocationItem } from '@/sidebar/search/AddressInputAutocomplete'
import { clearRecentLocations, getRecentLocations, saveRecentLocation } from '@/sidebar/search/RecentLocations'
import ArrowBack from './arrow_back.svg'
import Cross from '@/sidebar/times-solid-thin.svg'
import CurrentLocationIcon from './current-location.svg'
Expand Down Expand Up @@ -73,7 +74,9 @@ export default function AddressInput(props: AddressInputProps) {
const [poiSearch] = useState(new ReverseGeocoder(getApi(), props.point, AddressParseResult.handleGeocodingResponse))

// if item is selected we need to clear the autocompletion list
useEffect(() => setAutocompleteItems([]), [props.point])
useEffect(() => {
if (props.point.isInitialized) setAutocompleteItems([])
}, [props.point])

// highlighted result of geocoding results. Keep track which index is highlighted and change things on ArrowUp and Down
// on Enter select highlighted result or the 0th if nothing is highlighted
Expand Down Expand Up @@ -106,7 +109,7 @@ export default function AddressInput(props: AddressInputProps) {
setText(origText)
} else if (nextIndex >= 0) {
const item = autocompleteItems[nextIndex]
if (item instanceof GeocodingItem) setText(item.mainText)
if (item instanceof GeocodingItem || item instanceof RecentLocationItem) setText(item.mainText)
else setText(origText)
}
}
Expand All @@ -127,6 +130,8 @@ export default function AddressInput(props: AddressInputProps) {
if (item instanceof POIQueryItem) {
handlePoiSearch(poiSearch, item.result, props.map)
props.onAddressSelected(item.result.text(item.result.poi), undefined)
} else if (item instanceof RecentLocationItem) {
props.onAddressSelected(item.toText(), item.point)
} else if (highlightedResult < 0 && !props.point.isInitialized) {
// by default use the first result, otherwise the highlighted one
getApi()
Expand All @@ -136,12 +141,15 @@ export default function AddressInput(props: AddressInputProps) {
const hit: GeocodingHit = result.hits[0]
const res = nominatimHitToItem(hit)
props.onAddressSelected(res.mainText + ', ' + res.secondText, hit.point)
saveRecentLocation(res.mainText, res.secondText, hit.point)
} else if (item instanceof GeocodingItem) {
props.onAddressSelected(item.toText(), item.point)
saveRecentLocation(item.mainText, item.secondText, item.point)
}
})
} else if (item instanceof GeocodingItem) {
props.onAddressSelected(item.toText(), item.point)
saveRecentLocation(item.mainText, item.secondText, item.point)
}
}
// Enter: focus next address input, or blur if last
Expand Down Expand Up @@ -201,15 +209,32 @@ export default function AddressInput(props: AddressInputProps) {
onChange={e => {
const query = e.target.value
setText(query)
const coordinate = textToCoordinate(query)
if (!coordinate) geocoder.request(e.target.value, biasCoord, getMap().getView().getZoom())
if (query === '') {
geocoder.cancel()
const recents = buildRecentItems(undefined, 5)
if (recents.length > 0) setAutocompleteItems(recents)
else setAutocompleteItems([])
} else {
const coordinate = textToCoordinate(query)
if (!coordinate) {
if (query.length < 2) {
const recents = buildRecentItems(query, 5)
if (recents.length > 0) setAutocompleteItems(recents)
}
geocoder.request(query, biasCoord, getMap().getView().getZoom())
}
}
props.onChange(query)
}}
onKeyDown={onKeypress}
onFocus={() => {
setHasFocus(true)
props.clearDragDrop()
if (origAutocompleteItems.length > 0) setAutocompleteItems(origAutocompleteItems)
else if (text === '') {
const recents = buildRecentItems(undefined, 5)
if (recents.length > 0) setAutocompleteItems(recents)
}
}}
onBlur={() => {
setHasFocus(false)
Expand All @@ -233,6 +258,9 @@ export default function AddressInput(props: AddressInputProps) {
onClick={e => {
setText('')
props.onChange('')
const recents = buildRecentItems(undefined, 5)
if (recents.length > 0) setAutocompleteItems(recents)
else setAutocompleteItems([])
// if we clear the text without focus then explicitly request it to improve usability:
searchInput.current!.focus()
}}
Expand Down Expand Up @@ -268,12 +296,19 @@ export default function AddressInput(props: AddressInputProps) {
onSelect={item => {
if (item instanceof GeocodingItem) {
props.onAddressSelected(item.toText(), item.point)
saveRecentLocation(item.mainText, item.secondText, item.point)
} else if (item instanceof RecentLocationItem) {
props.onAddressSelected(item.toText(), item.point)
} else if (item instanceof POIQueryItem) {
handlePoiSearch(poiSearch, item.result, props.map)
setText(item.result.text(item.result.poi))
}
searchInput.current!.blur() // see also AutocompleteEntry->onMouseDown
}}
onClearRecents={() => {
clearRecentLocations()
setAutocompleteItems([])
}}
/>
</ResponsiveAutocomplete>
)}
Expand All @@ -282,6 +317,20 @@ export default function AddressInput(props: AddressInputProps) {
)
}

function buildRecentItems(filter?: string, limit?: number): RecentLocationItem[] {
let recents = getRecentLocations(1)
if (filter) {
const lower = filter.toLowerCase()
recents = recents.filter(
e =>
e.mainText.toLowerCase().startsWith(lower) ||
e.secondText.toLowerCase().split(/[\s,]+/).some(word => word.startsWith(lower)),
)
}
if (limit) recents = recents.slice(0, limit)
return recents.map(e => new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng }))
}

function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) {
if (!result.hasPOIs()) return

Expand Down
23 changes: 23 additions & 0 deletions src/sidebar/search/AddressInputAutocomplete.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,26 @@
font-size: small;
color: #5b616a;
}

.recentHeader {
display: flex;
justify-content: space-between;
align-items: center;
font-size: small;
font-weight: bold;
color: #5b616a;
padding: 0.3rem 0.5rem 0;
}

.clearRecentsButton {
border: none;
background: none;
color: #5b616a;
font-size: small;
cursor: pointer;
padding: 0;
}

.clearRecentsButton:hover {
color: #333;
}
85 changes: 79 additions & 6 deletions src/sidebar/search/AddressInputAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ export class GeocodingItem implements AutocompleteItem {
}
}

export class RecentLocationItem implements AutocompleteItem {
mainText: string
secondText: string
point: { lat: number; lng: number }

constructor(mainText: string, secondText: string, point: { lat: number; lng: number }) {
this.mainText = mainText
this.secondText = secondText
this.point = point
}

toText() {
return this.mainText + ', ' + this.secondText
}
}

export class POIQueryItem implements AutocompleteItem {
result: AddressParseResult

Expand All @@ -34,23 +50,61 @@ export interface AutocompleteProps {
items: AutocompleteItem[]
highlightedItem: AutocompleteItem
onSelect: (hit: AutocompleteItem) => void
onClearRecents?: () => void
}

export default function Autocomplete({ items, highlightedItem, onSelect }: AutocompleteProps) {
export default function Autocomplete({ items, highlightedItem, onSelect, onClearRecents }: AutocompleteProps) {
let recentHeaderShown = false
return (
<ul>
{items.map((item, i) => (
<li key={i} className={styles.autocompleteItem}>
{mapToComponent(item, highlightedItem === item, onSelect)}
</li>
))}
{items.map((item, i) => {
let header = null
if (item instanceof RecentLocationItem && !recentHeaderShown) {
recentHeaderShown = true
header = (
<div className={styles.recentHeader}>
<span title="Recent locations">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q150 0 255 105t105 255q0 150-105 255T480-120Zm0-80q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-117 0-198.5 81.5T200-480q0 117 81.5 198.5T480-200Zm-40-264v-216h80v184l128 128-56 56-152-152Z"
/>
</svg>
</span>
{onClearRecents && (
<button
className={styles.clearRecentsButton}
onMouseDown={e => e.preventDefault()}
onClick={onClearRecents}
title="Clear recent locations"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 -960 960 960">
<path
fill="currentColor"
d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"
/>
</svg>
</button>
)}
</div>
)
}
return (
<li key={i} className={styles.autocompleteItem}>
{header}
{mapToComponent(item, highlightedItem === item, onSelect)}
</li>
)
})}
</ul>
)
}

function mapToComponent(item: AutocompleteItem, isHighlighted: boolean, onSelect: (hit: AutocompleteItem) => void) {
if (item instanceof GeocodingItem)
return <GeocodingEntry item={item} isHighlighted={isHighlighted} onSelect={onSelect} />
else if (item instanceof RecentLocationItem)
return <RecentLocationEntry item={item} isHighlighted={isHighlighted} onSelect={onSelect} />
else if (item instanceof POIQueryItem)
return <POIQueryEntry item={item} isHighlighted={isHighlighted} onSelect={onSelect} />
else throw Error('Unsupported item type: ' + typeof item)
Expand Down Expand Up @@ -95,6 +149,25 @@ function GeocodingEntry({
)
}

function RecentLocationEntry({
item,
isHighlighted,
onSelect,
}: {
item: RecentLocationItem
isHighlighted: boolean
onSelect: (item: RecentLocationItem) => void
}) {
return (
<AutocompleteEntry isHighlighted={isHighlighted} onSelect={() => onSelect(item)}>
<div className={styles.geocodingEntry} title={item.toText()}>
<span className={styles.mainText}>{item.mainText}</span>
<span className={styles.secondaryText}>{item.secondText}</span>
</div>
</AutocompleteEntry>
)
}

function AutocompleteEntry({
isHighlighted,
children,
Expand Down
75 changes: 75 additions & 0 deletions src/sidebar/search/RecentLocations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { calcDist, Coordinate } from '@/utils'
import { tr } from '@/translation/Translation'
import { textToCoordinate } from '@/Converters'

const STORAGE_KEY = 'recentLocations'
const MAX_ENTRIES = 20
const DEDUP_DISTANCE_METERS = 100

export interface RecentLocation {
mainText: string
secondText: string
lat: number
lng: number
timestamp: number
count: number
}

export function getRecentLocations(minCount: number = 0): RecentLocation[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.filter(
(e: any) =>
typeof e.mainText === 'string' &&
typeof e.secondText === 'string' &&
typeof e.lat === 'number' &&
typeof e.lng === 'number' &&
typeof e.timestamp === 'number',
)
.map((e: any) => ({ ...e, count: typeof e.count === 'number' ? e.count : 1 }))
.filter((e: RecentLocation) => e.count > minCount)
.sort((a: RecentLocation, b: RecentLocation) => b.count - a.count || b.timestamp - a.timestamp)
} catch {
return []
}
}

export function clearRecentLocations(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// localStorage unavailable
}
}

export function saveRecentLocation(mainText: string, secondText: string, coordinate: Coordinate): void {
if (mainText === tr('current_location')) return
if (textToCoordinate(mainText)) return

try {
const all = getRecentLocations()
const existing = all.find(
e => calcDist({ lat: e.lat, lng: e.lng }, coordinate) <= DEDUP_DISTANCE_METERS,
)
const prevCount = existing ? existing.count : 0
const filtered = all.filter(
e => calcDist({ lat: e.lat, lng: e.lng }, coordinate) > DEDUP_DISTANCE_METERS,
)

filtered.unshift({
mainText,
secondText,
lat: coordinate.lat,
lng: coordinate.lng,
timestamp: Date.now(),
count: prevCount + 1,
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered.slice(0, MAX_ENTRIES)))
} catch {
// localStorage unavailable (private browsing, quota exceeded)
}
}
Loading