Quick save

This commit is contained in:
Andras Schmelczer 2026-02-07 22:19:44 +00:00
parent e5d5819098
commit 2906b01734
25 changed files with 1070 additions and 237 deletions

View file

@ -4,14 +4,17 @@ import MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
import { useSavedSearches } from './hooks/useSavedSearches';
declare global {
interface Window {
@ -19,6 +22,25 @@ declare global {
}
}
function pageToPath(page: Page): string {
switch (page) {
case 'dashboard': return '/dashboard';
case 'data-sources': return '/data-sources';
case 'faq': return '/faq';
case 'saved-searches': return '/saved';
default: return '/';
}
}
function pathToPage(pathname: string): Page | null {
if (pathname === '/dashboard') return 'dashboard';
if (pathname === '/data-sources') return 'data-sources';
if (pathname === '/faq') return 'faq';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/') return 'home';
return null;
}
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
@ -27,6 +49,10 @@ export default function App() {
const params = new URLSearchParams(window.location.search);
return params.get('screenshot') === '1';
}, []);
const isOgMode = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get('og') === '1';
}, []);
// Core data
const [features, setFeatures] = useState<FeatureMeta[]>([]);
@ -37,11 +63,27 @@ export default function App() {
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard';
// Derive page from URL pathname
const fromPath = pathToPage(window.location.pathname);
if (fromPath) return fromPath;
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
// Backward compat: dashboard params on unknown path
const params = new URLSearchParams(window.location.search);
return params.has('v') || params.has('f') || params.has('poi') || params.has('tab')
? 'dashboard'
: 'home';
if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) {
// Rewrite URL to /dashboard keeping query params
window.history.replaceState(
{ page: 'dashboard' },
'',
`/dashboard${window.location.search}`
);
return 'dashboard';
}
return 'home';
});
const { theme, toggleTheme } = useTheme();
@ -52,9 +94,14 @@ export default function App() {
login,
register,
logout,
requestPasswordReset,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
// Load features and POI categories on mount
useEffect(() => {
@ -92,21 +139,15 @@ export default function App() {
return () => controller.abort();
}, []);
// Screenshot mode ready signal
useEffect(() => {
if (isScreenshotMode && !initialLoading && features.length > 0) {
window.__og_ready = true;
}
}, [isScreenshotMode, initialLoading, features]);
// Screenshot mode ready signal — MapPage sets __og_ready once map data loads
// Navigation
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
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 path = pageToPath(page);
const url = hash ? `${path}#${hash}` : path;
window.history.pushState({ page }, '', url);
setActivePage(page);
trackPageview();
@ -114,7 +155,11 @@ export default function App() {
useEffect(() => {
if (!window.history.state?.page) {
window.history.replaceState({ page: activePage }, '');
window.history.replaceState(
{ page: activePage },
'',
pageToPath(activePage) + window.location.search + window.location.hash
);
}
const handlePopState = (e: PopStateEvent) => {
if (e.state?.page) {
@ -122,12 +167,23 @@ export default function App() {
if (e.state.infoFeature) {
setPendingInfoFeature(e.state.infoFeature);
}
} else {
// Fall back to deriving page from pathname
const page = pathToPage(window.location.pathname);
setActivePage(page || 'home');
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch saved searches when page becomes active
useEffect(() => {
if (activePage === 'saved-searches') {
savedSearches.fetchSearches();
}
}, [activePage, savedSearches.fetchSearches]);
const [exportState, setExportState] = useState<ExportState | null>(null);
if (isScreenshotMode) {
@ -145,6 +201,7 @@ export default function App() {
onClearPendingInfoFeature={() => {}}
onNavigateTo={() => {}}
screenshotMode
ogMode={isOgMode}
/>
);
}
@ -158,8 +215,17 @@ export default function App() {
onToggleTheme={toggleTheme}
onExport={exportState?.onExport ?? null}
exporting={exportState?.exporting ?? false}
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
savingSearch={savedSearches.saving}
user={user}
onLoginClick={() => setShowAuthModal(true)}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLogout={logout}
/>
{activePage === 'home' ? (
@ -168,6 +234,15 @@ export default function App() {
<DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}
loading={savedSearches.loading}
onDelete={savedSearches.deleteSearch}
onOpen={(params) => {
window.location.href = `/?${params}`;
}}
/>
) : (
<MapPage
features={features}
@ -189,9 +264,19 @@ export default function App() {
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
onClearError={clearError}
initialTab={authModalTab}
/>
)}
{showSaveModal && (
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
</div>

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format';
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
@ -11,6 +11,8 @@ import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
import { IconButton } from '../ui/IconButton';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -28,6 +30,10 @@ interface AreaPaneProps {
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
onNavigateToSource?: (slug: string, featureName: string) => void;
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
onRetryAiSummary?: () => void;
}
export default function AreaPane({
@ -42,11 +48,24 @@ export default function AreaPane({
hexagonLocation,
filters,
onNavigateToSource,
aiSummary,
aiSummaryLoading,
aiSummaryError,
onRetryAiSummary,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const toggleGroup = (name: string) =>
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
const numericByName = useMemo(() => {
if (!stats) return new Map();
@ -78,6 +97,7 @@ export default function AreaPane({
<div className="flex flex-col h-full">
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : 'Area Statistics'}
@ -86,6 +106,10 @@ export default function AreaPane({
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
)}
</div>
{loading && stats && (
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
)}
</div>
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
@ -109,17 +133,68 @@ export default function AreaPane({
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{/* AI Summary Card */}
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
<div className="px-3 pt-3 pb-1">
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
<div className="flex items-center gap-1.5 mb-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
</div>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
</div>
) : (
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
{aiSummary}
</p>
)}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div className="p-3 space-y-4">
{stats.price_history && stats.price_history.length > 0 && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
<div>
{/* Histogram color legend */}
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
</span>
</div>
</div>
</div>
)}
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
@ -136,12 +211,28 @@ export default function AreaPane({
) as string[] ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
return (
<div key={group.name}>
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
{group.name}
</h3>
<div className="space-y-3">
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && <div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' && stats.price_history && (() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
@ -218,7 +309,7 @@ export default function AreaPane({
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram && (
@ -343,10 +434,29 @@ export default function AreaPane({
</div>
);
})}
</div>
</div>}
</div>
);
})}
{/* Google Street View */}
{hexagonLocation && (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
Street View
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
<iframe
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&cbll=${hexagonLocation.lat},${hexagonLocation.lon}&cbp=11,0,0,0,0&output=svembed`}
/>
</div>
</div>
</div>
)}
</div>
) : null}
</div>

View file

@ -2,7 +2,8 @@ import { memo, useState, useMemo, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { Label } from '../ui/Label';
import { SearchInput } from '../ui/SearchInput';
import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format';
@ -95,18 +96,16 @@ function FeatureBrowser({
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<button
onClick={() => toggleGroup(group.name)}
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
<CollapsibleGroupHeader
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span>{group.name}</span>
<div className="flex items-center gap-1">
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
</button>
</CollapsibleGroupHeader>
{isExpanded &&
group.features.map((f) => {
const isPinned = pinnedFeature === f.name;

View file

@ -41,7 +41,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
return (
<div
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-warm-200 pointer-events-none z-50 min-w-[180px] max-w-[260px]"
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
style={{
left: x,
top: y - 12,
@ -61,14 +61,14 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-1">
<span className="font-semibold text-navy-950 dark:text-warm-100 truncate">
<span className="font-semibold text-navy-950 dark:text-white truncate">
{isPostcode ? id : 'Area'}
</span>
</div>
{/* Property count */}
{count != null && (
<div className="text-xs text-warm-500 dark:text-warm-400 mb-2">
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
</div>
)}
@ -78,8 +78,8 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
{displayStats.map((stat) => (
<div key={stat.name} className="flex justify-between gap-2 text-xs">
<span className="text-warm-500 dark:text-warm-400 truncate">{stat.name}</span>
<span className="font-medium text-teal-700 dark:text-teal-400 whitespace-nowrap">
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
{stat.value}
</span>
</div>
@ -89,7 +89,7 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
{/* Hint */}
{data && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
<div className="text-[10px] text-warm-400 dark:text-warm-400 mt-2 text-center">
Click for details
</div>
)}

View file

@ -59,6 +59,7 @@ interface MapProps {
initialViewState?: ViewState;
theme?: 'light' | 'dark';
screenshotMode?: boolean;
ogMode?: boolean;
filters?: FeatureFilters;
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
@ -109,6 +110,7 @@ export default memo(function Map({
initialViewState,
theme = 'light',
screenshotMode = false,
ogMode = false,
filters = {},
searchedPostcode,
onPostcodeSearched,
@ -452,7 +454,7 @@ export default memo(function Map({
getPosition: (f) => f.properties.centroid,
getText: (f) => f.properties.postcode,
getSize: 12,
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
@ -530,8 +532,15 @@ export default memo(function Map({
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
setHoveredPostcode(null);
setPopupInfo(null);
onHexagonHoverRef.current(null);
}, []);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}>
<MapGL
{...viewState}
onMove={handleMove}
@ -550,6 +559,7 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
{screenshotMode ? (
ogMode ? (
<div className="absolute inset-0 flex items-center justify-center z-20 pointer-events-none">
<h1
className="text-5xl font-bold text-white drop-shadow-lg"
@ -558,17 +568,18 @@ export default memo(function Map({
Your perfect postcodes
</h1>
</div>
) : null
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewSource === 'eye' && viewFeature && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
<span className="text-lg font-semibold text-navy-950 dark:text-white">
Previewing &ldquo;{viewFeature}&rdquo;
</span>
<button
onClick={onCancelPin}
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
className="px-4 py-1.5 rounded-md bg-warm-200 dark:bg-warm-700 text-warm-700 dark:text-white hover:bg-warm-300 dark:hover:bg-warm-600 font-medium text-sm"
>
Cancel
</button>
@ -596,7 +607,7 @@ export default memo(function Map({
)}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,
@ -604,8 +615,8 @@ export default memo(function Map({
zIndex: 9999,
}}
>
<strong>{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-400 text-xs">{popupInfo.category}</div>
<strong className="dark:text-white">{popupInfo.name}</strong>
<div className="text-warm-500 dark:text-warm-300 text-xs">{popupInfo.category}</div>
{osmIdToUrl(popupInfo.id) && (
<a
href={osmIdToUrl(popupInfo.id)!}

View file

@ -24,9 +24,9 @@ export default function MapLegend({
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{featureLabel}</span>
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (
<button
onClick={onCancel}
@ -38,7 +38,7 @@ export default function MapLegend({
)}
</div>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<span>Few</span>

View file

@ -14,6 +14,8 @@ import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
import { useAreaSummary } from '../../hooks/useAreaSummary';
import { useUrlSync } from '../../hooks/useUrlSync';
import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -36,6 +38,7 @@ interface MapPageProps {
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean;
ogMode?: boolean;
}
export default function MapPage({
@ -52,34 +55,8 @@ export default function MapPage({
onNavigateTo,
onExportStateChange,
screenshotMode,
ogMode,
}: MapPageProps) {
if (screenshotMode) {
return (
<div className="h-screen w-screen">
<Map
data={[]}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={() => {}}
viewFeature={null}
colorRange={null}
filterRange={null}
viewSource={null}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
/>
</div>
);
}
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
@ -137,6 +114,9 @@ export default function MapPage({
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Sync current state to URL
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab);
// Set initial view and tab from URL state
useEffect(() => {
mapData.setInitialView(initialViewState);
@ -146,10 +126,30 @@ export default function MapPage({
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
const hexId = selection.selectedHexagon?.id;
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
} else {
// For hexagons, get lat/lon from hexagon data
const hex = hexId ? mapData.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, resolution: mapData.resolution };
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
}
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
// AI area summary
const aiSummary = useAreaSummary({
stats: selection.areaStats,
hexagonId: selection.selectedHexagon?.id || null,
isPostcode: selection.selectedHexagon?.type === 'postcode',
filters,
features,
});
// Export to Excel
const [exporting, setExporting] = useState(false);
@ -185,6 +185,41 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__og_ready = true;
}
}, [screenshotMode, mapData.loading, mapData.data.length]);
if (screenshotMode) {
return (
<div className="h-screen w-screen">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
onCancelPin={() => {}}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={() => {}}
onHexagonHover={() => {}}
initialViewState={initialViewState}
theme={theme}
screenshotMode
ogMode={ogMode}
/>
</div>
);
}
return (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
@ -295,6 +330,10 @@ export default function MapPage({
hexagonLocation={hexagonLocation}
filters={filters}
onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)}
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
) : selection.rightPaneTab === 'properties' ? (
<PropertiesPane

View file

@ -61,7 +61,7 @@ export default function PostcodeSearch({
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500"
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400"
/>
<button
type="submit"
@ -72,7 +72,7 @@ export default function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
<span className="text-xs text-red-600 dark:text-red-300 bg-white/90 dark:bg-navy-800/90 rounded px-2 py-0.5 shadow">
{error}
</span>
)}

View file

@ -1,6 +1,6 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types';
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
@ -8,7 +8,7 @@ interface PriceHistoryChartProps {
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const HEIGHT = 120;
const priceFmt = FEATURE_FORMATS['Last known price'];
const priceFmt = { prefix: '£' };
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);

View file

@ -89,7 +89,7 @@ export function PropertiesPane({
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<div className="p-4 dark:text-warm-400">Loading...</div>
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
@ -99,9 +99,16 @@ export function PropertiesPane({
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50"
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
Loading...
</span>
) : (
`Load More (${total - properties.length} remaining)`
)}
</button>
)}
</>
@ -111,6 +118,32 @@ export function PropertiesPane({
);
}
function PropertyLoadingSkeleton() {
return (
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div
key={idx}
className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse"
>
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
))}
</div>
</div>
))}
</div>
);
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');

View file

@ -0,0 +1,167 @@
import { useState, useCallback } from 'react';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
export default function SavedSearchesPage({
searches,
loading,
onDelete,
onOpen,
}: {
searches: SavedSearch[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onOpen: (params: string) => void;
}) {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const handleShare = useCallback((params: string, id: string) => {
const url = `${window.location.origin}/?${params}`;
const onSuccess = () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
return (
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Saved Searches</h1>
{loading ? (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : searches.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
{copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setDeleteConfirmId(null)}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
<button
onClick={() => setDeleteConfirmId(null)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
Are you sure you want to delete this saved search? This cannot be undone.
</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,31 +1,37 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
type Tab = 'login' | 'register';
type View = 'login' | 'register' | 'forgot';
export default function AuthModal({
onClose,
onLogin,
onRegister,
onForgotPassword,
loading,
error,
onClearError,
initialTab = 'login',
}: {
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string, name?: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
onClearError: () => void;
initialTab?: 'login' | 'register';
}) {
const [tab, setTab] = useState<Tab>('login');
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [resetSent, setResetSent] = useState(false);
const switchTab = useCallback(
(newTab: Tab) => {
setTab(newTab);
const switchView = useCallback(
(newView: View) => {
setView(newView);
setResetSent(false);
onClearError();
},
[onClearError]
@ -35,66 +41,73 @@ export default function AuthModal({
async (e: React.FormEvent) => {
e.preventDefault();
try {
if (tab === 'login') {
if (view === 'login') {
await onLogin(email, password);
} else {
await onRegister(email, password, name || undefined);
}
onClose();
} else if (view === 'register') {
await onRegister(email, password, name || undefined);
onClose();
} else {
await onForgotPassword(email);
setResetSent(true);
}
} catch {
// Error is handled by the hook
}
},
[tab, email, password, name, onLogin, onRegister, onClose]
[view, email, password, name, onLogin, onRegister, onForgotPassword, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
{tab === 'login' ? 'Log in' : 'Create account'}
</h2>
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
{/* Tabs (hidden in forgot view) */}
{view !== 'forgot' && (
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'login'
view === 'login'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('login')}
onClick={() => switchView('login')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'register'
view === 'register'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('register')}
onClick={() => switchView('register')}
>
Register
</button>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{tab === 'register' && (
{view === 'register' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
@ -103,7 +116,7 @@ export default function AuthModal({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="Your name (optional)"
/>
</div>
@ -118,11 +131,12 @@ export default function AuthModal({
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
@ -133,20 +147,54 @@ export default function AuthModal({
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
placeholder={tab === 'register' ? 'Min 8 characters' : 'Your password'}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>

View file

@ -0,0 +1,30 @@
import { ChevronIcon } from './icons/ChevronIcon';
interface CollapsibleGroupHeaderProps {
name: string;
expanded: boolean;
onToggle: () => void;
className?: string;
children?: React.ReactNode;
}
export function CollapsibleGroupHeader({
name,
expanded,
onToggle,
className = '',
children,
}: CollapsibleGroupHeaderProps) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-between ${className}`}
>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
</button>
);
}

View file

@ -1,14 +1,16 @@
import { useState, useCallback } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { SunIcon } from './icons/SunIcon';
import { MoonIcon } from './icons/MoonIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
export default function Header({
activePage,
@ -17,8 +19,11 @@ export default function Header({
onToggleTheme,
onExport,
exporting,
onSaveSearch,
savingSearch,
user,
onLoginClick,
onRegisterClick,
onLogout,
}: {
activePage: Page;
@ -27,17 +32,34 @@ export default function Header({
onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLogout: () => void;
}) {
const [copied, setCopied] = useState(false);
const handleShare = useCallback(() => {
navigator.clipboard.writeText(window.location.href).then(() => {
const url = window.location.href;
const onSuccess = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = url;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}, []);
const tabClass = (page: Page) =>
@ -67,20 +89,30 @@ export default function Header({
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
{user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
Saved
</button>
)}
</nav>
</div>
<div className="flex items-center gap-2">
{activePage === 'dashboard' && (
<>
{onSaveSearch && (
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
onClick={onSaveSearch}
disabled={savingSearch}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
{savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
<BookmarkIcon className="w-4 h-4" />
)}
Save
</button>
)}
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
@ -97,17 +129,34 @@ export default function Header({
</>
)}
</button>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
</>
)}
{user ? (
<UserMenu user={user} onLogout={onLogout} />
) : (
<>
<button
onClick={onLoginClick}
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
Log in
</button>
<button
onClick={onRegisterClick}
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
>
Register
</button>
</>
)}
<button
onClick={onToggleTheme}

View file

@ -0,0 +1,95 @@
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({
onClose,
onSave,
saving,
error,
}: {
onClose: () => void;
onSave: (name: string) => Promise<void>;
saving: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || saving) return;
try {
await onSave(name.trim());
onClose();
} catch {
// Error displayed in modal
}
},
[name, saving, onSave, onClose]
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function TrashIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}

View file

@ -17,7 +17,9 @@ interface UseAreaSummaryResult {
retry: () => void;
}
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other'];
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other',
'Environmental risk', 'Collapsible deposits risk', 'Compressible ground risk', 'Landslide risk', 'Running sand risk', 'Shrink-swell risk', 'Soluble rocks risk'
];
export function useAreaSummary({
stats,

View file

@ -100,9 +100,23 @@ export function useAuth() {
setUser(null);
}, []);
const requestPasswordReset = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestPasswordReset(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Password reset request failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return { user, loading, error, login, register, loginWithOAuth, logout, clearError };
return { user, loading, error, login, register, loginWithOAuth, logout, requestPasswordReset, clearError };
}

View file

@ -0,0 +1,101 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl } from '../lib/api';
export interface SavedSearch {
id: string;
name: string;
params: string;
screenshotUrl: string;
created: string;
}
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState<SavedSearch[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSearches = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
filter: `user = "${userId}"`,
});
setSearches(
records.map((r) => ({
id: r.id,
name: (r as Record<string, unknown>).name as string,
params: (r as Record<string, unknown>).params as string,
screenshotUrl: (r as Record<string, unknown>).screenshot
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
: '',
created: r.created,
}))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load searches');
} finally {
setLoading(false);
}
}, [userId]);
const saveSearch = useCallback(
async (name: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
const params = window.location.search.replace(/^\?/, '');
// Try to capture a screenshot via the OG image endpoint
let screenshotBlob: Blob | null = null;
try {
const ogUrl = apiUrl('og-image', new URLSearchParams(params));
const res = await fetch(ogUrl);
if (res.ok) {
screenshotBlob = await res.blob();
}
} catch {
// Screenshot is optional — save without it
}
const formData = new FormData();
formData.append('user', userId);
formData.append('name', name);
formData.append('params', params);
if (screenshotBlob) {
formData.append('screenshot', screenshotBlob, 'screenshot.png');
}
await pb.collection('saved_searches').create(formData);
await fetchSearches();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
);
const deleteSearch = useCallback(
async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
}
},
[]
);
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
}

View file

@ -8,6 +8,7 @@ export const MAX_RETRY_MS = 10000;
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
export const MAP_MIN_ZOOM = 5.5;
export const BUFFER_MULTIPLIER = 1.5;
export const INITIAL_VIEW_STATE: ViewState = {
longitude: -1.5,
@ -30,7 +31,7 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: Infinity, resolution: 10 },
] as const;
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
export const POSTCODE_ZOOM_THRESHOLD = 16;

View file

@ -15,48 +15,6 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
return `${p}${value.toFixed(1)}${s}`;
}
/** Lookup table for feature-specific formatting */
export const FEATURE_FORMATS: Record<string, ValueFormat> = {
// Property
'Last known price': { prefix: '£' },
'Price per sqm': { prefix: '£' },
'Total floor area (sqm)': { suffix: ' sqm' },
'Number of bedrooms & living rooms': { suffix: ' rooms' },
'Transaction year': { raw: true },
'Construction age': { raw: true },
// Transport
'Public transport to Bank (mins)': { suffix: ' mins' },
'Public transport to Fitzrovia (mins)': { suffix: ' mins' },
'Cycling to Bank (mins)': { suffix: ' mins' },
'Cycling to Fitzrovia (mins)': { suffix: ' mins' },
// Crime
'Anti-social behaviour (avg/yr)': { suffix: '/yr' },
'Violence and sexual offences (avg/yr)': { suffix: '/yr' },
'Criminal damage and arson (avg/yr)': { suffix: '/yr' },
'Burglary (avg/yr)': { suffix: '/yr' },
'Vehicle crime (avg/yr)': { suffix: '/yr' },
'Robbery (avg/yr)': { suffix: '/yr' },
'Other theft (avg/yr)': { suffix: '/yr' },
'Shoplifting (avg/yr)': { suffix: '/yr' },
'Drugs (avg/yr)': { suffix: '/yr' },
'Possession of weapons (avg/yr)': { suffix: '/yr' },
'Public order (avg/yr)': { suffix: '/yr' },
'Bicycle theft (avg/yr)': { suffix: '/yr' },
'Theft from the person (avg/yr)': { suffix: '/yr' },
'Other crime (avg/yr)': { suffix: '/yr' },
'Serious crime (avg/yr)': { suffix: '/yr' },
'Minor crime (avg/yr)': { suffix: '/yr' },
// Demographics
'% White': { suffix: '%' },
'% Asian': { suffix: '%' },
'% Black': { suffix: '%' },
'% Mixed': { suffix: '%' },
'% Other': { suffix: '%' },
// Environment
'Noise (dB)': { suffix: ' dB' },
'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true },
};
export function formatFilterValue(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`;
@ -81,6 +39,21 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
}
export function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 30) return `${diffDay}d ago`;
return new Date(isoDate).toLocaleDateString();
}
// Calculate weighted mean from histogram with outlier bins.
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
export function calculateHistogramMean(histogram: {

View file

@ -8,7 +8,7 @@ import {
DENSITY_GRADIENT_DARK,
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
POSTCODE_ZOOM_THRESHOLD,
BUFFER_MULTIPLIER,
} from './consts';
// Re-export constants for backwards compatibility
@ -21,9 +21,12 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
// Use absolute URL for tiles - required by MapLibre
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
const isDark = theme === 'dark';
// Reduce road layer opacity so hexagons are more visible
// In dark mode, make all text white with dark outline
const modifiedLayers = baseLayers.map((layer) => {
// Modify road opacity
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
@ -31,6 +34,20 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
}
}
// Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
return {
...layer,
paint: {
...layer.paint,
'text-color': '#ffffff',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1.5,
},
};
}
return layer;
});
@ -41,7 +58,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
protomaps: {
type: 'vector',
tiles: [tileUrl],
maxzoom: POSTCODE_ZOOM_THRESHOLD,
maxzoom: 17,
},
},
layers: modifiedLayers,
@ -139,15 +156,18 @@ export function getBoundsFromViewState(
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
const bufferedWidth = width * BUFFER_MULTIPLIER;
const bufferedHeight = height * BUFFER_MULTIPLIER;
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
const halfWidthDeg = (bufferedWidth / 2) * degreesPerPixelLng;
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
const topPixelY = centerPixelY - bufferedHeight / 2;
const bottomPixelY = centerPixelY + bufferedHeight / 2;
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));

View file

@ -104,3 +104,33 @@ export function stateToParams(
return params;
}
export function summarizeParams(queryString: string): string {
const params = new URLSearchParams(queryString);
const parts: string[] = [];
const f = params.get('f');
if (f) {
const filterNames = f.split(',').map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
}).filter(Boolean);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
? filterNames.join(', ')
: `${filterNames.length} filters`
);
}
}
const poi = params.get('poi');
if (poi) {
const count = poi.split(',').filter(Boolean).length;
if (count > 0) {
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
}
}
return parts.length > 0 ? parts.join(' + ') : 'No filters';
}

View file

@ -13,6 +13,10 @@ export interface FeatureMeta {
description?: string;
detail?: string;
source?: string;
// Display format fields
prefix?: string;
suffix?: string;
raw?: boolean;
}
export interface FeatureGroup {