@pulseindex/sdk 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -1
- package/README.md +5 -0
- package/dist/index.d.mts +124 -3
- package/dist/index.d.ts +124 -3
- package/dist/index.js +182 -17
- package/dist/index.mjs +182 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,104 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 4.0.0
|
|
4
|
+
|
|
5
|
+
**A major, not a minor.** The previous draft of these notes said 3.2.0. Checking
|
|
6
|
+
what actually breaks says otherwise, so the number says otherwise too.
|
|
7
|
+
|
|
8
|
+
### Breaking
|
|
9
|
+
|
|
10
|
+
1. **`withinRadius` returns different results.** It has to: above 8 km it was
|
|
11
|
+
returning **nothing at all**, and below that it over-matched by up to 5.4x.
|
|
12
|
+
Measured against a real engine with 20,000 points:
|
|
13
|
+
|
|
14
|
+
| radius | true | before | after |
|
|
15
|
+
|--------|-----:|-------:|------:|
|
|
16
|
+
| 2 km | 7 | 38 | 9 |
|
|
17
|
+
| 5 km | 36 | 109 | 42 |
|
|
18
|
+
| 15 km | 386 | **0** | 518 |
|
|
19
|
+
| 50 km | 4,282| **0** | 4,800 |
|
|
20
|
+
|
|
21
|
+
2. **`getCoveringHashes()` refuses a precision nothing is indexed at.** Passing
|
|
22
|
+
4 used to return cells that matched no entity; it now throws.
|
|
23
|
+
|
|
24
|
+
3. **A radius too large for the indexed precisions is refused**, naming the
|
|
25
|
+
latitude. Cells narrow toward the poles, so 50 km is available to about 82
|
|
26
|
+
degrees and 15 km to about 89. Previously such a request came back
|
|
27
|
+
silently covering a fraction of its own circle.
|
|
28
|
+
|
|
29
|
+
4. **`SearchResponse` gained a required `totalIsExact`.** Code that *builds* the
|
|
30
|
+
type — a test double, a cache, a mapper — fails to compile until it sets it
|
|
31
|
+
(`TS2741`). Code that only reads search results is unaffected.
|
|
32
|
+
|
|
33
|
+
### Migrating
|
|
34
|
+
|
|
35
|
+
Nothing to change for the common case: index the same way, call `withinRadius`
|
|
36
|
+
the same way, and get results that are actually inside the radius you asked for.
|
|
37
|
+
|
|
38
|
+
If you pinned expectations to the old counts, they will move. If you passed an
|
|
39
|
+
explicit precision, pass one of the indexed precisions or drop the argument. If
|
|
40
|
+
you search above 80 degrees latitude at a large radius, catch the refusal.
|
|
41
|
+
|
|
42
|
+
### Everything else in this release
|
|
43
|
+
|
|
44
|
+
### The total on a paged search is not the number of matches
|
|
45
|
+
|
|
46
|
+
A paged search stops as soon as the page is full — that is what makes it cost
|
|
47
|
+
microseconds — so the total it reports is whatever it had counted when it
|
|
48
|
+
stopped. On a million entities, a query with 166,325 matches reported 10,866
|
|
49
|
+
when asked for a page of 100. Anything printing "page 1 of N" from that number
|
|
50
|
+
is wrong by an order of magnitude and looks entirely fine.
|
|
51
|
+
|
|
52
|
+
The result now says which it is, and there is a call that gets you the real one:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const page = await client.search(query.limit(20));
|
|
56
|
+
page.totalIsExact; // false — the search early-exited
|
|
57
|
+
|
|
58
|
+
const both = await client.searchWithTotal(query.limit(20));
|
|
59
|
+
both.totalIsExact; // true, at the cost of a second round trip
|
|
60
|
+
both.totalMatches; // the real total
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`limit(0)` still asks for the count alone and is exact by itself; nothing about
|
|
64
|
+
that changed, and `searchWithTotal` skips its second call when you already
|
|
65
|
+
passed it.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
### `withinRadius` was returning nothing above 8 km
|
|
69
|
+
|
|
70
|
+
`optimalPrecisionForRadius` chose geohash precision 4 for any radius over 8 km,
|
|
71
|
+
and entities are only ever tagged at precisions 5 and 6. A covering at
|
|
72
|
+
precision 4 therefore matched **nothing at all**. Measured against a real
|
|
73
|
+
engine with 20,000 points around Riyadh:
|
|
74
|
+
|
|
75
|
+
| radius | true matches | returned, before | returned, after |
|
|
76
|
+
|--------|-------------:|-----------------:|----------------:|
|
|
77
|
+
| 2 km | 7 | 38 | 9 |
|
|
78
|
+
| 5 km | 36 | 109 | 42 |
|
|
79
|
+
| 15 km | 386 | **0** | 518 |
|
|
80
|
+
| 50 km | 4,282 | **0** | 4,800 |
|
|
81
|
+
|
|
82
|
+
The precision is now always one the index carries, and of those the finest
|
|
83
|
+
whose complete covering fits a cell budget. Small radii also tightened: 2 km
|
|
84
|
+
went from 5.4x the true count to 1.3x.
|
|
85
|
+
|
|
86
|
+
### A covering is no longer truncated in silence
|
|
87
|
+
|
|
88
|
+
The 64-cell limit stopped the search mid-covering and returned what it had, so
|
|
89
|
+
a 50 km circle came back covered 18% and a 1 km circle at fine precision came
|
|
90
|
+
back covered 30% — with no error either time. The limit is now a budget the
|
|
91
|
+
precision is chosen to fit, so the covering always completes. A radius too
|
|
92
|
+
large for any indexed precision is refused by name.
|
|
93
|
+
|
|
94
|
+
### `withinRadius` is a pre-filter, not an exact radius
|
|
95
|
+
|
|
96
|
+
Cells are rectangles and the query is a circle, so the result still contains
|
|
97
|
+
some points outside it — now about 1.1x to 1.8x the circle's area rather than
|
|
98
|
+
up to 6x. The engine stores no coordinates, so only you can filter the
|
|
99
|
+
remainder, from your own data after hydration. This was always true and was
|
|
100
|
+
never written down.
|
|
101
|
+
|
|
4
102
|
|
|
5
103
|
### Delete many entities in one call
|
|
6
104
|
|
package/README.md
CHANGED
|
@@ -71,6 +71,11 @@ const result = await client.search(
|
|
|
71
71
|
.limit(50),
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
> **`withinRadius` is a fast pre-filter, not an exact radius.** It expands the
|
|
75
|
+
> circle into geohash cells, which are rectangles, so results include some points
|
|
76
|
+
> outside the radius — about 1.1x to 1.8x the circle's area. The engine stores no
|
|
77
|
+
> coordinates, so filter the remainder from your own data after hydration.
|
|
78
|
+
|
|
74
79
|
const ids = result.matchedEntityIds;
|
|
75
80
|
await client.close();
|
|
76
81
|
```
|
package/dist/index.d.mts
CHANGED
|
@@ -47,6 +47,17 @@ interface SearchResponse {
|
|
|
47
47
|
matchedEntityIds: string[];
|
|
48
48
|
totalMatches: number;
|
|
49
49
|
executionTimeUs: number;
|
|
50
|
+
/**
|
|
51
|
+
* Whether `totalMatches` is the real number of matches or the early-exit
|
|
52
|
+
* count from a paged search. A paged search stops as soon as the page is
|
|
53
|
+
* full, which is what makes it cost microseconds — and leaves the total far
|
|
54
|
+
* below the truth. Measured on a million entities: a query with 166,325
|
|
55
|
+
* matches reported 10,866 when asked for a page of 100.
|
|
56
|
+
*
|
|
57
|
+
* Use {@link PulseIndexClient.searchWithTotal} when you need a number you can
|
|
58
|
+
* divide by a page size.
|
|
59
|
+
*/
|
|
60
|
+
totalIsExact: boolean;
|
|
50
61
|
}
|
|
51
62
|
interface IndexEntityRequest {
|
|
52
63
|
entityId: string;
|
|
@@ -298,6 +309,19 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
298
309
|
static query(): QueryBuilder;
|
|
299
310
|
query(): QueryBuilder;
|
|
300
311
|
search(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
312
|
+
/**
|
|
313
|
+
* A page of ids together with the real number of matches.
|
|
314
|
+
*
|
|
315
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
316
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
317
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
318
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
319
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
320
|
+
*
|
|
321
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
322
|
+
* a total you can divide by a page size.
|
|
323
|
+
*/
|
|
324
|
+
searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
301
325
|
index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
|
|
302
326
|
indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
|
|
303
327
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
@@ -354,7 +378,35 @@ declare class GeoHash {
|
|
|
354
378
|
static readonly INDEX_PRECISIONS: readonly [5, 6];
|
|
355
379
|
private static readonly BASE32;
|
|
356
380
|
private static readonly EARTH_RADIUS_KM;
|
|
357
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
383
|
+
* predicates it sends.
|
|
384
|
+
*
|
|
385
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
386
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
387
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
388
|
+
* so a covering is always complete or the request is refused.
|
|
389
|
+
*
|
|
390
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
391
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
392
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
393
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
394
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
395
|
+
*
|
|
396
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
397
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
398
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
399
|
+
*/
|
|
400
|
+
static readonly COVERING_CELL_BUDGET = 2048;
|
|
401
|
+
/**
|
|
402
|
+
* How much area outside the circle a covering may carry before a finer
|
|
403
|
+
* precision is worth its cell count.
|
|
404
|
+
*
|
|
405
|
+
* 2.0 is where the measured choices come out right at every radius: it
|
|
406
|
+
* rejects the coarse cell at 2 km (6.91x) and 5 km (2.76x) and accepts it at
|
|
407
|
+
* 15 km (1.44x), which is also where the cell count turns from 47 into 1,120.
|
|
408
|
+
*/
|
|
409
|
+
static readonly ACCEPTABLE_COVER_RATIO = 2;
|
|
358
410
|
private static readonly NEIGHBORS;
|
|
359
411
|
private static readonly BORDERS;
|
|
360
412
|
static encode(lat: number, lon: number, precision?: number): string;
|
|
@@ -372,14 +424,83 @@ declare class GeoHash {
|
|
|
372
424
|
static neighbors(hash: string): string[];
|
|
373
425
|
static neighborhood3x3(hash: string): string[];
|
|
374
426
|
static neighborhoodTags(lat: number, lon: number, precision?: number): string[];
|
|
375
|
-
|
|
376
|
-
|
|
427
|
+
/**
|
|
428
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
429
|
+
*
|
|
430
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
431
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
432
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
433
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
434
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
435
|
+
* no error to explain it.
|
|
436
|
+
*
|
|
437
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
438
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
439
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
440
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
441
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
442
|
+
*
|
|
443
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
444
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
445
|
+
* radius needs more of them.
|
|
446
|
+
*
|
|
447
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
448
|
+
* refused rather than half-covered.
|
|
449
|
+
*/
|
|
450
|
+
static optimalPrecisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
451
|
+
static precisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
452
|
+
/**
|
|
453
|
+
* GeoHashes whose cells cover the search circle.
|
|
454
|
+
*
|
|
455
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
456
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
457
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
458
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
459
|
+
* call refuses.
|
|
460
|
+
*
|
|
461
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
462
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
463
|
+
* precision matches nothing at all rather than matching loosely.
|
|
464
|
+
*/
|
|
377
465
|
static getCoveringHashes(lat: number, lon: number, radiusKm: number, precision?: number): string[];
|
|
466
|
+
/**
|
|
467
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
468
|
+
* the centre and expanding only through cells that intersect.
|
|
469
|
+
*
|
|
470
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
471
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
472
|
+
* what every caller that wants an answer passes.
|
|
473
|
+
*/
|
|
474
|
+
private static walkCovering;
|
|
378
475
|
static tag(geohash: string): string;
|
|
379
476
|
static encodeTag(lat: number, lon: number, precision?: number): string;
|
|
380
477
|
static encodeMultiTags(lat: number, lon: number): string[];
|
|
381
478
|
static haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
382
479
|
private static cellIntersectsCircle;
|
|
480
|
+
/**
|
|
481
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
482
|
+
* round the globe.
|
|
483
|
+
*
|
|
484
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
485
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
486
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
487
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
488
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
489
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
490
|
+
* covering — silently.
|
|
491
|
+
*
|
|
492
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
493
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
494
|
+
* the nearer edge is the answer.
|
|
495
|
+
*/
|
|
496
|
+
private static closestLongitude;
|
|
497
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
498
|
+
private static normalizeLonDelta;
|
|
499
|
+
/**
|
|
500
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
501
|
+
* it wastes rather than only on what it costs.
|
|
502
|
+
*/
|
|
503
|
+
private static coveredRatio;
|
|
383
504
|
private static adjacent;
|
|
384
505
|
private static normalizeHash;
|
|
385
506
|
private static assertLatitude;
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ interface SearchResponse {
|
|
|
47
47
|
matchedEntityIds: string[];
|
|
48
48
|
totalMatches: number;
|
|
49
49
|
executionTimeUs: number;
|
|
50
|
+
/**
|
|
51
|
+
* Whether `totalMatches` is the real number of matches or the early-exit
|
|
52
|
+
* count from a paged search. A paged search stops as soon as the page is
|
|
53
|
+
* full, which is what makes it cost microseconds — and leaves the total far
|
|
54
|
+
* below the truth. Measured on a million entities: a query with 166,325
|
|
55
|
+
* matches reported 10,866 when asked for a page of 100.
|
|
56
|
+
*
|
|
57
|
+
* Use {@link PulseIndexClient.searchWithTotal} when you need a number you can
|
|
58
|
+
* divide by a page size.
|
|
59
|
+
*/
|
|
60
|
+
totalIsExact: boolean;
|
|
50
61
|
}
|
|
51
62
|
interface IndexEntityRequest {
|
|
52
63
|
entityId: string;
|
|
@@ -298,6 +309,19 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
298
309
|
static query(): QueryBuilder;
|
|
299
310
|
query(): QueryBuilder;
|
|
300
311
|
search(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
312
|
+
/**
|
|
313
|
+
* A page of ids together with the real number of matches.
|
|
314
|
+
*
|
|
315
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
316
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
317
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
318
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
319
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
320
|
+
*
|
|
321
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
322
|
+
* a total you can divide by a page size.
|
|
323
|
+
*/
|
|
324
|
+
searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
301
325
|
index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
|
|
302
326
|
indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
|
|
303
327
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
@@ -354,7 +378,35 @@ declare class GeoHash {
|
|
|
354
378
|
static readonly INDEX_PRECISIONS: readonly [5, 6];
|
|
355
379
|
private static readonly BASE32;
|
|
356
380
|
private static readonly EARTH_RADIUS_KM;
|
|
357
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
383
|
+
* predicates it sends.
|
|
384
|
+
*
|
|
385
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
386
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
387
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
388
|
+
* so a covering is always complete or the request is refused.
|
|
389
|
+
*
|
|
390
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
391
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
392
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
393
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
394
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
395
|
+
*
|
|
396
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
397
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
398
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
399
|
+
*/
|
|
400
|
+
static readonly COVERING_CELL_BUDGET = 2048;
|
|
401
|
+
/**
|
|
402
|
+
* How much area outside the circle a covering may carry before a finer
|
|
403
|
+
* precision is worth its cell count.
|
|
404
|
+
*
|
|
405
|
+
* 2.0 is where the measured choices come out right at every radius: it
|
|
406
|
+
* rejects the coarse cell at 2 km (6.91x) and 5 km (2.76x) and accepts it at
|
|
407
|
+
* 15 km (1.44x), which is also where the cell count turns from 47 into 1,120.
|
|
408
|
+
*/
|
|
409
|
+
static readonly ACCEPTABLE_COVER_RATIO = 2;
|
|
358
410
|
private static readonly NEIGHBORS;
|
|
359
411
|
private static readonly BORDERS;
|
|
360
412
|
static encode(lat: number, lon: number, precision?: number): string;
|
|
@@ -372,14 +424,83 @@ declare class GeoHash {
|
|
|
372
424
|
static neighbors(hash: string): string[];
|
|
373
425
|
static neighborhood3x3(hash: string): string[];
|
|
374
426
|
static neighborhoodTags(lat: number, lon: number, precision?: number): string[];
|
|
375
|
-
|
|
376
|
-
|
|
427
|
+
/**
|
|
428
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
429
|
+
*
|
|
430
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
431
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
432
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
433
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
434
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
435
|
+
* no error to explain it.
|
|
436
|
+
*
|
|
437
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
438
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
439
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
440
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
441
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
442
|
+
*
|
|
443
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
444
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
445
|
+
* radius needs more of them.
|
|
446
|
+
*
|
|
447
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
448
|
+
* refused rather than half-covered.
|
|
449
|
+
*/
|
|
450
|
+
static optimalPrecisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
451
|
+
static precisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
452
|
+
/**
|
|
453
|
+
* GeoHashes whose cells cover the search circle.
|
|
454
|
+
*
|
|
455
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
456
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
457
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
458
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
459
|
+
* call refuses.
|
|
460
|
+
*
|
|
461
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
462
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
463
|
+
* precision matches nothing at all rather than matching loosely.
|
|
464
|
+
*/
|
|
377
465
|
static getCoveringHashes(lat: number, lon: number, radiusKm: number, precision?: number): string[];
|
|
466
|
+
/**
|
|
467
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
468
|
+
* the centre and expanding only through cells that intersect.
|
|
469
|
+
*
|
|
470
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
471
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
472
|
+
* what every caller that wants an answer passes.
|
|
473
|
+
*/
|
|
474
|
+
private static walkCovering;
|
|
378
475
|
static tag(geohash: string): string;
|
|
379
476
|
static encodeTag(lat: number, lon: number, precision?: number): string;
|
|
380
477
|
static encodeMultiTags(lat: number, lon: number): string[];
|
|
381
478
|
static haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
382
479
|
private static cellIntersectsCircle;
|
|
480
|
+
/**
|
|
481
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
482
|
+
* round the globe.
|
|
483
|
+
*
|
|
484
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
485
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
486
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
487
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
488
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
489
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
490
|
+
* covering — silently.
|
|
491
|
+
*
|
|
492
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
493
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
494
|
+
* the nearer edge is the answer.
|
|
495
|
+
*/
|
|
496
|
+
private static closestLongitude;
|
|
497
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
498
|
+
private static normalizeLonDelta;
|
|
499
|
+
/**
|
|
500
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
501
|
+
* it wastes rather than only on what it costs.
|
|
502
|
+
*/
|
|
503
|
+
private static coveredRatio;
|
|
383
504
|
private static adjacent;
|
|
384
505
|
private static normalizeHash;
|
|
385
506
|
private static assertLatitude;
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,35 @@ var GeoHash = class {
|
|
|
41
41
|
static INDEX_PRECISIONS = [5, 6];
|
|
42
42
|
static BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
43
43
|
static EARTH_RADIUS_KM = 6371;
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
46
|
+
* predicates it sends.
|
|
47
|
+
*
|
|
48
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
49
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
50
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
51
|
+
* so a covering is always complete or the request is refused.
|
|
52
|
+
*
|
|
53
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
54
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
55
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
56
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
57
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
58
|
+
*
|
|
59
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
60
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
61
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
62
|
+
*/
|
|
63
|
+
static COVERING_CELL_BUDGET = 2048;
|
|
64
|
+
/**
|
|
65
|
+
* How much area outside the circle a covering may carry before a finer
|
|
66
|
+
* precision is worth its cell count.
|
|
67
|
+
*
|
|
68
|
+
* 2.0 is where the measured choices come out right at every radius: it
|
|
69
|
+
* rejects the coarse cell at 2 km (6.91x) and 5 km (2.76x) and accepts it at
|
|
70
|
+
* 15 km (1.44x), which is also where the cell count turns from 47 into 1,120.
|
|
71
|
+
*/
|
|
72
|
+
static ACCEPTABLE_COVER_RATIO = 2;
|
|
45
73
|
static NEIGHBORS = {
|
|
46
74
|
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
47
75
|
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
@@ -166,31 +194,98 @@ var GeoHash = class {
|
|
|
166
194
|
static neighborhoodTags(lat, lon, precision = 6) {
|
|
167
195
|
return this.neighborhood3x3(this.encode(lat, lon, precision)).map((cell) => this.tag(cell));
|
|
168
196
|
}
|
|
169
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
199
|
+
*
|
|
200
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
201
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
202
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
203
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
204
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
205
|
+
* no error to explain it.
|
|
206
|
+
*
|
|
207
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
208
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
209
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
210
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
211
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
212
|
+
*
|
|
213
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
214
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
215
|
+
* radius needs more of them.
|
|
216
|
+
*
|
|
217
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
218
|
+
* refused rather than half-covered.
|
|
219
|
+
*/
|
|
220
|
+
static optimalPrecisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
170
221
|
if (radiusKm < 0) {
|
|
171
222
|
throw new Error("Radius must be non-negative.");
|
|
172
223
|
}
|
|
173
|
-
|
|
174
|
-
|
|
224
|
+
const budget = this.COVERING_CELL_BUDGET;
|
|
225
|
+
let fallback = null;
|
|
226
|
+
for (const precision of [...this.INDEX_PRECISIONS].sort((a, b) => a - b)) {
|
|
227
|
+
const cells = this.walkCovering(lat, lon, radiusKm, precision, budget + 1);
|
|
228
|
+
if (cells.length > budget) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (radiusKm > 0 && this.coveredRatio(cells, radiusKm) <= this.ACCEPTABLE_COVER_RATIO) {
|
|
232
|
+
return precision;
|
|
233
|
+
}
|
|
234
|
+
fallback = precision;
|
|
175
235
|
}
|
|
176
|
-
if (
|
|
177
|
-
return
|
|
236
|
+
if (fallback !== null) {
|
|
237
|
+
return fallback;
|
|
178
238
|
}
|
|
179
|
-
|
|
239
|
+
throw new Error(
|
|
240
|
+
`A ${radiusKm} km radius at latitude ${lat.toFixed(1)} needs more than ${budget} geohash cells at every indexed precision (${this.INDEX_PRECISIONS.join(", ")}). Geohash cells narrow toward the poles, so the same radius costs more cells the further from the equator it is asked. Use a smaller radius, move the search nearer the equator, or index a coarser precision.`
|
|
241
|
+
);
|
|
180
242
|
}
|
|
181
|
-
static precisionForRadius(radiusKm) {
|
|
182
|
-
return this.optimalPrecisionForRadius(radiusKm);
|
|
243
|
+
static precisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
244
|
+
return this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
183
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* GeoHashes whose cells cover the search circle.
|
|
248
|
+
*
|
|
249
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
250
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
251
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
252
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
253
|
+
* call refuses.
|
|
254
|
+
*
|
|
255
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
256
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
257
|
+
* precision matches nothing at all rather than matching loosely.
|
|
258
|
+
*/
|
|
184
259
|
static getCoveringHashes(lat, lon, radiusKm, precision) {
|
|
185
260
|
if (radiusKm < 0) {
|
|
186
261
|
throw new Error("Radius must be non-negative.");
|
|
187
262
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
263
|
+
let resolved;
|
|
264
|
+
if (precision === void 0) {
|
|
265
|
+
resolved = this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
266
|
+
} else {
|
|
267
|
+
this.assertPrecision(precision);
|
|
268
|
+
if (!this.INDEX_PRECISIONS.includes(precision)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Precision ${precision} is not indexed, so a covering at it matches nothing. Indexed precisions: ${this.INDEX_PRECISIONS.join(", ")}.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
resolved = precision;
|
|
274
|
+
}
|
|
275
|
+
return this.walkCovering(lat, lon, radiusKm, resolved, null);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
279
|
+
* the centre and expanding only through cells that intersect.
|
|
280
|
+
*
|
|
281
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
282
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
283
|
+
* what every caller that wants an answer passes.
|
|
284
|
+
*/
|
|
285
|
+
static walkCovering(lat, lon, radiusKm, precision, limit) {
|
|
191
286
|
const covering = [];
|
|
192
287
|
const visited = /* @__PURE__ */ new Set();
|
|
193
|
-
const queue = [
|
|
288
|
+
const queue = [this.encode(lat, lon, precision)];
|
|
194
289
|
while (queue.length > 0) {
|
|
195
290
|
const current = queue.shift();
|
|
196
291
|
if (current === void 0 || visited.has(current)) {
|
|
@@ -201,8 +296,8 @@ var GeoHash = class {
|
|
|
201
296
|
continue;
|
|
202
297
|
}
|
|
203
298
|
covering.push(current);
|
|
204
|
-
if (covering.length >=
|
|
205
|
-
|
|
299
|
+
if (limit !== null && covering.length >= limit) {
|
|
300
|
+
return covering;
|
|
206
301
|
}
|
|
207
302
|
for (const neighbor of this.neighbors(current)) {
|
|
208
303
|
if (!visited.has(neighbor)) {
|
|
@@ -231,9 +326,49 @@ var GeoHash = class {
|
|
|
231
326
|
static cellIntersectsCircle(hash, lat, lon, radiusKm) {
|
|
232
327
|
const bounds = this.decodeBounds(hash);
|
|
233
328
|
const closestLat = Math.min(Math.max(lat, bounds.latMin), bounds.latMax);
|
|
234
|
-
const closestLon =
|
|
329
|
+
const closestLon = this.closestLongitude(lon, bounds.lonMin, bounds.lonMax);
|
|
235
330
|
return this.haversineKm(lat, lon, closestLat, closestLon) <= radiusKm;
|
|
236
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
334
|
+
* round the globe.
|
|
335
|
+
*
|
|
336
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
337
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
338
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
339
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
340
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
341
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
342
|
+
* covering — silently.
|
|
343
|
+
*
|
|
344
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
345
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
346
|
+
* the nearer edge is the answer.
|
|
347
|
+
*/
|
|
348
|
+
static closestLongitude(lon, lonMin, lonMax) {
|
|
349
|
+
const toMin = this.normalizeLonDelta(lonMin - lon);
|
|
350
|
+
const toMax = this.normalizeLonDelta(lonMax - lon);
|
|
351
|
+
if (toMin <= 0 && toMax >= 0) {
|
|
352
|
+
return lon;
|
|
353
|
+
}
|
|
354
|
+
return Math.abs(toMin) <= Math.abs(toMax) ? lonMin : lonMax;
|
|
355
|
+
}
|
|
356
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
357
|
+
static normalizeLonDelta(delta) {
|
|
358
|
+
return ((delta + 180) % 360 + 360) % 360 - 180;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
362
|
+
* it wastes rather than only on what it costs.
|
|
363
|
+
*/
|
|
364
|
+
static coveredRatio(cells, radiusKm) {
|
|
365
|
+
const rad = (d) => d * Math.PI / 180;
|
|
366
|
+
const covered = cells.reduce((sum, hash) => {
|
|
367
|
+
const b = this.decodeBounds(hash);
|
|
368
|
+
return sum + this.EARTH_RADIUS_KM * rad(b.latMax - b.latMin) * this.EARTH_RADIUS_KM * Math.cos(rad((b.latMax + b.latMin) / 2)) * rad(b.lonMax - b.lonMin);
|
|
369
|
+
}, 0);
|
|
370
|
+
return covered / (Math.PI * radiusKm ** 2);
|
|
371
|
+
}
|
|
237
372
|
static adjacent(hash, direction) {
|
|
238
373
|
if (hash.length === 0) {
|
|
239
374
|
throw new Error("GeoHash must not be empty.");
|
|
@@ -1095,7 +1230,37 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1095
1230
|
return {
|
|
1096
1231
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1097
1232
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1098
|
-
executionTimeUs: Number(raw.executionTimeUs ?? 0)
|
|
1233
|
+
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1234
|
+
// Exact only when nothing made the engine stop early. limit=0 asks for
|
|
1235
|
+
// the count and no ids, and is the only shape that counts every match;
|
|
1236
|
+
// any page can early-exit as soon as it is full.
|
|
1237
|
+
totalIsExact: builder.toArray().limit === 0
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* A page of ids together with the real number of matches.
|
|
1242
|
+
*
|
|
1243
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
1244
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
1245
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
1246
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1247
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
1248
|
+
*
|
|
1249
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
1250
|
+
* a total you can divide by a page size.
|
|
1251
|
+
*/
|
|
1252
|
+
async searchWithTotal(query) {
|
|
1253
|
+
const page = await this.search(query);
|
|
1254
|
+
if (page.totalIsExact) {
|
|
1255
|
+
return page;
|
|
1256
|
+
}
|
|
1257
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1258
|
+
const counted = await this.search(builder.limit(0));
|
|
1259
|
+
return {
|
|
1260
|
+
matchedEntityIds: page.matchedEntityIds,
|
|
1261
|
+
totalMatches: counted.totalMatches,
|
|
1262
|
+
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1263
|
+
totalIsExact: true
|
|
1099
1264
|
};
|
|
1100
1265
|
}
|
|
1101
1266
|
async index(entityIdOrInput, attributes = {}) {
|
package/dist/index.mjs
CHANGED
|
@@ -18,7 +18,35 @@ var GeoHash = class {
|
|
|
18
18
|
static INDEX_PRECISIONS = [5, 6];
|
|
19
19
|
static BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
20
20
|
static EARTH_RADIUS_KM = 6371;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
23
|
+
* predicates it sends.
|
|
24
|
+
*
|
|
25
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
26
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
27
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
28
|
+
* so a covering is always complete or the request is refused.
|
|
29
|
+
*
|
|
30
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
31
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
32
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
33
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
34
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
35
|
+
*
|
|
36
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
37
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
38
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
39
|
+
*/
|
|
40
|
+
static COVERING_CELL_BUDGET = 2048;
|
|
41
|
+
/**
|
|
42
|
+
* How much area outside the circle a covering may carry before a finer
|
|
43
|
+
* precision is worth its cell count.
|
|
44
|
+
*
|
|
45
|
+
* 2.0 is where the measured choices come out right at every radius: it
|
|
46
|
+
* rejects the coarse cell at 2 km (6.91x) and 5 km (2.76x) and accepts it at
|
|
47
|
+
* 15 km (1.44x), which is also where the cell count turns from 47 into 1,120.
|
|
48
|
+
*/
|
|
49
|
+
static ACCEPTABLE_COVER_RATIO = 2;
|
|
22
50
|
static NEIGHBORS = {
|
|
23
51
|
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
24
52
|
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
@@ -143,31 +171,98 @@ var GeoHash = class {
|
|
|
143
171
|
static neighborhoodTags(lat, lon, precision = 6) {
|
|
144
172
|
return this.neighborhood3x3(this.encode(lat, lon, precision)).map((cell) => this.tag(cell));
|
|
145
173
|
}
|
|
146
|
-
|
|
174
|
+
/**
|
|
175
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
176
|
+
*
|
|
177
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
178
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
179
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
180
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
181
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
182
|
+
* no error to explain it.
|
|
183
|
+
*
|
|
184
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
185
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
186
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
187
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
188
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
189
|
+
*
|
|
190
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
191
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
192
|
+
* radius needs more of them.
|
|
193
|
+
*
|
|
194
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
195
|
+
* refused rather than half-covered.
|
|
196
|
+
*/
|
|
197
|
+
static optimalPrecisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
147
198
|
if (radiusKm < 0) {
|
|
148
199
|
throw new Error("Radius must be non-negative.");
|
|
149
200
|
}
|
|
150
|
-
|
|
151
|
-
|
|
201
|
+
const budget = this.COVERING_CELL_BUDGET;
|
|
202
|
+
let fallback = null;
|
|
203
|
+
for (const precision of [...this.INDEX_PRECISIONS].sort((a, b) => a - b)) {
|
|
204
|
+
const cells = this.walkCovering(lat, lon, radiusKm, precision, budget + 1);
|
|
205
|
+
if (cells.length > budget) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (radiusKm > 0 && this.coveredRatio(cells, radiusKm) <= this.ACCEPTABLE_COVER_RATIO) {
|
|
209
|
+
return precision;
|
|
210
|
+
}
|
|
211
|
+
fallback = precision;
|
|
152
212
|
}
|
|
153
|
-
if (
|
|
154
|
-
return
|
|
213
|
+
if (fallback !== null) {
|
|
214
|
+
return fallback;
|
|
155
215
|
}
|
|
156
|
-
|
|
216
|
+
throw new Error(
|
|
217
|
+
`A ${radiusKm} km radius at latitude ${lat.toFixed(1)} needs more than ${budget} geohash cells at every indexed precision (${this.INDEX_PRECISIONS.join(", ")}). Geohash cells narrow toward the poles, so the same radius costs more cells the further from the equator it is asked. Use a smaller radius, move the search nearer the equator, or index a coarser precision.`
|
|
218
|
+
);
|
|
157
219
|
}
|
|
158
|
-
static precisionForRadius(radiusKm) {
|
|
159
|
-
return this.optimalPrecisionForRadius(radiusKm);
|
|
220
|
+
static precisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
221
|
+
return this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
160
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* GeoHashes whose cells cover the search circle.
|
|
225
|
+
*
|
|
226
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
227
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
228
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
229
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
230
|
+
* call refuses.
|
|
231
|
+
*
|
|
232
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
233
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
234
|
+
* precision matches nothing at all rather than matching loosely.
|
|
235
|
+
*/
|
|
161
236
|
static getCoveringHashes(lat, lon, radiusKm, precision) {
|
|
162
237
|
if (radiusKm < 0) {
|
|
163
238
|
throw new Error("Radius must be non-negative.");
|
|
164
239
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
240
|
+
let resolved;
|
|
241
|
+
if (precision === void 0) {
|
|
242
|
+
resolved = this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
243
|
+
} else {
|
|
244
|
+
this.assertPrecision(precision);
|
|
245
|
+
if (!this.INDEX_PRECISIONS.includes(precision)) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Precision ${precision} is not indexed, so a covering at it matches nothing. Indexed precisions: ${this.INDEX_PRECISIONS.join(", ")}.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
resolved = precision;
|
|
251
|
+
}
|
|
252
|
+
return this.walkCovering(lat, lon, radiusKm, resolved, null);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
256
|
+
* the centre and expanding only through cells that intersect.
|
|
257
|
+
*
|
|
258
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
259
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
260
|
+
* what every caller that wants an answer passes.
|
|
261
|
+
*/
|
|
262
|
+
static walkCovering(lat, lon, radiusKm, precision, limit) {
|
|
168
263
|
const covering = [];
|
|
169
264
|
const visited = /* @__PURE__ */ new Set();
|
|
170
|
-
const queue = [
|
|
265
|
+
const queue = [this.encode(lat, lon, precision)];
|
|
171
266
|
while (queue.length > 0) {
|
|
172
267
|
const current = queue.shift();
|
|
173
268
|
if (current === void 0 || visited.has(current)) {
|
|
@@ -178,8 +273,8 @@ var GeoHash = class {
|
|
|
178
273
|
continue;
|
|
179
274
|
}
|
|
180
275
|
covering.push(current);
|
|
181
|
-
if (covering.length >=
|
|
182
|
-
|
|
276
|
+
if (limit !== null && covering.length >= limit) {
|
|
277
|
+
return covering;
|
|
183
278
|
}
|
|
184
279
|
for (const neighbor of this.neighbors(current)) {
|
|
185
280
|
if (!visited.has(neighbor)) {
|
|
@@ -208,9 +303,49 @@ var GeoHash = class {
|
|
|
208
303
|
static cellIntersectsCircle(hash, lat, lon, radiusKm) {
|
|
209
304
|
const bounds = this.decodeBounds(hash);
|
|
210
305
|
const closestLat = Math.min(Math.max(lat, bounds.latMin), bounds.latMax);
|
|
211
|
-
const closestLon =
|
|
306
|
+
const closestLon = this.closestLongitude(lon, bounds.lonMin, bounds.lonMax);
|
|
212
307
|
return this.haversineKm(lat, lon, closestLat, closestLon) <= radiusKm;
|
|
213
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
311
|
+
* round the globe.
|
|
312
|
+
*
|
|
313
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
314
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
315
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
316
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
317
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
318
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
319
|
+
* covering — silently.
|
|
320
|
+
*
|
|
321
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
322
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
323
|
+
* the nearer edge is the answer.
|
|
324
|
+
*/
|
|
325
|
+
static closestLongitude(lon, lonMin, lonMax) {
|
|
326
|
+
const toMin = this.normalizeLonDelta(lonMin - lon);
|
|
327
|
+
const toMax = this.normalizeLonDelta(lonMax - lon);
|
|
328
|
+
if (toMin <= 0 && toMax >= 0) {
|
|
329
|
+
return lon;
|
|
330
|
+
}
|
|
331
|
+
return Math.abs(toMin) <= Math.abs(toMax) ? lonMin : lonMax;
|
|
332
|
+
}
|
|
333
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
334
|
+
static normalizeLonDelta(delta) {
|
|
335
|
+
return ((delta + 180) % 360 + 360) % 360 - 180;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
339
|
+
* it wastes rather than only on what it costs.
|
|
340
|
+
*/
|
|
341
|
+
static coveredRatio(cells, radiusKm) {
|
|
342
|
+
const rad = (d) => d * Math.PI / 180;
|
|
343
|
+
const covered = cells.reduce((sum, hash) => {
|
|
344
|
+
const b = this.decodeBounds(hash);
|
|
345
|
+
return sum + this.EARTH_RADIUS_KM * rad(b.latMax - b.latMin) * this.EARTH_RADIUS_KM * Math.cos(rad((b.latMax + b.latMin) / 2)) * rad(b.lonMax - b.lonMin);
|
|
346
|
+
}, 0);
|
|
347
|
+
return covered / (Math.PI * radiusKm ** 2);
|
|
348
|
+
}
|
|
214
349
|
static adjacent(hash, direction) {
|
|
215
350
|
if (hash.length === 0) {
|
|
216
351
|
throw new Error("GeoHash must not be empty.");
|
|
@@ -1072,7 +1207,37 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1072
1207
|
return {
|
|
1073
1208
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1074
1209
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1075
|
-
executionTimeUs: Number(raw.executionTimeUs ?? 0)
|
|
1210
|
+
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1211
|
+
// Exact only when nothing made the engine stop early. limit=0 asks for
|
|
1212
|
+
// the count and no ids, and is the only shape that counts every match;
|
|
1213
|
+
// any page can early-exit as soon as it is full.
|
|
1214
|
+
totalIsExact: builder.toArray().limit === 0
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* A page of ids together with the real number of matches.
|
|
1219
|
+
*
|
|
1220
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
1221
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
1222
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
1223
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1224
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
1225
|
+
*
|
|
1226
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
1227
|
+
* a total you can divide by a page size.
|
|
1228
|
+
*/
|
|
1229
|
+
async searchWithTotal(query) {
|
|
1230
|
+
const page = await this.search(query);
|
|
1231
|
+
if (page.totalIsExact) {
|
|
1232
|
+
return page;
|
|
1233
|
+
}
|
|
1234
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1235
|
+
const counted = await this.search(builder.limit(0));
|
|
1236
|
+
return {
|
|
1237
|
+
matchedEntityIds: page.matchedEntityIds,
|
|
1238
|
+
totalMatches: counted.totalMatches,
|
|
1239
|
+
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1240
|
+
totalIsExact: true
|
|
1076
1241
|
};
|
|
1077
1242
|
}
|
|
1078
1243
|
async index(entityIdOrInput, attributes = {}) {
|
package/package.json
CHANGED