vibes
This commit is contained in:
parent
39ef5c6646
commit
c995f12f8b
78 changed files with 4830 additions and 1619 deletions
|
|
@ -556,6 +556,7 @@ export default function App() {
|
|||
initialViewState={initialViewState}
|
||||
initialPOICategories={urlState.poiCategories}
|
||||
initialOverlays={urlState.overlays}
|
||||
initialBasemap={urlState.basemap}
|
||||
initialTab={urlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
@ -661,6 +662,7 @@ export default function App() {
|
|||
initialViewState={initialViewState}
|
||||
initialPOICategories={mapUrlState.poiCategories}
|
||||
initialOverlays={mapUrlState.overlays}
|
||||
initialBasemap={mapUrlState.basemap}
|
||||
initialTab={mapUrlState.tab}
|
||||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
|||
},
|
||||
{
|
||||
id: 'conservation-areas',
|
||||
url: 'https://opendata-historicengland.hub.arcgis.com/datasets/historicengland::conservation-areas/explore',
|
||||
url: 'https://www.planning.data.gov.uk/dataset/conservation-area',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
} from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
|
||||
import {
|
||||
getActiveAmenityFilterFeatureNames,
|
||||
isPoiFilterFeatureName,
|
||||
} from '../../lib/poi-distance-filter';
|
||||
import {
|
||||
PARTY_FEATURE_COLORS,
|
||||
STACKED_GROUPS,
|
||||
|
|
@ -88,7 +92,7 @@ const STATION_GROUP_NAMES = new Set([STATION_GROUP_NAME, 'Public Transport']);
|
|||
|
||||
function MetricTextLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="block truncate text-[13px] font-medium leading-5 text-warm-900 dark:text-warm-100">
|
||||
<span className="block min-w-0 flex-1 break-words text-[13px] font-medium leading-snug text-warm-900 dark:text-warm-100">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
|
@ -106,7 +110,7 @@ function MetricFeatureLabel({
|
|||
aboutLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<MetricTextLabel>{label ?? ts(feature.name)}</MetricTextLabel>
|
||||
{feature.detail && (
|
||||
<button
|
||||
|
|
@ -239,14 +243,40 @@ export default function AreaPane({
|
|||
const filtersActive = activeFilterCount > 0;
|
||||
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
||||
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
|
||||
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||
const activeAmenityFeatureNames = useMemo(
|
||||
() => getActiveAmenityFilterFeatureNames(filters),
|
||||
[filters]
|
||||
);
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const paneFeatureGroups = useMemo<FeatureGroup[]>(
|
||||
() =>
|
||||
featureGroups
|
||||
.map((group) => {
|
||||
if (group.name !== 'Amenities') return group;
|
||||
|
||||
const features = group.features.filter((feature) => {
|
||||
if (isPoiFilterFeatureName(feature.name)) {
|
||||
return activeAmenityFeatureNames.has(feature.name);
|
||||
}
|
||||
return activeFilterNames.has(feature.name);
|
||||
});
|
||||
|
||||
return { ...group, features };
|
||||
})
|
||||
.filter((group) => group.name !== 'Amenities' || group.features.length > 0),
|
||||
[activeAmenityFeatureNames, activeFilterNames, featureGroups]
|
||||
);
|
||||
const displayFeatureGroups = useMemo<FeatureGroup[]>(() => {
|
||||
if (!hexagonLocation || featureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))) {
|
||||
return featureGroups;
|
||||
if (
|
||||
!hexagonLocation ||
|
||||
paneFeatureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))
|
||||
) {
|
||||
return paneFeatureGroups;
|
||||
}
|
||||
|
||||
return [{ name: STATION_GROUP_NAME, features: [] }, ...featureGroups];
|
||||
}, [featureGroups, hexagonLocation]);
|
||||
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
||||
}, [paneFeatureGroups, hexagonLocation]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||
restoreKey: scrollRestoreKey ?? hexagonId,
|
||||
|
|
@ -361,17 +391,17 @@ export default function AreaPane({
|
|||
</div>
|
||||
|
||||
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
||||
{t('areaPane.statsBasis')}
|
||||
</span>
|
||||
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<div className="grid min-w-0 flex-1 basis-52 grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filtersActive}
|
||||
aria-pressed={statsUseFilters && filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
statsUseFilters && filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
|
|
@ -383,7 +413,7 @@ export default function AreaPane({
|
|||
type="button"
|
||||
aria-pressed={!statsUseFilters || !filtersActive}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
!statsUseFilters || !filtersActive
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
|
|
@ -426,7 +456,7 @@ export default function AreaPane({
|
|||
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
||||
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
||||
>
|
||||
<div className="truncate font-medium">
|
||||
<div className="break-words font-medium leading-snug">
|
||||
{getExclusionLabel(exclusion)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
||||
|
|
@ -479,7 +509,8 @@ export default function AreaPane({
|
|||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData && !showNearbyStations) return null;
|
||||
const expanded = isGroupExpanded(group.name);
|
||||
if (!hasData && !showNearbyStations && stats.count === 0) return null;
|
||||
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||
|
|
@ -490,8 +521,6 @@ export default function AreaPane({
|
|||
) ?? []
|
||||
);
|
||||
|
||||
const expanded = isGroupExpanded(group.name);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<CollapsibleGroupHeader
|
||||
|
|
@ -560,9 +589,10 @@ export default function AreaPane({
|
|||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -634,44 +664,46 @@ export default function AreaPane({
|
|||
chart={
|
||||
crimeSeries && crimeSeries.points.length > 1 ? (
|
||||
<CrimeYearChart points={crimeSeries.points} />
|
||||
) : (numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
meanLabel={t('areaPane.nationalAvg')}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
)))
|
||||
numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
meanLabel={t('areaPane.nationalAvg')}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={(v) =>
|
||||
formatFilterValue(
|
||||
v,
|
||||
feature.suffix === '%'
|
||||
? { raw: feature.raw, suffix: feature.suffix }
|
||||
: feature.raw
|
||||
)
|
||||
}
|
||||
integerAxisLabels={feature.step === 1}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
value={formatValue(numericStats.mean, feature)}
|
||||
valueTitle={
|
||||
|
|
@ -690,7 +722,11 @@ export default function AreaPane({
|
|||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
wrap
|
||||
/>
|
||||
<EnumBarChart
|
||||
counts={enumStats.counts}
|
||||
globalCounts={globalFeature?.counts}
|
||||
|
|
@ -729,9 +765,10 @@ export default function AreaPane({
|
|||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -769,6 +806,7 @@ export default function AreaPane({
|
|||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
wrap
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import JourneyInstructions, { googleMapsUrl } from './JourneyInstructions';
|
||||
|
|
@ -12,6 +12,8 @@ vi.mock('react-i18next', () => ({
|
|||
if (key === 'common.minute') return 'min';
|
||||
if (key === 'common.loading') return 'Loading';
|
||||
if (key === 'travel.bestCase') return 'Best case';
|
||||
if (key === 'travel.noChange') return 'No change';
|
||||
if (key === 'travel.noBuses') return 'No buses';
|
||||
if (key === 'areaPane.walk') return 'Walk';
|
||||
if (key === 'areaPane.cycle') return 'Cycle';
|
||||
if (key === 'areaPane.viewOnGoogleMaps') return 'View on Google Maps';
|
||||
|
|
@ -24,6 +26,7 @@ vi.mock('react-i18next', () => ({
|
|||
describe('JourneyInstructions', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
|
@ -39,6 +42,8 @@ describe('JourneyInstructions', () => {
|
|||
minutes: 42,
|
||||
bestMinutes: 25,
|
||||
useBest: true,
|
||||
noChange: true,
|
||||
noBuses: true,
|
||||
legs: [
|
||||
{ mode: 'walk', minutes: 8 },
|
||||
{
|
||||
|
|
@ -61,6 +66,8 @@ describe('JourneyInstructions', () => {
|
|||
);
|
||||
|
||||
expect(screen.getByText(/Best case/)).toBeTruthy();
|
||||
expect(screen.getByText(/No change/)).toBeTruthy();
|
||||
expect(screen.getByText(/No buses/)).toBeTruthy();
|
||||
expect(screen.getByText('Jubilee line')).toBeTruthy();
|
||||
expect(screen.getByText('Northern line')).toBeTruthy();
|
||||
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
|
||||
|
|
@ -89,4 +96,46 @@ describe('JourneyInstructions', () => {
|
|||
|
||||
expect(parsed.searchParams.get('destination')).toBe('Bank tube station');
|
||||
});
|
||||
|
||||
it('requests journey data with the selected transit variant', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
journey: null,
|
||||
minutes: 42,
|
||||
best_minutes: 25,
|
||||
destination_lat: 51.5132819,
|
||||
destination_lon: -0.0895555,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<JourneyInstructions
|
||||
postcode="E14 2DG"
|
||||
entries={[
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'bank-tube-station',
|
||||
label: 'Bank',
|
||||
timeRange: [0, 60],
|
||||
useBest: false,
|
||||
noChange: true,
|
||||
noBuses: true,
|
||||
},
|
||||
]}
|
||||
showGoogleMapsLink={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
expect(parsed.pathname).toBe('/api/journey');
|
||||
expect(parsed.searchParams.get('postcode')).toBe('E14 2DG');
|
||||
expect(parsed.searchParams.get('mode')).toBe('transit-no-change-no-bus');
|
||||
expect(parsed.searchParams.get('slug')).toBe('bank-tube-station');
|
||||
await screen.findByText('No change · No buses');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JourneyLeg } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { resolveTransitVariant, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
|
|
@ -30,6 +30,8 @@ interface JourneyData {
|
|||
destinationLon: number | null;
|
||||
/** Whether the dashboard filter is currently using best-case time. */
|
||||
useBest: boolean;
|
||||
noChange: boolean;
|
||||
noBuses: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +46,8 @@ export interface JourneyInstructionPreset {
|
|||
destinationLat?: number | null;
|
||||
destinationLon?: number | null;
|
||||
useBest?: boolean;
|
||||
noChange?: boolean;
|
||||
noBuses?: boolean;
|
||||
}
|
||||
|
||||
// Official TfL line colors + other known London transit
|
||||
|
|
@ -305,14 +309,17 @@ export default function JourneyInstructions({
|
|||
destinationLat: null,
|
||||
destinationLon: null,
|
||||
useBest: e.useBest,
|
||||
noChange: e.noChange ?? false,
|
||||
noBuses: e.noBuses ?? false,
|
||||
loading: true,
|
||||
}));
|
||||
setJourneys([...results]);
|
||||
|
||||
transitEntries.forEach((entry, idx) => {
|
||||
const serverMode = resolveTransitVariant(entry);
|
||||
const params = new URLSearchParams({
|
||||
postcode,
|
||||
mode: 'transit',
|
||||
mode: serverMode,
|
||||
slug: entry.slug,
|
||||
});
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
|
@ -367,6 +374,8 @@ export default function JourneyInstructions({
|
|||
destinationLat: journey.destinationLat ?? null,
|
||||
destinationLon: journey.destinationLon ?? null,
|
||||
useBest: journey.useBest ?? false,
|
||||
noChange: journey.noChange ?? false,
|
||||
noBuses: journey.noBuses ?? false,
|
||||
loading: false,
|
||||
}))
|
||||
: journeys;
|
||||
|
|
@ -382,20 +391,30 @@ export default function JourneyInstructions({
|
|||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
|
||||
const isBestCase = j.useBest && j.bestMinutes != null;
|
||||
const journeyLabels: string[] = [];
|
||||
if (isBestCase) journeyLabels.push(t('travel.bestCase'));
|
||||
if (j.noChange) journeyLabels.push(t('travel.noChange'));
|
||||
if (j.noBuses) journeyLabels.push(t('travel.noBuses'));
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
const destination = j.label || j.slug;
|
||||
|
||||
return (
|
||||
<div key={`${j.slug}-${index}`} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span className="min-w-0 text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.to', { destination })}
|
||||
</span>
|
||||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
||||
{totalMin} {t('common.minute')}
|
||||
</span>
|
||||
<div className="min-w-0 max-w-[58%] text-right">
|
||||
{journeyLabels.length > 0 && (
|
||||
<div className="text-[10px] font-semibold leading-tight text-teal-700 dark:text-teal-400">
|
||||
{journeyLabels.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-semibold leading-tight text-teal-700 dark:text-teal-400">
|
||||
{totalMin} {t('common.minute')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{j.loading ? (
|
||||
|
|
@ -441,8 +460,8 @@ export default function JourneyInstructions({
|
|||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
||||
{t('common.minute')}
|
||||
{journeyLabels.length > 0 ? journeyLabels.join(' · ') : t('areaPane.walk')} ·{' '}
|
||||
{totalMin} {t('common.minute')}
|
||||
</span>
|
||||
</div>
|
||||
{showGoogleMapsLink && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -27,8 +27,14 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
|||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import type { BasemapId } from '../../lib/basemaps';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import {
|
||||
getActiveAmenityFilterFeatureNames,
|
||||
isPoiFilterFeatureName,
|
||||
} from '../../lib/poi-distance-filter';
|
||||
import {
|
||||
AreaPane,
|
||||
Filters,
|
||||
|
|
@ -74,6 +80,7 @@ export default function MapPage({
|
|||
initialViewState,
|
||||
initialPOICategories,
|
||||
initialOverlays,
|
||||
initialBasemap = 'standard',
|
||||
initialTab,
|
||||
initialLoading,
|
||||
theme,
|
||||
|
|
@ -107,6 +114,7 @@ export default function MapPage({
|
|||
const [activeOverlays, setActiveOverlays] = useState<Set<OverlayId>>(
|
||||
() => new Set(initialOverlays ?? [])
|
||||
);
|
||||
const [basemap, setBasemap] = useState<BasemapId>(initialBasemap);
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
|
@ -229,10 +237,10 @@ export default function MapPage({
|
|||
noBuses: parsed.noBuses,
|
||||
slug: tt.slug,
|
||||
label: tt.label,
|
||||
timeRange: [
|
||||
tt.min ?? 0,
|
||||
Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES),
|
||||
] as [number, number],
|
||||
timeRange: [tt.min ?? 0, Math.min(tt.max ?? MAX_TRAVEL_MINUTES, MAX_TRAVEL_MINUTES)] as [
|
||||
number,
|
||||
number,
|
||||
],
|
||||
useBest: false,
|
||||
}))
|
||||
);
|
||||
|
|
@ -300,6 +308,29 @@ export default function MapPage({
|
|||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
|
||||
const license = useLicense();
|
||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
||||
const activeAmenityFeatureNames = useMemo(
|
||||
() => getActiveAmenityFilterFeatureNames(filters),
|
||||
[filters]
|
||||
);
|
||||
const areaStatsFields = useMemo(
|
||||
() =>
|
||||
groupFeaturesByCategory(features)
|
||||
.filter((group) => isAreaGroupExpanded(group.name))
|
||||
.flatMap((group) =>
|
||||
group.features
|
||||
.filter((feature) => {
|
||||
if (group.name !== 'Amenities') return true;
|
||||
if (isPoiFilterFeatureName(feature.name)) {
|
||||
return activeAmenityFeatureNames.has(feature.name);
|
||||
}
|
||||
return activeFilterNames.has(feature.name);
|
||||
})
|
||||
.map((feature) => feature.name)
|
||||
),
|
||||
[activeAmenityFeatureNames, activeFilterNames, features, isAreaGroupExpanded]
|
||||
);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string, _lat: number, _lon: number) => {
|
||||
|
|
@ -338,6 +369,7 @@ export default function MapPage({
|
|||
resolution: mapData.resolution,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
travelTimeEntries: entries,
|
||||
areaStatsFields,
|
||||
shareCode,
|
||||
journeyDest,
|
||||
});
|
||||
|
|
@ -452,7 +484,7 @@ export default function MapPage({
|
|||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
|
||||
const { listings: actualListings } = useActualListings(
|
||||
actualListingsEnabled ? mapData.bounds : null,
|
||||
actualListingsEnabled ? mapData.visibleBounds : null,
|
||||
{
|
||||
filterParam: actualListingsFilterParam,
|
||||
travelParam: actualListingsTravelParam,
|
||||
|
|
@ -464,7 +496,6 @@ export default function MapPage({
|
|||
if (!__DEV__) return;
|
||||
setDevActualListingsEnabled((enabled) => !enabled);
|
||||
}, []);
|
||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||
|
||||
useUrlSync(
|
||||
mapData.currentView,
|
||||
|
|
@ -474,7 +505,8 @@ export default function MapPage({
|
|||
rightPaneTab,
|
||||
entries,
|
||||
shareCode,
|
||||
activeOverlays
|
||||
activeOverlays,
|
||||
basemap
|
||||
);
|
||||
|
||||
useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab);
|
||||
|
|
@ -548,10 +580,12 @@ export default function MapPage({
|
|||
rightPaneTab,
|
||||
entries,
|
||||
shareCode,
|
||||
activeOverlays
|
||||
activeOverlays,
|
||||
basemap
|
||||
).toString(),
|
||||
[
|
||||
activeOverlays,
|
||||
basemap,
|
||||
entries,
|
||||
features,
|
||||
filters,
|
||||
|
|
@ -596,6 +630,7 @@ export default function MapPage({
|
|||
ogMode={ogMode}
|
||||
travelTimeEntries={entries}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -656,6 +691,8 @@ export default function MapPage({
|
|||
<OverlayPane
|
||||
selectedOverlays={activeOverlays}
|
||||
onOverlaysChange={setActiveOverlays}
|
||||
basemap={basemap}
|
||||
onBasemapChange={setBasemap}
|
||||
zoomedIn={overlaysZoomedIn}
|
||||
onClose={() => setOverlayPaneOpen(false)}
|
||||
/>
|
||||
|
|
@ -790,6 +827,7 @@ export default function MapPage({
|
|||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
@ -860,6 +898,7 @@ export default function MapPage({
|
|||
mapData={mapData}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
mapViewFeature={mapViewFeature}
|
||||
filterRange={filterRange}
|
||||
viewSource={viewSource}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { OVERLAYS, type OverlayId } from '../../lib/overlays';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import { useState } from 'react';
|
||||
import { BASEMAPS, type BasemapId } from '../../lib/basemaps';
|
||||
import { OVERLAYS, type OverlayDefinition, type OverlayId } from '../../lib/overlays';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { CloseIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { CloseIcon, InfoIcon } from '../ui/icons';
|
||||
|
||||
interface OverlayPaneProps {
|
||||
selectedOverlays: Set<OverlayId>;
|
||||
onOverlaysChange: (overlays: Set<OverlayId>) => void;
|
||||
basemap: BasemapId;
|
||||
onBasemapChange: (basemap: BasemapId) => void;
|
||||
zoomedIn: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
@ -13,9 +18,13 @@ interface OverlayPaneProps {
|
|||
export default function OverlayPane({
|
||||
selectedOverlays,
|
||||
onOverlaysChange,
|
||||
basemap,
|
||||
onBasemapChange,
|
||||
zoomedIn,
|
||||
onClose,
|
||||
}: OverlayPaneProps) {
|
||||
const [infoOverlay, setInfoOverlay] = useState<OverlayDefinition | null>(null);
|
||||
|
||||
const toggleOverlay = (overlay: OverlayId) => {
|
||||
const next = new Set(selectedOverlays);
|
||||
if (next.has(overlay)) {
|
||||
|
|
@ -28,6 +37,8 @@ export default function OverlayPane({
|
|||
|
||||
const selectNone = () => onOverlaysChange(new Set());
|
||||
|
||||
const showZoomWarning = !zoomedIn && selectedOverlays.size > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
|
|
@ -56,26 +67,68 @@ export default function OverlayPane({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!zoomedIn && (
|
||||
<div className="mt-2 rounded border border-warm-200 bg-warm-50 px-2 py-1.5 text-xs text-warm-500 dark:border-warm-700 dark:bg-navy-950 dark:text-warm-400">
|
||||
Zoom in to view overlays.
|
||||
{showZoomWarning && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
|
||||
>
|
||||
Zoom in further to see the selected{' '}
|
||||
{selectedOverlays.size === 1 ? 'overlay' : 'overlays'}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<PillGroup className="flex-wrap overflow-x-visible">
|
||||
{OVERLAYS.map((overlay) => (
|
||||
<PillToggle
|
||||
key={overlay.id}
|
||||
label={overlay.label}
|
||||
active={selectedOverlays.has(overlay.id)}
|
||||
onClick={() => toggleOverlay(overlay.id)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
Base map
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{BASEMAPS.map((option) => (
|
||||
<PillToggle
|
||||
key={option.id}
|
||||
label={option.label}
|
||||
active={basemap === option.id}
|
||||
onClick={() => onBasemapChange(option.id)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-warm-400 dark:text-warm-500">
|
||||
Data overlays
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{OVERLAYS.map((overlay) => (
|
||||
<div key={overlay.id} className="inline-flex items-center gap-0.5">
|
||||
<PillToggle
|
||||
label={overlay.label}
|
||||
active={selectedOverlays.has(overlay.id)}
|
||||
onClick={() => toggleOverlay(overlay.id)}
|
||||
size="sm"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => setInfoOverlay(overlay)}
|
||||
title={`About ${overlay.label}`}
|
||||
ariaLabel={`About ${overlay.label}`}
|
||||
>
|
||||
<InfoIcon className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{infoOverlay && (
|
||||
<InfoPopup title={infoOverlay.label} onClose={() => setInfoOverlay(null)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
{infoOverlay.detail}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,14 +93,14 @@ export function TravelTimeCard({
|
|||
className={`space-y-2 px-2 py-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<ModeIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400" />
|
||||
<span className="min-w-0 flex-1 break-words text-sm font-medium leading-snug text-navy-950 dark:text-warm-100">
|
||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-0.5">
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')} size="md">
|
||||
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
|
|
@ -133,8 +133,8 @@ export function TravelTimeCard({
|
|||
|
||||
{/* Transit-only toggles — shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<PillToggle
|
||||
label={t('travel.bestCase')}
|
||||
active={useBest}
|
||||
|
|
@ -145,7 +145,7 @@ export function TravelTimeCard({
|
|||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<PillToggle
|
||||
label={t('travel.noChange')}
|
||||
active={noChange}
|
||||
|
|
@ -156,7 +156,7 @@ export function TravelTimeCard({
|
|||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<PillToggle
|
||||
label={t('travel.noBuses')}
|
||||
active={noBuses}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export function ElectionVoteShareFilterCard({
|
|||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export function EnumFeatureFilterCard({
|
|||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} size="sm" />
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export function EthnicityFilterCard({
|
|||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,13 @@ export function NumericFeatureFilterCard({
|
|||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,13 @@ export function PoiDistanceFilterCard({
|
|||
}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={poiMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureLabel
|
||||
feature={poiMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={poiFeature.name}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,13 @@ export function SchoolFilterCard({
|
|||
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureLabel
|
||||
feature={schoolMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={schoolMeta}
|
||||
isPinned={isPinned}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export function SpecificCrimeFilterCard({
|
|||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
wrap
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { useTutorial } from '../../../hooks/useTutorial';
|
|||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { getTutorialStyles } from '../../../lib/tutorial-styles';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { BasemapId } from '../../../lib/basemaps';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
|
|
@ -39,6 +40,7 @@ interface DesktopMapPageProps {
|
|||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
basemap: BasemapId;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -91,6 +93,7 @@ export function DesktopMapPage({
|
|||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
basemap,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -184,6 +187,7 @@ export function DesktopMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -224,7 +228,9 @@ export function DesktopMapPage({
|
|||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
>
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Listings</span>
|
||||
<span className="text-sm font-medium">
|
||||
Listings{actualListingsEnabled ? ` (${actualListings.length})` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -244,7 +250,7 @@ export function DesktopMapPage({
|
|||
</button>
|
||||
</div>
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[220px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div className="absolute bottom-28 right-4 z-10 flex h-[260px] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { BasemapId } from '../../../lib/basemaps';
|
||||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import MobileBottomSheet from '../MobileBottomSheet';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
|
|
@ -30,6 +31,7 @@ interface MobileMapPageProps {
|
|||
mapData: MapData;
|
||||
pois: POI[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
basemap: BasemapId;
|
||||
mapViewFeature: string | null;
|
||||
filterRange: [number, number] | null;
|
||||
viewSource: 'drag' | 'eye' | null;
|
||||
|
|
@ -79,6 +81,7 @@ export function MobileMapPage({
|
|||
mapData,
|
||||
pois,
|
||||
activeOverlays,
|
||||
basemap,
|
||||
mapViewFeature,
|
||||
filterRange,
|
||||
viewSource,
|
||||
|
|
@ -135,6 +138,7 @@ export function MobileMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={pois}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
@ -196,7 +200,7 @@ export function MobileMapPage({
|
|||
</div>
|
||||
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[220px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex h-[260px] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FeatureMeta, ViewState } from '../../../types';
|
|||
import type { useMapData } from '../../../hooks/useMapData';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { BasemapId } from '../../../lib/basemaps';
|
||||
import { MapFallback } from './Fallbacks';
|
||||
import { Map } from './lazyComponents';
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ interface ScreenshotMapPageProps {
|
|||
ogMode?: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
activeOverlays: Set<OverlayId>;
|
||||
basemap: BasemapId;
|
||||
}
|
||||
|
||||
export function ScreenshotMapPage({
|
||||
|
|
@ -33,6 +35,7 @@ export function ScreenshotMapPage({
|
|||
ogMode,
|
||||
travelTimeEntries,
|
||||
activeOverlays,
|
||||
basemap,
|
||||
}: ScreenshotMapPageProps) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -43,6 +46,7 @@ export function ScreenshotMapPage({
|
|||
usePostcodeView={mapData.usePostcodeView}
|
||||
pois={[]}
|
||||
activeOverlays={activeOverlays}
|
||||
basemap={basemap}
|
||||
onViewChange={mapData.handleViewChange}
|
||||
viewFeature={mapViewFeature}
|
||||
colorRange={mapData.colorRange}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
} from '../../../types';
|
||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||
import type { OverlayId } from '../../../lib/overlays';
|
||||
import type { BasemapId } from '../../../lib/basemaps';
|
||||
import type { Page } from '../../ui/Header';
|
||||
import type { PointerEvent } from 'react';
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ export interface MapPageProps {
|
|||
initialViewState: ViewState;
|
||||
initialPOICategories: Set<string>;
|
||||
initialOverlays?: Set<OverlayId>;
|
||||
initialBasemap?: BasemapId;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface FeatureLabelProps {
|
|||
description?: string;
|
||||
label?: string;
|
||||
hideIconOnMobile?: boolean;
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
export function FeatureLabel({
|
||||
|
|
@ -23,10 +24,12 @@ export function FeatureLabel({
|
|||
description,
|
||||
label,
|
||||
hideIconOnMobile,
|
||||
wrap = false,
|
||||
}: FeatureLabelProps) {
|
||||
const { t } = useTranslation();
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
|
||||
const alignmentClass = wrap ? 'items-start' : size === 'xs' ? 'items-center' : 'items-start';
|
||||
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
||||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
|
|
@ -38,7 +41,11 @@ export function FeatureLabel({
|
|||
const nameContent = (
|
||||
<>
|
||||
<span
|
||||
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
|
||||
className={`${textClass} ${
|
||||
size === 'sm'
|
||||
? 'font-medium text-navy-950 dark:text-warm-100'
|
||||
: 'text-warm-700 dark:text-warm-300'
|
||||
} ${wrap ? 'min-w-0 flex-1 break-words leading-snug' : size === 'xs' ? 'truncate' : ''}`}
|
||||
>
|
||||
{translatedName}
|
||||
</span>
|
||||
|
|
@ -56,14 +63,14 @@ export function FeatureLabel({
|
|||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
|
||||
>
|
||||
<div className={`flex ${alignmentClass} ${gapClass} min-w-0 ${className}`}>
|
||||
{featureIcon}
|
||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||
{translatedDesc ? (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">{nameContent}</div>
|
||||
<div className={`flex ${wrap ? 'items-start' : 'items-center'} gap-1`}>
|
||||
{nameContent}
|
||||
</div>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">{translatedDesc}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ interface UseDeckLayersProps {
|
|||
currentLocation?: { lat: number; lng: number } | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
mapDataBeforeId: string;
|
||||
}
|
||||
|
||||
/** Normalize a distribution count array to [0..1] ratios, padded to 10 values. */
|
||||
|
|
@ -88,6 +89,7 @@ export function useDeckLayers({
|
|||
currentLocation,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
mapDataBeforeId,
|
||||
}: UseDeckLayersProps) {
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
|
@ -419,10 +421,10 @@ export function useDeckLayers({
|
|||
highPrecision: true,
|
||||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
beforeId: 'landuse_park',
|
||||
beforeId: mapDataBeforeId,
|
||||
...pieProps,
|
||||
});
|
||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover]);
|
||||
}, [data, colorTrigger, handleHexagonClick, handleHexagonHover, mapDataBeforeId]);
|
||||
|
||||
const postcodeLayer = useMemo(() => {
|
||||
const isEnum = enumCountRef.current > 0;
|
||||
|
|
@ -578,9 +580,15 @@ export function useDeckLayers({
|
|||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'landuse_park',
|
||||
beforeId: mapDataBeforeId,
|
||||
});
|
||||
}, [postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]);
|
||||
}, [
|
||||
postcodeData,
|
||||
postcodeColorTrigger,
|
||||
handlePostcodeClick,
|
||||
handlePostcodeHoverCallback,
|
||||
mapDataBeforeId,
|
||||
]);
|
||||
|
||||
const labeledPostcodeData = useMemo(
|
||||
() => postcodeData.filter((feature) => feature.properties.count > 0),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { useHexagonSelection } from './useHexagonSelection';
|
||||
import type { FeatureMeta, HexagonStatsResponse, PostcodeGeometry } from '../types';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
|
|
@ -41,9 +42,24 @@ function jsonResponse(body: unknown): Response {
|
|||
});
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('useHexagonSelection', () => {
|
||||
const requests: string[] = [];
|
||||
const features: FeatureMeta[] = [{ name: 'Price', type: 'numeric', min: 0, max: 100 }];
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Price', type: 'numeric', min: 0, max: 100 },
|
||||
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||
{ name: 'Estimated current price', type: 'numeric', min: 0, max: 1_000_000 },
|
||||
{ name: 'Price per sqm', type: 'numeric', min: 0, max: 20_000 },
|
||||
{ name: 'Est. price per sqm', type: 'numeric', min: 0, max: 20_000 },
|
||||
{ name: 'Total floor area (sqm)', type: 'numeric', min: 0, max: 500 },
|
||||
{ name: 'Number of bedrooms & living rooms', type: 'numeric', min: 0, max: 12 },
|
||||
{ name: 'Construction year', type: 'numeric', min: 0, max: 2026 },
|
||||
{ name: 'Date of last transaction', type: 'numeric', min: 0, max: 2026 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
|
|
@ -64,6 +80,18 @@ describe('useHexagonSelection', () => {
|
|||
return Promise.resolve(jsonResponse(stats(12)));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/postcode-properties') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/hexagon-properties') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
|
@ -201,4 +229,203 @@ describe('useHexagonSelection', () => {
|
|||
expect(requests.some((url) => url.startsWith('/api/postcode/'))).toBe(false);
|
||||
expect(requests.some((url) => url.startsWith('/api/hexagon-stats'))).toBe(false);
|
||||
});
|
||||
|
||||
it('passes area stat field projections to stats requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: false,
|
||||
travelTimeEntries: [],
|
||||
areaStatsFields: ['Price'],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleHexagonClick('89195da49abffff');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
});
|
||||
|
||||
const statsRequest = requests.find((url) => url.startsWith('/api/hexagon-stats'));
|
||||
expect(statsRequest).toBeDefined();
|
||||
expect(new URL(statsRequest!, 'http://localhost').searchParams.get('fields')).toBe('Price');
|
||||
});
|
||||
|
||||
it('keeps existing area stats visible while area field projections refetch', async () => {
|
||||
const pendingStatsRequests: Array<{ resolve: (response: Response) => void }> = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
requests.push(`${url.pathname}${url.search}`);
|
||||
|
||||
if (url.pathname === '/api/hexagon-stats') {
|
||||
return new Promise<Response>((resolve) => {
|
||||
pendingStatsRequests.push({ resolve });
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ areaStatsFields }: { areaStatsFields: string[] }) =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: false,
|
||||
travelTimeEntries: [],
|
||||
areaStatsFields,
|
||||
}),
|
||||
{ initialProps: { areaStatsFields: [] as string[] } }
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleHexagonClick('89195da49abffff');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pendingStatsRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pendingStatsRequests[0].resolve(jsonResponse(stats(12)));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
expect(result.current.loadingAreaStats).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rerender({ areaStatsFields: ['Price'] });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pendingStatsRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.loadingAreaStats).toBe(true);
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
|
||||
const refetchRequest = requests.filter((url) => url.startsWith('/api/hexagon-stats'))[1];
|
||||
expect(new URL(refetchRequest, 'http://localhost').searchParams.get('fields')).toBe('Price');
|
||||
|
||||
await act(async () => {
|
||||
pendingStatsRequests[1].resolve(jsonResponse(stats(12)));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loadingAreaStats).toBe(false);
|
||||
expect(result.current.areaStats?.count).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes property card field projections to property requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: {},
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries: [],
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handlePropertiesTabClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests.some((url) => url.startsWith('/api/postcode-properties'))).toBe(true);
|
||||
});
|
||||
|
||||
const propertiesRequest = requests.find((url) => url.startsWith('/api/postcode-properties'));
|
||||
const fieldsParam = new URL(propertiesRequest!, 'http://localhost').searchParams.get('fields');
|
||||
expect(fieldsParam).toContain('Last known price');
|
||||
expect(fieldsParam).toContain('Date of last transaction');
|
||||
expect(fieldsParam).not.toContain('Distance to nearest amenity');
|
||||
});
|
||||
|
||||
it('refetches property requests when stats basis switches to all properties', async () => {
|
||||
const propertyFilters = { Price: [0, 50] as [number, number] };
|
||||
const travelTimeEntries: TravelTimeEntry[] = [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'kings-cross',
|
||||
label: 'Kings Cross',
|
||||
timeRange: [0, 30],
|
||||
useBest: false,
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useHexagonSelection({
|
||||
filters: propertyFilters,
|
||||
features,
|
||||
hexagonData: [],
|
||||
resolution: 9,
|
||||
usePostcodeView: true,
|
||||
travelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleLocationSearch('SW1A 1AA', postcodeGeometry, 51.505, -0.115);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handlePropertiesTabClick();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(1);
|
||||
});
|
||||
|
||||
const filteredPropertiesRequest = requests.find((url) =>
|
||||
url.startsWith('/api/postcode-properties')
|
||||
);
|
||||
const filteredParams = new URL(filteredPropertiesRequest!, 'http://localhost').searchParams;
|
||||
expect(filteredParams.has('filters')).toBe(true);
|
||||
expect(filteredParams.has('travel')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setAreaStatsUseFilters(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.areaStats?.count).toBe(4);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(requests.filter((url) => url.startsWith('/api/postcode-properties')).length).toBe(2);
|
||||
});
|
||||
|
||||
const propertyRequests = requests.filter((url) => url.startsWith('/api/postcode-properties'));
|
||||
const allPropertiesRequest = propertyRequests[propertyRequests.length - 1];
|
||||
const allPropertiesParams = new URL(allPropertiesRequest, 'http://localhost').searchParams;
|
||||
expect(allPropertiesParams.has('filters')).toBe(false);
|
||||
expect(allPropertiesParams.has('travel')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,11 +42,23 @@ interface UseHexagonSelectionOptions {
|
|||
resolution: number;
|
||||
usePostcodeView: boolean;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
areaStatsFields?: string[];
|
||||
shareCode?: string;
|
||||
/** First transit destination — used to pick the best central_postcode for journey display. */
|
||||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
||||
const PROPERTY_PANE_FIELDS = [
|
||||
'Last known price',
|
||||
'Estimated current price',
|
||||
'Price per sqm',
|
||||
'Est. price per sqm',
|
||||
'Total floor area (sqm)',
|
||||
'Number of bedrooms & living rooms',
|
||||
'Construction year',
|
||||
'Date of last transaction',
|
||||
];
|
||||
|
||||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
|
|
@ -54,6 +66,7 @@ export function useHexagonSelection({
|
|||
resolution,
|
||||
usePostcodeView,
|
||||
travelTimeEntries,
|
||||
areaStatsFields,
|
||||
shareCode,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
|
|
@ -93,6 +106,11 @@ export function useHexagonSelection({
|
|||
}, []);
|
||||
|
||||
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
|
||||
const areaStatsFieldsKey = useMemo(() => areaStatsFields?.join(';;') ?? '', [areaStatsFields]);
|
||||
const propertyPaneFieldsParam = useMemo(() => {
|
||||
const availableFields = new Set(features.map((feature) => feature.name));
|
||||
return PROPERTY_PANE_FIELDS.filter((field) => availableFields.has(field)).join(';;');
|
||||
}, [features]);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (
|
||||
|
|
@ -110,8 +128,9 @@ export function useHexagonSelection({
|
|||
if (filterStr) params.append('filters', filterStr);
|
||||
if (includeFilters && travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
if (fields) {
|
||||
params.set('fields', fields.join(';;'));
|
||||
const requestedFields = fields ?? areaStatsFields;
|
||||
if (requestedFields) {
|
||||
params.set('fields', requestedFields.join(';;'));
|
||||
}
|
||||
if (journeyDest) {
|
||||
params.set('journey_mode', journeyDest.mode);
|
||||
|
|
@ -121,27 +140,34 @@ export function useHexagonSelection({
|
|||
assertOk(response, 'hexagon-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features, journeyDest, shareCode, travelParam]
|
||||
[areaStatsFields, filters, features, journeyDest, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal, includeFilters = true) => {
|
||||
async (
|
||||
postcode: string,
|
||||
signal?: AbortSignal,
|
||||
includeFilters = true,
|
||||
fields?: string[]
|
||||
) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = includeFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (includeFilters && travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const requestedFields = fields ?? areaStatsFields;
|
||||
if (requestedFields) params.set('fields', requestedFields.join(';;'));
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
assertOk(response, 'postcode-stats');
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[filters, features, shareCode, travelParam]
|
||||
[areaStatsFields, filters, features, shareCode, travelParam]
|
||||
);
|
||||
|
||||
const filterStr = useMemo(() => buildFilterString(filters, features), [filters, features]);
|
||||
const hasStatsFilters = filterStr.length > 0 || travelParam.length > 0;
|
||||
const journeyKey = journeyDest ? `${journeyDest.mode}:${journeyDest.slug}` : '';
|
||||
const areaStatsQueryKey = useMemo(
|
||||
const areaStatsDataKey = useMemo(
|
||||
() =>
|
||||
[
|
||||
areaStatsUseFilters ? 'filtered' : 'all',
|
||||
|
|
@ -152,6 +178,10 @@ export function useHexagonSelection({
|
|||
].join('|'),
|
||||
[areaStatsUseFilters, filterStr, journeyKey, shareCode, travelParam]
|
||||
);
|
||||
const areaStatsQueryKey = useMemo(
|
||||
() => [areaStatsDataKey, areaStatsFieldsKey].join('|'),
|
||||
[areaStatsDataKey, areaStatsFieldsKey]
|
||||
);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
|
||||
|
|
@ -162,8 +192,8 @@ export function useHexagonSelection({
|
|||
|
||||
const stats =
|
||||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
? await fetchPostcodeStats(selection.id, signal, false, [])
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, [], false);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
|
||||
|
|
@ -209,9 +239,10 @@ export function useHexagonSelection({
|
|||
offset: offset.toString(),
|
||||
});
|
||||
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
|
||||
params.set('fields', propertyPaneFieldsParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
|
||||
|
|
@ -235,8 +266,10 @@ export function useHexagonSelection({
|
|||
[
|
||||
filters,
|
||||
features,
|
||||
areaStatsUseFilters,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentPropertyRequest,
|
||||
propertyPaneFieldsParam,
|
||||
shareCode,
|
||||
travelParam,
|
||||
]
|
||||
|
|
@ -255,9 +288,10 @@ export function useHexagonSelection({
|
|||
params.set('focus_address', focusAddress);
|
||||
}
|
||||
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
const filterStr = areaStatsUseFilters ? buildFilterString(filters, features) : '';
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (areaStatsUseFilters && travelParam) params.set('travel', travelParam);
|
||||
params.set('fields', propertyPaneFieldsParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const response = await fetch(apiUrl('postcode-properties', params), authHeaders());
|
||||
|
|
@ -281,8 +315,10 @@ export function useHexagonSelection({
|
|||
[
|
||||
filters,
|
||||
features,
|
||||
areaStatsUseFilters,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentPropertyRequest,
|
||||
propertyPaneFieldsParam,
|
||||
shareCode,
|
||||
travelParam,
|
||||
]
|
||||
|
|
@ -546,25 +582,34 @@ export function useHexagonSelection({
|
|||
rightPaneTab,
|
||||
]);
|
||||
|
||||
// Re-fetch stats when filters or travel constraints change while an area is selected
|
||||
const prevAreaStatsQueryKey = useRef(areaStatsQueryKey);
|
||||
// Re-fetch stats when the selected stats basis or requested field projection changes.
|
||||
const prevAreaStatsQueryRef = useRef({
|
||||
dataKey: areaStatsDataKey,
|
||||
queryKey: areaStatsQueryKey,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (prevAreaStatsQueryKey.current === areaStatsQueryKey) return;
|
||||
prevAreaStatsQueryKey.current = areaStatsQueryKey;
|
||||
const previousQuery = prevAreaStatsQueryRef.current;
|
||||
if (previousQuery.queryKey === areaStatsQueryKey) return;
|
||||
prevAreaStatsQueryRef.current = {
|
||||
dataKey: areaStatsDataKey,
|
||||
queryKey: areaStatsQueryKey,
|
||||
};
|
||||
|
||||
if (!selectedHexagon) return;
|
||||
const fieldProjectionOnlyChanged = previousQuery.dataKey === areaStatsDataKey;
|
||||
|
||||
// Clear stale properties
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
if (!fieldProjectionOnlyChanged) {
|
||||
// Clear stale properties
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
}
|
||||
|
||||
setLoadingAreaStats(true);
|
||||
let cancelled = false;
|
||||
const requestId = invalidateAreaRequests();
|
||||
|
||||
const fetchStats =
|
||||
|
|
@ -580,11 +625,11 @@ export function useHexagonSelection({
|
|||
|
||||
fetchStats
|
||||
.then((stats) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
// Re-fetch properties if the properties tab is active and the selected basis has matches.
|
||||
if (!fieldProjectionOnlyChanged && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
|
|
@ -593,17 +638,14 @@ export function useHexagonSelection({
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
logNonAbortError('Failed to refresh stats', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
if (isCurrentAreaRequest(requestId)) setLoadingAreaStats(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
areaStatsDataKey,
|
||||
areaStatsQueryKey,
|
||||
selectedHexagon,
|
||||
fetchHexagonStats,
|
||||
|
|
|
|||
|
|
@ -1,42 +1,211 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Layer, PickingInfo } from '@deck.gl/core';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import Supercluster from 'supercluster';
|
||||
|
||||
import type { ActualListing } from '../types';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
|
||||
const PRICE_LABEL_MIN_ZOOM = 14;
|
||||
const ADDRESS_LABEL_MIN_ZOOM = 16;
|
||||
const LISTING_CLUSTER_RADIUS = 18;
|
||||
const LISTING_CLUSTER_MAX_ZOOM = 24;
|
||||
const LISTING_CLUSTER_POPUP_LIMIT = 30;
|
||||
const LISTING_SPIDERFY_LIMIT = 12;
|
||||
const TILE_SIZE = 512;
|
||||
|
||||
export interface ListingPopupInfo {
|
||||
interface SingleListingPopupInfo {
|
||||
mode: 'single';
|
||||
x: number;
|
||||
y: number;
|
||||
listing: ActualListing;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
interface ListingClusterPopupInfo {
|
||||
mode: 'cluster';
|
||||
x: number;
|
||||
y: number;
|
||||
count: number;
|
||||
listings: ActualListing[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export type ListingPopupInfo = SingleListingPopupInfo | ListingClusterPopupInfo;
|
||||
|
||||
interface UseListingLayersProps {
|
||||
listings: ActualListing[];
|
||||
zoom: number;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface ListingClusterPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
count: number;
|
||||
clusterId: number;
|
||||
}
|
||||
|
||||
interface ExpandedListingMarker {
|
||||
listing: ActualListing;
|
||||
lng: number;
|
||||
lat: number;
|
||||
anchorLng: number;
|
||||
anchorLat: number;
|
||||
}
|
||||
|
||||
function formatShortPrice(price: number): string {
|
||||
if (price >= 1_000_000) return `£${(price / 1_000_000).toFixed(price >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (price >= 1_000) return `£${Math.round(price / 1_000)}k`;
|
||||
return `£${price}`;
|
||||
}
|
||||
|
||||
function formatClusterCount(count: number): string {
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
function compareListingsForDisplay(left: ActualListing, right: ActualListing): number {
|
||||
const dateCompare = (right.listing_date_iso ?? '').localeCompare(left.listing_date_iso ?? '');
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return (right.asking_price ?? 0) - (left.asking_price ?? 0);
|
||||
}
|
||||
|
||||
function getClusterListings(
|
||||
index: Supercluster<ActualListing>,
|
||||
clusterId: number,
|
||||
limit: number
|
||||
): ActualListing[] {
|
||||
return index
|
||||
.getLeaves(clusterId, limit, 0)
|
||||
.map((feature) => feature.properties)
|
||||
.sort(compareListingsForDisplay);
|
||||
}
|
||||
|
||||
function offsetLngLat(
|
||||
lng: number,
|
||||
lat: number,
|
||||
dxPixels: number,
|
||||
dyPixels: number,
|
||||
zoom: number
|
||||
): [number, number] {
|
||||
const worldSize = TILE_SIZE * Math.pow(2, zoom);
|
||||
const lngPerPixel = 360 / worldSize;
|
||||
const cosLat = Math.max(0.25, Math.cos((lat * Math.PI) / 180));
|
||||
const latPerPixel = lngPerPixel / cosLat;
|
||||
return [lng + dxPixels * lngPerPixel, lat - dyPixels * latPerPixel];
|
||||
}
|
||||
|
||||
function spiderfyPosition(
|
||||
lng: number,
|
||||
lat: number,
|
||||
index: number,
|
||||
total: number,
|
||||
zoom: number
|
||||
): [number, number] {
|
||||
if (total <= 1) return [lng, lat];
|
||||
const radius = total <= 6 ? 24 : 32;
|
||||
const angle = -Math.PI / 2 + (index / total) * Math.PI * 2;
|
||||
return offsetLngLat(lng, lat, Math.cos(angle) * radius, Math.sin(angle) * radius, zoom);
|
||||
}
|
||||
|
||||
export function useListingLayers({ listings, zoom, isDark }: UseListingLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
|
||||
const [selectedCluster, setSelectedCluster] = useState<ListingClusterPoint | null>(null);
|
||||
|
||||
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ x: info.x, y: info.y, listing: info.object });
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
useEffect(() => {
|
||||
setSelectedCluster(null);
|
||||
setPopupInfo(null);
|
||||
}, [listings]);
|
||||
|
||||
const clusterIndex = useMemo(() => {
|
||||
if (listings.length === 0) return null;
|
||||
const index = new Supercluster<ActualListing>({
|
||||
radius: LISTING_CLUSTER_RADIUS,
|
||||
maxZoom: LISTING_CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
const features: Supercluster.PointFeature<ActualListing>[] = listings
|
||||
.filter((listing) => Number.isFinite(listing.lat) && Number.isFinite(listing.lon))
|
||||
.map((listing) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [listing.lon, listing.lat] },
|
||||
properties: listing,
|
||||
}));
|
||||
index.load(features);
|
||||
return index;
|
||||
}, [listings]);
|
||||
|
||||
const clusterIndexRef = useRef(clusterIndex);
|
||||
clusterIndexRef.current = clusterIndex;
|
||||
|
||||
const clusterZoom = Math.min(Math.floor(zoom), LISTING_CLUSTER_MAX_ZOOM);
|
||||
const { visibleListings, clusters } = useMemo(() => {
|
||||
if (!clusterIndex) {
|
||||
return {
|
||||
visibleListings: [] as ActualListing[],
|
||||
clusters: [] as ListingClusterPoint[],
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const features = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
|
||||
const individual: ActualListing[] = [];
|
||||
const clusterPoints: ListingClusterPoint[] = [];
|
||||
for (const feature of features) {
|
||||
if (feature.properties.cluster) {
|
||||
clusterPoints.push({
|
||||
lng: feature.geometry.coordinates[0],
|
||||
lat: feature.geometry.coordinates[1],
|
||||
count: feature.properties.point_count,
|
||||
clusterId: feature.properties.cluster_id,
|
||||
});
|
||||
} else {
|
||||
individual.push(feature.properties as ActualListing);
|
||||
}
|
||||
}
|
||||
return { visibleListings: individual, clusters: clusterPoints };
|
||||
}, [clusterIndex, clusterZoom]);
|
||||
|
||||
const expandedListings = useMemo(() => {
|
||||
if (!selectedCluster || !clusterIndex) return [];
|
||||
const leaves = getClusterListings(
|
||||
clusterIndex,
|
||||
selectedCluster.clusterId,
|
||||
LISTING_SPIDERFY_LIMIT
|
||||
);
|
||||
return leaves.map((listing, index) => {
|
||||
const [lng, lat] = spiderfyPosition(
|
||||
selectedCluster.lng,
|
||||
selectedCluster.lat,
|
||||
index,
|
||||
leaves.length,
|
||||
zoom
|
||||
);
|
||||
return {
|
||||
listing,
|
||||
lng,
|
||||
lat,
|
||||
anchorLng: selectedCluster.lng,
|
||||
anchorLat: selectedCluster.lat,
|
||||
};
|
||||
});
|
||||
}, [clusterIndex, selectedCluster, zoom]);
|
||||
|
||||
const clearUnlockedPopup = useCallback(() => {
|
||||
setPopupInfo((current) => (current?.locked ? current : null));
|
||||
}, []);
|
||||
|
||||
const handleHover = useCallback(
|
||||
(info: PickingInfo<ActualListing>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object });
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleClick = useCallback((info: PickingInfo<ActualListing>) => {
|
||||
const url = info.object?.listing_url;
|
||||
if (!url) return;
|
||||
|
|
@ -58,25 +227,115 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
[]
|
||||
);
|
||||
|
||||
const handleExpandedHover = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({ mode: 'single', x: info.x, y: info.y, listing: info.object.listing });
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleExpandedClick = useCallback((info: PickingInfo<ExpandedListingMarker>) => {
|
||||
const url = info.object?.listing.listing_url;
|
||||
if (!url) return;
|
||||
trackEvent('Actual Listing Click', { url, source: 'cluster_expanded' });
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
const handleExpandedHoverRef = useRef(handleExpandedHover);
|
||||
handleExpandedHoverRef.current = handleExpandedHover;
|
||||
const stableExpandedHover = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedHoverRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleExpandedClickRef = useRef(handleExpandedClick);
|
||||
handleExpandedClickRef.current = handleExpandedClick;
|
||||
const stableExpandedClick = useCallback(
|
||||
(info: PickingInfo<ExpandedListingMarker>) => handleExpandedClickRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClusterHover = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
const cluster = info.object;
|
||||
setPopupInfo((current) =>
|
||||
current?.locked
|
||||
? current
|
||||
: {
|
||||
mode: 'cluster',
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
count: cluster.count,
|
||||
listings: [],
|
||||
}
|
||||
);
|
||||
} else {
|
||||
clearUnlockedPopup();
|
||||
}
|
||||
},
|
||||
[clearUnlockedPopup]
|
||||
);
|
||||
|
||||
const handleClusterClick = useCallback((info: PickingInfo<ListingClusterPoint>) => {
|
||||
if (!info.object || info.x === undefined || info.y === undefined) return;
|
||||
const index = clusterIndexRef.current;
|
||||
if (!index) return;
|
||||
const cluster = info.object;
|
||||
const clusterListings = getClusterListings(
|
||||
index,
|
||||
cluster.clusterId,
|
||||
LISTING_CLUSTER_POPUP_LIMIT
|
||||
);
|
||||
setSelectedCluster(cluster);
|
||||
setPopupInfo({
|
||||
mode: 'cluster',
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
count: cluster.count,
|
||||
listings: clusterListings,
|
||||
locked: true,
|
||||
});
|
||||
trackEvent('Actual Listing Cluster Click', { count: cluster.count });
|
||||
}, []);
|
||||
|
||||
const handleClusterHoverRef = useRef(handleClusterHover);
|
||||
handleClusterHoverRef.current = handleClusterHover;
|
||||
const stableClusterHover = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => handleClusterHoverRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClusterClickRef = useRef(handleClusterClick);
|
||||
handleClusterClickRef.current = handleClusterClick;
|
||||
const stableClusterClick = useCallback(
|
||||
(info: PickingInfo<ListingClusterPoint>) => handleClusterClickRef.current(info),
|
||||
[]
|
||||
);
|
||||
|
||||
const pinShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-shadow',
|
||||
data: listings,
|
||||
data: visibleListings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 8,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 80] : [0, 0, 0, 40],
|
||||
pickable: false,
|
||||
}),
|
||||
[listings, isDark]
|
||||
[visibleListings, isDark]
|
||||
);
|
||||
|
||||
const pinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ActualListing>({
|
||||
id: 'actual-listing-pin',
|
||||
data: listings,
|
||||
data: visibleListings,
|
||||
getPosition: (d) => [d.lon, d.lat],
|
||||
getRadius: 7,
|
||||
radiusUnits: 'pixels',
|
||||
|
|
@ -91,12 +350,108 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
}),
|
||||
[listings, stableHover, stableClick]
|
||||
[visibleListings, stableHover, stableClick]
|
||||
);
|
||||
|
||||
const clusterShadowLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster-shadow',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(32, 13 + Math.sqrt(d.count) * 1.8),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [0, 0, 0, 90] : [0, 0, 0, 45],
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters, isDark]
|
||||
);
|
||||
|
||||
const clusterLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: (d) => Math.min(30, 12 + Math.sqrt(d.count) * 1.8),
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: isDark ? [185, 28, 28, 230] : [220, 38, 38, 230],
|
||||
getLineColor: [255, 255, 255, isDark ? 90 : 180],
|
||||
getLineWidth: 2,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableClusterHover,
|
||||
onClick: stableClusterClick,
|
||||
}),
|
||||
[clusters, isDark, stableClusterHover, stableClusterClick]
|
||||
);
|
||||
|
||||
const clusterTextLayer = useMemo(
|
||||
() =>
|
||||
new TextLayer<ListingClusterPoint>({
|
||||
id: 'actual-listing-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) => formatClusterCount(d.count),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: 800,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
sizeUnits: 'pixels',
|
||||
sizeMinPixels: 10,
|
||||
sizeMaxPixels: 13,
|
||||
pickable: false,
|
||||
}),
|
||||
[clusters]
|
||||
);
|
||||
|
||||
const expandedConnectorLayer = useMemo(
|
||||
() =>
|
||||
new PathLayer<ExpandedListingMarker>({
|
||||
id: 'actual-listing-expanded-lines',
|
||||
data: expandedListings,
|
||||
getPath: (d) => [
|
||||
[d.anchorLng, d.anchorLat],
|
||||
[d.lng, d.lat],
|
||||
],
|
||||
getColor: isDark ? [255, 255, 255, 80] : [80, 60, 50, 110],
|
||||
getWidth: 1,
|
||||
widthUnits: 'pixels',
|
||||
pickable: false,
|
||||
}),
|
||||
[expandedListings, isDark]
|
||||
);
|
||||
|
||||
const expandedPinLayer = useMemo(
|
||||
() =>
|
||||
new ScatterplotLayer<ExpandedListingMarker>({
|
||||
id: 'actual-listing-expanded-pin',
|
||||
data: expandedListings,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getRadius: 6,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: [231, 76, 60, 245],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableExpandedHover,
|
||||
onClick: stableExpandedClick,
|
||||
}),
|
||||
[expandedListings, stableExpandedHover, stableExpandedClick]
|
||||
);
|
||||
|
||||
const priceLabelLayer = useMemo(() => {
|
||||
if (zoom < PRICE_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = listings.filter((l) => l.asking_price && l.asking_price > 0);
|
||||
const labeled = visibleListings.filter((l) => l.asking_price && l.asking_price > 0);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-price',
|
||||
data: labeled,
|
||||
|
|
@ -117,11 +472,11 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
sizeMaxPixels: 14,
|
||||
pickable: false,
|
||||
});
|
||||
}, [listings, zoom, isDark]);
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
|
||||
const detailLabelLayer = useMemo(() => {
|
||||
if (zoom < ADDRESS_LABEL_MIN_ZOOM) return null;
|
||||
const labeled = listings.filter((l) => l.address || l.bedrooms != null);
|
||||
const labeled = visibleListings.filter((l) => l.address || l.bedrooms != null);
|
||||
return new TextLayer<ActualListing>({
|
||||
id: 'actual-listing-detail',
|
||||
data: labeled,
|
||||
|
|
@ -148,16 +503,39 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
sizeMaxPixels: 12,
|
||||
pickable: false,
|
||||
});
|
||||
}, [listings, zoom, isDark]);
|
||||
}, [visibleListings, zoom, isDark]);
|
||||
|
||||
const listingLayers = useMemo(() => {
|
||||
const layers: Layer[] = [pinShadowLayer, pinLayer];
|
||||
const layers: Layer[] = [
|
||||
clusterShadowLayer,
|
||||
clusterLayer,
|
||||
clusterTextLayer,
|
||||
pinShadowLayer,
|
||||
pinLayer,
|
||||
];
|
||||
if (expandedListings.length > 0) {
|
||||
layers.push(expandedConnectorLayer, expandedPinLayer);
|
||||
}
|
||||
if (priceLabelLayer) layers.push(priceLabelLayer);
|
||||
if (detailLabelLayer) layers.push(detailLabelLayer);
|
||||
return layers;
|
||||
}, [pinShadowLayer, pinLayer, priceLabelLayer, detailLabelLayer]);
|
||||
}, [
|
||||
clusterShadowLayer,
|
||||
clusterLayer,
|
||||
clusterTextLayer,
|
||||
pinShadowLayer,
|
||||
pinLayer,
|
||||
expandedListings.length,
|
||||
expandedConnectorLayer,
|
||||
expandedPinLayer,
|
||||
priceLabelLayer,
|
||||
detailLabelLayer,
|
||||
]);
|
||||
|
||||
const clearListingPopup = useCallback(() => setPopupInfo(null), []);
|
||||
const clearListingPopup = useCallback(() => {
|
||||
setPopupInfo(null);
|
||||
setSelectedCluster(null);
|
||||
}, []);
|
||||
|
||||
return { listingLayers, listingPopup: popupInfo, clearListingPopup };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function viewChange(bounds: Bounds): ViewChangeParams {
|
|||
return {
|
||||
resolution: 8,
|
||||
bounds,
|
||||
visibleBounds: bounds,
|
||||
zoom: 10,
|
||||
latitude: (bounds.south + bounds.north) / 2,
|
||||
longitude: (bounds.west + bounds.east) / 2,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function useMapData({
|
|||
const [postcodeData, setPostcodeData] = useState<PostcodeFeature[]>([]);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
const [visibleBounds, setVisibleBounds] = useState<Bounds | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [zoom, setZoom] = useState<number>(10);
|
||||
const [currentView, setCurrentView] = useState<{
|
||||
|
|
@ -685,6 +686,7 @@ export function useMapData({
|
|||
({
|
||||
resolution: newRes,
|
||||
bounds: newBounds,
|
||||
visibleBounds: newVisibleBounds,
|
||||
zoom: newZoom,
|
||||
latitude,
|
||||
longitude,
|
||||
|
|
@ -697,6 +699,7 @@ export function useMapData({
|
|||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
}
|
||||
setVisibleBounds(newVisibleBounds);
|
||||
setZoom(newZoom);
|
||||
setCurrentView({ latitude, longitude, zoom: newZoom });
|
||||
setCurrentVisibleView({
|
||||
|
|
@ -729,6 +732,7 @@ export function useMapData({
|
|||
postcodeData: effectivePostcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
visibleBounds,
|
||||
loading: isLoading,
|
||||
zoom,
|
||||
currentView,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { stateToParams } from '../lib/url-state';
|
||||
import type { OverlayId } from '../lib/overlays';
|
||||
import type { BasemapId } from '../lib/basemaps';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
const URL_DEBOUNCE_MS = 300;
|
||||
|
|
@ -14,7 +15,8 @@ export function useUrlSync(
|
|||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntry[],
|
||||
share?: string,
|
||||
selectedOverlays?: Set<OverlayId>
|
||||
selectedOverlays?: Set<OverlayId>,
|
||||
basemap?: BasemapId
|
||||
) {
|
||||
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
|
@ -31,7 +33,8 @@ export function useUrlSync(
|
|||
rightPaneTab,
|
||||
travelTimeEntries,
|
||||
share,
|
||||
selectedOverlays
|
||||
selectedOverlays,
|
||||
basemap
|
||||
);
|
||||
const search = params.toString();
|
||||
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
||||
|
|
@ -50,5 +53,6 @@ export function useUrlSync(
|
|||
travelTimeEntries,
|
||||
share,
|
||||
selectedOverlays,
|
||||
basemap,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Street tree density percentile':
|
||||
"Couverture arborée approximative autour du centroïde du code postal, dérivée de la carte Trees Outside Woodland 2025 de Forest Research. Les polygones de couvert arboré des arbres isolés et groupes d'arbres sont comptés dans un rayon de 50 m de chaque centroïde de code postal, puis convertis en percentile parmi les codes postaux anglais. Il s'agit d'une approximation fondée sur le centroïde du code postal, pas d'une mesure exacte du bien ou du segment de rue.",
|
||||
'Within conservation area':
|
||||
"Limites de zones de conservation de Historic England, rattachées au point représentatif du code postal. Le jeu de données national est indicatif plutôt que définitif ; les décisions sensibles aux limites doivent être vérifiées auprès de l'autorité locale de planification.",
|
||||
"Limites de zones de conservation de Planning Data, rattachées au point représentatif du code postal. Le jeu de données national est en cours d'amélioration et peut contenir des doublons ou une couverture locale incomplète ; les décisions sensibles aux limites doivent être vérifiées auprès de l'autorité locale de planification.",
|
||||
'Listed building':
|
||||
"Points de bâtiments classés de la National Heritage List for England de Historic England, associés prudemment aux adresses des biens à partir du nom de l'entrée classée et de codes postaux proches candidats. À traiter comme un signal de présélection, pas comme une décision juridique : vérifiez tout bien précis dans la NHLE et auprès de l'autorité locale de planification.",
|
||||
'Good+ primary schools within 2km':
|
||||
|
|
@ -188,7 +188,7 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Street tree density percentile':
|
||||
'Ungefähre Baumkronenbedeckung rund um den Postleitzahlen-Zentroiden aus der Forest-Research-Karte Trees Outside Woodland 2025. Baumkronen-Polygone für Einzelbäume und Baumgruppen werden im Umkreis von 50 m um jeden Postleitzahlen-Zentroiden gezählt und dann in ein Perzentil über englische Postleitzahlen umgerechnet. Dies ist ein Näherungswert auf Basis des Postleitzahlen-Zentroids, keine exakte Messung für Immobilie oder Straßenabschnitt.',
|
||||
'Within conservation area':
|
||||
'Historic-England-Grenzen für Erhaltungsgebiete, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz ist indikativ und nicht rechtsverbindlich; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
|
||||
'Planning-Data-Grenzen für Erhaltungsgebiete, dem repräsentativen Punkt der Postleitzahl zugeordnet. Der nationale Datensatz wird laufend verbessert und kann Duplikate oder unvollständige lokale Abdeckung enthalten; grenznahe Entscheidungen sollten bei der lokalen Planungsbehörde geprüft werden.',
|
||||
'Listed building':
|
||||
'Punktdaten zu denkmalgeschützten Gebäuden aus der National Heritage List for England von Historic England, vorsichtig mit Immobilienadressen abgeglichen anhand des Namens des Denkmaleintrags und nahegelegener Postleitzahlkandidaten. Behandle dies als Vorauswahl-Hinweis, nicht als rechtliche Feststellung: Prüfe jede konkrete Immobilie in der NHLE und bei der lokalen Planungsbehörde.',
|
||||
'Good+ primary schools within 2km':
|
||||
|
|
@ -338,7 +338,7 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Street tree density percentile':
|
||||
'基于 Forest Research 2025 年 Trees Outside Woodland 地图估算的邮编质心周边树冠覆盖率。系统会统计每个邮编质心 50 米范围内的孤立树木和树群树冠多边形,然后转换为英格兰邮编范围内的百分位。这是邮编质心近似指标,不是精确的房产或道路路段测量。',
|
||||
'Within conservation area':
|
||||
'Historic England 保护区边界,与邮编代表点匹配。全国数据集是指示性而非最终权威;涉及边界的决策应向地方规划部门核实。',
|
||||
'Planning Data 保护区边界,与邮编代表点匹配。全国数据集仍在完善中,可能包含重复记录或地方覆盖不完整;涉及边界的决策应向地方规划部门核实。',
|
||||
'Listed building':
|
||||
'Historic England 英格兰国家遗产名录(NHLE)中的受保护建筑点位记录,会根据名录条目名称和附近候选邮编,谨慎匹配到房产地址。请把它当作初筛信号,而不是法律认定:具体房产应在 NHLE 和地方规划部门核实。',
|
||||
'Good+ primary schools within 2km':
|
||||
|
|
@ -480,7 +480,7 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Street tree density percentile':
|
||||
'Forest Research के 2025 Trees Outside Woodland नक्शे से निकाला गया पोस्टकोड केंद्र के आसपास का अनुमानित वृक्ष आच्छादन. अकेले पेड़ों और पेड़ों के समूहों के वृक्ष-शिखर बहुभुजों को हर पोस्टकोड केंद्र से 50m के भीतर गिना जाता है, फिर इंग्लैंड के पोस्टकोडों के मुकाबले प्रतिशतक में बदला जाता है. यह पोस्टकोड-केंद्र पर आधारित अनुमानक है, किसी संपत्ति या सड़क-खंड की सटीक माप नहीं.',
|
||||
'Within conservation area':
|
||||
'Historic England संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट संकेतक है, अंतिम आधिकारिक नहीं; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
||||
'Planning Data संरक्षण क्षेत्र सीमाएं पोस्टकोड प्रतिनिधि बिंदु से मिलाई जाती हैं. राष्ट्रीय डेटासेट अभी बेहतर किया जा रहा है और इसमें डुप्लीकेट या अधूरी स्थानीय कवरेज हो सकती है; सीमा-संवेदनशील निर्णय स्थानीय योजना प्राधिकरण से जांचे जाने चाहिए.',
|
||||
'Listed building':
|
||||
'Historic England की इंग्लैंड की राष्ट्रीय धरोहर सूची (NHLE) में सूचीबद्ध भवनों के बिंदु रिकॉर्ड, जिन्हें सूचीबद्ध प्रविष्टि के नाम और पास के संभावित पोस्टकोड के आधार पर संपत्ति पते से सावधानी से मिलाया गया है. इसे केवल प्रारंभिक जांच संकेत मानें, कानूनी निर्णय नहीं: किसी भी विशिष्ट संपत्ति को NHLE और स्थानीय योजना प्राधिकरण से सत्यापित करें.',
|
||||
'Good+ primary schools within 2km':
|
||||
|
|
@ -630,7 +630,7 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Street tree density percentile':
|
||||
'A Forest Research 2025-os Trees Outside Woodland térképéből származó hozzávetőleges lombkorona-fedettség az irányítószám-középpont körül. A magányos fák és facsoportok lombkorona-poligonjait minden irányítószám-középpont 50 méteres körzetében számoljuk, majd az angliai irányítószámok közötti percentilissé alakítjuk. Ez az irányítószám-középponton alapuló közelítő mutató, nem pontos ingatlan- vagy utcaszakasz-mérés.',
|
||||
'Within conservation area':
|
||||
'A Historic England műemléki területeinek határai az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány tájékoztató jellegű, nem végleges; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
|
||||
'A Planning Data műemléki területeinek határai az irányítószám reprezentatív pontjához rendelve. Az országos adatállomány fejlesztés alatt áll, és tartalmazhat duplikátumokat vagy hiányos helyi lefedettséget; határérzékeny döntéseknél a helyi tervezési hatóság adatait kell ellenőrizni.',
|
||||
'Listed building':
|
||||
'A Historic England National Heritage List for England műemlékiépület-pontrekordjai, amelyeket óvatosan egyeztetünk ingatlancímekhez a műemléki bejegyzés neve és a közeli irányítószám-jelöltek alapján. Előszűrési jelzésként kezelendő, nem jogi megállapításként: minden konkrét ingatlant ellenőrizni kell az NHLE-ben és a helyi tervezési hatóságnál.',
|
||||
'Good+ primary schools within 2km':
|
||||
|
|
|
|||
|
|
@ -1139,8 +1139,8 @@ const de: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Baumkronen-Polygone für Einzelbäume, Baumgruppen und kleine Gehölze in England. Hier verwendet, um Baumdeckungs-Perzentile rund um Postleitzahlen-Zentroide zu schätzen.',
|
||||
dsConservationAreasName: 'Historic England Conservation Areas (Denkmalschutzgebiete)',
|
||||
dsConservationAreasOrigin: 'Historic England und lokale Planungsbehörden',
|
||||
dsConservationAreasName: 'Planning Data Conservation Areas (Denkmalschutzgebiete)',
|
||||
dsConservationAreasOrigin: 'Planning Data / lokale Planungsbehörden',
|
||||
dsConservationAreasUse:
|
||||
'Grenzen ausgewiesener Conservation Areas in England. Wird genutzt, um zu kennzeichnen, ob der repräsentative Punkt einer Postleitzahl innerhalb eines solchen Denkmalschutzgebiets liegt.',
|
||||
dsListedBuildingsName: 'Historic England denkmalgeschützte Gebäude',
|
||||
|
|
|
|||
|
|
@ -811,8 +811,8 @@ const en = {
|
|||
rooms: 'Rooms:',
|
||||
built: 'Built:',
|
||||
formerCouncil: 'Ex-council:',
|
||||
exCouncilBadge: 'Maybe ex-council house',
|
||||
listedBuildingBadge: 'Maybe listed',
|
||||
exCouncilBadge: 'Likely ex-council house',
|
||||
listedBuildingBadge: 'Likely listed',
|
||||
epcRating: 'EPC rating:',
|
||||
epcPotential: 'EPC potential:',
|
||||
renovations: 'Renovations',
|
||||
|
|
@ -1113,8 +1113,8 @@ const en = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Tree canopy polygons for lone trees, groups of trees, and small woodlands in England. Used here to estimate tree coverage percentiles around postcode centroids.',
|
||||
dsConservationAreasName: 'Historic England Conservation Areas',
|
||||
dsConservationAreasOrigin: 'Historic England and local planning authorities',
|
||||
dsConservationAreasName: 'Planning Data Conservation Areas',
|
||||
dsConservationAreasOrigin: 'Planning Data / local planning authorities',
|
||||
dsConservationAreasUse:
|
||||
'Designated conservation area boundaries for England. Used to flag whether a postcode representative point falls within a conservation area.',
|
||||
dsListedBuildingsName: 'Historic England Listed Buildings',
|
||||
|
|
|
|||
|
|
@ -1148,8 +1148,8 @@ const fr: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Polygones de couvert arboré pour les arbres isolés, groupes d’arbres et petits bois en Angleterre. Utilisés ici pour estimer les percentiles de couvert arboré autour des centroïdes de codes postaux.',
|
||||
dsConservationAreasName: 'Zones de conservation de Historic England',
|
||||
dsConservationAreasOrigin: 'Historic England et autorités locales de planification',
|
||||
dsConservationAreasName: 'Zones de conservation de Planning Data',
|
||||
dsConservationAreasOrigin: 'Planning Data / autorités locales de planification',
|
||||
dsConservationAreasUse:
|
||||
'Limites des zones de conservation désignées en Angleterre. Utilisées pour indiquer si le point représentatif d’un code postal se trouve dans une zone de conservation.',
|
||||
dsListedBuildingsName: 'Bâtiments classés Historic England',
|
||||
|
|
|
|||
|
|
@ -1091,8 +1091,8 @@ const hi: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'इंग्लैंड में अकेले पेड़ों, पेड़ों के समूहों और छोटे वन क्षेत्रों के वृक्ष आच्छादन बहुभुज. यहां पोस्टकोड केंद्रों के आसपास वृक्ष आच्छादन प्रतिशतक का अनुमान लगाने के लिए उपयोग किया गया है.',
|
||||
dsConservationAreasName: 'Historic England संरक्षण क्षेत्र',
|
||||
dsConservationAreasOrigin: 'Historic England और स्थानीय योजना प्राधिकरण',
|
||||
dsConservationAreasName: 'Planning Data संरक्षण क्षेत्र',
|
||||
dsConservationAreasOrigin: 'Planning Data / स्थानीय योजना प्राधिकरण',
|
||||
dsConservationAreasUse:
|
||||
'इंग्लैंड में नामित संरक्षण क्षेत्रों की सीमाएं. इसका उपयोग यह दिखाने के लिए किया जाता है कि पोस्टकोड का प्रतिनिधि बिंदु संरक्षण क्षेत्र में आता है या नहीं.',
|
||||
dsListedBuildingsName: 'Historic England सूचीबद्ध भवन',
|
||||
|
|
|
|||
|
|
@ -1134,8 +1134,8 @@ const hu: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'Fák lombkorona-poligonjai magányos fákhoz, facsoportokhoz és kisebb erdőfoltokhoz Angliában. Itt az irányítószám-középpontok körüli lombkorona-fedettségi percentilisek becslésére használjuk.',
|
||||
dsConservationAreasName: 'Historic England műemlékvédelmi területek',
|
||||
dsConservationAreasOrigin: 'Historic England és helyi tervezési hatóságok',
|
||||
dsConservationAreasName: 'Planning Data műemlékvédelmi területek',
|
||||
dsConservationAreasOrigin: 'Planning Data / helyi tervezési hatóságok',
|
||||
dsConservationAreasUse:
|
||||
'Anglia kijelölt műemlékvédelmi területeinek határai. Annak jelzésére használjuk, hogy egy irányítószám reprezentatív pontja ilyen területre esik-e.',
|
||||
dsListedBuildingsName: 'Historic England műemlék épületek',
|
||||
|
|
|
|||
|
|
@ -1064,8 +1064,8 @@ const zh: Translations = {
|
|||
dsTowOrigin: 'Forest Research / Defra NCEA',
|
||||
dsTowUse:
|
||||
'英格兰孤立树木、树群和小片林地的树冠多边形。此处用于估算邮编质心周围的树冠覆盖率百分位。',
|
||||
dsConservationAreasName: 'Historic England 保护区',
|
||||
dsConservationAreasOrigin: 'Historic England 和地方规划部门',
|
||||
dsConservationAreasName: 'Planning Data 保护区',
|
||||
dsConservationAreasOrigin: 'Planning Data / 地方规划部门',
|
||||
dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。',
|
||||
dsListedBuildingsName: 'Historic England 登录建筑',
|
||||
dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录',
|
||||
|
|
|
|||
19
frontend/src/lib/basemaps.ts
Normal file
19
frontend/src/lib/basemaps.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const BASEMAP_IDS = ['standard', 'satellite'] as const;
|
||||
|
||||
export type BasemapId = (typeof BASEMAP_IDS)[number];
|
||||
|
||||
export interface BasemapDefinition {
|
||||
id: BasemapId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const BASEMAPS: BasemapDefinition[] = [
|
||||
{ id: 'standard', label: 'Map' },
|
||||
{ id: 'satellite', label: 'Satellite' },
|
||||
];
|
||||
|
||||
const BASEMAP_ID_SET = new Set<string>(BASEMAP_IDS);
|
||||
|
||||
export function isBasemapId(value: string): value is BasemapId {
|
||||
return BASEMAP_ID_SET.has(value);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export const COLOR_RANGE_HIGH_PERCENTILE = 95;
|
|||
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
|
||||
export const MAP_MIN_ZOOM = 5.5;
|
||||
|
||||
export const BUFFER_MULTIPLIER = 1.5;
|
||||
export const BUFFER_MULTIPLIER = 1;
|
||||
|
||||
/** Demo free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
|
||||
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 };
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
DENSITY_GRADIENT,
|
||||
ENUM_PALETTE,
|
||||
FEATURE_GRADIENT,
|
||||
BUFFER_MULTIPLIER,
|
||||
MAP_BOUNDS,
|
||||
POI_CATEGORY_LOGOS,
|
||||
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
enumIndexToColor,
|
||||
getBoundsFromViewState,
|
||||
getBoundsWithBottomScreenInset,
|
||||
getVisibleBoundsFromViewState,
|
||||
getLatitudeAtVerticalPixelOffset,
|
||||
getFeatureFillColor,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
|
|
@ -31,17 +33,33 @@ describe('map utilities', () => {
|
|||
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
|
||||
});
|
||||
|
||||
it('computes buffered bounds around a view state', () => {
|
||||
const bounds = getBoundsFromViewState(
|
||||
{ latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 },
|
||||
1200,
|
||||
800
|
||||
);
|
||||
it('computes exact viewport bounds by default', () => {
|
||||
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
|
||||
const bounds = getBoundsFromViewState(viewState, 1200, 800);
|
||||
const exactBounds = getBoundsFromViewState(viewState, 1200, 800, 1);
|
||||
const bufferedBounds = getBoundsFromViewState(viewState, 1200, 800, 1.5);
|
||||
|
||||
expect(BUFFER_MULTIPLIER).toBe(1);
|
||||
expect(bounds).toEqual(exactBounds);
|
||||
expect(bounds.south).toBeLessThan(51.5);
|
||||
expect(bounds.north).toBeGreaterThan(51.5);
|
||||
expect(bounds.west).toBeLessThan(-0.1);
|
||||
expect(bounds.east).toBeGreaterThan(-0.1);
|
||||
expect(bufferedBounds.south).toBeLessThan(bounds.south);
|
||||
expect(bufferedBounds.north).toBeGreaterThan(bounds.north);
|
||||
expect(bufferedBounds.west).toBeLessThan(bounds.west);
|
||||
expect(bufferedBounds.east).toBeGreaterThan(bounds.east);
|
||||
});
|
||||
|
||||
it('excludes mobile bottom-sheet covered map area from visible bounds', () => {
|
||||
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
|
||||
const fullBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 0);
|
||||
const visibleBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 352);
|
||||
|
||||
expect(visibleBounds.west).toBeCloseTo(fullBounds.west, 6);
|
||||
expect(visibleBounds.east).toBeCloseTo(fullBounds.east, 6);
|
||||
expect(visibleBounds.north).toBeCloseTo(fullBounds.north, 6);
|
||||
expect(visibleBounds.south).toBeGreaterThan(fullBounds.south);
|
||||
});
|
||||
|
||||
it('moves the map center so a target lands in the requested screen position', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ViewState, Bounds } from '../types';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { layers, namedFlavor } from '@protomaps/basemaps';
|
||||
import type { BasemapId } from './basemaps';
|
||||
import {
|
||||
GLYPHS_URL,
|
||||
FEATURE_GRADIENT,
|
||||
|
|
@ -9,11 +10,19 @@ import {
|
|||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
POI_CATEGORY_LOGOS,
|
||||
MAP_MIN_ZOOM,
|
||||
type GradientStop,
|
||||
} from './consts';
|
||||
const ROAD_OPACITY = 0.4;
|
||||
const TILE_SIZE = 512;
|
||||
const MAX_MERCATOR_LATITUDE = 85;
|
||||
const SATELLITE_MAX_ZOOM = 13;
|
||||
const SATELLITE_ATTRIBUTION =
|
||||
'Sentinel-2 cloudless - https://s2maps.eu by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)';
|
||||
|
||||
export function getMapDataBeforeId(basemap: BasemapId): string {
|
||||
return basemap === 'satellite' ? 'roads_runway' : 'landuse_park';
|
||||
}
|
||||
|
||||
function clampLatitude(latitude: number): number {
|
||||
return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, latitude));
|
||||
|
|
@ -66,10 +75,52 @@ export function getMapCenterForTargetScreenPoint(
|
|||
};
|
||||
}
|
||||
|
||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||
function isSatelliteReferenceLayer(layer: ReturnType<typeof layers>[number]): boolean {
|
||||
if (layer.type === 'symbol') return true;
|
||||
if (layer.type !== 'line') return false;
|
||||
return (
|
||||
layer.id.startsWith('roads_') ||
|
||||
layer.id.startsWith('boundaries') ||
|
||||
layer.id.startsWith('water_')
|
||||
);
|
||||
}
|
||||
|
||||
function satelliteReferenceLayer(layer: ReturnType<typeof layers>[number]) {
|
||||
if (layer.type === 'symbol') {
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'text-color': '#f8fafc',
|
||||
'text-halo-color': '#111827',
|
||||
'text-halo-width': 1.6,
|
||||
'text-halo-blur': 0.3,
|
||||
'icon-opacity': 0.9,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (layer.type === 'line') {
|
||||
const isCasing = layer.id.includes('casing');
|
||||
const isBoundary = layer.id.startsWith('boundaries');
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'line-color': isBoundary ? '#f8fafc' : isCasing ? '#111827' : '#f9fafb',
|
||||
'line-opacity': isBoundary ? 0.45 : isCasing ? 0.62 : 0.78,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
export function getMapStyle(theme: 'light' | 'dark', basemap: BasemapId): StyleSpecification {
|
||||
const flavor = namedFlavor(theme);
|
||||
// Use absolute URL for tiles - required by MapLibre
|
||||
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
|
||||
const satelliteTileUrl = `${window.location.origin}/api/tiles/satellite/{z}/{x}/{y}`;
|
||||
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
|
|
@ -105,6 +156,50 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
return layer;
|
||||
});
|
||||
|
||||
if (basemap === 'satellite') {
|
||||
return {
|
||||
version: 8,
|
||||
sprite: `${window.location.origin}/assets/sprites/${theme}`,
|
||||
glyphs: GLYPHS_URL,
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
tiles: [satelliteTileUrl],
|
||||
tileSize: 256,
|
||||
minzoom: MAP_MIN_ZOOM,
|
||||
maxzoom: SATELLITE_MAX_ZOOM,
|
||||
attribution: SATELLITE_ATTRIBUTION,
|
||||
},
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
tiles: [tileUrl],
|
||||
maxzoom: 15,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-background',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': isDark ? '#111827' : '#d4cec3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'satellite-raster',
|
||||
type: 'raster',
|
||||
source: 'satellite',
|
||||
paint: {
|
||||
'raster-fade-duration': 120,
|
||||
'raster-brightness-min': isDark ? 0.08 : 0,
|
||||
'raster-brightness-max': isDark ? 0.86 : 1,
|
||||
'raster-contrast': isDark ? 0.08 : 0.03,
|
||||
},
|
||||
},
|
||||
...modifiedLayers.filter(isSatelliteReferenceLayer).map(satelliteReferenceLayer),
|
||||
],
|
||||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
sprite: `${window.location.origin}/assets/sprites/${theme}`,
|
||||
|
|
@ -209,15 +304,16 @@ export function zoomToResolution(zoom: number): number {
|
|||
export function getBoundsFromViewState(
|
||||
viewState: ViewState,
|
||||
width: number,
|
||||
height: number
|
||||
height: number,
|
||||
bufferMultiplier: number = BUFFER_MULTIPLIER
|
||||
): Bounds {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
const clampedLat = clampLatitude(latitude);
|
||||
const scale = Math.pow(2, zoom);
|
||||
const worldSize = TILE_SIZE * scale;
|
||||
|
||||
const bufferedWidth = width * BUFFER_MULTIPLIER;
|
||||
const bufferedHeight = height * BUFFER_MULTIPLIER;
|
||||
const bufferedWidth = width * bufferMultiplier;
|
||||
const bufferedHeight = height * bufferMultiplier;
|
||||
|
||||
const degreesPerPixelLng = 360 / worldSize;
|
||||
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
|
||||
|
|
@ -235,6 +331,58 @@ export function getBoundsFromViewState(
|
|||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
export function getBoundsFromScreenRect(
|
||||
viewState: ViewState,
|
||||
width: number,
|
||||
height: number,
|
||||
rect: { left?: number; top?: number; right?: number; bottom?: number } = {}
|
||||
): Bounds {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
const worldSize = TILE_SIZE * Math.pow(2, zoom);
|
||||
const centerPixelX = longitudeToWorldX(longitude, worldSize);
|
||||
const centerPixelY = latitudeToWorldY(clampLatitude(latitude), worldSize);
|
||||
|
||||
const left = Math.min(rect.left ?? 0, rect.right ?? width);
|
||||
const right = Math.max(rect.left ?? 0, rect.right ?? width);
|
||||
const top = Math.min(rect.top ?? 0, rect.bottom ?? height);
|
||||
const bottom = Math.max(rect.top ?? 0, rect.bottom ?? height);
|
||||
|
||||
const longitudeAtX = (screenX: number) => {
|
||||
const worldX = centerPixelX + screenX - width / 2;
|
||||
const rawLongitude = (worldX / worldSize) * 360 - 180;
|
||||
return Math.max(-180, Math.min(180, rawLongitude));
|
||||
};
|
||||
const latitudeAtY = (screenY: number) => {
|
||||
const worldY = centerPixelY + screenY - height / 2;
|
||||
return Math.max(
|
||||
-MAX_MERCATOR_LATITUDE,
|
||||
Math.min(MAX_MERCATOR_LATITUDE, worldYToLatitude(worldY, worldSize))
|
||||
);
|
||||
};
|
||||
|
||||
const west = longitudeAtX(left);
|
||||
const east = longitudeAtX(right);
|
||||
const topLatitude = latitudeAtY(top);
|
||||
const bottomLatitude = latitudeAtY(bottom);
|
||||
|
||||
return {
|
||||
south: Math.min(topLatitude, bottomLatitude),
|
||||
west: Math.min(west, east),
|
||||
north: Math.max(topLatitude, bottomLatitude),
|
||||
east: Math.max(west, east),
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleBoundsFromViewState(
|
||||
viewState: ViewState,
|
||||
width: number,
|
||||
height: number,
|
||||
bottomScreenInset: number = 0
|
||||
): Bounds {
|
||||
const visibleBottom = height - Math.max(0, Math.min(height, bottomScreenInset));
|
||||
return getBoundsFromScreenRect(viewState, width, height, { bottom: visibleBottom });
|
||||
}
|
||||
|
||||
export function getLatitudeAtVerticalPixelOffset(
|
||||
latitude: number,
|
||||
zoom: number,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface OverlayDefinition {
|
|||
id: OverlayId;
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const OVERLAYS: OverlayDefinition[] = [
|
||||
|
|
@ -13,16 +14,22 @@ export const OVERLAYS: OverlayDefinition[] = [
|
|||
id: 'noise',
|
||||
label: 'Noise',
|
||||
description: 'High-resolution Defra Lden noise raster',
|
||||
detail:
|
||||
'Defra Strategic Noise Mapping Round 4 (2022), combining road, rail, and airport sources. Values are the EU-standard Lden metric (day-evening-night 24-hour weighted average), modelled on a 10m grid at 4m above ground. Brighter areas indicate higher modelled noise. Licensed under the Open Government Licence v3.0.',
|
||||
},
|
||||
{
|
||||
id: 'crime-hotspots',
|
||||
label: 'Crime hotspots',
|
||||
description: 'Approximate police.uk street-crime heatmap',
|
||||
detail:
|
||||
'Client-side heatmap of street-level crimes published by police.uk over the most recent months. Police.uk coordinates are anonymised snap-to-grid points, not exact offence locations, so the heatmap should be read as an approximation of relative density rather than a precise map of incidents.',
|
||||
},
|
||||
{
|
||||
id: 'trees-outside-woodlands',
|
||||
label: 'Trees',
|
||||
description: 'Trees Outside Woodland canopy polygons',
|
||||
detail:
|
||||
'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons covering lone trees and groups of trees outside mapped woodland blocks. Useful for spotting tree-lined streets and green pockets that broader land-use layers miss. Polygon opacity scales with canopy area.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import type { FeatureFilters, FeatureMeta } from '../types';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
POI_DISTANCE_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
clampPoiFilterRange,
|
||||
createPoiFilterKey,
|
||||
createPoiDistanceFilterKey,
|
||||
getActiveAmenityFilterFeatureNames,
|
||||
getPoiFilterFeatureOptions,
|
||||
getPoiFilterName,
|
||||
} from './poi-distance-filter';
|
||||
|
|
@ -60,6 +63,20 @@ describe('poi-distance-filter', () => {
|
|||
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts only active non-transport amenity backend feature names', () => {
|
||||
const cafeDistance = 'Distance to nearest amenity (Cafe) (km)';
|
||||
const parkCount = 'Number of amenities (Park) within 2km';
|
||||
const busStopDistance = 'Distance to nearest amenity (Bus stop) (km)';
|
||||
const filters: FeatureFilters = {
|
||||
[createPoiDistanceFilterKey(cafeDistance, 0)]: [0, 1],
|
||||
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, parkCount, 1)]: [2, 10],
|
||||
[createPoiFilterKey(TRANSPORT_DISTANCE_FILTER_NAME, busStopDistance, 2)]: [0, 0.5],
|
||||
Price: [0, 500000],
|
||||
};
|
||||
|
||||
expect([...getActiveAmenityFilterFeatureNames(filters)]).toEqual([cafeDistance, parkCount]);
|
||||
});
|
||||
|
||||
it('clamps fixed amenity distance scales to the 0-5km slider bounds', () => {
|
||||
const feature = numeric('Distance to nearest amenity (Cafe) (km)', {
|
||||
absolute: true,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,20 @@ export function getPoiDistanceFeatureName(name: string): string | null {
|
|||
return parsePoiFilterKey(name);
|
||||
}
|
||||
|
||||
export function getActiveAmenityFilterFeatureNames(filters: FeatureFilters): Set<string> {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const name of Object.keys(filters)) {
|
||||
const filterName = getPoiFilterName(name);
|
||||
if (!filterName || filterName === TRANSPORT_DISTANCE_FILTER_NAME) continue;
|
||||
|
||||
const featureName = getPoiDistanceFeatureName(name);
|
||||
if (featureName) names.add(featureName);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
export function replacePoiFilterKeySelection(key: string, featureName: string): string {
|
||||
const filterName =
|
||||
getPoiFilterName(key) ??
|
||||
|
|
|
|||
|
|
@ -173,6 +173,30 @@ describe('url-state', () => {
|
|||
expect(state.overlays).toEqual(new Set(['noise', 'crime-hotspots']));
|
||||
});
|
||||
|
||||
it('round-trips satellite basemap selection', () => {
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'satellite'
|
||||
);
|
||||
|
||||
expect(params.get('basemap')).toBe('satellite');
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.basemap).toBe('satellite');
|
||||
|
||||
window.history.replaceState({}, '', '/?basemap=unknown');
|
||||
expect(parseUrlState().basemap).toBe('standard');
|
||||
});
|
||||
|
||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
} from './poi-distance-filter';
|
||||
import { dedupeTravelTimeEntries } from './travel-params';
|
||||
import { isOverlayId, type OverlayId } from './overlays';
|
||||
import { isBasemapId, type BasemapId } from './basemaps';
|
||||
|
||||
const POI_NONE_PARAM = '__none';
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ export interface UrlState {
|
|||
filters: FeatureFilters;
|
||||
poiCategories: Set<string>;
|
||||
overlays: Set<OverlayId>;
|
||||
basemap: BasemapId;
|
||||
tab: 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
postcode?: string;
|
||||
|
|
@ -213,6 +215,7 @@ export function parseUrlState(): UrlState {
|
|||
filters: parseFilters(params),
|
||||
poiCategories: new Set(),
|
||||
overlays: new Set(),
|
||||
basemap: 'standard',
|
||||
tab: 'area',
|
||||
};
|
||||
|
||||
|
|
@ -253,6 +256,11 @@ export function parseUrlState(): UrlState {
|
|||
result.overlays = new Set(overlayParams.filter(isOverlayId));
|
||||
}
|
||||
|
||||
const basemap = params.get('basemap');
|
||||
if (basemap && isBasemapId(basemap)) {
|
||||
result.basemap = basemap;
|
||||
}
|
||||
|
||||
// Tab: full name
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'properties' || tab === 'area') {
|
||||
|
|
@ -320,7 +328,8 @@ export function stateToParams(
|
|||
rightPaneTab: 'properties' | 'area',
|
||||
travelTimeEntries?: TravelTimeEntry[],
|
||||
share?: string,
|
||||
selectedOverlays?: Set<OverlayId>
|
||||
selectedOverlays?: Set<OverlayId>,
|
||||
basemap?: BasemapId
|
||||
): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
|
|
@ -409,6 +418,10 @@ export function stateToParams(
|
|||
}
|
||||
}
|
||||
|
||||
if (basemap && basemap !== 'standard') {
|
||||
params.set('basemap', basemap);
|
||||
}
|
||||
|
||||
// Travel time: repeated `tt` params
|
||||
if (travelTimeEntries) {
|
||||
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export interface MapFlyToOptions {
|
|||
export interface ViewChangeParams {
|
||||
resolution: number;
|
||||
bounds: Bounds;
|
||||
visibleBounds: Bounds;
|
||||
zoom: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue