Skip to content

Commit ec89b9f

Browse files
committed
Add _insidePolygon support to geosearch
1 parent 043669a commit ec89b9f

File tree

5 files changed

+188
-1
lines changed

5 files changed

+188
-1
lines changed

packages/instant-meilisearch/__tests__/assets/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,108 +104,129 @@ const geoDataset = [
104104
id: '1',
105105
city: 'Lille',
106106
_geo: { lat: 50.629973371633746, lng: 3.056944739941957 },
107+
_geojson: { type: 'Point', coordinates: [3.056944739941957, 50.629973371633746] },
107108
},
108109
{
109110
id: '2',
110111
city: 'Mons-en-Barœul',
111112
_geo: { lat: 50.64158612012105, lng: 3.110659348034867 },
113+
_geojson: { type: 'Point', coordinates: [3.110659348034867, 50.64158612012105] },
112114
},
113115
{
114116
id: '3',
115117
city: 'Hellemmes',
116118
_geo: { lat: 50.63122096551808, lng: 3.1106399673339933 },
119+
_geojson: { type: 'Point', coordinates: [3.1106399673339933, 50.63122096551808] },
117120
},
118121
{
119122
id: '4',
120123
city: "Villeneuve-d'Ascq",
121124
_geo: { lat: 50.622468098014565, lng: 3.147642551343714 },
125+
_geojson: { type: 'Point', coordinates: [3.147642551343714, 50.622468098014565] },
122126
},
123127
{
124128
id: '5',
125129
city: 'Hem',
126130
_geo: { lat: 50.655250871381355, lng: 3.189729726624413 },
131+
_geojson: { type: 'Point', coordinates: [3.189729726624413, 50.655250871381355] },
127132
},
128133
{
129134
id: '6',
130135
city: 'Roubaix',
131136
_geo: { lat: 50.69247345189671, lng: 3.176332673774765 },
137+
_geojson: { type: 'Point', coordinates: [3.176332673774765, 50.69247345189671] },
132138
},
133139
{
134140
id: '7',
135141
city: 'Tourcoing',
136142
_geo: { lat: 50.72639746673648, lng: 3.154165365957867 },
143+
_geojson: { type: 'Point', coordinates: [3.154165365957867, 50.72639746673648] },
137144
},
138145
{
139146
id: '8',
140147
city: 'Mouscron',
141148
_geo: { lat: 50.74532555490861, lng: 3.2206407854429853 },
149+
_geojson: { type: 'Point', coordinates: [3.2206407854429853, 50.74532555490861] },
142150
},
143151
{
144152
id: '9',
145153
city: 'Tournai',
146154
_geo: { lat: 50.60534252860263, lng: 3.3758586941351414 },
155+
_geojson: { type: 'Point', coordinates: [3.3758586941351414, 50.60534252860263] },
147156
},
148157
{
149158
id: '10',
150159
city: 'Ghent',
151160
_geo: { lat: 51.053777403679035, lng: 3.695773311992693 },
161+
_geojson: { type: 'Point', coordinates: [3.695773311992693, 51.053777403679035] },
152162
},
153163
{
154164
id: '11',
155165
city: 'Brussels',
156166
_geo: { lat: 50.84664097454469, lng: 4.337066356428184 },
167+
_geojson: { type: 'Point', coordinates: [4.337066356428184, 50.84664097454469] },
157168
},
158169
{
159170
id: '12',
160171
city: 'Charleroi',
161172
_geo: { lat: 50.40957013888948, lng: 4.434735431508552 },
173+
_geojson: { type: 'Point', coordinates: [4.434735431508552, 50.40957013888948] },
162174
},
163175
{
164176
id: '13',
165177
city: 'Mons',
166178
_geo: { lat: 50.45029417885542, lng: 3.962372287090469 },
179+
_geojson: { type: 'Point', coordinates: [3.962372287090469, 50.45029417885542] },
167180
},
168181
{
169182
id: '14',
170183
city: 'Valenciennes',
171184
_geo: { lat: 50.351817774473545, lng: 3.53262836469288 },
185+
_geojson: { type: 'Point', coordinates: [3.53262836469288, 50.351817774473545] },
172186
},
173187
{
174188
id: '15',
175189
city: 'Arras',
176190
_geo: { lat: 50.28448752857995, lng: 2.763751584447816 },
191+
_geojson: { type: 'Point', coordinates: [2.763751584447816, 50.28448752857995] },
177192
},
178193
{
179194
id: '16',
180195
city: 'Cambrai',
181196
_geo: { lat: 50.1793405779067, lng: 3.218940995250293 },
197+
_geojson: { type: 'Point', coordinates: [3.218940995250293, 50.1793405779067] },
182198
},
183199
{
184200
id: '17',
185201
city: 'Bapaume',
186202
_geo: { lat: 50.1112761272364, lng: 2.854789466608312 },
203+
_geojson: { type: 'Point', coordinates: [2.854789466608312, 50.1112761272364] },
187204
},
188205
{
189206
id: '18',
190207
city: 'Amiens',
191208
_geo: { lat: 49.931472529669996, lng: 2.271049975831708 },
209+
_geojson: { type: 'Point', coordinates: [2.271049975831708, 49.931472529669996] },
192210
},
193211
{
194212
id: '19',
195213
city: 'Compiègne',
196214
_geo: { lat: 49.444980887725656, lng: 2.7913841281529015 },
215+
_geojson: { type: 'Point', coordinates: [2.7913841281529015, 49.444980887725656] },
197216
},
198217
{
199218
id: '20',
200219
city: 'Paris',
201220
_geo: { lat: 48.90210006089548, lng: 2.370840086740693 },
221+
_geojson: { type: 'Point', coordinates: [2.370840086740693, 48.90210006089548] },
202222
},
203223
]
204224

205225
export type City = {
206226
id: string
207227
city: string
208228
_geo: { lat: number; lng: number }
229+
_geojson?: { type: 'Point'; coordinates: [number, number] }
209230
}
210231

211232
export type Movies = {

packages/instant-meilisearch/__tests__/geosearch.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe('Instant Meilisearch Browser test', () => {
1111
await meilisearchClient.deleteIndex('geotest').waitTask()
1212
await meilisearchClient
1313
.index('geotest')
14-
.updateFilterableAttributes(['_geo'])
14+
.updateFilterableAttributes(['_geo', '_geojson'])
1515
.waitTask()
1616
await meilisearchClient
1717
.index('geotest')
@@ -108,4 +108,96 @@ describe('Instant Meilisearch Browser test', () => {
108108
expect(hits.length).toEqual(2)
109109
expect(hits[0].city).toEqual('Brussels')
110110
})
111+
112+
test('insidePolygon in geo search', async () => {
113+
const response = await searchClient.search<City>([
114+
{
115+
indexName: 'geotest',
116+
params: {
117+
query: '',
118+
// Simple triangle roughly around Brussels area
119+
insidePolygon: [
120+
[50.95, 4.1],
121+
[50.75, 4.6],
122+
[50.70, 4.2],
123+
],
124+
},
125+
},
126+
])
127+
128+
const hits = response.results[0].hits
129+
// Expect Brussels to be included
130+
expect(hits.find((h: City) => h.city === 'Brussels')).toBeTruthy()
131+
// Expect far cities like Paris to be excluded
132+
expect(hits.find((h: City) => h.city === 'Paris')).toBeFalsy()
133+
})
134+
135+
test('insidePolygon ignores documents without _geojson', async () => {
136+
// Add a document inside the polygon but only with _geo (no _geojson)
137+
await meilisearchClient
138+
.index('geotest')
139+
.addDocuments([
140+
{
141+
id: 'geo-only',
142+
city: 'GeoOnly',
143+
_geo: { lat: 50.80, lng: 4.35 },
144+
},
145+
])
146+
.waitTask()
147+
148+
const response = await searchClient.search<City>([
149+
{
150+
indexName: 'geotest',
151+
params: {
152+
query: '',
153+
insidePolygon: [
154+
[50.95, 4.1],
155+
[50.75, 4.6],
156+
[50.70, 4.2],
157+
],
158+
},
159+
},
160+
])
161+
162+
const hits = response.results[0].hits
163+
// Should not include the _geo-only document
164+
expect(hits.find((h: any) => h.city === 'GeoOnly')).toBeFalsy()
165+
166+
// Cleanup
167+
await meilisearchClient.index('geotest').deleteDocument('geo-only').waitTask()
168+
})
169+
170+
test('aroundRadius matches _geojson-only documents', async () => {
171+
// Add a document only with _geojson near Brussels
172+
await meilisearchClient
173+
.index('geotest')
174+
.addDocuments([
175+
{
176+
id: 'geojson-only',
177+
city: 'GeoJSONOnly',
178+
_geojson: { type: 'Point', coordinates: [4.35, 50.8467] },
179+
},
180+
])
181+
.waitTask()
182+
183+
const response = await searchClient.search<City>([
184+
{
185+
indexName: 'geotest',
186+
params: {
187+
query: '',
188+
aroundRadius: 5000,
189+
aroundLatLng: '50.8466, 4.35',
190+
},
191+
},
192+
])
193+
194+
const hits = response.results[0].hits
195+
expect(hits.find((h: any) => h.city === 'GeoJSONOnly')).toBeTruthy()
196+
197+
// Cleanup
198+
await meilisearchClient
199+
.index('geotest')
200+
.deleteDocument('geojson-only')
201+
.waitTask()
202+
})
111203
})

packages/instant-meilisearch/src/adapter/search-request-adapter/__tests__/geo-rules.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,56 @@ test('Adapt instantsearch geo parameters to meilisearch filters with aroundLatLn
8181

8282
expect(filter).toBe('_geoBoundingBox([1, 2], [3, 4])')
8383
})
84+
85+
test('Adapt instantsearch geo parameters to meilisearch filters with insidePolygon (triangle)', () => {
86+
const filter = adaptGeoSearch({
87+
insidePolygon: [
88+
[50.0, 3.0],
89+
[50.7, 3.2],
90+
[50.6, 2.9],
91+
],
92+
})
93+
94+
expect(filter).toBe('_geoPolygon([50, 3], [50.7, 3.2], [50.6, 2.9])')
95+
})
96+
97+
test('Adapt instantsearch geo parameters to meilisearch filters with insidePolygon (quadrilateral)', () => {
98+
const filter = adaptGeoSearch({
99+
insidePolygon: [
100+
[50.9, 4.1],
101+
[50.9, 4.6],
102+
[50.7, 4.6],
103+
[50.7, 4.1],
104+
],
105+
})
106+
107+
expect(filter).toBe(
108+
'_geoPolygon([50.9, 4.1], [50.9, 4.6], [50.7, 4.6], [50.7, 4.1])'
109+
)
110+
})
111+
112+
test('insidePolygon takes precedence over insideBoundingBox and around*', () => {
113+
const filter = adaptGeoSearch({
114+
insidePolygon: [
115+
[1, 1],
116+
[2, 2],
117+
[3, 3],
118+
],
119+
insideBoundingBox: '1,2,3,4',
120+
aroundLatLng: '51.1241999, 9.662499900000057',
121+
aroundRadius: 10,
122+
})
123+
124+
expect(filter).toBe('_geoPolygon([1, 1], [2, 2], [3, 3])')
125+
})
126+
127+
test('Invalid insidePolygon (<3 points) gracefully ignored', () => {
128+
const filter = adaptGeoSearch({
129+
insidePolygon: [
130+
[1, 1],
131+
[2, 2],
132+
],
133+
})
134+
135+
expect(filter).toBeUndefined()
136+
})

packages/instant-meilisearch/src/adapter/search-request-adapter/geo-rules-adapter.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { InstantSearchGeoParams } from '../../types/index.js'
22

33
export function adaptGeoSearch({
4+
insidePolygon,
45
insideBoundingBox,
56
aroundLatLng,
67
aroundRadius,
@@ -10,6 +11,24 @@ export function adaptGeoSearch({
1011
let radius: number | undefined
1112
let filter: string | undefined
1213

14+
// Highest precedence: insidePolygon
15+
if (Array.isArray(insidePolygon) && insidePolygon.length >= 3) {
16+
const formattedPoints = insidePolygon
17+
.map((pair) => {
18+
if (!Array.isArray(pair) || pair.length < 2) return null
19+
const lat = Number.parseFloat(String(pair[0]))
20+
const lng = Number.parseFloat(String(pair[1]))
21+
if (Number.isNaN(lat) || Number.isNaN(lng)) return null
22+
return `[${lat}, ${lng}]`
23+
})
24+
.filter((pt): pt is string => pt !== null)
25+
26+
if (formattedPoints.length >= 3) {
27+
filter = `_geoPolygon(${formattedPoints.join(', ')})`
28+
return filter
29+
}
30+
}
31+
1332
if (aroundLatLng) {
1433
const [lat, lng] = aroundLatLng
1534
.split(',')

packages/instant-meilisearch/src/adapter/search-request-adapter/search-params-adapter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,15 @@ export function MeiliParamsCreator(searchContext: SearchContext) {
206206
},
207207
addGeoSearchFilter() {
208208
const {
209+
insidePolygon,
209210
insideBoundingBox,
210211
aroundLatLng,
211212
aroundRadius,
212213
minimumAroundRadius,
213214
} = searchContext
214215

215216
const filter = adaptGeoSearch({
217+
insidePolygon,
216218
insideBoundingBox,
217219
aroundLatLng,
218220
aroundRadius,

0 commit comments

Comments
 (0)