Skip to content

Commit

Permalink
Support lat/long entry and display lat/long for custom pin (#382)
Browse files Browse the repository at this point in the history
Merges LocationSourceType.{SelectedOnMap,UrlWithoutString}, which are never treated differently.

Also fixes "Current Location" not being translated in search bar (yikes).
  • Loading branch information
graue authored Aug 29, 2024
1 parent 0d8cf71 commit 8ba6345
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 34 deletions.
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"lodash": "^4.17.21",
"luxon": "^3.2.1",
"maplibre-gl": "^1.15.2",
"parse-coords": "^1.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-intl": "^6.2.5",
Expand Down
28 changes: 26 additions & 2 deletions src/components/SearchAutocompleteDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import {
LocationSourceType,
selectCurrentLocation,
selectGeocodedLocation,
selectLocationFromTypedCoords,
} from '../features/routeParams';
import describePlace from '../lib/describePlace';
import { parsePossibleCoordsString, stringifyCoords } from '../lib/geometry';
import Icon from './primitives/Icon';
import PlaceIcon from './PlaceIcon';
import SelectionList from './SelectionList';
import SelectionListItem from './SelectionListItem';

import Pin from 'iconoir/icons/map-pin.svg?react';
import Position from 'iconoir/icons/position.svg?react';

const LIST_ITEM_CLASSNAME = 'SearchAutocompleteDropdown_place';
Expand All @@ -41,15 +44,19 @@ export default function SearchAutocompleteDropdown(props) {
showCurrentLocationOption,
loading,
noResults, // we explicitly searched and found no results
parsedCoords,
} = useSelector((state) => {
const startOrEnd = state.routeParams.editingLocation;
const inputText = state.routeParams[startOrEnd + 'InputText'].trim();

const parsedCoords = parsePossibleCoordsString(inputText);

let autocompletedText = inputText; // May get changed below
let cache = inputText && state.geocoding.typeaheadCache['@' + inputText];
let fallbackToGeocodedLocationSourceText = false;
let loading = false;
let noResults = false;
if (!cache || cache.status !== 'succeeded') {
if (!parsedCoords && (!cache || cache.status !== 'succeeded')) {
if (inputText !== '' && (!cache || cache?.status === 'fetching')) {
loading = true;
}
Expand Down Expand Up @@ -152,6 +159,7 @@ export default function SearchAutocompleteDropdown(props) {
showCurrentLocationOption,
loading,
noResults,
parsedCoords,
};
}, shallowEqual);

Expand All @@ -169,6 +177,10 @@ export default function SearchAutocompleteDropdown(props) {
);
};

const handleCoordsClick = () => {
dispatch(selectLocationFromTypedCoords(startOrEnd, parsedCoords));
};

const handleCurrentLocationClick = () => {
dispatch(selectCurrentLocation(startOrEnd));
};
Expand All @@ -180,6 +192,18 @@ export default function SearchAutocompleteDropdown(props) {
return (
<div className="flex flex-col m-0">
<SelectionList className="flex-grow pointer-events-auto">
{parsedCoords && (
<AutocompleteItem
onClick={handleCoordsClick}
onMouseDown={handleResultMousedown}
icon={
<Icon>
<Pin />
</Icon>
}
text={stringifyCoords(parsedCoords)}
/>
)}
{showCurrentLocationOption && (
<AutocompleteItem
onClick={handleCurrentLocationClick}
Expand Down Expand Up @@ -210,7 +234,7 @@ export default function SearchAutocompleteDropdown(props) {
{(loading || noResults) && (
<div className="relative inset-x-0 pt-4 pl-12 pointer-events-none">
<MoonLoader size={30} loading={loading} />
{noResults && (
{noResults && !parsedCoords && (
<span className="text-sm">
<FormattedMessage
defaultMessage="Nothing found for ''{inputText}''"
Expand Down
27 changes: 12 additions & 15 deletions src/components/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import NavLeftArrow from 'iconoir/icons/nav-arrow-left.svg?react';
import SwapArrows from 'iconoir/icons/data-transfer-both.svg?react';
import SettingsIcon from 'iconoir/icons/settings.svg?react';

const CURRENT_LOCATION_STRING = 'Current Location';

export default function SearchBar(props) {
const dispatch = useDispatch();
const intl = useIntl();
Expand Down Expand Up @@ -107,13 +105,15 @@ export default function SearchBar(props) {
const relevantLocation = which === 'start' ? startLocation : endLocation;
const textModified =
which === 'start' ? startTextModified : endTextModified;
// If the input contains the magic string "Current Location", or if it contains
// unmodified text from the geocoder (often a very long address), select all to
// If the input contains unmodified text from the geocoder (often a very
// long address), or any placeholder or autogenerated value, select all to
// make it easier to delete.
if (
relevantLocation &&
(relevantLocation.source === LocationSourceType.UserGeolocation ||
(relevantLocation.source === LocationSourceType.Geocoded &&
!textModified) ||
(relevantLocation.source === LocationSourceType.FromCoords &&
!textModified))
) {
event.target.select();
Expand Down Expand Up @@ -333,19 +333,16 @@ function _getDisplayedText(intl, text, loc, isFocused) {
case LocationSourceType.UrlWithString:
// Initially set to address from geocoder/URL; may have been modified by user.
return text;
case LocationSourceType.SelectedOnMap:
case LocationSourceType.UrlWithoutString:
if (text !== '') return text;
return isFocused
? ''
: intl.formatMessage({
defaultMessage: 'Custom',
description:
'description of a route start/end point that was selected on the map',
});
case LocationSourceType.FromCoords:
return text;
case LocationSourceType.UserGeolocation:
if (text !== '') return text;
return CURRENT_LOCATION_STRING;
return intl.formatMessage({
defaultMessage: 'Current Location',
description:
'option that can be selected (or typed in) to get directions from or ' +
'to the current location of the user, as determined by GPS',
});
default:
console.error('unexpected location type', loc.source);
if (text !== '') return text;
Expand Down
55 changes: 40 additions & 15 deletions src/features/routeParams.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import produce from 'immer';
import type { Action } from 'redux';
// @ts-ignore
import { point as turfPoint } from '@turf/helpers';
import describePlace from '../lib/describePlace';
import * as TransitModes from '../lib/TransitModes';
import type { ModeCategory } from '../lib/TransitModes';
import { geocodeTypedLocation } from './geocoding';
import { parsePossibleCoordsString, stringifyCoords } from '../lib/geometry';
import type { PhotonOsmHash } from '../lib/BikeHopperClient';
import { geolocate } from './geolocation';
import { fetchRoute } from './routes';
import type { BikeHopperAction, BikeHopperThunkAction } from '../store';

export enum LocationSourceType {
Geocoded = 'geocoded',
SelectedOnMap = 'selected_on_map', // marker drag or long-press/right-click

/* Just a pair of coordinates. This could result from a marker drag, a
* long-press/right-click, or from a past user geolocation and then the
* page was reloaded, hydrating the location from the URL: */
FromCoords = 'coordinates',

UserGeolocation = 'user_geolocation',
UrlWithString = 'url_with_string',
UrlWithoutString = 'url_without_string',
}

type Location =
Expand All @@ -35,9 +39,7 @@ type Location =
fromInputText: string;
}
| {
source:
| LocationSourceType.SelectedOnMap
| LocationSourceType.UrlWithoutString;
source: LocationSourceType.FromCoords;
point: GeoJSON.Feature<GeoJSON.Point>;
};

Expand Down Expand Up @@ -108,9 +110,11 @@ export function routeParamsReducer(
return produce(state, (draft) => {
draft[action.startOrEnd] = {
point: turfPoint(action.coords),
source: LocationSourceType.SelectedOnMap,
source: LocationSourceType.FromCoords,
};
draft[startOrEndInputText(action.startOrEnd)] = '';
draft[startOrEndInputText(action.startOrEnd)] = stringifyCoords(
action.coords,
);
if (
action.startOrEnd === 'end' &&
state.start == null &&
Expand All @@ -132,11 +136,13 @@ export function routeParamsReducer(
source: LocationSourceType.UrlWithString,
fromInputText: action.startText,
};
draft.startInputText = action.startText;
} else {
draft.start = {
point: turfPoint(action.startCoords),
source: LocationSourceType.UrlWithoutString,
source: LocationSourceType.FromCoords,
};
draft.startInputText = stringifyCoords(action.startCoords);
}

if (action.endText) {
Expand All @@ -145,14 +151,14 @@ export function routeParamsReducer(
source: LocationSourceType.UrlWithString,
fromInputText: action.endText,
};
draft.endInputText = action.endText;
} else {
draft.end = {
point: turfPoint(action.endCoords),
source: LocationSourceType.UrlWithoutString,
source: LocationSourceType.FromCoords,
};
draft.endInputText = stringifyCoords(action.endCoords);
}
draft.startInputText = action.startText || '';
draft.endInputText = action.endText || '';
draft.arriveBy = action.arriveBy;
draft.initialTime = action.initialTime;
});
Expand Down Expand Up @@ -337,6 +343,13 @@ export function locationsSubmitted(): BikeHopperThunkAction {
if (!useLocation) {
text = text.trim();

const parsedCoords = parsePossibleCoordsString(text);
if (parsedCoords)
return {
point: turfPoint(parsedCoords),
source: LocationSourceType.FromCoords,
};

let geocodingState = getState().geocoding;
let cacheEntry = geocodingState.typeaheadCache['@' + text];
if (cacheEntry && cacheEntry.status === 'succeeded') {
Expand Down Expand Up @@ -532,6 +545,15 @@ export function locationSelectedOnMap(
};
}

export function selectLocationFromTypedCoords(
startOrEnd: StartOrEnd,
coords: [number, number],
): BikeHopperThunkAction {
// For now, typing in coordinates is not distinguished from selecting that
// location visually on the map.
return locationSelectedOnMap(startOrEnd, coords);
}

type ParamsHydratedFromUrlAction = Action<'params_hydrated_from_url'> & {
startCoords: [number, number];
endCoords: [number, number];
Expand Down Expand Up @@ -603,9 +625,12 @@ export function changeLocationTextInput(
value,
});

await dispatch(
geocodeTypedLocation(value, startOrEnd, { fromTextAutocomplete: true }),
);
const valueParsesAsCoords = Boolean(parsePossibleCoordsString(value));
if (!valueParsesAsCoords) {
await dispatch(
geocodeTypedLocation(value, startOrEnd, { fromTextAutocomplete: true }),
);
}
};
}

Expand Down
3 changes: 1 addition & 2 deletions src/lib/BikeHopperClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import delay from './delay';
import { DEFAULT_VIEWPORT_BOUNDS } from './region';
import { InstructionSign } from './InstructionSigns';
import { Mode } from './TransitModes';
import { POINT_PRECISION } from './geometry';

function getApiPath(): string {
const apiDomain = import.meta.env.VITE_API_DOMAIN;
Expand All @@ -12,8 +13,6 @@ function getApiPath(): string {
return apiDomain || '';
}

const POINT_PRECISION = 5;

export class BikeHopperClientError extends Error {
code: number;
json: any;
Expand Down
32 changes: 32 additions & 0 deletions src/lib/geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import bezierSpline from '@turf/bezier-spline';
import distance from '@turf/distance';
import turfLength from '@turf/length';
import lineSliceAlong from '@turf/line-slice-along';
import parseCoords from 'parse-coords';
import {
darkenLegColor,
DEFAULT_PT_COLOR,
TRANSITION_COLOR,
getTextColor,
} from './colors';

export const POINT_PRECISION = 5;

export const EMPTY_GEOJSON = {
type: 'FeatureCollection',
features: [],
Expand Down Expand Up @@ -387,3 +390,32 @@ function _elevationChangeInKm(lineSegment) {
1000
);
}

/* Parse a lat-lng string, such as "37.835889, -122.289222".
*
* This is for UI-facing use. Note that this is LAT-LNG order, for consistency
* with the user interfaces of other commonly used mapping apps, and NOT in the
* LNG-LAT order that BikeHopper uses in URLs and in most places internally.
*
* In fact, the return value is in [lng, lat] format, or null if not parseable
* as a lat-lng string.
*/
export function parsePossibleCoordsString(str) {
// In the parse coords lib, commas are optional.
// This means something like "33 19" gets parsed as coords when in SF that
// should return 33 19th Avenue. So as a pre-filter, require at least one of
// comma, degree, minute or second symbol.
if (!str.match(/[,'"°]/)) return null;

const result = parseCoords(str);
return result ? [result.lng, result.lat] : null;
}

/* Stringify coordinates for display in the UI.
* As with the corresponding parse function, it is assumed that in the UI we
* will have LAT-LNG order rather than the LNG-LAT order we use internally in
* the code.
*/
export function stringifyCoords([lng, lat]) {
return `${lat.toFixed(POINT_PRECISION)}, ${lng.toFixed(POINT_PRECISION)}`;
}

0 comments on commit 8ba6345

Please sign in to comment.