-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathutils.ts
315 lines (287 loc) · 10.4 KB
/
utils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
import { URLSearchParams } from 'url'
import bugsnag from '@bugsnag/js'
import { fromCoordinates } from '@conveyal/lonlat'
import { getDistance } from 'geolib'
import fetch from 'node-fetch'
import type { LonLatOutput } from '@conveyal/lonlat'
import type { Feature, FeatureCollection, Position } from 'geojson'
import { AnyGeocoderQuery } from '@opentripplanner/geocoder/lib/geocoders/types'
// Types
export type ServerlessEvent = {
headers: Record<string, string>
queryStringParameters: Record<string, string>
}
export type ServerlessCallbackFunction = (
error: string | null,
response: {
body: string
headers: Record<string, string>
statusCode: number
} | null
) => void
export type ServerlessResponse = {
body: string
headers: Record<string, string>
statusCode: number
}
// Consts
const PREFERRED_LAYERS = ['venue', 'address', 'street', 'intersection']
const { COORDINATE_COMPARISON_PRECISION_DIGITS } = process.env
/**
* This method removes all characters Pelias doesn't support.
* Unfortunately, these characters not only don't match if they're found in the
* elasticsearch record, but make the query fail and return no results.
* Therefore, they are removed using this method. The search still completes
* as one would expect: "ab @ c" gets converted to "ab c" which still matches
* an item named "ab @ c"
* @param queryString The *URL decoded* string with invalid characters to be cleaned
* @returns The string with invalid characters replaced
*/
export const makeQueryPeliasCompatible = (queryString: string): string => {
return queryString.replace(/@/g, ' ').replace(/&/g, ' ')
}
/**
* This method converts Query String Parameters from AWS into an object
* which can be passed into a geocoder from @otp-ui/geocoder.
* @param queryStringParams The query string parameters from the event object
* @returns The object with the valid geocoder query.
*/
export const convertQSPToGeocoderArgs = (
queryStringParams: Record<string, string>
): AnyGeocoderQuery => {
const params = new URLSearchParams(queryStringParams)
const geocoderArgs: AnyGeocoderQuery = {}
const [minLat, minLon, maxLat, maxLon, size] = [
params.get('boundary.rect.min_lat'),
params.get('boundary.rect.min_lon'),
params.get('boundary.rect.max_lat'),
params.get('boundary.rect.max_lon'),
params.get('size')
].map((p) => p && parseFloat(p))
const text = params.get('text')
const layers = params.get('layers')
if (minLat && minLon && maxLat && maxLon) {
geocoderArgs.boundary = {
rect: { maxLat, maxLon, minLat, minLon }
}
}
if (params.get('focus.point.lat')) {
geocoderArgs.focusPoint = {
lat: params.get('focus.point.lat'),
lon: params.get('focus.point.lon')
}
}
if (params.get('point.lat')) {
geocoderArgs.point = {
lat: params.get('point.lat'),
lon: params.get('point.lon')
}
}
if (text) {
geocoderArgs.text = text
}
// Safe, performant default
geocoderArgs.size = size || 4
geocoderArgs.layers = layers || PREFERRED_LAYERS.join(',')
return geocoderArgs
}
/**
* Compares two GeoJSON positions and returns if they are equal within 10m accuracy
* @param a One GeoJSON Position object
* @param b One GeoJSON Position Object
* @param precision How many digits after the decimal point to use when comparing
* @returns True if the positions describe the same place, false if they are different
*/
export const arePointsRoughlyEqual = (
a: Position,
b: Position,
precision = 4
): boolean => {
// 4 decimal places is approximately 10 meters, which is acceptable error
const aRounded = a?.map((point: number): number =>
parseFloat(point?.toFixed(precision))
)
const bRounded = b?.map((point: number): number =>
parseFloat(point?.toFixed(precision))
)
return (
aRounded?.every((element, index) => element === bRounded?.[index]) || false
)
}
/**
* Inspects a feature and removes it if a similar feature is included within a
* second list of features
* @param feature The feature to either keep or remove
* @param customFeatures The set of features to check against
* @returns True or false depending on if the feature is unique
*/
const filterOutDuplicateStops = (
feature: Feature,
customFeatures: Feature[],
checkNameDuplicates: boolean
): boolean => {
// If the names are the same, or if the feature is too far away, we can't consider the feature
if (
customFeatures.find(
(otherFeature: Feature) =>
(checkNameDuplicates &&
(feature?.properties?.name || '')
.toLowerCase()
.includes((otherFeature?.properties?.name || '').toLowerCase())) ||
// Any feature this far away is likely not worth being considered
feature?.properties?.distance > 7500
)
) {
return false
}
// If the feature to be tested isn't a stop, we don't have to check its coordinates.
// In OpenStreetMap, some transit stops have an "operator" tag which is
// added to the addendum field in Pelias. Therefore, there is still potential
// for some transit stops without the "operator" tag to still be included in
// search results.
if (
!feature.properties ||
!feature.properties.addendum ||
// if a OSM feature has an operator tag, it is a transit stop
((!feature.properties.addendum.osm ||
!feature.properties.addendum.osm.operator) &&
// HERE public transport categories start with a 400
!feature.properties.addendum.categories?.find(
(c) => !!c.id.match(/^400-/)
))
) {
// Returning true ensures the Feature is *saved*
return true
}
// If a custom feature at the same location *can't* be found, return the Feature
return !customFeatures.find((otherFeature: Feature) => {
// Check Point data exists before working with it
if (
feature.geometry.type !== 'Point' ||
otherFeature.geometry.type !== 'Point'
) {
return null
}
// If this is true, we have a match! Which will be negated above to remove the
// duplicate
return arePointsRoughlyEqual(
feature.geometry.coordinates,
otherFeature.geometry.coordinates,
COORDINATE_COMPARISON_PRECISION_DIGITS
? parseInt(COORDINATE_COMPARISON_PRECISION_DIGITS)
: undefined
)
})
}
/**
* Merges two Pelias responses together
* @param responses An object containing two Pelias response objects
* @returns A single Pelias response object the features from both input objects
*/
export const mergeResponses = (
responses: {
customResponse: FeatureCollection
primaryResponse: FeatureCollection
},
checkNameDuplicates = true,
focusPoint?: LonLatOutput
): FeatureCollection => {
// Openstreetmap can sometimes include bus stop info with less
// correct information than the GTFS feed.
// Remove anything from the geocode.earth response that's within 10 meters of a custom result
responses.primaryResponse.features =
responses?.primaryResponse?.features?.filter((feature: Feature) =>
filterOutDuplicateStops(
feature,
responses.customResponse.features,
checkNameDuplicates
)
) || []
// If a focus point is specified, sort custom features by distance to the focus point
// This ensures the 3 stops are all relevant.
if (focusPoint && responses.customResponse.features) {
responses.customResponse.features.sort((a, b) => {
if (
a &&
a.geometry.type === 'Point' &&
b &&
b.geometry.type === 'Point'
) {
// Use lonlat to convert GeoJSON Point to input geolib can handle
// Compare distances between coordiante and focus point
return (
getDistance(fromCoordinates(a.geometry.coordinates), focusPoint) -
getDistance(fromCoordinates(b.geometry.coordinates), focusPoint)
)
}
// Can't do a comparison, becuase types are wrong
return 0
})
}
// Insert merged features back into Geocode.Earth response
const mergedResponse: FeatureCollection = {
...responses.primaryResponse,
features: [
// Merge features together
// customResponses may be null, but we know primaryResponse to exist
...(responses.customResponse.features || []),
...responses.primaryResponse.features
]
}
return mergedResponse
}
/**
* Formerly allowed caching requests in Redis store. This method is kept around now
* in case another caching solution is attempted again in the future
* @param geocoder geocoder object returned from geocoder package
* @param requestMethod Geocoder Request Method
* @param args Args for Geocoder request method
* @returns FeatureCollection either from cache or live
*/
export const cachedGeocoderRequest = async (
geocoder: Record<string, (q: AnyGeocoderQuery) => Promise<FeatureCollection>>,
requestMethod: string,
args: AnyGeocoderQuery
): Promise<FeatureCollection> => {
const { focusPoint, text } = args
if (!text) return { features: [], type: 'FeatureCollection' }
const onlineResponse = await geocoder[requestMethod](args)
return onlineResponse
}
/**
* Checks if a feature collection provides "satisfactory" results for a given queryString.
* Satisfactory is defined as having results, having results where at least one is of a set of
* preferred layers, and as at least one of the results contains the entirety of the query string.
*
* This method does two passes over the array for readability -- the temporal difference to doing
* some form of reducer is minimal.
*
* @param featureCollection The GeoJSON featureCollection to check
* @param queryString The query string which the featureCollection results are supposed to represent
* @returns true if the results are deemed satisfactory, false otherwise
*/
export const checkIfResultsAreSatisfactory = (
featureCollection: FeatureCollection,
queryString: string
): boolean => {
const { features } = featureCollection
// Check for zero length
if (features?.length === 0) return false
// Check for at least one layer being one of the preferred layers
if (
!features?.some((feature) =>
PREFERRED_LAYERS.includes(feature?.properties?.layer)
)
)
return false
// Check that the query string is present in at least one returned string
if (
!features?.some((feature) =>
feature?.properties?.name
?.toLowerCase()
.includes(queryString.toLowerCase())
)
)
return false
return true
}