diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx
index 59f5bd4..d111bf3 100644
--- a/frontend/src/components/map/JourneyInstructions.tsx
+++ b/frontend/src/components/map/JourneyInstructions.tsx
@@ -212,9 +212,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
const displayLegs = j.legs ? invertLegs(j.legs) : null;
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
- const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null;
- const bestWaitingMin =
- j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null;
return (
@@ -238,22 +235,6 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
{displayLegs.map((leg, i) => (
))}
- {waitingMin != null && waitingMin > 0 && (
-
-
- Waiting & transfers
-
-
- {waitingMin} min
- {bestWaitingMin != null && (
-
- {' '}
- (best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min)
-
- )}
-
-
- )}
) : (
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx
index 41791a8..748e241 100644
--- a/frontend/src/components/map/MapPage.tsx
+++ b/frontend/src/components/map/MapPage.tsx
@@ -33,6 +33,7 @@ import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
+import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
export interface ExportState {
onExport: () => void;
@@ -101,6 +102,22 @@ export default function MapPage({
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
+ const [showBookmarkToast, setShowBookmarkToast] = useState(false);
+ const bookmarkToastDismissed = useRef(
+ localStorage.getItem('bookmark_toast_dismissed') === '1'
+ );
+
+ const handleSavePropertyWithToast = useCallback(
+ (property: Property) => {
+ onSaveProperty?.(property);
+ if (!bookmarkToastDismissed.current) {
+ setShowBookmarkToast(true);
+ bookmarkToastDismissed.current = true;
+ }
+ },
+ [onSaveProperty]
+ );
+
const {
filters,
activeFeature,
@@ -358,6 +375,31 @@ export default function MapPage({
}
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
+ const bookmarkToast = showBookmarkToast && (
+
+
+ Property saved!
+
+
+
+ );
+
if (screenshotMode) {
return (
@@ -416,7 +458,7 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
- onSaveProperty={onSaveProperty}
+ onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
@@ -592,6 +634,8 @@ export default function MapPage({
/>
)}
+ {bookmarkToast}
+
{mapData.licenseRequired && (
)}
+ {bookmarkToast}
+
{mapData.licenseRequired && (
Travel Time ({MODE_LABELS[mode]})
+
-
setShowInfo(true)} title="Feature info">
-
-
{slug && (
diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx
index a7e8dd2..08d0d7e 100644
--- a/frontend/src/components/ui/FeatureIcons.tsx
+++ b/frontend/src/components/ui/FeatureIcons.tsx
@@ -35,9 +35,13 @@ export function FeatureActions({
{onAdd && (
-
onAdd(feature.name)} title="Add filter" size="md">
-
-
+
)}
{onRemove && (
onRemove(feature.name)} title="Remove filter">
diff --git a/frontend/src/components/ui/icons/PlusIcon.tsx b/frontend/src/components/ui/icons/PlusIcon.tsx
index 3ec5721..4e6a0ea 100644
--- a/frontend/src/components/ui/icons/PlusIcon.tsx
+++ b/frontend/src/components/ui/icons/PlusIcon.tsx
@@ -1,15 +1,16 @@
-interface IconProps {
+interface PlusIconProps {
className?: string;
+ strokeWidth?: number;
}
-export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
+export function PlusIcon({ className = 'w-7 h-7', strokeWidth = 2 }: PlusIconProps) {
return (
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts
index c4fbf80..9cf38c0 100644
--- a/frontend/src/hooks/useDeckLayers.ts
+++ b/frontend/src/hooks/useDeckLayers.ts
@@ -414,27 +414,63 @@ export function useDeckLayers({
data: postcodeData as PostcodeFeature[],
getFillColor: (f) => {
const d = f.properties;
+ const dark = isDarkRef.current;
+ const entries = travelTimeEntriesRef.current;
+
+ // Dim-filter: all travel entries with timeRange dim postcodes outside range
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ if (!entry.timeRange || !entry.slug) continue;
+ const fk = travelFieldKey(entry);
+ const modeVal = d[`avg_${fk}`];
+ if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
+ return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
+ }
+ }
+
const vf = viewFeatureRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
- const dark = isDarkRef.current;
- if (vf && clr && cfm) {
- const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
- const minVal = d[`min_${vf}`] as number | undefined;
- const maxVal = d[`max_${vf}`] as number | undefined;
- return getFeatureFillColor(
- val as number | null | undefined,
- minVal,
- maxVal,
- clr,
- fr,
- 0,
- densityGradientRef.current,
- dark,
- 180,
- enumCountRef.current
- );
+
+ if (vf && clr) {
+ // Travel time feature: dim postcodes with no data
+ if (vf.startsWith('tt_')) {
+ const ttVal = d[`avg_${vf}`];
+ if (ttVal == null) {
+ return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
+ }
+ return getFeatureFillColor(
+ ttVal as number,
+ ttVal as number,
+ ttVal as number,
+ clr,
+ null,
+ 0,
+ densityGradientRef.current,
+ dark,
+ 180
+ );
+ }
+
+ // Regular feature
+ if (cfm) {
+ const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
+ const minVal = d[`min_${vf}`] as number | undefined;
+ const maxVal = d[`max_${vf}`] as number | undefined;
+ return getFeatureFillColor(
+ val as number | null | undefined,
+ minVal,
+ maxVal,
+ clr,
+ fr,
+ 0,
+ densityGradientRef.current,
+ dark,
+ 180,
+ enumCountRef.current
+ );
+ }
}
const cr = postcodeCountRangeRef.current;
const c = d.count;
diff --git a/frontend/src/hooks/useTravelModes.ts b/frontend/src/hooks/useTravelModes.ts
new file mode 100644
index 0000000..5bdcb0b
--- /dev/null
+++ b/frontend/src/hooks/useTravelModes.ts
@@ -0,0 +1,34 @@
+import { useState, useEffect } from 'react';
+import { logNonAbortError } from '../lib/api';
+import type { TransportMode } from './useTravelTime';
+
+interface TravelModeInfo {
+ mode: TransportMode;
+ destinations: number;
+}
+
+/** Fetches which transport modes have precomputed travel time data. */
+export function useTravelModes() {
+ const [availableModes, setAvailableModes] = useState | null>(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ fetch('/api/travel-modes', { signal: controller.signal })
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ })
+ .then((data: { modes: TravelModeInfo[] }) => {
+ const modes = new Set(
+ data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
+ );
+ setAvailableModes(modes);
+ })
+ .catch((err) => logNonAbortError('travel modes', err));
+
+ return () => controller.abort();
+ }, []);
+
+ return availableModes;
+}
diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts
index b6ac7fc..73f49a7 100644
--- a/frontend/src/hooks/useTutorial.ts
+++ b/frontend/src/hooks/useTutorial.ts
@@ -13,6 +13,14 @@ const STEPS: Step[] = [
placement: 'right',
disableBeacon: true,
},
+ {
+ target: '[data-tutorial="ai-filters"]',
+ title: 'AI-Powered Filters',
+ content:
+ 'Describe your ideal area in plain English — like "quiet neighbourhood with good schools" — and AI will set up the right filters for you automatically.',
+ placement: 'right',
+ disableBeacon: true,
+ },
{
target: '[data-tutorial="map"]',
title: 'Explore the Map',
diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts
index 74701cf..75385c7 100644
--- a/frontend/src/lib/consts.ts
+++ b/frontend/src/lib/consts.ts
@@ -35,7 +35,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 },
] as const;
-export const POSTCODE_ZOOM_THRESHOLD = 15;
+export const POSTCODE_ZOOM_THRESHOLD = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts
index cf868c0..1829b39 100644
--- a/frontend/src/lib/map-utils.ts
+++ b/frontend/src/lib/map-utils.ts
@@ -52,6 +52,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
return {
version: 8,
+ sprite: `${window.location.origin}/assets/sprites/${theme}`,
glyphs: GLYPHS_URL,
sources: {
protomaps: {
diff --git a/pipeline/download/greenspace_water.py b/pipeline/download/greenspace_water.py
index fb28ddb..d85dd9a 100644
--- a/pipeline/download/greenspace_water.py
+++ b/pipeline/download/greenspace_water.py
@@ -3,7 +3,7 @@
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
into Shapely polygons, reprojects to BNG (EPSG:27700), and saves as parquet.
-Reuses the same great-britain-latest.osm.pbf as pois.py.
+Reuses the same england-latest.osm.pbf as pois.py.
"""
import argparse