meilisearch / meilisearch-js-plugins

The search client to use Meilisearch with InstantSearch.
https://www.meilisearch.com
MIT License
476 stars 59 forks source link

Add a instantMeiliSearch parameter to sort geopoints by proximity to the center of the map #554

Open bidoubiwa opened 3 years ago

bidoubiwa commented 3 years ago

Current Behavior

When moving inside the Google Maps, search results are returned based on its position. To determine what documents should be returned, instant-meilisearch calculates the middle point of the Maps window and the distance between each corner. It then creates a filter (see geo filter documentation based on that middle point and the distance between each corner.

_geoRadius(lat, lng, distance_in_meters)

No sorting

The first itteration of instant-meilisearch's geosearch also provided by default a sort rule whereas geo points closer to the center of the map were considered more important than other. Which created packs of markers in the center of the map:

Screenshot 2021-10-13 at 15 48 11

By removing them, the markers are more scattered, which make the maps look nicer:

Screenshot 2021-10-13 at 15 49 49

But what if we want to sort?

Without providing by default the sorting feature, we still want to provide the possibility to the user to sort its markers to be as close as possible to the center.

Solution

To enable sorting from the center point of the map, the solution is to provide a parameter proximityGeoRule in the instantMeiliSearch parameters. Which would default to false.

const searchClient = instantMeiliSearch(
  'https://demos.meilisearch.com',
  '...',
  {
    proximityGeoRule: true
  }
)

This will enable sorting.

Here are the steps to follow to implement this feature:

1. add a proximityGeoRule in the InstantMeiliSearch Parameter

// types.ts
type ClientParams = {
  primaryKey?: string
  placeholderSearch?: boolean
  sort?: string
  indexUid: string
  paginationTotalHits: number
  proximityGeoRule?: boolean
}

2. Add the information in the geoSearchContext

export type GeoSearchContext = {
  aroundLatLng?: string
  aroundLatLngViaIP?: boolean
  aroundRadius?: number | 'all'
  aroundPrecision?: number
  minimumAroundRadius?: number
  insideBoundingBox?: InsideBoundingBox
  insidePolygon?: ReadonlyArray<readonly number[]>
  proximityGeoRule?: boolean
}

3. Use the information in the geo-rule adapter

In geo-rules-adapter.ts if proximityGeoRule is set to true, sort filter should be added:

if (proximityGeoRule) {
  sort = `_geoPoint(${lat3}, ${lng3}):asc`
} else {
  sort = undefined
}

4. make aroundLatLng work without aroundRadius

Because aroundLatLng only provided a middle point, and not a radius, only a sort was previously done.

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

Because of a lack of radius provided, we cannot create a filter is not possible.

Thus, if proximityGeoRule is set to true, the following condition should be added in geo-rules-adapter.ts

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

5. Add the sort in the search params adapter.

In the search params adapter filter from the geosearch adapter are added in the final search requests parameters sent to MeiliSearch.

if (geoRules?.filter) { ... }

The same should be added for sort:

  if (geoRules?.sort) {
    if (meiliSearchParams.sort) {
      meiliSearchParams.sort.unshift(geoRules.sort)
    } else {
      meiliSearchParams.sort = [geoRules.sort]
    }
  }

6. add tests

This behavior should be tested in geosearch.tests.ts

bidoubiwa commented 3 years ago

These are the tests that should be working when proximityGeoRule is set to true

// geo-rules.tests.ts

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'
  )
})
bidoubiwa commented 3 years ago

They should also pass the following tests in search-params.tests.ts

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)
})