Skip to content

Commit 3c1c496

Browse files
authored
Favourite locations (#449)
* initial version * simpler * minor fixes * sort favs first by count * limit to 5 * move clock to right + every item * recent via text * fix clear button color * bug fix and simplify * as we now show all locations (min=0 instead 1) the previous input shouldn't be shown * make recent list disappear even if we click into an (empty) input that shows a new recent list * fix like we did for normal entries also needed for recent (c7629bd) * populate recents via useEffect so excludeCoord is fresh after re-render Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * use clock on left and settings * use switch for save and clear if off * cleanup * combine entry selection with save * again minor bug necessary: don't show list for first focus (ie. initial load)
1 parent 7eddcaf commit 3c1c496

File tree

10 files changed

+342
-36
lines changed

10 files changed

+342
-36
lines changed

package-lock.json

Lines changed: 1 addition & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/sidebar/SettingsBox.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SettingsContext } from '@/contexts/SettingsContext'
1010
import { RoutingProfile } from '@/api/graphhopper'
1111
import * as config from 'config'
1212
import { ProfileGroupMap } from '@/utils'
13+
import { clearRecentLocations } from '@/sidebar/search/RecentLocations'
1314

1415
export default function SettingsBox({ profile }: { profile: RoutingProfile }) {
1516
const settings = useContext(SettingsContext)
@@ -51,6 +52,14 @@ export default function SettingsBox({ profile }: { profile: RoutingProfile }) {
5152
Dispatcher.dispatch(new UpdateSettings({ showDistanceInMiles: !settings.showDistanceInMiles }))
5253
}
5354
/>
55+
<SettingsToggle
56+
title={tr('save_recent_locations')}
57+
enabled={settings.saveRecentLocations}
58+
onClick={() => {
59+
if (settings.saveRecentLocations) clearRecentLocations()
60+
Dispatcher.dispatch(new UpdateSettings({ saveRecentLocations: !settings.saveRecentLocations }))
61+
}}
62+
/>
5463
</div>
5564
<div className={styles.title}>{tr('settings_gpx_export')}</div>
5665
<div className={styles.settingsTable}>

src/sidebar/search/AddressInput.tsx

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { JSX, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
22
import { QueryPoint, QueryPointType } from '@/stores/QueryStore'
33
import { Bbox, GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper'
4-
import Autocomplete, { AutocompleteItem, GeocodingItem, POIQueryItem } from '@/sidebar/search/AddressInputAutocomplete'
4+
import Autocomplete, {
5+
AutocompleteItem,
6+
GeocodingItem,
7+
POIQueryItem,
8+
RecentLocationItem,
9+
} from '@/sidebar/search/AddressInputAutocomplete'
10+
import { getRecentLocations } from '@/sidebar/search/RecentLocations'
511
import ArrowBack from './arrow_back.svg'
612
import Cross from '@/sidebar/times-solid-thin.svg'
713
import CurrentLocationIcon from './current-location.svg'
@@ -17,13 +23,13 @@ import { toLonLat, transformExtent } from 'ol/proj'
1723
import { Map } from 'ol'
1824
import { AddressParseResult } from '@/pois/AddressParseResult'
1925
import { getMap } from '@/map/map'
20-
import { Coordinate, getBBoxFromCoord } from '@/utils'
26+
import { calcDist, Coordinate, getBBoxFromCoord } from '@/utils'
2127

2228
export interface AddressInputProps {
2329
point: QueryPoint
2430
points: QueryPoint[]
2531
onCancel: () => void
26-
onAddressSelected: (queryText: string, coord: Coordinate | undefined) => void
32+
onLocationSelected: (mainText: string, secondText: string | undefined, coord: Coordinate | undefined) => void
2733
onChange: (value: string) => void
2834
clearDragDrop: () => void
2935
moveStartIndex: number
@@ -41,6 +47,8 @@ export default function AddressInput(props: AddressInputProps) {
4147
// keep track of focus and toggle fullscreen display on small screens
4248
const [hasFocus, setHasFocus] = useState(false)
4349
const isSmallScreen = useMediaQuery({ query: '(max-width: 44rem)' })
50+
const prevPoint = props.index > 0 ? props.points[props.index - 1] : undefined
51+
const excludeCoord = prevPoint?.isInitialized ? prevPoint.coordinate : undefined
4452

4553
// container for geocoding results which gets set by the geocoder class and set to empty if the underlying query
4654
// point gets changed from outside also gets filled with an item to select the current location as input if input
@@ -72,7 +80,21 @@ export default function AddressInput(props: AddressInputProps) {
7280
const [poiSearch] = useState(new ReverseGeocoder(getApi(), props.point, AddressParseResult.handleGeocodingResponse))
7381

7482
// if item is selected we need to clear the autocompletion list
75-
useEffect(() => setAutocompleteItems([]), [props.point])
83+
useEffect(() => {
84+
if (props.point.isInitialized) setAutocompleteItems([])
85+
}, [props.point])
86+
87+
useEffect(() => {
88+
if (!hasFocus) return
89+
if (isInitialFocus.current) {
90+
isInitialFocus.current = false
91+
return
92+
}
93+
if (text === '') {
94+
const recents = buildRecentItems(undefined, 5, excludeCoord)
95+
if (recents.length > 0) setAutocompleteItems(recents)
96+
}
97+
}, [hasFocus, excludeCoord])
7698

7799
// highlighted result of geocoding results. Keep track which index is highlighted and change things on ArrowUp and Down
78100
// on Enter select highlighted result or the 0th if nothing is highlighted
@@ -105,7 +127,8 @@ export default function AddressInput(props: AddressInputProps) {
105127
setText(origText)
106128
} else if (nextIndex >= 0) {
107129
const item = autocompleteItems[nextIndex]
108-
if (item instanceof GeocodingItem) setText(item.mainText)
130+
if (item instanceof GeocodingItem || item instanceof RecentLocationItem)
131+
setText(item.mainText)
109132
else setText(origText)
110133
}
111134
}
@@ -119,13 +142,15 @@ export default function AddressInput(props: AddressInputProps) {
119142
// try to parse input as coordinate. Otherwise query nominatim
120143
const coordinate = textToCoordinate(text)
121144
if (coordinate) {
122-
props.onAddressSelected(text, coordinate)
145+
props.onLocationSelected(text, undefined, coordinate)
123146
} else if (autocompleteItems.length > 0) {
124147
const index = highlightedResult >= 0 ? highlightedResult : 0
125148
const item = autocompleteItems[index]
126149
if (item instanceof POIQueryItem) {
127150
handlePoiSearch(poiSearch, item.result, props.map)
128-
props.onAddressSelected(item.result.text(item.result.poi), undefined)
151+
props.onLocationSelected(item.result.text(item.result.poi), undefined, undefined)
152+
} else if (item instanceof RecentLocationItem) {
153+
props.onLocationSelected(item.mainText, item.secondText, item.point)
129154
} else if (highlightedResult < 0 && !props.point.isInitialized) {
130155
// by default use the first result, otherwise the highlighted one
131156
getApi()
@@ -134,13 +159,13 @@ export default function AddressInput(props: AddressInputProps) {
134159
if (result && result.hits.length > 0) {
135160
const hit: GeocodingHit = result.hits[0]
136161
const res = nominatimHitToItem(hit)
137-
props.onAddressSelected(res.mainText + ', ' + res.secondText, hit.point)
162+
props.onLocationSelected(res.mainText, res.secondText, hit.point)
138163
} else if (item instanceof GeocodingItem) {
139-
props.onAddressSelected(item.toText(), item.point)
164+
props.onLocationSelected(item.mainText, item.secondText, item.point)
140165
}
141166
})
142167
} else if (item instanceof GeocodingItem) {
143-
props.onAddressSelected(item.toText(), item.point)
168+
props.onLocationSelected(item.mainText, item.secondText, item.point)
144169
}
145170
}
146171
if (event.key === 'Enter') focusNextOrBlur()
@@ -168,6 +193,7 @@ export default function AddressInput(props: AddressInputProps) {
168193

169194
// do not focus on mobile as we would hide the map with the "input"-view
170195
const focusFirstInput = props.index == 0 && !isSmallScreen
196+
const isInitialFocus = useRef(focusFirstInput)
171197

172198
return (
173199
<div className={containerClass}>
@@ -202,8 +228,21 @@ export default function AddressInput(props: AddressInputProps) {
202228
onChange={e => {
203229
const query = e.target.value
204230
setText(query)
205-
const coordinate = textToCoordinate(query)
206-
if (!coordinate) geocoder.request(e.target.value, biasCoord, getMap().getView().getZoom())
231+
if (query === '') {
232+
geocoder.cancel()
233+
const recents = buildRecentItems(undefined, 5, excludeCoord)
234+
if (recents.length > 0) setAutocompleteItems(recents)
235+
else setAutocompleteItems([])
236+
} else {
237+
const coordinate = textToCoordinate(query)
238+
if (!coordinate) {
239+
if (query.length < 2) {
240+
const recents = buildRecentItems(query, 5, excludeCoord)
241+
if (recents.length > 0) setAutocompleteItems(recents)
242+
}
243+
geocoder.request(query, biasCoord, getMap().getView().getZoom())
244+
}
245+
}
207246
props.onChange(query)
208247
}}
209248
onKeyDown={onKeypress}
@@ -232,6 +271,9 @@ export default function AddressInput(props: AddressInputProps) {
232271
onClick={e => {
233272
setText('')
234273
props.onChange('')
274+
const recents = buildRecentItems(undefined, 5, excludeCoord)
275+
if (recents.length > 0) setAutocompleteItems(recents)
276+
else setAutocompleteItems([])
235277
// if we clear the text without focus then explicitly request it to improve usability:
236278
searchInput.current!.focus()
237279
}}
@@ -247,15 +289,15 @@ export default function AddressInput(props: AddressInputProps) {
247289
e => e.preventDefault() // prevents that input->onBlur is called when clicking the button (would hide this button and prevent onClick)
248290
}
249291
onClick={() => {
250-
onCurrentLocationSelected(props.onAddressSelected)
292+
onCurrentLocationSelected((text, coord) => props.onLocationSelected(text, undefined, coord))
251293
// but when clicked => we want to lose the focus e.g. to close mobile-input view
252294
searchInput.current!.blur()
253295
}}
254296
>
255297
<CurrentLocationIcon />
256298
</PlainButton>
257299

258-
{autocompleteItems.length > 0 && (
300+
{hasFocus && autocompleteItems.length > 0 && (
259301
<ResponsiveAutocomplete
260302
inputRef={searchInputContainer.current!}
261303
index={props.index}
@@ -267,7 +309,10 @@ export default function AddressInput(props: AddressInputProps) {
267309
onSelect={item => {
268310
if (item instanceof GeocodingItem) {
269311
setText(item.toText())
270-
props.onAddressSelected(item.toText(), item.point)
312+
props.onLocationSelected(item.mainText, item.secondText, item.point)
313+
} else if (item instanceof RecentLocationItem) {
314+
setText(item.toText())
315+
props.onLocationSelected(item.mainText, item.secondText, item.point)
271316
} else if (item instanceof POIQueryItem) {
272317
handlePoiSearch(poiSearch, item.result, props.map)
273318
setText(item.result.text(item.result.poi))
@@ -282,6 +327,24 @@ export default function AddressInput(props: AddressInputProps) {
282327
)
283328
}
284329

330+
function buildRecentItems(filter?: string, limit?: number, excludeCoord?: Coordinate): RecentLocationItem[] {
331+
let recents = getRecentLocations(0)
332+
if (excludeCoord) recents = recents.filter(e => calcDist({ lat: e.lat, lng: e.lng }, excludeCoord) > 0)
333+
if (filter) {
334+
const lower = filter.toLowerCase()
335+
recents = recents.filter(
336+
e =>
337+
e.mainText.toLowerCase().startsWith(lower) ||
338+
e.secondText
339+
.toLowerCase()
340+
.split(/[\s,]+/)
341+
.some(word => word.startsWith(lower)),
342+
)
343+
}
344+
if (limit) recents = recents.slice(0, limit)
345+
return recents.map(e => new RecentLocationItem(e.mainText, e.secondText, { lat: e.lat, lng: e.lng }))
346+
}
347+
285348
function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) {
286349
if (!result.hasPOIs()) return
287350

0 commit comments

Comments
 (0)