mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compute nearby-brewery distance in SQL instead of client-side Haversine, and add the spatial index it relies on.
This commit is contained in:
@@ -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
|
|
||||||
-- );
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user