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

@ -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]
);
}