This commit is contained in:
Andras Schmelczer 2026-02-15 09:48:30 +00:00
parent 128b3191e7
commit 03445188ea
54 changed files with 596953 additions and 3577 deletions

View file

@ -4,6 +4,7 @@ import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import LearnPage from './components/learn/LearnPage';
import AccountPage from './components/account/AccountPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
@ -32,6 +33,8 @@ case 'saved-searches':
return '/learn';
case 'pricing':
return '/pricing';
case 'account':
return '/account';
default:
return '/';
}
@ -42,6 +45,7 @@ function pathToPage(pathname: string): Page | null {
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/learn') return 'learn';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/account') return 'account';
if (pathname === '/') return 'home';
return null;
}
@ -92,6 +96,7 @@ export default function App() {
register,
logout,
requestPasswordReset,
refreshAuth,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
@ -233,6 +238,8 @@ export default function App() {
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} />
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}

View file

@ -0,0 +1,131 @@
import { useState } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
const SUBSCRIPTION_OPTIONS = ['free', 'rental', 'buyer'] as const;
const SUBSCRIPTION_LABELS: Record<string, string> = {
free: 'Free',
rental: 'Rental',
buyer: 'Buyer',
};
export default function AccountPage({
user,
onRefreshAuth,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
}) {
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const res = await fetch(apiUrl('subscription'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: selectedSubscription }),
}),
});
assertOk(res, 'Update subscription');
await onRefreshAuth();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update subscription';
setError(msg);
} finally {
setSaving(false);
}
};
const badgeColor =
user.subscription === 'buyer'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: user.subscription === 'rental'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-lg mx-auto px-6 py-16">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-8">Account</h1>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'}
</span>
</div>
</div>
{/* Admin section */}
{user.isAdmin && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
Admin: Change subscription
</p>
<div className="flex items-center gap-3">
<select
value={selectedSubscription}
onChange={(e) => setSelectedSubscription(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
>
{SUBSCRIPTION_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{SUBSCRIPTION_LABELS[opt]}
</option>
))}
</select>
<button
onClick={handleSave}
disabled={saving || selectedSubscription === user.subscription}
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{saving ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckIcon className="w-4 h-4" />
) : null}
{saved ? 'Saved' : 'Save'}
</button>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,131 +1,63 @@
import { useRef, useEffect } from 'react';
import { useMemo } from 'react';
const HEX_COUNT = 70;
const TAU = Math.PI * 2;
const HEX_COUNT = 50;
interface Hex {
x: number;
y: number;
baseY: number;
interface HexConfig {
size: number;
opacity: number;
speed: number;
phase: number;
top: number;
driftDuration: number;
bobDuration: number;
bobAmount: number;
delay: number;
reverse: boolean;
}
function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
function generateHexes(): HexConfig[] {
const hexes: HexConfig[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
const driftDuration = 18 + Math.random() * 35;
hexes.push({
x,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
size: 10 + Math.random() * 32,
opacity: 0.06 + Math.random() * 0.18,
top: Math.random() * 100,
driftDuration,
bobDuration: 3 + Math.random() * 5,
bobAmount: 8 + Math.random() * 30,
delay: -Math.random() * driftDuration,
reverse: Math.random() < 0.3,
});
}
return hexes;
}
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (TAU / 6) * i - Math.PI / 6;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0;
let h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas!.parentElement!.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = `${w}px`;
canvas!.style.height = `${h}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
hexesRef.current = initHexes(w, h);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement!);
let prev = performance.now();
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
ctx!.clearRect(0, 0, w, h);
for (const hex of hexesRef.current) {
hex.x += hex.speed * dt * 0.3;
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
hex.x = w * 0.7 + hex.size;
}
if (hex.x > w + hex.size * 2) {
hex.x = -hex.size * 2;
hex.y = Math.random() * h;
hex.baseY = hex.y;
}
const bob = Math.sin(now / 1000 + hex.phase) * 8;
hex.y = hex.baseY + bob;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.stroke();
}
animRef.current = requestAnimationFrame(frame);
}
animRef.current = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animRef.current);
ro.disconnect();
};
}, []);
const hexes = useMemo(generateHexes, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
/>
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
{hexes.map((hex, i) => (
<div
key={i}
className="absolute"
style={{
top: `${hex.top}%`,
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
}}
>
<div
className="bg-teal-500"
style={{
width: hex.size,
height: hex.size,
opacity: hex.opacity * (isDark ? 0.6 : 1),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties}
/>
</div>
))}
</div>
);
}

View file

@ -43,7 +43,7 @@ export default function HomePage({
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6">
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>

View file

@ -119,7 +119,37 @@ export default memo(function Filters({
onAiFilterSubmit,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const enabledFeatureList = features.filter(
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
);
const listingToggles = useMemo(() => {
const val = filters['Listing status'] as string[] | undefined;
if (!val) return { historical: true, buy: true, rent: true };
return {
historical: val.includes('Historical sale'),
buy: val.includes('For sale'),
rent: val.includes('For rent'),
};
}, [filters]);
const handleListingToggle = useCallback(
(key: 'historical' | 'buy' | 'rent') => {
const next = { ...listingToggles, [key]: !listingToggles[key] };
const allOn = next.historical && next.buy && next.rent;
const allOff = !next.historical && !next.buy && !next.rent;
if (allOn || allOff) {
onRemoveFilter('Listing status');
return;
}
const values: string[] = [];
if (next.historical) values.push('Historical sale');
if (next.buy) values.push('For sale');
if (next.rent) values.push('For rent');
onFilterChange('Listing status', values);
},
[listingToggles, onFilterChange, onRemoveFilter]
);
const containerRef = useRef<HTMLDivElement>(null);
const [showPhilosophy, setShowPhilosophy] = useState(false);
@ -155,7 +185,8 @@ export default memo(function Filters({
return scales;
}, [features]);
const badgeCount = enabledFeatureList.length + activeModes.length;
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -171,6 +202,17 @@ export default memo(function Filters({
</button>
</div>
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
<PillGroup>
<PillToggle label="Historical" active={listingToggles.historical}
onClick={() => handleListingToggle('historical')} size="xs" />
<PillToggle label="Buy" active={listingToggles.buy}
onClick={() => handleListingToggle('buy')} size="xs" />
<PillToggle label="Rent" active={listingToggles.rent}
onClick={() => handleListingToggle('rent')} size="xs" />
</PillGroup>
</div>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">

View file

@ -118,7 +118,7 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput

View file

@ -37,7 +37,7 @@ interface MapProps {
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
@ -18,6 +18,9 @@ import { usePaneResize } from '../../hooks/usePaneResize';
import { useAiFilters } from '../../hooks/useAiFilters';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
TRANSPORT_MODES,
@ -191,8 +194,8 @@ export default function MapPage({
// On mobile, open drawer and switch tab when hexagon is clicked
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean) => {
handleHexagonClick(id, isPostcode);
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
handleHexagonClick(id, isPostcode, geometry);
if (id) {
setMobileDrawerOpen(true);
}
@ -225,6 +228,9 @@ export default function MapPage({
mapData.resolution,
]);
// Tutorial
const tutorial = useTutorial(initialLoading, isMobile);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
@ -551,8 +557,20 @@ export default function MapPage({
</div>
)}
<Joyride
steps={tutorial.steps}
run={tutorial.run}
continuous
showProgress
showSkipButton
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
/>
{/* Left Pane */}
<div
data-tutorial="filters"
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
@ -566,7 +584,7 @@ export default function MapPage({
</div>
{/* Map */}
<div className="flex-1 relative">
<div data-tutorial="map" className="flex-1 relative">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
@ -599,6 +617,7 @@ export default function MapPage({
)}
{/* Floating POI button */}
<button
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
@ -614,6 +633,7 @@ export default function MapPage({
{/* Right Pane */}
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
export default function Header({
activePage,
@ -200,7 +200,7 @@ export default function Header({
{!isMobile && (
<>
{user ? (
<UserMenu user={user} onLogout={onLogout} />
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
) : (
<>
<button

View file

@ -83,6 +83,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (

View file

@ -1,7 +1,16 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { Page } from './Header';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
export default function UserMenu({
user,
onLogout,
onPageChange,
}: {
user: AuthUser;
onLogout: () => void;
onPageChange: (page: Page) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -37,6 +46,15 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onPageChange('account');
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</button>
<button
onClick={() => {
setOpen(false);

View file

@ -5,6 +5,8 @@ export interface AuthUser {
id: string;
email: string;
verified: boolean;
isAdmin: boolean;
subscription: string;
}
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
@ -15,6 +17,8 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
id: record.id,
email: record.email,
verified: typeof record.verified === 'boolean' ? record.verified : false,
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
};
}
@ -110,6 +114,11 @@ export function useAuth() {
}
}, []);
const refreshAuth = useCallback(async () => {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
@ -123,6 +132,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
refreshAuth,
clearError,
};
}

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState, useMemo } from 'react';
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
@ -18,6 +18,7 @@ import {
type TransportMode,
type TravelTimeEntries,
} from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null {
@ -40,7 +41,7 @@ interface UseDeckLayersProps {
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
@ -89,9 +90,18 @@ export function useDeckLayers({
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
// Marching ants animation
const [marchTime, setMarchTime] = useState(0);
const hasPostcodeGeometry = selectedPostcodeGeometry != null;
useEffect(() => {
if (!hasPostcodeGeometry) return;
setMarchTime(0);
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
return () => clearInterval(id);
}, [hasPostcodeGeometry]);
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
@ -110,8 +120,6 @@ export function useDeckLayers({
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
@ -233,8 +241,7 @@ export function useDeckLayers({
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
const pc = info.object?.properties?.postcode;
if (pc) {
setSelectedPostcode((prev) => (prev === pc ? null : pc));
onHexagonClickRef.current(pc, true);
onHexagonClickRef.current(pc, true, info.object?.geometry);
}
}, []);
@ -265,7 +272,7 @@ export function useDeckLayers({
}, [travelTimeEntries, travelTimeColorRanges]);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
// --- Layers ---
const hexLayer = useMemo(
@ -423,8 +430,6 @@ export function useDeckLayers({
getLineColor: (f) => {
const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === selectedPostcodeRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
@ -436,7 +441,6 @@ export function useDeckLayers({
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
if (pc === selectedPostcodeRef.current) return 3;
if (pc === hoveredPostcodeRef.current) return 2;
return 1;
},
@ -500,37 +504,28 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Check if the selected postcode has data (passes current filters)
const selectedPostcodeHasData = useMemo(() => {
if (!selectedPostcodeGeometry || !selectedHexagonId) return false;
return postcodeData.some((f) => f.properties.postcode === selectedHexagonId);
}, [selectedPostcodeGeometry, selectedHexagonId, postcodeData]);
// Highlight layer for selected postcode (from search)
const selectedPostcodeHighlightLayer = useMemo(() => {
// Marching ants highlight layer for selected postcode (click or search)
const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
const hasData = selectedPostcodeHasData;
const feature = {
type: 'Feature' as const,
geometry: selectedPostcodeGeometry,
properties: {},
};
return new GeoJsonLayer({
id: 'searched-postcode-highlight',
data: [feature],
getFillColor: hasData
? [29, 228, 195, 40] // teal tint when has data
: [255, 180, 0, 30], // orange tint when filtered out
getLineColor: hasData
? [29, 228, 195, 255] // solid teal when has data
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
getLineWidth: hasData ? 4 : 3,
lineWidthUnits: 'pixels',
id: 'marching-ants',
data: [
{
type: 'Feature' as const,
geometry: selectedPostcodeGeometry,
properties: {},
},
],
filled: false,
stroked: true,
filled: true,
getLineColor: [29, 228, 195, 255],
getLineWidth: 3,
lineWidthUnits: 'pixels' as const,
pickable: false,
marchTime,
extensions: [new MarchingAntsExtension()],
});
}, [selectedPostcodeGeometry, selectedPostcodeHasData]);
}, [selectedPostcodeGeometry, marchTime]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
@ -566,7 +561,7 @@ export function useDeckLayers({
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (selectedPostcodeHighlightLayer) baseLayers.push(selectedPostcodeHighlightLayer);
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
@ -575,7 +570,7 @@ export function useDeckLayers({
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
selectedPostcodeHighlightLayer,
marchingAntsLayer,
destinationMarkerLayer,
]);
@ -594,7 +589,6 @@ export function useDeckLayers({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
selectedPostcode,
hoveredPostcode,
primaryTravelMode,
};

View file

@ -99,15 +99,16 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
);
const handleHexagonClick = useCallback(
(id: string, isPostcode = false) => {
setSelectedPostcodeGeometry(null);
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
setPropertiesTotal(0);
setPropertiesOffset(0);

View file

@ -0,0 +1,86 @@
import { useState, useCallback, useMemo } from 'react';
import type { Step, CallBackProps } from 'react-joyride';
import { ACTIONS, EVENTS, STATUS } from 'react-joyride';
const STORAGE_KEY = 'tutorial_completed';
const STEPS: Step[] = [
{
target: '[data-tutorial="filters"]',
title: 'Filter Properties',
content:
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tutorial="map"]',
title: 'Explore the Map',
content:
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tutorial="search"]',
title: 'Search Locations',
content:
'Search for a place name or postcode to jump directly to that area on the map.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
title: 'Area Stats & Properties',
content:
'After clicking a hexagon, view aggregated area statistics or browse individual properties in this pane.',
placement: 'left',
disableBeacon: true,
},
{
target: '[data-tutorial="poi-button"]',
title: 'Points of Interest',
content:
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
placement: 'left',
disableBeacon: true,
},
];
export function useTutorial(initialLoading: boolean, isMobile: boolean) {
const [run, setRun] = useState(() => {
if (isMobile) return false;
return !localStorage.getItem(STORAGE_KEY);
});
const shouldRun = run && !initialLoading && !isMobile;
const handleCallback = useCallback((data: CallBackProps) => {
const { status, action, type } = data;
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
localStorage.setItem(STORAGE_KEY, '1');
setRun(false);
}
// Also stop if user closes a tooltip via the X button
if (action === ACTIONS.CLOSE && type === EVENTS.STEP_AFTER) {
localStorage.setItem(STORAGE_KEY, '1');
setRun(false);
}
}, []);
const resetTutorial = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setRun(true);
}, []);
return useMemo(
() => ({
steps: STEPS,
run: shouldRun,
handleCallback,
resetTutorial,
}),
[shouldRun, handleCallback, resetTutorial]
);
}

View file

@ -40,6 +40,17 @@ h3 {
color 0.2s ease;
}
/* Hexagon background animations */
@keyframes hex-drift {
from { transform: translateX(-5vw); }
to { transform: translateX(105vw); }
}
@keyframes hex-bob {
0%, 100% { transform: translateY(var(--bob)); }
50% { transform: translateY(calc(var(--bob) * -1)); }
}
/* Fade-in animation for homepage sections */
.fade-in-section {
opacity: 0;

View file

@ -0,0 +1,53 @@
import { LayerExtension } from '@deck.gl/core';
/** Animates a marching-ants border on PathLayer sublayers (alternating white/green dashes). */
export class MarchingAntsExtension extends LayerExtension {
static extensionName = 'MarchingAntsExtension';
static defaultProps = {
marchTime: { type: 'number', value: 0 },
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isEnabled(layer: any): boolean {
return 'pathTesselator' in layer.state;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getShaders(extension: any): any {
if (!extension.isEnabled(this)) return null;
return {
modules: [
{
name: 'marchingAnts',
inject: {
'fs:#decl': `\
uniform marchingAntsUniforms {
float marchTime;
} marchingAnts;`,
'fs:DECKGL_FILTER_COLOR': `\
float marchSegLen = 4.0;
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
if (marchPos < marchSegLen) {
color = vec4(1.0, 1.0, 1.0, color.a);
} else {
color = vec4(0.114, 0.894, 0.765, color.a);
}`,
},
uniformTypes: {
marchTime: 'f32',
},
},
],
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateState(_params: any, extension: any): void {
if (!extension.isEnabled(this)) return;
// @ts-expect-error setShaderModuleProps exists on Layer
this.setShaderModuleProps({
// @ts-expect-error marchTime is a custom prop from this extension
marchingAnts: { marchTime: this.props.marchTime || 0 },
});
}
}

View file

@ -0,0 +1,52 @@
import type { Styles } from 'react-joyride';
export function getTutorialStyles(theme: 'light' | 'dark'): Partial<Styles> {
const isDark = theme === 'dark';
return {
options: {
arrowColor: isDark ? '#292524' : '#ffffff',
backgroundColor: isDark ? '#292524' : '#ffffff',
overlayColor: isDark ? 'rgba(10,14,26,0.75)' : 'rgba(0,0,0,0.5)',
primaryColor: '#00a28c',
textColor: isDark ? '#d6d3d1' : '#44403c',
zIndex: 1000,
},
tooltip: {
borderRadius: 8,
padding: 16,
},
tooltipTitle: {
color: isDark ? '#f5f5f4' : '#0a0e1a',
fontSize: 15,
fontWeight: 600,
},
tooltipContent: {
fontSize: 13,
lineHeight: 1.5,
padding: '8px 0 0',
},
buttonNext: {
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
padding: '6px 14px',
},
buttonBack: {
color: isDark ? '#a8a29e' : '#78716c',
fontSize: 13,
fontWeight: 500,
marginRight: 8,
},
buttonSkip: {
color: isDark ? '#78716c' : '#a8a29e',
fontSize: 12,
},
buttonClose: {
color: isDark ? '#a8a29e' : '#78716c',
},
spotlight: {
borderRadius: 8,
},
};
}