From 1569d116a94707476d110f07d01fb791d6c9153d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 15 Mar 2026 09:02:10 +0000 Subject: [PATCH] UI improvements --- .../src/components/account/AccountPage.tsx | 70 ++++++++++--------- frontend/src/components/map/AiFilterInput.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 31 +++++--- .../components/map/JourneyInstructions.tsx | 19 ----- frontend/src/components/map/MapPage.tsx | 48 ++++++++++++- .../src/components/map/TravelTimeCard.tsx | 10 ++- frontend/src/components/ui/FeatureIcons.tsx | 10 ++- frontend/src/components/ui/icons/PlusIcon.tsx | 7 +- frontend/src/hooks/useDeckLayers.ts | 70 ++++++++++++++----- frontend/src/hooks/useTravelModes.ts | 34 +++++++++ frontend/src/hooks/useTutorial.ts | 8 +++ frontend/src/lib/consts.ts | 2 +- frontend/src/lib/map-utils.ts | 1 + pipeline/download/greenspace_water.py | 2 +- 14 files changed, 222 insertions(+), 92 deletions(-) create mode 100644 frontend/src/hooks/useTravelModes.ts diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index ed14b6e..757c35a 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -280,11 +280,10 @@ function SavedPropertiesTab({ key={prop.id} className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4" > -
+

{prop.address}

-

{prop.postcode}

{price && ( @@ -304,17 +303,6 @@ function SavedPropertiesTab({ > Open postcode - {prop.data.listingUrl && ( - - View listing → - - )}
+ {prop.data.listingUrl && ( + + View listing → + + )} ); })} @@ -359,7 +357,9 @@ export function SavedPage({ onDeleteProperty: (id: string) => Promise; onOpenProperty: (postcode: string) => void; }) { - const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches'); + const [activeTab, setActiveTab] = useState<'searches' | 'properties'>( + window.location.hash === '#properties' ? 'properties' : 'searches' + ); const tabClass = (tab: string) => `px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ @@ -448,20 +448,37 @@ function InviteTable({ No invites generated yet

) : ( - +
- - - + {invites.map((inv) => ( - - ))} diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 073f845..937f6de 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -25,7 +25,7 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: const hasContent = query.trim().length > 0; return ( -
+
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 412ef27..03eee0c 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect } from 'react'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; +import { useTravelModes } from '../../hooks/useTravelModes'; import { SearchInput } from '../ui/SearchInput'; import { FilterIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; @@ -11,7 +12,6 @@ import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons'; import type { ComponentType } from 'react'; -import { IconButton } from '../ui/IconButton'; import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; const MODE_ICONS: Record> = { @@ -53,6 +53,7 @@ export default function FeatureBrowser({ const [search, setSearch] = useState(''); const [infoFeature, setInfoFeature] = useState(null); const [expandedGroups, toggleGroup] = useCollapsibleGroups(); + const availableTravelModes = useTravelModes(); useEffect(() => { if (openInfoFeature) { @@ -73,9 +74,15 @@ export default function FeatureBrowser({ // When searching, expand all groups so results are visible const isSearching = search.length > 0; - // All modes are always available (can add multiple entries per mode) + // Only show modes that have precomputed travel time data + const visibleModes = useMemo( + () => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []), + [availableTravelModes], + ); + const showTravelModes = - !search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()); + visibleModes.length > 0 && + (!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase())); return ( <> @@ -92,15 +99,15 @@ export default function FeatureBrowser({ className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800" > - {TRANSPORT_MODES.length} + {visibleModes.length} - {(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => { + {(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => { const ModeIcon = MODE_ICONS[mode]; return (
onAddTravelTimeEntry(mode)}> @@ -114,9 +121,13 @@ export default function FeatureBrowser({
- onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md"> - - +
); @@ -143,7 +154,7 @@ export default function FeatureBrowser({ return (
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
LinkStatusCreated + StatusCreated
- {inv.code} + +
+ + {inv.url} + + +
{formatRelativeTime(inv.created)} - -