diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 971be4a..39efee0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import DataSources from './components/DataSources'; import DataSourcesPage from './components/DataSourcesPage'; import FAQPage from './components/FAQPage'; import HomePage from './components/HomePage'; +import Header, { type Page } from './components/Header'; import type { FeatureMeta, FeatureGroup, @@ -21,321 +22,31 @@ import type { POIResponse, POICategoriesResponse, POICategoryGroup, - ViewState, Property, HexagonPropertiesResponse, HexagonStatsResponse, } from './types'; +import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api'; +import { parseUrlState, DEFAULT_VIEW } from './lib/url-state'; +import { useTheme } from './hooks/useTheme'; +import { useUrlSync } from './hooks/useUrlSync'; -type Theme = 'light' | 'dark'; +declare global { + interface Window { + __og_ready?: boolean; + } +} const DEBOUNCE_MS = 150; -const URL_DEBOUNCE_MS = 300; -const INITIAL_RETRY_MS = 1000; -const MAX_RETRY_MS = 10000; - -async function fetchWithRetry( - url: string, - onSuccess: (data: T) => void, - signal: AbortSignal -): Promise { - let delay = INITIAL_RETRY_MS; - while (!signal.aborted) { - try { - const res = await fetch(url, { signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - onSuccess(json); - return; - } catch (err) { - if (signal.aborted) return; - console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err); - await new Promise((resolve) => setTimeout(resolve, delay)); - delay = Math.min(delay * 2, MAX_RETRY_MS); - } - } -} - -// Detect if running through VS Code web proxy and construct API base URL -function getApiBaseUrl(): string { - // In production builds, always use same-origin (Rust server serves both API and frontend) - if (process.env.NODE_ENV === 'production') { - return ''; - } - - const { pathname, href } = window.location; - - // Check pathname for /proxy/PORT pattern (VS Code web proxy) - const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/); - if (pathMatch) { - return `${pathMatch[1]}8001`; - } - - // Check full href in case proxy rewrites pathname - const hrefMatch = href.match(/(\/proxy\/)\d+/); - if (hrefMatch) { - return `${hrefMatch[1]}8001`; - } - - // Default: same origin (works for local dev with webpack proxy) - return ''; -} - -const DEFAULT_VIEW: ViewState = { - longitude: -1.5, - latitude: 53.5, - zoom: 6, - pitch: 0, -}; - -// --- URL State helpers --- - -function parseUrlState(): { - viewState?: ViewState; - filters?: FeatureFilters; - poiCategories?: Set; - tab?: 'pois' | 'properties' | 'area'; -} { - const params = new URLSearchParams(window.location.search); - const result: ReturnType = {}; - - // Parse view: v=lat,lng,zoom - const v = params.get('v'); - if (v) { - const parts = v.split(',').map(Number); - if (parts.length === 3 && parts.every((n) => !isNaN(n))) { - result.viewState = { - latitude: parts[0], - longitude: parts[1], - zoom: parts[2], - pitch: 0, - }; - } - } - - // Parse filters: f=name:min:max,name:val1|val2 - const f = params.get('f'); - if (f) { - const filters: FeatureFilters = {}; - for (const segment of f.split(',')) { - const colonIdx = segment.indexOf(':'); - if (colonIdx === -1) continue; - const name = segment.substring(0, colonIdx); - const rest = segment.substring(colonIdx + 1); - if (rest.includes(':')) { - // Numeric: name:min:max - const [minStr, maxStr] = rest.split(':'); - const min = Number(minStr); - const max = Number(maxStr); - if (!isNaN(min) && !isNaN(max)) { - filters[name] = [min, max]; - } - } else if (rest.includes('|')) { - // Enum: name:val1|val2 - filters[name] = rest.split('|'); - } else { - // Single enum value - filters[name] = [rest]; - } - } - if (Object.keys(filters).length > 0) { - result.filters = filters; - } - } - - // Parse POI categories: poi=Cafe,Pub,School - const poi = params.get('poi'); - if (poi) { - result.poiCategories = new Set(poi.split(',').filter(Boolean)); - } - - // Parse tab: tab=p or tab=o or tab=a - const tab = params.get('tab'); - if (tab === 'p') result.tab = 'properties'; - else if (tab === 'o') result.tab = 'pois'; - else if (tab === 'a') result.tab = 'area'; - - return result; -} - -function stateToParams( - viewState: { latitude: number; longitude: number; zoom: number } | null, - filters: FeatureFilters, - features: FeatureMeta[], - selectedPOICategories: Set, - rightPaneTab: 'pois' | 'properties' | 'area' -): URLSearchParams { - const params = new URLSearchParams(); - - // View - if (viewState) { - params.set( - 'v', - `${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}` - ); - } - - // Filters - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filtersStr = filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.set('f', filtersStr); - } - - // POI categories - if (selectedPOICategories.size > 0) { - params.set('poi', Array.from(selectedPOICategories).join(',')); - } - - // Tab (only if non-default) - if (rightPaneTab === 'properties') { - params.set('tab', 'p'); - } else if (rightPaneTab === 'area') { - params.set('tab', 'a'); - } - - return params; -} - -// --- Header --- - -type Page = 'home' | 'dashboard' | 'data-sources' | 'faq'; - -function Header({ - activePage, - onPageChange, - theme, - onToggleTheme, -}: { - activePage: Page; - onPageChange: (page: Page) => void; - theme: Theme; - onToggleTheme: () => void; -}) { - const [copied, setCopied] = useState(false); - - const handleShare = useCallback(() => { - navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, []); - - const tabClass = (page: Page) => - `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ - activePage === page - ? 'bg-navy-700 text-white' - : 'text-warm-300 hover:bg-navy-800 hover:text-white' - }`; - - return ( -
-
- - -
-
- - {activePage === 'dashboard' && ( - - )} -
-
- ); -} - -// --- App --- export default function App() { - // Parse URL state once on mount const urlState = useMemo(() => parseUrlState(), []); + const isScreenshotMode = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get('screenshot') === '1'; + }, []); + const [features, setFeatures] = useState([]); const [filters, setFilters] = useState(urlState.filters || {}); const [activeFeature, setActiveFeature] = useState(null); @@ -351,7 +62,6 @@ export default function App() { const abortControllerRef = useRef(null); const dragAbortRef = useRef(null); - // View state for URL serialization const [currentView, setCurrentView] = useState<{ latitude: number; longitude: number; @@ -366,10 +76,8 @@ export default function App() { : null ); - // Initial view state for Map const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []); - // POI state const [pois, setPois] = useState([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [selectedPOICategories, setSelectedPOICategories] = useState>( @@ -378,7 +86,6 @@ export default function App() { const poiDebounceRef = useRef | null>(null); const poiAbortControllerRef = useRef(null); - // Hexagon properties state const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>( null ); @@ -386,13 +93,13 @@ export default function App() { const [propertiesTotal, setPropertiesTotal] = useState(0); const [propertiesOffset, setPropertiesOffset] = useState(0); const [loadingProperties, setLoadingProperties] = useState(false); - const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(urlState.tab || 'pois'); + const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>( + urlState.tab || 'pois' + ); - // Area stats state const [areaStats, setAreaStats] = useState(null); const [loadingAreaStats, setLoadingAreaStats] = useState(false); - // Hover state const [hoveredHexagon, setHoveredHexagon] = useState(null); const [hoveredAreaStats, setHoveredAreaStats] = useState(null); const [hoveredProperties, setHoveredProperties] = useState(null); @@ -402,8 +109,9 @@ export default function App() { const hoverAbortRef = useRef(null); const hoverDebounceRef = useRef | null>(null); const [initialLoading, setInitialLoading] = useState(true); + const [activePage, setActivePage] = useState(() => { - // Restore from history state if available (e.g. back/forward navigation) + if (isScreenshotMode) return 'dashboard'; if (window.history.state?.page) return window.history.state.page; const params = new URLSearchParams(window.location.search); return params.has('v') || params.has('f') || params.has('poi') || params.has('tab') @@ -411,24 +119,21 @@ export default function App() { : 'home'; }); - // Feature name to auto-open in the info popup after back navigation const [pendingInfoFeature, setPendingInfoFeature] = useState(null); - // Navigate between pages with history support const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { - // Before pushing, tag the current state with the info feature so back restores it if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } - const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`; + const url = hash + ? `${window.location.pathname}${window.location.search}#${hash}` + : `${window.location.pathname}${window.location.search}`; window.history.pushState({ page }, '', url); setActivePage(page); trackPageview(); }, []); - // Handle browser back/forward useEffect(() => { - // Tag the initial state so popstate can restore it if (!window.history.state?.page) { window.history.replaceState({ page: activePage }, ''); } @@ -444,37 +149,19 @@ export default function App() { return () => window.removeEventListener('popstate', handlePopState); }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Theme state — defaults to system preference on first visit - const [theme, setTheme] = useState(() => { - const stored = localStorage.getItem('theme'); - if (stored === 'light' || stored === 'dark') return stored; - return 'light'; - }); + const { theme, toggleTheme } = useTheme(); - // Sync dark class on and persist to localStorage useEffect(() => { - const root = document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); + if (isScreenshotMode && !initialLoading && rawData.length > 0) { + window.__og_ready = true; } - localStorage.setItem('theme', theme); - }, [theme]); + }, [isScreenshotMode, initialLoading, rawData]); - const toggleTheme = useCallback(() => { - setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); - }, []); - - // Derive enabled features from filter keys const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); - // Derive view feature: active drag takes priority over pinned const viewFeature = activeFeature || pinnedFeature; const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; - // Color range: use the filter slider range when a numeric filter is active, - // otherwise fall back to the feature's full range from metadata. - // For enum features, use ordinal index range [0, values.length - 1]. + const colorRange = useMemo((): [number, number] | null => { if (!viewFeature) return null; const meta = features.find((f) => f.name === viewFeature); @@ -482,7 +169,6 @@ export default function App() { if (meta.type === 'enum' && meta.values && meta.values.length > 0) { return [0, meta.values.length - 1]; } - // Use live drag values or committed filter range if available if (activeFeature === viewFeature && dragValue) return dragValue; const filterVal = filters[viewFeature]; if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number]; @@ -490,7 +176,6 @@ export default function App() { return null; }, [viewFeature, features, activeFeature, dragValue, filters]); - // Filter range: current drag or committed filter values, used for gray-out const filterRange = useMemo((): [number, number] | null => { if (!viewFeature) return null; if (activeFeature && dragValue) return dragValue; @@ -499,32 +184,8 @@ export default function App() { return null; }, [viewFeature, activeFeature, dragValue, filters]); - // --- URL sync --- - const urlDebounceRef = useRef | null>(null); + useUrlSync(currentView, filters, features, selectedPOICategories, rightPaneTab); - useEffect(() => { - if (urlDebounceRef.current) { - clearTimeout(urlDebounceRef.current); - } - urlDebounceRef.current = setTimeout(() => { - const params = stateToParams( - currentView, - filters, - features, - selectedPOICategories, - rightPaneTab - ); - const search = params.toString(); - const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; - window.history.replaceState({ ...window.history.state }, '', newUrl); - }, URL_DEBOUNCE_MS); - - return () => { - if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); - }; - }, [currentView, filters, features, selectedPOICategories, rightPaneTab]); - - // Fetch feature metadata + POI categories on mount with exponential backoff useEffect(() => { const controller = new AbortController(); let featuresLoaded = false; @@ -560,23 +221,11 @@ export default function App() { return () => controller.abort(); }, []); - // Build filter query string helper - const buildFilterParam = useCallback((): string => { - const filterEntries = Object.entries(filters); - if (filterEntries.length === 0) return ''; - return filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - }, [filters, features]); + const buildFilterParam = useCallback( + (): string => buildFilterString(filters, features), + [filters, features] + ); - // Debounced fetch when resolution/bounds/filters change — always fetch hexagons useEffect(() => { if (!bounds) return; @@ -600,7 +249,6 @@ export default function App() { bounds: boundsStr, }); if (filtersStr) params.set('filters', filtersStr); - // Only request data for the actively viewed feature (reduces bandwidth) if (viewFeature) { params.set('fields', viewFeature); } else { @@ -627,11 +275,8 @@ export default function App() { }; }, [resolution, bounds, filters, buildFilterParam, viewFeature]); - // During slider drag, use the expanded dataset (without active feature filter) - // so both narrowing and expanding are visible. Otherwise use server-filtered data. const data = dragData ?? rawData; - // Fetch POIs when bounds or selected categories change useEffect(() => { if (!bounds || selectedPOICategories.size === 0) { setPois([]); @@ -683,7 +328,6 @@ export default function App() { latitude, longitude, }: ViewChangeParams) => { - // Only update bounds/resolution when quantized values actually change const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; if (boundsKey !== prevBoundsRef.current) { prevBoundsRef.current = boundsKey; @@ -725,12 +369,11 @@ export default function App() { const handleDragStart = useCallback( (name: string) => { const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') return; // No drag interaction for enum features + if (meta?.type === 'enum') return; setActiveFeature(name); const fval = filters[name]; setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); - // Fetch hexagons without this feature's filter so we can expand the range if (!bounds) return; if (dragAbortRef.current) dragAbortRef.current.abort(); dragAbortRef.current = new AbortController(); @@ -751,7 +394,6 @@ export default function App() { const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr }); if (filtersStr) params.set('filters', filtersStr); - // Only request the dragged feature's data params.set('fields', name); fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { @@ -799,20 +441,8 @@ export default function App() { h3, resolution: res.toString(), }); - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((feature) => feature.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } + const filterStr = buildFilterString(filters, features); + if (filterStr) params.append('filters', filterStr); if (fields) { params.set('fields', fields.join(',')); } @@ -833,21 +463,8 @@ export default function App() { offset: offset.toString(), }); - // Add current filters - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } + const filterStr = buildFilterString(filters, features); + if (filterStr) params.append('filters', filterStr); const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`); const data: HexagonPropertiesResponse = await response.json(); @@ -871,14 +488,13 @@ export default function App() { const handleHexagonClick = useCallback( (h3: string) => { if (selectedHexagon?.h3 === h3) { - // Deselect if clicking same hexagon setSelectedHexagon(null); setProperties([]); setAreaStats(null); } else { setSelectedHexagon({ h3, resolution }); setPropertiesOffset(0); - setRightPaneTab('area'); // Auto-switch to area tab + setRightPaneTab('area'); setLoadingAreaStats(true); fetchHexagonStats(h3, resolution) .then((stats) => setAreaStats(stats)) @@ -914,9 +530,13 @@ export default function App() { try { if (rightPaneTab === 'area') { setLoadingHoveredAreaStats(true); - // On hover, only fetch stats for features that have active filters const hoverFields = Object.keys(filters); - const stats = await fetchHexagonStats(h3, resolution, signal, hoverFields.length > 0 ? hoverFields : undefined); + const stats = await fetchHexagonStats( + h3, + resolution, + signal, + hoverFields.length > 0 ? hoverFields : undefined + ); if (!signal.aborted) setHoveredAreaStats(stats); } else if (rightPaneTab === 'properties') { const params = new URLSearchParams({ @@ -925,18 +545,8 @@ export default function App() { limit: '3', offset: '0', }); - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((feature) => feature.name === name); - if (meta?.type === 'enum') return `${name}:${(value as string[]).join('|')}`; - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } + const filterStr = buildFilterString(filters, features); + if (filterStr) params.append('filters', filterStr); const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, { signal, }); @@ -978,9 +588,38 @@ export default function App() { setAreaStats(null); }, []); + if (isScreenshotMode) { + return ( +
+ {}} + onHexagonHover={() => {}} + initialViewState={initialViewState} + theme={theme} + /> +
+ ); + } + return (
-
+
{activePage === 'home' ? ( navigateTo('dashboard')} theme={theme} /> ) : activePage === 'data-sources' ? ( @@ -1065,7 +704,6 @@ export default function App() { navigateTo('data-sources')} />
- {/* Tab headers */}
- {/* Tab content */}
{rightPaneTab === 'area' ? ( { - const hexId = hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 + hexagonLocation={(() => { + const hexId = + hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? hoveredHexagon : selectedHexagon?.h3; - const hex = hexId ? data.find((d) => d.h3 === hexId) : null; - if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; - return { - lat: hex.lat as number, - lon: hex.lon as number, - postcode: (hex.postcode as string | undefined) ?? null, - resolution, - }; - })() - } + const hex = hexId ? data.find((d) => d.h3 === hexId) : null; + if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') + return null; + return { + lat: hex.lat as number, + lon: hex.lon as number, + postcode: (hex.postcode as string | undefined) ?? null, + resolution, + }; + })()} filters={filters} /> ) : rightPaneTab === 'properties' ? ( @@ -1134,11 +786,20 @@ export default function App() { properties={hoverMode && hoveredProperties ? hoveredProperties : properties} total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal} loading={loadingProperties} - hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null} + hexagonId={ + hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null + } onLoadMore={handleLoadMoreProperties} onClose={handleCloseProperties} onNavigateToSource={(slug) => navigateTo('data-sources', slug)} - isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)} + isHoveredPreview={ + !!( + hoverMode && + hoveredProperties && + hoveredHexagon && + hoveredHexagon !== selectedHexagon?.h3 + ) + } hoverMode={hoverMode} onHoverModeChange={setHoverMode} /> diff --git a/frontend/src/components/AreaPane.tsx b/frontend/src/components/AreaPane.tsx index 3d551fb..88ba313 100644 --- a/frontend/src/components/AreaPane.tsx +++ b/frontend/src/components/AreaPane.tsx @@ -1,12 +1,10 @@ import { useMemo } from 'react'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types'; - -interface HexagonLocation { - lat: number; - lon: number; - postcode: string | null; - resolution: number; -} +import type { HexagonLocation } from '../lib/external-search'; +import { formatValue } from '../lib/format'; +import { DualHistogram, LoadingSkeleton } from './DualHistogram'; +import EnumBarChart from './EnumBarChart'; +import ExternalSearchLinks from './ExternalSearchLinks'; interface AreaPaneProps { stats: HexagonStatsResponse | null; @@ -22,17 +20,7 @@ interface AreaPaneProps { filters: FeatureFilters; } -function formatValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`; - if (Number.isInteger(value)) return value.toLocaleString(); - return value.toFixed(1); -} - -// Group features by their group field from globalFeatures -function groupFeatures( - globalFeatures: FeatureMeta[] -): { name: string; features: FeatureMeta[] }[] { +function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] { const groups: { name: string; features: FeatureMeta[] }[] = []; const seen = new Set(); for (const feature of globalFeatures) { @@ -46,302 +34,6 @@ function groupFeatures( return groups; } -function downsampleBars(counts: number[], targetBars: number): number[] { - const step = Math.max(1, Math.floor(counts.length / targetBars)); - const bars: number[] = []; - for (let index = 0; index < counts.length; index += step) { - let sum = 0; - for (let offset = 0; offset < step && index + offset < counts.length; offset++) { - sum += counts[index + offset]; - } - bars.push(sum); - } - return bars; -} - -function DualHistogram({ - localCounts, - globalCounts, - min, - max, - globalMean, -}: { - localCounts: number[]; - globalCounts: number[]; - min: number; - max: number; - globalMean?: number; -}) { - const targetBars = 25; - const localBars = downsampleBars(localCounts, targetBars); - const globalBars = downsampleBars(globalCounts, targetBars); - - const barCount = Math.min(localBars.length, globalBars.length); - const localMax = Math.max(...localBars, 1); - const globalMax = Math.max(...globalBars, 1); - - const meanFraction = - globalMean != null && max > min ? (globalMean - min) / (max - min) : null; - - return ( -
-
- {Array.from({ length: barCount }).map((_, index) => { - const globalHeight = (globalBars[index] / globalMax) * 100; - const localHeight = (localBars[index] / localMax) * 100; - return ( -
-
-
0 ? 1 : 0.1, - }} - /> -
- ); - })} - {meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && ( -
- )} -
-
- ); -} - -function SkeletonHistogram() { - return ( -
-
-
-
-
-
- {Array.from({ length: 15 }).map((_, i) => ( -
- ))} -
-
-
-
-
-
- ); -} - -function LoadingSkeleton() { - return ( -
- {[0, 1, 2].map((groupIdx) => ( -
-
-
- {Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( - - ))} -
-
- ))} -
- ); -} - -// Map app property types to each site's expected values -const PROPERTY_TYPE_MAP: Record = { - 'House': { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' }, - 'Detached': { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' }, - 'Semi-Detached': { rightmove: 'semi-detached', onthemarket: 'semi-detached', zoopla: 'semi_detached' }, - 'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' }, - 'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' }, - 'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' }, - 'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' }, - 'Flat': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' }, - 'Maisonette': { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' }, - 'Bungalow': { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' }, - 'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' }, -}; - -// Approximate H3 hex edge length in miles by resolution -// See https://h3geo.org/docs/core-library/restable -const H3_RADIUS_MILES: Record = { - 4: 15, // ~24km edge → ~15mi - 5: 6, // ~9km → ~6mi - 6: 3, // ~3.5km → ~3mi - 7: 1, // ~1.3km → ~1mi - 8: 0.5, // ~0.5km → ~0.3mi, round up - 9: 0.25, // ~0.17km - 10: 0.25, // ~0.07km - 11: 0.25, // ~0.025km - 12: 0.25, -}; - -// Rightmove only accepts specific radius values -const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40]; -// OnTheMarket and Zoopla accept similar sets -const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40]; -const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30]; - -function nearestRadius(target: number, allowed: number[]): number { - return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best)); -} - -function buildPropertySearchUrls( - location: HexagonLocation, - filters: FeatureFilters -): { rightmove: string; onthemarket: string; zoopla: string } { - const { lat, lon, postcode, resolution } = location; - const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1; - const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`; - - // Extract price filters - const priceFilter = filters['Last known price']; - const minPrice = Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined; - const maxPrice = Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined; - - // Extract property type filters - const propertyTypes = filters['Property type']; - const selectedTypes = Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string' ? propertyTypes as string[] : []; - - // --- Rightmove --- - // Rightmove accepts both postcodes and lat,lon in searchLocation - const rmParams = new URLSearchParams(); - rmParams.set('searchLocation', postcode || coordStr); - rmParams.set('channel', 'BUY'); - rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); - if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); - if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); - if (selectedTypes.length > 0) { - const rmTypes = [...new Set(selectedTypes.flatMap((t) => { - const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; - return mapped ? mapped.split(',') : []; - }))]; - if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); - } - const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`; - - // --- OnTheMarket --- - let otmType = 'property'; - if (selectedTypes.length > 0) { - const otmTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean))]; - if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!; - } - const otmParams = new URLSearchParams(); - otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII))); - if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice))); - if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice))); - let onthemarket: string; - if (postcode) { - const slug = postcode.replace(/\s+/g, '-').toLowerCase(); - onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`; - } else { - // Use lat/lon search with geo params for bigger hexagons without a postcode - otmParams.set('search-site', 'geo'); - otmParams.set('geo-lat', String(lat)); - otmParams.set('geo-lng', String(lon)); - onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`; - } - - // --- Zoopla --- - const zParams = new URLSearchParams(); - zParams.set('q', postcode || coordStr); - zParams.set('search_source', 'for-sale'); - zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII))); - if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice))); - if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice))); - if (selectedTypes.length > 0) { - const zTypes = [...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean))]; - for (const zt of zTypes) { - zParams.append('property_sub_type', zt!); - } - } - let zoopla: string; - if (postcode) { - const slug = postcode.replace(/\s+/g, '-').toLowerCase(); - zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`; - } else { - // Use coordinate-based path for bigger hexagons - zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`); - zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`; - } - - return { rightmove, onthemarket, zoopla }; -} - -function ExternalSearchLinks({ location, filters }: { location: HexagonLocation; filters: FeatureFilters }) { - const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]); - const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1; - const label = location.postcode || `${radiusMiles}mi radius`; - - return ( -
-

- Search {label} on -

- -
- ); -} - -function EnumBarChart({ counts }: { counts: Record }) { - const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); - const maxCount = Math.max(...entries.map(([, count]) => count), 1); - - return ( -
- {entries.map(([label, count]) => ( -
- - {label} - -
-
-
- {count} -
- ))} -
- ); -} - export default function AreaPane({ stats, globalFeatures, @@ -357,7 +49,6 @@ export default function AreaPane({ }: AreaPaneProps) { const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]); - // Build lookup maps from stats const numericByName = useMemo(() => { if (!stats) return new Map(); return new Map(stats.numeric_features.map((feature) => [feature.name, feature])); @@ -368,7 +59,6 @@ export default function AreaPane({ return new Map(stats.enum_features.map((feature) => [feature.name, feature])); }, [stats]); - // Build lookup for global feature metadata (for histogram overlay) const globalFeatureByName = useMemo( () => new Map(globalFeatures.map((f) => [f.name, f])), [globalFeatures] @@ -384,7 +74,6 @@ export default function AreaPane({ return (
- {/* Header */}
@@ -403,18 +92,40 @@ export default function AreaPane({ ? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300' }`} - title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'} + title={ + hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)' + } > - - - + + + @@ -435,17 +146,16 @@ export default function AreaPane({ )}
- {/* External search links */} - {hexagonLocation && stats && } + {hexagonLocation && stats && ( + + )} - {/* Stats content */}
{loading && !stats ? ( ) : stats ? (
{featureGroups.map((group) => { - // Check if any feature in this group has data const hasData = group.features.some( (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) ); @@ -464,14 +174,14 @@ export default function AreaPane({ if (numericStats) { const globalFeature = globalFeatureByName.get(feature.name); const globalHistogram = globalFeature?.histogram; - // Compute a global mean from the global histogram for the mean line let globalMean: number | undefined; if (globalHistogram && globalHistogram.counts.length > 0) { const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0); if (totalCount > 0) { let weightedSum = 0; for (let i = 0; i < globalHistogram.counts.length; i++) { - const binCenter = globalHistogram.min + (i + 0.5) * globalHistogram.bin_width; + const binCenter = + globalHistogram.min + (i + 0.5) * globalHistogram.bin_width; weightedSum += binCenter * globalHistogram.counts[i]; } globalMean = weightedSum / totalCount; @@ -479,7 +189,10 @@ export default function AreaPane({ } return ( -
+
{feature.name} @@ -514,7 +227,10 @@ export default function AreaPane({ if (enumStats) { return ( -
+
{feature.name} diff --git a/frontend/src/components/DataSourcesPage.tsx b/frontend/src/components/DataSourcesPage.tsx index f836a26..f39b8a8 100644 --- a/frontend/src/components/DataSourcesPage.tsx +++ b/frontend/src/components/DataSourcesPage.tsx @@ -97,6 +97,14 @@ const DATA_SOURCES = [ url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0', }, + { + id: 'council-tax', + name: 'Council Tax Levels 2025-26', + origin: 'Ministry of Housing, Communities & Local Government', + use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.', + url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026', + license: 'Open Government Licence v3.0', + }, ]; export default function DataSourcesPage() { @@ -135,7 +143,9 @@ export default function DataSourcesPage() {
{ cardRefs.current[source.id] = el; }} + ref={(el) => { + cardRefs.current[source.id] = el; + }} className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${ highlightedId === source.id ? 'border-teal-400 ring-2 ring-teal-400' @@ -143,12 +153,16 @@ export default function DataSourcesPage() { }`} >
-

{source.name}

+

+ {source.name} +

{source.license}
-

Source: {source.origin}

+

+ Source: {source.origin} +

{source.use}

min ? (globalMean - min) / (max - min) : null; + + return ( +
+
+ {Array.from({ length: barCount }).map((_, index) => { + const globalHeight = (globalBars[index] / globalMax) * 100; + const localHeight = (localBars[index] / localMax) * 100; + return ( +
+
+
0 ? 1 : 0.1, + }} + /> +
+ ); + })} + {meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && ( +
+ )} +
+
+ ); +} + +export function SkeletonHistogram() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 15 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ); +} + +export function LoadingSkeleton() { + return ( +
+ {[0, 1, 2].map((groupIdx) => ( +
+
+
+ {Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/EnumBarChart.tsx b/frontend/src/components/EnumBarChart.tsx new file mode 100644 index 0000000..8b305ae --- /dev/null +++ b/frontend/src/components/EnumBarChart.tsx @@ -0,0 +1,23 @@ +export default function EnumBarChart({ counts }: { counts: Record }) { + const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); + const maxCount = Math.max(...entries.map(([, count]) => count), 1); + + return ( +
+ {entries.map(([label, count]) => ( +
+ + {label} + +
+
+
+ {count} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/ExternalSearchLinks.tsx b/frontend/src/components/ExternalSearchLinks.tsx new file mode 100644 index 0000000..2ae4bd2 --- /dev/null +++ b/frontend/src/components/ExternalSearchLinks.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import type { FeatureFilters } from '../types'; +import { + buildPropertySearchUrls, + H3_RADIUS_MILES, + type HexagonLocation, +} from '../lib/external-search'; + +export default function ExternalSearchLinks({ + location, + filters, +}: { + location: HexagonLocation; + filters: FeatureFilters; +}) { + const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]); + const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1; + const label = location.postcode || `${radiusMiles}mi radius`; + + return ( +
+ ); +} diff --git a/frontend/src/components/FAQPage.tsx b/frontend/src/components/FAQPage.tsx index 36bd032..da6b582 100644 --- a/frontend/src/components/FAQPage.tsx +++ b/frontend/src/components/FAQPage.tsx @@ -29,7 +29,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'What does the eye icon do on a filter?', answer: - 'The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature\'s value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.', + "The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.", }, { question: 'How fresh is the data?', @@ -39,7 +39,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'How are EPC records matched to Land Registry sales?', answer: - 'EPC and Land Registry records don\'t share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.', + "EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.", }, { question: 'What are Points of Interest (POIs)?', diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 6c8e396..33176ea 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -2,6 +2,8 @@ import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Slider } from './ui/slider'; import { Label } from './ui/label'; import type { FeatureMeta, FeatureFilters } from '../types'; +import { formatFilterValue } from '../lib/format'; +import InfoPopup from './InfoPopup'; interface FiltersProps { features: FeatureMeta[]; @@ -39,68 +41,6 @@ function EyeIcon({ filled, className }: { filled: boolean; className?: string }) ); } -function InfoPopup({ - feature, - onClose, - onNavigateToSource, -}: { - feature: FeatureMeta; - onClose: () => void; - onNavigateToSource?: (slug: string, featureName: string) => void; -}) { - const popupRef = useRef(null); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (popupRef.current && !popupRef.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose]); - - return ( -
-
-
-

- {feature.name} -

- -
- {feature.description && ( -

{feature.description}

- )} - {feature.detail && ( -

{feature.detail}

- )} - {feature.source && onNavigateToSource && ( - - )} -
-
- ); -} - function FeatureBrowser({ availableFeatures, allFeatures, @@ -123,7 +63,6 @@ function FeatureBrowser({ const [search, setSearch] = useState(''); const [infoFeature, setInfoFeature] = useState(null); - // Auto-open info popup when navigating back useEffect(() => { if (openInfoFeature) { const feat = allFeatures.find((f) => f.name === openInfoFeature); @@ -181,7 +120,9 @@ function FeatureBrowser({
{f.name} {f.description && ( - {f.description} + + {f.description} + )}
@@ -191,7 +132,13 @@ function FeatureBrowser({ className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded" title="Feature info" > - + @@ -209,7 +156,13 @@ function FeatureBrowser({ className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded" title="Add filter" > - + @@ -227,22 +180,36 @@ function FeatureBrowser({
{infoFeature && ( setInfoFeature(null)} - onNavigateToSource={onNavigateToSource} - /> + sourceLink={ + infoFeature.source && onNavigateToSource + ? { + label: 'View data source', + onClick: () => { + onNavigateToSource(infoFeature.source!, infoFeature.name); + setInfoFeature(null); + }, + } + : undefined + } + > + {infoFeature.description && ( +

+ {infoFeature.description} +

+ )} + {infoFeature.detail && ( +

+ {infoFeature.detail} +

+ )} +
)} ); } -function formatValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - if (Number.isInteger(value)) return value.toString(); - return value.toFixed(2); -} - export default memo(function Filters({ features, filters, @@ -258,7 +225,7 @@ export default memo(function Filters({ zoom, pinnedFeature, onTogglePin, - onCancelPin, + onCancelPin: _onCancelPin, onNavigateToSource, openInfoFeature, onClearOpenInfoFeature, @@ -270,38 +237,35 @@ export default memo(function Filters({ const [splitFraction, setSplitFraction] = useState(0.65); const draggingRef = useRef(false); - const handleSeparatorPointerDown = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - draggingRef.current = true; - }, - [] - ); + const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + draggingRef.current = true; + }, []); - const handleSeparatorPointerMove = useCallback( - (e: React.PointerEvent) => { - if (!draggingRef.current || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const y = e.clientY - rect.top; - const fraction = Math.min(0.8, Math.max(0.15, y / rect.height)); - setSplitFraction(fraction); - }, - [] - ); + const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => { + if (!draggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const y = e.clientY - rect.top; + const fraction = Math.min(0.8, Math.max(0.15, y / rect.height)); + setSplitFraction(fraction); + }, []); const handleSeparatorPointerUp = useCallback(() => { draggingRef.current = false; }, []); return ( -
- {/* Top: Active filters — user-resizable, scrollable */} +
- {/* Active Filters header */}
- Active Filters + + Active Filters + {enabledFeatureList.length > 0 && ( {enabledFeatureList.length} @@ -314,11 +278,25 @@ export default memo(function Filters({
{enabledFeatureList.length === 0 && (
- - + + - No active filters - Browse features below and click + to add a filter + + No active filters + + + Browse features below and click + to add a filter +
)} @@ -327,14 +305,21 @@ export default memo(function Filters({ const selectedValues = (filters[feature.name] as string[]) || []; const allValues = feature.values || []; return ( -
+
@@ -363,7 +348,10 @@ export default memo(function Filters({
{allValues.map((val) => ( -