vibes
This commit is contained in:
parent
39ef5c6646
commit
c995f12f8b
78 changed files with 4830 additions and 1619 deletions
|
|
@ -22,9 +22,10 @@ import type {
|
|||
|
||||
import {
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
getVisibleBoundsFromViewState,
|
||||
getBoundsWithBottomScreenInset,
|
||||
getMapStyle,
|
||||
getMapDataBeforeId,
|
||||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
|
|
@ -45,6 +46,7 @@ import { useDeckLayers } from '../../hooks/useDeckLayers';
|
|||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -52,6 +54,7 @@ interface MapProps {
|
|||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
activeOverlays?: Set<OverlayId>;
|
||||
basemap?: BasemapId;
|
||||
actualListings?: ActualListing[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -105,6 +108,130 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
|
|||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
function ListingPopupSingleContent({ listing, t }: { listing: ActualListing; t: TFunction }) {
|
||||
return (
|
||||
<a
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-3 py-2"
|
||||
>
|
||||
{listing.asking_price != null && (
|
||||
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
|
||||
{formatListingPrice(listing.asking_price)}
|
||||
{listing.price_qualifier ? (
|
||||
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{listing.price_qualifier}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{formatListingHeadline(listing, t) && (
|
||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||
{formatListingHeadline(listing, t)}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
{listing.postcode && (
|
||||
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
{listing.floor_area_sqm != null && (
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
||||
{Math.round(listing.floor_area_sqm)} sqm
|
||||
{listing.asking_price_per_sqm != null
|
||||
? ` · £${Math.round(listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
{listing.features.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
|
||||
{listing.features.slice(0, 3).map((feature, idx) => (
|
||||
<li key={idx} className="line-clamp-1">
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
|
||||
Open listing ↗
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ListingClusterPopupContent({
|
||||
count,
|
||||
listings,
|
||||
t,
|
||||
}: {
|
||||
count: number;
|
||||
listings: ActualListing[];
|
||||
t: TFunction;
|
||||
}) {
|
||||
const visibleCount = listings.length;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-warm-200 px-3 py-2 dark:border-warm-700">
|
||||
<div className="text-base font-bold text-red-600 dark:text-red-400">
|
||||
{count.toLocaleString()} listings
|
||||
</div>
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{visibleCount > 0
|
||||
? `Showing ${visibleCount.toLocaleString()} of ${count.toLocaleString()}`
|
||||
: 'Grouped near this map position'}
|
||||
</div>
|
||||
</div>
|
||||
{visibleCount > 0 && (
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{listings.map((listing, idx) => {
|
||||
const headline = formatListingHeadline(listing, t);
|
||||
return (
|
||||
<a
|
||||
key={`${listing.listing_url}-${idx}`}
|
||||
href={listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border-b border-warm-100 px-3 py-2 last:border-b-0 hover:bg-warm-50 dark:border-warm-700 dark:hover:bg-warm-700/60"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-teal-700 dark:text-teal-300">
|
||||
{listing.asking_price != null
|
||||
? formatListingPrice(listing.asking_price)
|
||||
: 'Listing'}
|
||||
</div>
|
||||
{headline && (
|
||||
<div className="mt-0.5 truncate text-xs text-warm-700 dark:text-warm-200">
|
||||
{headline}
|
||||
</div>
|
||||
)}
|
||||
{listing.address && (
|
||||
<div className="mt-0.5 line-clamp-1 text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{listing.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{listing.postcode && (
|
||||
<div className="shrink-0 text-[11px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PoiPopupCardData {
|
||||
name: string;
|
||||
category: string;
|
||||
|
|
@ -581,6 +708,7 @@ export default memo(function Map({
|
|||
usePostcodeView,
|
||||
pois,
|
||||
activeOverlays = EMPTY_OVERLAYS,
|
||||
basemap = 'standard',
|
||||
actualListings = EMPTY_ACTUAL_LISTINGS,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -665,9 +793,13 @@ export default memo(function Map({
|
|||
frame = window.requestAnimationFrame(emit);
|
||||
return;
|
||||
}
|
||||
// The bottom sheet can reveal covered map area without a pan/zoom event.
|
||||
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
|
||||
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
|
||||
const bounds = getVisibleBoundsFromViewState(
|
||||
renderedViewState,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
bottomScreenInset
|
||||
);
|
||||
const visibleBounds = bounds;
|
||||
const resolution = zoomToResolution(renderedViewState.zoom);
|
||||
const renderedVisibleCenter =
|
||||
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
|
||||
|
|
@ -676,6 +808,7 @@ export default memo(function Map({
|
|||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
visibleBounds,
|
||||
zoom: renderedViewState.zoom,
|
||||
latitude: renderedViewState.latitude,
|
||||
longitude: renderedViewState.longitude,
|
||||
|
|
@ -739,7 +872,8 @@ export default memo(function Map({
|
|||
|
||||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
const mapStyle = useMemo(() => getMapStyle(theme, basemap), [theme, basemap]);
|
||||
const mapDataBeforeId = useMemo(() => getMapDataBeforeId(basemap), [basemap]);
|
||||
const maxBounds = useMemo(
|
||||
() => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
|
||||
[bottomScreenInset]
|
||||
|
|
@ -794,6 +928,7 @@ export default memo(function Map({
|
|||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries,
|
||||
mapDataBeforeId,
|
||||
});
|
||||
|
||||
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
|
||||
|
|
@ -849,6 +984,14 @@ export default memo(function Map({
|
|||
<OverlayTileLayers activeOverlays={activeOverlays} zoom={viewState.zoom} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
</MapGL>
|
||||
{basemap === 'satellite' && (
|
||||
<div
|
||||
className="pointer-events-auto absolute left-2 z-10 max-w-[calc(100%_-_1rem)] rounded bg-white/85 px-1.5 py-0.5 text-[10px] leading-tight text-warm-600 shadow-sm dark:bg-warm-900/85 dark:text-warm-300"
|
||||
style={{ bottom: bottomScreenInset > 0 ? bottomScreenInset + 8 : 34 }}
|
||||
>
|
||||
Sentinel-2 cloudless by EOX, contains modified Copernicus Sentinel data 2024
|
||||
</div>
|
||||
)}
|
||||
{screenshotMode ? (
|
||||
ogMode ? (
|
||||
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
|
||||
|
|
@ -1019,7 +1162,9 @@ export default memo(function Map({
|
|||
)}
|
||||
{listingPopup && (
|
||||
<div
|
||||
className="pointer-events-auto absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white max-w-[280px]"
|
||||
className={`pointer-events-auto absolute rounded-lg bg-white text-sm shadow-lg dark:bg-warm-800 dark:text-white ${
|
||||
listingPopup.mode === 'cluster' ? 'w-80 max-w-[calc(100vw-2rem)]' : 'max-w-[280px]'
|
||||
}`}
|
||||
style={{
|
||||
left: listingPopup.x,
|
||||
top: listingPopup.y - 12,
|
||||
|
|
@ -1029,63 +1174,21 @@ export default memo(function Map({
|
|||
onMouseLeave={clearListingPopup}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto absolute -top-2 -right-2 w-5 h-5 flex items-center justify-center rounded-full bg-warm-200 dark:bg-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shadow-sm"
|
||||
onClick={clearListingPopup}
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<a
|
||||
href={listingPopup.listing.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block px-3 py-2"
|
||||
>
|
||||
{listingPopup.listing.asking_price != null && (
|
||||
<div className="text-base font-bold text-teal-600 dark:text-teal-400">
|
||||
{formatListingPrice(listingPopup.listing.asking_price)}
|
||||
{listingPopup.listing.price_qualifier ? (
|
||||
<span className="ml-1 text-xs font-medium text-warm-500 dark:text-warm-400">
|
||||
{listingPopup.listing.price_qualifier}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{formatListingHeadline(listingPopup.listing, t) && (
|
||||
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
|
||||
{formatListingHeadline(listingPopup.listing, t)}
|
||||
</div>
|
||||
)}
|
||||
{listingPopup.listing.address && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5 line-clamp-2">
|
||||
{listingPopup.listing.address}
|
||||
</div>
|
||||
)}
|
||||
{listingPopup.listing.postcode && (
|
||||
<div className="text-[11px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
{listingPopup.listing.postcode}
|
||||
</div>
|
||||
)}
|
||||
{listingPopup.listing.floor_area_sqm != null && (
|
||||
<div className="text-[11px] text-warm-500 dark:text-warm-400 mt-0.5">
|
||||
{Math.round(listingPopup.listing.floor_area_sqm)} sqm
|
||||
{listingPopup.listing.asking_price_per_sqm != null
|
||||
? ` · £${Math.round(listingPopup.listing.asking_price_per_sqm).toLocaleString()}/sqm`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
{listingPopup.listing.features.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-warm-600 dark:text-warm-300 list-disc pl-4 space-y-0.5">
|
||||
{listingPopup.listing.features.slice(0, 3).map((feature, idx) => (
|
||||
<li key={idx} className="line-clamp-1">
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-1.5 text-[11px] text-teal-600 dark:text-teal-400 font-medium">
|
||||
Open listing ↗
|
||||
</div>
|
||||
</a>
|
||||
{listingPopup.mode === 'single' ? (
|
||||
<ListingPopupSingleContent listing={listingPopup.listing} t={t} />
|
||||
) : (
|
||||
<ListingClusterPopupContent
|
||||
count={listingPopup.count}
|
||||
listings={listingPopup.listings}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue