Compute nearby-brewery distance in SQL instead of client-side Haversine, and add the spatial index it relies on.

This commit is contained in:
Aaron Po
2026-08-27 02:40:58 -04:00
parent dab7aa2458
commit 7330eabebe
10 changed files with 244 additions and 121 deletions

View File

@@ -41,14 +41,10 @@ CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
-- To assess when the time comes: CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
ON BreweryPostLocation(Coordinates)
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations. USING GEOGRAPHY_GRID
WITH (
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
-- ON BreweryPostLocation(Coordinates) CELLS_PER_OBJECT = 16
-- USING GEOGRAPHY_GRID );
-- WITH (
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
-- CELLS_PER_OBJECT = 16
-- );

View File

@@ -21,4 +21,7 @@ public sealed class BreweryPost
// Navigation Properties // Navigation Properties
public BreweryPostLocation? Location { get; set; } public BreweryPostLocation? Location { get; set; }
public List<BeerPost> BeerPosts { get; set; } = []; public List<BeerPost> BeerPosts { get; set; } = [];
// Query-computed
public DistanceInformation? Distance { get; set; }
} }

View File

@@ -2,6 +2,8 @@ namespace Domain.Entities;
public sealed record CoordinateData(double Latitude, double Longitude); public sealed record CoordinateData(double Latitude, double Longitude);
/// <summary>The distance, as computed by the database, from a query's origin point to a brewery.</summary>
public sealed record DistanceInformation(CoordinateData From, double DistanceMetres);
public sealed class BreweryPostLocation public sealed class BreweryPostLocation
{ {

View File

@@ -32,8 +32,13 @@ public record BreweryDto(
byte[]? RowVersion, byte[]? RowVersion,
BreweryLocationDto? Location BreweryLocationDto? Location
); );
/// <param name="DistanceMetres">
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
/// </param>
public record SimplifiedBreweryDto( public record SimplifiedBreweryDto(
Guid BreweryPostId, Guid BreweryPostId,
string BreweryName, string BreweryName,
BreweryLocationDto? Location BreweryLocationDto? Location,
double? DistanceMetres
); );

View File

@@ -68,7 +68,8 @@ public static class BreweryDtoMapper
AddressLine2: brewery.Location.AddressLine2, AddressLine2: brewery.Location.AddressLine2,
PostalCode: brewery.Location.PostalCode, PostalCode: brewery.Location.PostalCode,
Coordinates: brewery.Location.Coordinates Coordinates: brewery.Location.Coordinates
) ),
DistanceMetres: brewery.Distance?.DistanceMetres
); );
} }
} }

View File

@@ -134,6 +134,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
City, City,
StateProvince, StateProvince,
Country, Country,
DistanceRow,
BreweryPost BreweryPost
> ( > (
""" """
@@ -154,7 +155,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
sp.ISO3166_2 AS Iso31662, sp.ISO3166_2 AS Iso31662,
co.CountryID, co.CountryID,
co.CountryName, co.CountryName,
co.ISO3166_1 AS Iso31661 co.ISO3166_1 AS Iso31661,
bpl.Coordinates.STDistance(@Origin) AS DistanceMetres
FROM dbo.BreweryPost bp FROM dbo.BreweryPost bp
INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
@@ -164,14 +166,15 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres
ORDER BY bpl.Coordinates.STDistance(@Origin) ASC ORDER BY bpl.Coordinates.STDistance(@Origin) ASC
""", """,
MapBreweryRow, (post, location, city, stateProvince, country, distance) =>
MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords),
param: new param: new
{ {
coords.Latitude, coords.Latitude,
coords.Longitude, coords.Longitude,
RangeInMetres = rangeInMetres RangeInMetres = rangeInMetres
}, },
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres"
); );
} }
@@ -583,4 +586,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
post.Location = location; post.Location = location;
return post; return post;
} }
/// <summary>Dapper multi-mapping row for the <c>DistanceMetres</c> column computed by a range query.</summary>
private sealed class DistanceRow
{
public double DistanceMetres { get; set; }
}
/// <summary>
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
/// </summary>
private static BreweryPost MapBreweryRowWithDistance(
BreweryPost post,
BreweryPostLocation location,
City city,
StateProvince stateProvince,
Country country,
DistanceRow distance,
CoordinateData origin
)
{
BreweryPost mapped = MapBreweryRow(post, location, city, stateProvince, country);
mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres);
return mapped;
}
} }

View File

@@ -18,7 +18,7 @@ Static copy only. No data.
## 2. Featured brewery ## 2. Featured brewery
| Field | Status | Current source | Gap | | Field | Status | Current source | Gap |
|--------------------------------------|--------|------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ------------------------------------ | ------ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | — | | Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | — |
| "Editorially featured" selection | ❌ | none — defaults to the most recently created brewery | No `IsFeatured` flag or rotation concept anywhere in `BreweryPost`. Needs a field + an admin toggle, or a scheduled rotation query, to back the "THIS WEEK" cadence the badge implies. | | "Editorially featured" selection | ❌ | none — defaults to the most recently created brewery | No `IsFeatured` flag or rotation concept anywhere in `BreweryPost`. Needs a field + an admin toggle, or a scheduled rotation query, to back the "THIS WEEK" cadence the badge implies. |
| Founded year | ❌ | none | `BreweryPost` has no founding date. Would need a new column (distinct from `CreatedAt`, which is post-creation time, not the brewery's actual founding). | | Founded year | ❌ | none | `BreweryPost` has no founding date. Would need a new column (distinct from `CreatedAt`, which is post-creation time, not the brewery's actual founding). |
@@ -28,9 +28,9 @@ Static copy only. No data.
## 3. Breweries near you ## 3. Breweries near you
| Field | Status | Current source | Gap | | Field | Status | Current source | Gap |
|---------------------------------------------------------|--------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | — | | Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | — |
| Distance from search center | ✅ | Computed client-side (Haversine) from the coordinates the endpoint already returns | — | | Distance from search center | ✅ | `SimplifiedBreweryDto.DistanceMetres`, computed server-side via `geography::STDistance` | — |
| One-line note / tagline per brewery | ❌ | none | `SimplifiedBreweryDto` (used by both `/locations` and `/locations/nearby`) has no description-like field. List and map-popup text use filler. Needs either a short tagline field or a truncated `Description` added to that DTO. | | One-line note / tagline per brewery | ❌ | none | `SimplifiedBreweryDto` (used by both `/locations` and `/locations/nearby`) has no description-like field. List and map-popup text use filler. Needs either a short tagline field or a truncated `Description` added to that DTO. |
| User's current location | ✅ | Browser Geolocation API | — | | User's current location | ✅ | Browser Geolocation API | — |
| Fallback region (before permission granted / on denial) | ⚠️ | Falls back to the featured brewery's own coordinates as a proxy center | Works, but isn't a deliberate "default region." A real deployment likely wants a configured default (e.g. per-tenant HQ city) rather than borrowing the featured brewery's location. | | Fallback region (before permission granted / on denial) | ⚠️ | Falls back to the featured brewery's own coordinates as a proxy center | Works, but isn't a deliberate "default region." A real deployment likely wants a configured default (e.g. per-tenant HQ city) rather than borrowing the featured brewery's location. |
@@ -40,7 +40,7 @@ Static copy only. No data.
## 4. Recently added ## 4. Recently added
| Field | Status | Current source | Gap | | Field | Status | Current source | Gap |
|-----------------------------------------|--------|--------------------------------------------------|-----| | --------------------------------------- | ------ | ------------------------------------------------ | --- |
| Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — | | Newest breweries, name/city/description | ✅ | `GET /api/brewery` (already sorted newest-first) | — |
No gaps — this section is fully real. No gaps — this section is fully real.
@@ -48,7 +48,7 @@ No gaps — this section is fully real.
## 5. Directory CTA ## 5. Directory CTA
| Field | Status | Current source | Gap | | Field | Status | Current source | Gap |
|-----------------------------------------|--------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --------------------------------------- | ------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Total partner brewery count | ❌ | Filler number (`128`) | No lightweight count endpoint. Needs something like `GET /api/brewery/count` rather than fetching the full unbounded list just to measure it. | | Total partner brewery count | ❌ | Filler number (`128`) | No lightweight count endpoint. Needs something like `GET /api/brewery/count` rather than fetching the full unbounded list just to measure it. |
| Filter by country / region / beer style | ❌ | Copy only — not implemented | This is CTA copy describing the destination directory page's future capability, not a data need of this page itself. Tracked here because it implies the directory page will eventually need country/region/style filter params. | | Filter by country / region / beer style | ❌ | Copy only — not implemented | This is CTA copy describing the destination directory page's future capability, not a data need of this page itself. Tracked here because it implies the directory page will eventually need country/region/style filter params. |

View File

@@ -41,6 +41,7 @@ export interface SimplifiedBrewery {
breweryPostId: string; breweryPostId: string;
breweryName: string; breweryName: string;
location: BreweryLocation | null; location: BreweryLocation | null;
distanceMetres: number | null;
} }
const fetchApi = async (path: string): Promise<Response> => { const fetchApi = async (path: string): Promise<Response> => {

View File

@@ -4,7 +4,7 @@ import type { LatLngTuple } from 'leaflet';
import ClientOnly from '../../../components/ClientOnly'; import ClientOnly from '../../../components/ClientOnly';
import type { SimplifiedBrewery } from '../breweries.server'; import type { SimplifiedBrewery } from '../breweries.server';
import type { loader as nearbyLoader } from '../routes/breweries-nearby'; import type { loader as nearbyLoader } from '../routes/breweries-nearby';
import { formatDistance, haversineDistanceMetres, type DistanceUnit } from '../utils/distance'; import { formatDistance, type DistanceUnit } from '../utils/distance';
import type { NearbyMapPin } from './NearbyBreweryMap'; import type { NearbyMapPin } from './NearbyBreweryMap';
const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap')); const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap'));
@@ -43,6 +43,139 @@ const cardClassName = (selected: boolean) =>
selected ? 'border-l-primary' : 'border-l-transparent' selected ? 'border-l-primary' : 'border-l-transparent'
}`; }`;
interface BreweriesNearByHeaderParams {
center: LatLngTuple | null;
withDistance: { brewery: SimplifiedBrewery; distanceMetres: number }[];
radiusKm: number;
unit: 'km' | 'mi';
centerLabel: string;
}
const BreweriesNearByHeader = ({
center,
centerLabel,
radiusKm,
unit,
withDistance,
}: BreweriesNearByHeaderParams) => {
return (
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
<span className="text-sm text-base-content/60">
{center
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
: 'Finding breweries near you…'}
</span>
</div>
);
};
interface RadiusUnitControlsProps {
radiusKm: number;
unit: DistanceUnit;
onRadiusChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectKm: () => void;
onSelectMi: () => void;
}
const RadiusUnitControls = ({
radiusKm,
unit,
onRadiusChange,
onSelectKm,
onSelectMi,
}: RadiusUnitControlsProps) => {
return (
<div className="flex items-center justify-between gap-3">
<label className="flex-1 flex flex-col gap-1 text-sm text-base-content/70">
<span className="flex justify-between">
<span>Search radius</span>
<span className="font-semibold">{formatDistance(radiusKm * 1000, unit)}</span>
</span>
<input
type="range"
min={MIN_RADIUS_KM}
max={MAX_RADIUS_KM}
step={RADIUS_STEP_KM}
value={radiusKm}
onChange={onRadiusChange}
className="range range-primary range-sm"
/>
</label>
<div className="join self-end">
<button
type="button"
onClick={onSelectKm}
className={`btn btn-xs join-item ${unit === 'km' ? 'btn-active' : ''}`}
>
km
</button>
<button
type="button"
onClick={onSelectMi}
className={`btn btn-xs join-item ${unit === 'mi' ? 'btn-active' : ''}`}
>
mi
</button>
</div>
</div>
);
};
interface BreweryWithDistance {
brewery: SimplifiedBrewery;
distanceMetres: number;
}
interface NearbyBreweryListProps {
withDistance: BreweryWithDistance[];
center: LatLngTuple | null;
radiusKm: number;
unit: DistanceUnit;
renderItem: (item: BreweryWithDistance) => React.JSX.Element;
}
const NearbyBreweryList = ({
withDistance,
center,
radiusKm,
unit,
renderItem,
}: NearbyBreweryListProps) => {
return (
<>
{withDistance.length === 0 && center && (
<p className="text-sm text-base-content/60">
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
</p>
)}
{withDistance.map(renderItem)}
</>
);
};
interface ChangeLocationButtonProps {
onClick: () => void;
}
const ChangeLocationButton = ({ onClick }: ChangeLocationButtonProps) => {
return (
<div className="pt-1">
<button type="button" onClick={onClick} className="btn btn-ghost btn-sm">
Change location
</button>
</div>
);
};
interface ClientOnlyMapProps {
render: () => React.JSX.Element;
}
const ClientOnlyMap = ({ render }: ClientOnlyMapProps) => {
return <ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>{render}</ClientOnly>;
};
const NearbyBreweriesSection = ({ const NearbyBreweriesSection = ({
featured, featured,
fallbackCenter, fallbackCenter,
@@ -77,7 +210,10 @@ const NearbyBreweriesSection = ({
}, []); }, []);
const center: LatLngTuple | null = const center: LatLngTuple | null =
userLocation ?? (geoAttempted && fallbackCenter ? [fallbackCenter.latitude, fallbackCenter.longitude] : null); userLocation ??
(geoAttempted && fallbackCenter
? [fallbackCenter.latitude, fallbackCenter.longitude]
: null);
const centerLabel = userLocation ? locationLabel : (fallbackCenter?.label ?? locationLabel); const centerLabel = userLocation ? locationLabel : (fallbackCenter?.label ?? locationLabel);
useEffect(() => { useEffect(() => {
@@ -91,13 +227,10 @@ const NearbyBreweriesSection = ({
const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? []; const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? [];
const withDistance = center const withDistance = center
? nearby ? nearby
.filter((b) => b.location?.coordinates) .filter((b) => b.location?.coordinates && b.distanceMetres !== null)
.map((b) => ({ .map((b) => ({
brewery: b, brewery: b,
distanceMetres: haversineDistanceMetres( distanceMetres: b.distanceMetres as number,
{ latitude: center[0], longitude: center[1] },
b.location!.coordinates!,
),
})) }))
.sort((a, b) => a.distanceMetres - b.distanceMetres) .sort((a, b) => a.distanceMetres - b.distanceMetres)
: []; : [];
@@ -105,7 +238,9 @@ const NearbyBreweriesSection = ({
const pins: NearbyMapPin[] = withDistance.map(({ brewery }) => ({ const pins: NearbyMapPin[] = withDistance.map(({ brewery }) => ({
id: brewery.breweryPostId, id: brewery.breweryPostId,
name: brewery.breweryName, name: brewery.breweryName,
cityLabel: brewery.location ? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}` : null, cityLabel: brewery.location
? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}`
: null,
latitude: brewery.location!.coordinates!.latitude, latitude: brewery.location!.coordinates!.latitude,
longitude: brewery.location!.coordinates!.longitude, longitude: brewery.location!.coordinates!.longitude,
})); }));
@@ -121,86 +256,59 @@ const NearbyBreweriesSection = ({
return ( return (
<section id="breweries-near-you" className="max-w-7xl mx-auto px-5 pt-11"> <section id="breweries-near-you" className="max-w-7xl mx-auto px-5 pt-11">
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5"> <BreweriesNearByHeader
<h2 className="font-serif text-2xl m-0">Breweries near you</h2> center={center}
<span className="text-sm text-base-content/60"> withDistance={withDistance}
{center radiusKm={radiusKm}
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}` unit={unit}
: 'Finding breweries near you…'} centerLabel={centerLabel}
</span> />
</div>
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]"> <div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
<div className="flex flex-col gap-3 order-2 md:order-1"> <div className="flex flex-col gap-3 order-2 md:order-1">
<div className="flex items-center justify-between gap-3"> <RadiusUnitControls
<label className="flex-1 flex flex-col gap-1 text-sm text-base-content/70"> radiusKm={radiusKm}
<span className="flex justify-between"> unit={unit}
<span>Search radius</span> onRadiusChange={(e) => setRadiusKm(Number(e.target.value))}
<span className="font-semibold">{formatDistance(radiusKm * 1000, unit)}</span> onSelectKm={() => setUnit('km')}
</span> onSelectMi={() => setUnit('mi')}
<input />
type="range" <NearbyBreweryList
min={MIN_RADIUS_KM} withDistance={withDistance}
max={MAX_RADIUS_KM} center={center}
step={RADIUS_STEP_KM} radiusKm={radiusKm}
value={radiusKm} unit={unit}
onChange={(e) => setRadiusKm(Number(e.target.value))} renderItem={({ brewery, distanceMetres }) => (
className="range range-primary range-sm"
/>
</label>
<div className="join self-end">
<button <button
key={brewery.breweryPostId}
type="button" type="button"
onClick={() => setUnit('km')} onClick={() => onSelect(brewery.breweryPostId)}
className={`btn btn-xs join-item ${unit === 'km' ? 'btn-active' : ''}`} className={cardClassName(selectedId === brewery.breweryPostId)}
> >
km <div className="flex justify-between items-baseline gap-3">
</button> <h3 className="font-serif text-lg m-0">
<button {brewery.breweryName}
type="button" </h3>
onClick={() => setUnit('mi')} <span className="text-sm font-semibold text-primary whitespace-nowrap">
className={`btn btn-xs join-item ${unit === 'mi' ? 'btn-active' : ''}`} {formatDistance(distanceMetres, unit)}
> </span>
mi
</button>
</div>
</div>
{withDistance.length === 0 && center && (
<p className="text-sm text-base-content/60">
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
</p>
)}
{withDistance.map(({ brewery, distanceMetres }) => (
<button
key={brewery.breweryPostId}
type="button"
onClick={() => onSelect(brewery.breweryPostId)}
className={cardClassName(selectedId === brewery.breweryPostId)}
>
<div className="flex justify-between items-baseline gap-3">
<h3 className="font-serif text-lg m-0">{brewery.breweryName}</h3>
<span className="text-sm font-semibold text-primary whitespace-nowrap">
{formatDistance(distanceMetres, unit)}
</span>
</div>
{brewery.location && (
<div className="text-sm text-base-content/60 mt-1">
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
</div> </div>
)} {brewery.location && (
<div className="text-sm mt-2">{FILLER_NOTE}</div> <div className="text-sm text-base-content/60 mt-1">
</button> {brewery.location.cityName},{' '}
))} {brewery.location.stateProvinceCode}
<div className="pt-1"> </div>
<button type="button" onClick={requestGeolocation} className="btn btn-ghost btn-sm"> )}
Change location <div className="text-sm mt-2">{FILLER_NOTE}</div>
</button> </button>
</div> )}
/>
<ChangeLocationButton onClick={requestGeolocation} />
</div> </div>
<div className="order-1 md:order-2"> <div className="order-1 md:order-2">
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}> <ClientOnlyMap
{() => render={() =>
center ? ( center ? (
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}> <Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
<NearbyBreweryMap <NearbyBreweryMap
@@ -215,7 +323,7 @@ const NearbyBreweriesSection = ({
<div className={MAP_PLACEHOLDER_CLASS} /> <div className={MAP_PLACEHOLDER_CLASS} />
) )
} }
</ClientOnly> />
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,24 +1,3 @@
const EARTH_RADIUS_METRES = 6_371_000;
export interface Coordinates {
latitude: number;
longitude: number;
}
export const haversineDistanceMetres = (a: Coordinates, b: Coordinates): number => {
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const deltaLat = toRadians(b.latitude - a.latitude);
const deltaLng = toRadians(b.longitude - a.longitude);
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const h =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
return 2 * EARTH_RADIUS_METRES * Math.asin(Math.sqrt(h));
};
export type DistanceUnit = 'km' | 'mi'; export type DistanceUnit = 'km' | 'mi';
const METRES_PER_MILE = 1609.344; const METRES_PER_MILE = 1609.344;