Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,4 @@ cypress/plugins
cypress/fixtures

.vscode
data.ms
65 changes: 62 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,13 +474,72 @@ instantsearch.widgets.hits({
})
```

### Geo Search
### Geo Search

[Geo search references](https://www.algolia.com/doc/api-reference/widgets/geo-search/js/)

No compatibility because MeiliSearch does not support Geo Search.
The `geoSearch` widget displays search results on a Google Map. It lets you search for results based on their position and provides some common usage patterns such as “search on map interactions”.

If you'd like to see it implemented please vote for it in the [roadmap](https://roadmap.meilisearch.com/c/33-geo-search?utm_medium=social&utm_source=portal_share).
- ✅ container: The CSS Selector or HTMLElement to insert the Google maps into. _required_
- ✅ googleReference: The reference to the global window.google object. See the [Google Maps](https://developers.google.com/maps/documentation/javascript/overview) documentation for more information. _required_

- ✅ initialZoom: When no search results are found, google map will default to this zoom.
- ✅ initialPosition: When no search results are found, google map will default to this position.
- ✅ mapOptions: The options forwarded to the Google Maps constructor.
- ❔ builtInMarker: Used to customize Google Maps markers. Because of lack of tests we cannot guarantee its compatibility. For more information please visit [InstantSearch related documentation](https://www.algolia.com/doc/api-reference/widgets/geo-search/js/#widget-param-builtinmarker).
- customHTMLMarker: Same as `builtInMarker`. Because of lack of tests, we cannot guarantee its compatibility. For more information please visit [InstantSearch related documentation](https://www.algolia.com/doc/api-reference/widgets/geo-search/js/#widget-param-customhtmlmarker).
- ✅ enableRefine: If true, the map is used for refining the search. Otherwise, it’s only for display purposes.
- ✅ enableClearMapRefinement: If `true`, a button is displayed on the map when the refinement is coming from interacting with it, to remove it.
- ✅ enableRefineControl: If `true`, the map is used for refining the search. Otherwise, it’s only for display purposes.
- ✅ enableRefineOnMapMove: If `true`, a button is displayed on the map when the refinement is coming from interacting with it, to remove it.,
- ✅ templates: The templates to use for the widget.
- ✅ cssClasses: The CSS classes to override.

#### Usage

The classic usage, with only the `required` elements, renders an embedded Google Map on which you can move and refine search based on the position maps.

```js
instantsearch.widgets.geoSearch({
container: '#maps',
googleReference: window.google,
}),
```

For further customization, for example to determine an initial position for the map. Contrary to `initialZoom` and `initialPosition`, triggers a search request with the provided information.

The following parameters exist:

- `insideBoundingBox`: The Google Map window box. It is used as parameter in a search request. It takes precedent on all the following parameters.
- `aroundLatLng`: The middle point of the Google Map. If `insideBoundingBox` is present, it is ignored.
- `aroundRadius`: The radius around a Geo Point, used for sorting in the search request. It only works if `aroundLatLng` is present as well. If `insideBoundingBox` is present, it is ignored.


For exemple, by adding `insideBoundingBox` in the [`instantSearch`](#-instantsearch) widget parameters, the parameter is used to refine the first search request.

```js
initialUiState: {
geo: {
geoSearch: {
boundingBox:
'50.680720183653065, 3.273798366642514,50.55969330590075, 2.9625244444490253',
},
},
},
```
Without providing this parameter, Google Maps will default to a window containing all markers from the provided search results.

Alternatively, the parameters can be passed through the [`searchFunction`](https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/#widget-param-searchfunction) parameter of the [`instantSearch`](#-instantsearch) widget. Contrary to `initialUiState` these parameters overwrite the values on each search.

```js
searchFunction: function (helper) {
helper.setQueryParameter('aroundRadius', 75000)
helper.setQueryParameter('aroundLatLng', '51.1241999, 9.662499900000057');
helper.search()
},
```

[Read the guide on how GeoSearch works in MeiliSearch](https://docs.meilisearch.com/reference/features/geosearch.html#geosearch).

### ❌ Answers

Expand Down
98 changes: 98 additions & 0 deletions src/adapter/search-request-adapter/__tests__/geo-rules.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { adaptGeoPointsRules } from '../geo-rules-adapter'

test('Adapt geoPoints rules without a boundingBox', () => {
const rules = adaptGeoPointsRules()
expect(rules).toBeUndefined()
})

test('Adapt geoPoints rules with same 0 lat and 0 lng geo points', () => {
const rules = adaptGeoPointsRules({
insideBoundingBox: '0,0,0,0',
})

expect(rules?.filter).toBe('_geoRadius(0, 0, 0)')
expect(rules?.sort).toBe('_geoPoint(0, 0):asc')
})

test('Adapt geoPoints rules with integer geo points', () => {
const rules = adaptGeoPointsRules({
insideBoundingBox: '1,2,3,4',
})
expect(rules?.filter).toBe(
'_geoRadius(2.0003044085023727, 2.999390393801055, 157201.5)'
)
expect(rules?.sort).toBe(
'_geoPoint(2.0003044085023727, 2.999390393801055):asc'
)
})

test('Try geoContext with only a radius', () => {
const rules = adaptGeoPointsRules({
aroundRadius: 1,
})
expect(rules).toBeUndefined()
})

test('Try geoContext with an aroundLatLng', () => {
const rules = adaptGeoPointsRules({
aroundLatLng: '51.1241999, 9.662499900000057',
})
expect(rules?.sort).toBe('_geoPoint(51.1241999, 9.662499900000057):asc')
expect(rules?.filter).toBeUndefined()
})

test('Try geoContext with an aroundLatLng and a radius', () => {
const rules = adaptGeoPointsRules({
aroundLatLng: '51.1241999, 9.662499900000057',
aroundRadius: 1,
})
expect(rules?.sort).toBe('_geoPoint(51.1241999, 9.662499900000057):asc')
expect(rules?.filter).toBe('_geoRadius(51.1241999, 9.662499900000057, 1)')
})

test('Try geoContext with an aroundLatLng and a 0 radius', () => {
const rules = adaptGeoPointsRules({
aroundLatLng: '51.1241999, 9.662499900000057',
aroundRadius: 0,
})
expect(rules?.sort).toBe('_geoPoint(51.1241999, 9.662499900000057):asc')
expect(rules?.filter).toBe('_geoRadius(51.1241999, 9.662499900000057, 0)')
})

test('Try geoContext with aroundLatLng, radius and insideBoundingBox', () => {
const rules = adaptGeoPointsRules({
aroundLatLng: '51.1241999, 9.662499900000057',
aroundRadius: 1,
insideBoundingBox: '1,2,3,4',
})
expect(rules?.filter).toBe(
'_geoRadius(2.0003044085023727, 2.999390393801055, 157201.5)'
)
expect(rules?.sort).toBe(
'_geoPoint(2.0003044085023727, 2.999390393801055):asc'
)
})
test('Try geoContext with a radius and insideBoundingBox', () => {
const rules = adaptGeoPointsRules({
aroundRadius: 1,
insideBoundingBox: '1,2,3,4',
})
expect(rules?.filter).toBe(
'_geoRadius(2.0003044085023727, 2.999390393801055, 157201.5)'
)
expect(rules?.sort).toBe(
'_geoPoint(2.0003044085023727, 2.999390393801055):asc'
)
})
test('Try geoContext with aroundLatLng and insideBoundingBox', () => {
const rules = adaptGeoPointsRules({
aroundLatLng: '51.1241999, 9.662499900000057',
insideBoundingBox: '1,2,3,4',
})
expect(rules?.filter).toBe(
'_geoRadius(2.0003044085023727, 2.999390393801055, 157201.5)'
)
expect(rules?.sort).toBe(
'_geoPoint(2.0003044085023727, 2.999390393801055):asc'
)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { adaptSearchParams } from '../search-params-adapter'

test('Adapt basic SearchContext ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
})
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with filters, sort and no geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
sort: 'id < 1',
})

expect(searchParams.filter).toStrictEqual([
['genres="Drama"', 'genres="Thriller"'],
['title="Ariel"'],
])
expect(searchParams.sort).toStrictEqual(['id < 1'])
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with filters, sort and geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
insideBoundingBox: '0,0,0,0',
sort: 'id < 1',
})

expect(searchParams.filter).toStrictEqual([
'_geoRadius(0, 0, 0)',
['genres="Drama"', 'genres="Thriller"'],
['title="Ariel"'],
])
expect(searchParams.sort).toStrictEqual(['_geoPoint(0, 0):asc', 'id < 1'])
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with only facetFilters and geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
facetFilters: [['genres:Drama', 'genres:Thriller'], ['title:Ariel']],
insideBoundingBox: '0,0,0,0',
})

expect(searchParams.filter).toEqual([
'_geoRadius(0, 0, 0)',
['genres="Drama"', 'genres="Thriller"'],
['title="Ariel"'],
])
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with only sort and geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
insideBoundingBox: '0,0,0,0',
sort: 'id < 1',
})

expect(searchParams.filter).toEqual(['_geoRadius(0, 0, 0)'])
expect(searchParams.sort).toStrictEqual(['_geoPoint(0, 0):asc', 'id < 1'])
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})

test('Adapt SearchContext with no sort abd no filters and geo rules ', () => {
const searchParams = adaptSearchParams({
indexUid: 'test',
paginationTotalHits: 20,
insideBoundingBox: '0,0,0,0',
})

expect(searchParams.filter).toEqual(['_geoRadius(0, 0, 0)'])
expect(searchParams.sort).toStrictEqual(['_geoPoint(0, 0):asc'])
expect(searchParams.attributesToHighlight).toContain('*')
expect(searchParams.attributesToHighlight?.length).toBe(1)
})
104 changes: 104 additions & 0 deletions src/adapter/search-request-adapter/geo-rules-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { SearchContext, GeoSearchContext } from '../../types'
import { getDistanceInMeter, middleGeoPoints } from '../../utils/geographic'

export function adaptGeoPointsRules(
geoSearchContext?: GeoSearchContext
): { filter?: string; sort?: string } | undefined {
if (!geoSearchContext) {
return undefined
}
const {
insideBoundingBox,
aroundLatLng,
aroundRadius,
minimumAroundRadius,
} = geoSearchContext

let middlePoint
let radius

if (aroundLatLng) {
middlePoint = aroundLatLng
}
if (aroundRadius != null || minimumAroundRadius != null) {
if (aroundRadius != null) radius = aroundRadius
else radius = minimumAroundRadius
}

// If insideBoundingBox is provided it takes precedent over all other options
if (insideBoundingBox && typeof insideBoundingBox === 'string') {
const [lat1Raw, lng1Raw, lat2Raw, lng2Raw] = insideBoundingBox.split(',')

const [lat1, lng1, lat2, lng2] = [
parseFloat(lat1Raw),
parseFloat(lng1Raw),
parseFloat(lat2Raw),
parseFloat(lng2Raw),
]
radius = getDistanceInMeter(lat1, lng1, lat2, lng2) / 2
middlePoint = middleGeoPoints(lat1, lng1, lat2, lng2)
}

if (middlePoint != null && radius != null) {
const [lat3, lng3] = middlePoint.split(',')

// check if radius is big enough
const filter = `_geoRadius(${lat3}, ${lng3}, ${radius})`

const sort = `_geoPoint(${lat3}, ${lng3}):asc`

return { filter, sort }
} else if (middlePoint != null) {
const [lat3, lng3] = middlePoint.split(',')
const sort = `_geoPoint(${lat3}, ${lng3}):asc`
return { sort }
}
return undefined
}

export function createGeoSearchContext(
searchContext: SearchContext
): GeoSearchContext {
const geoContext: Record<string, any> = {}
const {
aroundLatLng,
aroundLatLngViaIP,
aroundRadius,
aroundPrecision,
minimumAroundRadius,
insideBoundingBox,
insidePolygon,
} = searchContext

if (aroundLatLng) {
geoContext.aroundLatLng = aroundLatLng
}

if (aroundLatLngViaIP) {
console.warn('instant-meilisearch: `aroundLatLngViaIP` is not supported.')
}

if (aroundRadius) {
geoContext.aroundRadius = aroundRadius
}

if (aroundPrecision) {
console.warn(`instant-meilisearch: \`aroundPrecision\` is not supported.
See this discussion to track its implementation https://github.com/meilisearch/product/discussions/264`)
}

if (minimumAroundRadius) {
geoContext.minimumAroundRadius = minimumAroundRadius
}

if (insideBoundingBox) {
geoContext.insideBoundingBox = insideBoundingBox
}
// TODO: issue
if (insidePolygon) {
console.warn(
`instant-meilisearch: \`insidePolygon\` is not implented in instant-meilisearch.`
)
}
return geoContext
}
Loading