Checkpoint all changes

This commit is contained in:
Andras Schmelczer 2026-02-01 19:30:33 +00:00
parent 65877acf95
commit 66c2a25457
28 changed files with 3035 additions and 621 deletions

View file

@ -3,8 +3,10 @@ import Map from './components/Map';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
import AreaPane from './components/AreaPane';
import DataSources from './components/DataSources';
import DataSourcesPage from './components/DataSourcesPage';
import FAQPage from './components/FAQPage';
import HomePage from './components/HomePage';
import type {
FeatureMeta,
@ -21,18 +23,43 @@ import type {
ViewState,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
} from './types';
type Theme = 'light' | 'dark';
const DEBOUNCE_MS = 150;
const URL_DEBOUNCE_MS = 300;
const INITIAL_RETRY_MS = 1000;
const MAX_RETRY_MS = 10000;
async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
signal: AbortSignal
): Promise<void> {
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 {
const { hostname, pathname, href } = window.location;
const { pathname, href } = window.location;
// Check pathname for /proxy/PORT pattern
// Check pathname for /proxy/PORT pattern (VS Code web proxy)
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
if (pathMatch) {
return `${pathMatch[1]}8001`;
@ -44,12 +71,7 @@ function getApiBaseUrl(): string {
return `${hrefMatch[1]}8001`;
}
// If not localhost, assume we're behind a proxy and need explicit backend port
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
return '/proxy/8001';
}
// Local development - webpack proxies /api to :8001
// Default: same origin (works for both local dev with webpack proxy and production)
return '';
}
@ -66,7 +88,7 @@ function parseUrlState(): {
viewState?: ViewState;
filters?: FeatureFilters;
poiCategories?: Set<string>;
tab?: 'pois' | 'properties';
tab?: 'pois' | 'properties' | 'area';
} {
const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {};
@ -121,10 +143,11 @@ function parseUrlState(): {
result.poiCategories = new Set(poi.split(',').filter(Boolean));
}
// Parse tab: tab=p or tab=o
// 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;
}
@ -134,7 +157,7 @@ function stateToParams(
filters: FeatureFilters,
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties'
rightPaneTab: 'pois' | 'properties' | 'area'
): URLSearchParams {
const params = new URLSearchParams();
@ -170,6 +193,8 @@ function stateToParams(
// Tab (only if non-default)
if (rightPaneTab === 'properties') {
params.set('tab', 'p');
} else if (rightPaneTab === 'area') {
params.set('tab', 'a');
}
return params;
@ -177,7 +202,7 @@ function stateToParams(
// --- Header ---
type Page = 'home' | 'dashboard' | 'data-sources';
type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
function Header({
activePage,
@ -200,9 +225,9 @@ function Header({
}, []);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm transition-colors ${
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
? 'bg-navy-700 font-semibold'
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
@ -234,16 +259,16 @@ function Header({
</svg>
<span className="font-semibold text-lg">Narrowit</span>
</button>
<nav className="flex items-center gap-1">
<button className={tabClass('home')} onClick={() => onPageChange('home')}>
Home
</button>
<nav className="flex items-center gap-2">
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
Dashboard
</button>
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
</nav>
</div>
<div className="flex items-center gap-2">
@ -355,8 +380,62 @@ 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'>(urlState.tab || 'pois');
const [activePage, setActivePage] = useState<Page>('home');
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(urlState.tab || 'pois');
// Area stats state
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
// Hover state
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
const [hoverMode, setHoverMode] = useState(true);
const hoverAbortRef = useRef<AbortController | null>(null);
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [activePage, setActivePage] = useState<Page>(() => {
// Restore from history state if available (e.g. back/forward navigation)
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')
? 'dashboard'
: 'home';
});
// Feature name to auto-open in the info popup after back navigation
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(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}`;
window.history.pushState({ page }, '', url);
setActivePage(page);
}, []);
// Handle browser back/forward
useEffect(() => {
// Tag the initial state so popstate can restore it
if (!window.history.state?.page) {
window.history.replaceState({ page: activePage }, '');
}
const handlePopState = (e: PopStateEvent) => {
if (e.state?.page) {
setActivePage(e.state.page);
if (e.state.infoFeature) {
setPendingInfoFeature(e.state.infoFeature);
}
}
};
window.addEventListener('popstate', handlePopState);
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<Theme>(() => {
@ -387,10 +466,15 @@ export default function App() {
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
// Color range: always the feature's full slider 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);
if (meta?.min != null && meta?.max != null) return [meta.min, meta.max];
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features]);
@ -420,7 +504,7 @@ export default function App() {
);
const search = params.toString();
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
window.history.replaceState(null, '', newUrl);
window.history.replaceState({ ...window.history.state }, '', newUrl);
}, URL_DEBOUNCE_MS);
return () => {
@ -428,25 +512,40 @@ export default function App() {
};
}, [currentView, filters, features, selectedPOICategories, rightPaneTab]);
// Fetch feature metadata + POI categories on mount
// Fetch feature metadata + POI categories on mount with exponential backoff
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { groups: FeatureGroup[] }) => {
// Flatten grouped response into a flat feature list with group annotation
const controller = new AbortController();
let featuresLoaded = false;
let poisLoaded = false;
const checkDone = () => {
if (featuresLoaded && poisLoaded) setInitialLoading(false);
};
fetchWithRetry<{ groups: FeatureGroup[] }>(
`${getApiBaseUrl()}/api/features`,
(json) => {
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
g.features.map((f) => ({ ...f, group: g.name }))
);
setFeatures(flat);
})
.catch((err) => console.error('Failed to fetch features:', err));
featuresLoaded = true;
checkDone();
},
controller.signal
);
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: POICategoriesResponse) => {
fetchWithRetry<POICategoriesResponse>(
`${getApiBaseUrl()}/api/poi-categories`,
(json) => {
setPOICategoryGroups(json.groups);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
poisLoaded = true;
checkDone();
},
controller.signal
);
return () => controller.abort();
}, []);
// Build filter query string helper
@ -674,6 +773,32 @@ export default function App() {
setPinnedFeature(null);
}, []);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal) => {
const params = new URLSearchParams({
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 response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
@ -726,16 +851,96 @@ export default function App() {
// Deselect if clicking same hexagon
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
} else {
setSelectedHexagon({ h3, resolution });
setPropertiesOffset(0);
setRightPaneTab('properties'); // Auto-switch to properties tab
fetchHexagonProperties(h3, resolution, 0);
setRightPaneTab('area'); // Auto-switch to area tab
setLoadingAreaStats(true);
fetchHexagonStats(h3, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch area stats:', error);
}
})
.finally(() => setLoadingAreaStats(false));
}
},
[selectedHexagon, resolution, fetchHexagonProperties]
[selectedHexagon, resolution, fetchHexagonStats]
);
const handleHexagonHover = useCallback(
(h3: string | null) => {
setHoveredHexagon(h3);
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
if (hoverAbortRef.current) hoverAbortRef.current.abort();
setHoveredAreaStats(null);
setHoveredProperties(null);
setHoveredPropertiesTotal(0);
return;
}
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
hoverDebounceRef.current = setTimeout(async () => {
if (hoverAbortRef.current) hoverAbortRef.current.abort();
hoverAbortRef.current = new AbortController();
const signal = hoverAbortRef.current.signal;
try {
if (rightPaneTab === 'area') {
setLoadingHoveredAreaStats(true);
const stats = await fetchHexagonStats(h3, resolution, signal);
if (!signal.aborted) setHoveredAreaStats(stats);
} else if (rightPaneTab === 'properties') {
const params = new URLSearchParams({
h3,
resolution: resolution.toString(),
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 response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
signal,
});
const data: HexagonPropertiesResponse = await response.json();
if (!signal.aborted) {
setHoveredProperties(data.properties);
setHoveredPropertiesTotal(data.total);
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch hover data:', error);
}
} finally {
if (!signal.aborted) setLoadingHoveredAreaStats(false);
}
}, DEBOUNCE_MS);
},
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon) {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
@ -745,17 +950,48 @@ export default function App() {
const handleCloseProperties = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
}, []);
return (
<div className="h-screen flex flex-col">
<Header activePage={activePage} onPageChange={setActivePage} theme={theme} onToggleTheme={toggleTheme} />
<Header activePage={activePage} onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} />
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => setActivePage('dashboard')} theme={theme} />
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : (
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<svg
className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
<Filters
features={features}
filters={filters}
@ -772,6 +1008,11 @@ export default function App() {
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
onNavigateToSource={(slug, featureName) => {
navigateTo('data-sources', slug, featureName);
}}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
/>
<div className="flex-1 relative">
<Map
@ -785,29 +1026,31 @@ export default function App() {
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white dark:bg-warm-800 dark:text-warm-200 px-3 py-1 rounded shadow">
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
Loading...
</div>
)}
<DataSources onNavigate={() => setActivePage('data-sources')} />
<DataSources onNavigate={() => navigateTo('data-sources')} />
</div>
<div className="w-72 bg-white dark:bg-warm-900 shadow-lg z-10 flex flex-col">
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-warm-200 dark:border-warm-700">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
rightPaneTab === 'area'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={() => setRightPaneTab('pois')}
onClick={() => setRightPaneTab('area')}
>
POIs {pois.length > 0 && `(${pois.length})`}
Area {areaStats ? `(${areaStats.count})` : ''}
</button>
<button
className={`flex-1 p-3 ${
@ -819,25 +1062,52 @@ export default function App() {
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
{rightPaneTab === 'area' ? (
<AreaPane
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
globalFeatures={features}
loading={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? loadingHoveredAreaStats : loadingAreaStats}
hexagonId={hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3 ? hoveredHexagon : selectedHexagon?.h3 || null}
isHoveredPreview={!!(hoverMode && hoveredAreaStats && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
onViewProperties={handleViewPropertiesFromArea}
onClose={handleCloseProperties}
/>
) : rightPaneTab === 'properties' ? (
<PropertiesPane
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
loading={loadingProperties}
hexagonId={hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
/>
) : (
<POIPane
groups={poiCategoryGroups}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
/>
)}
</div>

View file

@ -0,0 +1,243 @@
import { useMemo } from 'react';
import type { FeatureMeta, HexagonStatsResponse } from '../types';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
globalFeatures: FeatureMeta[];
loading: boolean;
hexagonId: string | null;
isHoveredPreview: boolean;
hoverMode: boolean;
onHoverModeChange: (enabled: boolean) => void;
onViewProperties: () => void;
onClose: () => void;
}
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[] }[] {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Set<string>();
for (const feature of globalFeatures) {
const groupName = feature.group || 'Other';
if (!seen.has(groupName)) {
seen.add(groupName);
groups.push({ name: groupName, features: [] });
}
groups.find((group) => group.name === groupName)!.features.push(feature);
}
return groups;
}
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
if (maxCount === 0) return null;
// Downsample to ~20 bars for display
const targetBars = 20;
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);
}
const barMax = Math.max(...bars, 1);
return (
<div className="flex items-end gap-px h-8 mt-1">
{bars.map((count, index) => (
<div
key={index}
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
/>
))}
</div>
);
}
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{label}
</span>
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
<div
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
</div>
))}
</div>
);
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isHoveredPreview,
hoverMode,
onHoverModeChange,
onViewProperties,
onClose,
}: 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]));
}, [stats]);
const enumByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
Click a hexagon to view area statistics
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<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">
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? '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)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{stats && (
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{stats.count.toLocaleString()} properties
</p>
)}
{stats && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
View {stats.count.toLocaleString()} Properties
</button>
)}
</div>
{/* Stats content */}
<div className="flex-1 overflow-y-auto">
{loading && !stats ? (
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
) : stats ? (
<div className="p-3 space-y-4">
{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)
);
if (!hasData) return null;
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">
{group.features.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const maxCount = Math.max(...numericStats.histogram.counts);
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<div className="flex justify-between items-baseline">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{feature.name}
</span>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)}
</span>
</div>
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.min)}</span>
<span>{formatValue(numericStats.max)}</span>
</div>
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
</div>
);
}
if (enumStats) {
return (
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{feature.name}
</span>
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
return null;
})}
</div>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
return (
<button
onClick={onNavigate}
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
className="absolute bottom-2 right-2 bg-white/90 dark:bg-navy-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
>
Data Sources
</button>

View file

@ -1,5 +1,8 @@
import { useEffect, useState, useRef } from 'react';
const DATA_SOURCES = [
{
id: 'price-paid',
name: 'Price Paid Data',
origin: 'HM Land Registry',
use: 'Complete historical property sale prices for England and Wales. Used for the last known sale price of each property.',
@ -7,6 +10,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'epc',
name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.',
@ -14,6 +18,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'nspl',
name: 'National Statistics Postcode Lookup (NSPL)',
origin: 'ONS / ArcGIS',
use: 'Maps postcodes to latitude/longitude, LSOA, and Output Area codes for geolocation and joining area-level datasets.',
@ -21,6 +26,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'iod',
name: 'English Indices of Deprivation 2025',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Relative deprivation scores for 33,755 LSOAs across domains: Income, Employment, Education, Health, Crime, Living Environment, and sub-domains. Joined to properties via LSOA code.',
@ -28,6 +34,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'ethnicity',
name: 'Population by Ethnicity (2021 Census)',
origin: 'ONS',
use: 'Population percentages by ethnic group (Asian, Black, Mixed, White, Other) per Local Authority. Joined via Local Authority District code.',
@ -35,6 +42,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'crime',
name: 'Street-level Crime Data',
origin: 'data.police.uk',
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
@ -42,6 +50,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'tfl-journey-times',
name: 'TfL Journey Times',
origin: 'Transport for London',
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
@ -49,6 +58,7 @@ const DATA_SOURCES = [
license: 'Powered by TfL Open Data',
},
{
id: 'osm-pois',
name: 'OpenStreetMap POIs',
origin: 'OpenStreetMap contributors / Geofabrik',
use: 'Points of interest extracted from the Great Britain PBF extract. Covers amenities, shops, healthcare, leisure, tourism, and more. Filtered and remapped to friendly category names.',
@ -56,6 +66,7 @@ const DATA_SOURCES = [
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'naptan',
name: 'NaPTAN (Public Transport Stops)',
origin: 'Department for Transport',
use: 'National Public Transport Access Nodes providing station and stop locations (rail, bus, metro/tram, ferry, airport), merged into the POI dataset.',
@ -63,6 +74,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'noise',
name: 'Defra Noise Mapping',
origin: 'Defra / Environment Agency',
use: 'Strategic noise mapping Round 4 (2022) for road, rail, and airport sources. Lden (day-evening-night 24h weighted average) at 10m grid resolution, modelled at 4m above ground. Sampled at postcode centroids via WCS GeoTIFF tiles.',
@ -70,6 +82,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'ofsted',
name: 'Ofsted School Inspections',
origin: 'Ofsted',
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
@ -77,6 +90,7 @@ const DATA_SOURCES = [
license: 'Open Government Licence v3.0',
},
{
id: 'broadband',
name: 'Ofcom Broadband Performance',
origin: 'Ofcom',
use: 'Fixed broadband coverage and speeds by Output Area from Connected Nations 2025. Includes max download/upload speeds across different speed tiers.',
@ -86,8 +100,29 @@ const DATA_SOURCES = [
];
export default function DataSourcesPage() {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
useEffect(() => {
function handleHash() {
const hash = window.location.hash.replace('#', '');
if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
setHighlightedId(hash);
// Scroll after a brief delay to allow render
setTimeout(() => {
cardRefs.current[hash]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
} else {
setHighlightedId(null);
}
}
handleHash();
window.addEventListener('hashchange', handleHash);
return () => window.removeEventListener('hashchange', handleHash);
}, []);
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 flex flex-col">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 flex flex-col">
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">Data Sources</h1>
@ -97,10 +132,19 @@ export default function DataSourcesPage() {
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCES.map((source) => (
<div key={source.name} className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700 p-5">
<div
key={source.id}
id={source.id}
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'
: 'border-warm-200 dark:border-navy-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">{source.name}</h2>
<span className="shrink-0 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
<span className="shrink-0 text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>

View file

@ -0,0 +1,119 @@
import { useState } from 'react';
interface FAQItem {
question: string;
answer: string;
}
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
answer:
'Narrowit is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
},
{
question: 'Where does the data come from?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources page for full details and links.',
},
{
question: 'What are the coloured hexagons on the map?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
},
{
question: 'How do filters work?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
},
{
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.',
},
{
question: 'How fresh is the data?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
},
{
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.',
},
{
question: 'What are Points of Interest (POIs)?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
},
{
question: 'How do I see individual properties?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
},
{
question: 'Why are some hexagons grey?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.',
},
];
function FAQItemCard({ item }: { item: FAQItem }) {
const [open, setOpen] = useState(false);
return (
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
<button
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
onClick={() => setOpen(!open)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<svg
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
</div>
)}
</div>
);
}
export default function FAQPage() {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
Frequently Asked Questions
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
Common questions about how Narrowit works, where the data comes from, and how to use the
map.
</p>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => (
<FAQItemCard key={index} item={item} />
))}
</div>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { memo, useState, useRef, useEffect, useMemo } from 'react';
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';
@ -19,38 +19,129 @@ interface FiltersProps {
pinnedFeature: string | null;
onTogglePin: (name: string) => void;
onCancelPin: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}
function FilterDropdown({
availableFeatures,
onAddFilter,
function EyeIcon({ filled, className }: { filled: boolean; className?: string }) {
return (
<svg
className={className || 'w-3.5 h-3.5'}
viewBox="0 0 24 24"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function InfoPopup({
feature,
onClose,
onNavigateToSource,
}: {
availableFeatures: FeatureMeta[];
onAddFilter: (name: string) => void;
feature: FeatureMeta;
onClose: () => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [open]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={popupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{feature.name}
</h3>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{feature.description && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
)}
{feature.detail && (
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">{feature.detail}</p>
)}
{feature.source && onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource(feature.source!, feature.name);
onClose();
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
);
}
function FeatureBrowser({
availableFeatures,
allFeatures,
pinnedFeature,
onAddFilter,
onTogglePin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: {
availableFeatures: FeatureMeta[];
allFeatures: FeatureMeta[];
pinnedFeature: string | null;
onAddFilter: (name: string) => void;
onTogglePin: (name: string) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
}) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
// Auto-open info popup when navigating back
useEffect(() => {
if (openInfoFeature) {
const feat = allFeatures.find((f) => f.name === openInfoFeature);
if (feat) setInfoFeature(feat);
onClearOpenInfoFeature?.();
}
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
const filtered = useMemo(() => {
if (!search) return availableFeatures;
const lower = search.toLowerCase();
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
}, [availableFeatures, search]);
const grouped = useMemo(() => {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Map<string, FeatureMeta[]>();
for (const f of availableFeatures) {
for (const f of filtered) {
const g = f.group || 'Other';
let arr = seen.get(g);
if (!arr) {
@ -61,40 +152,87 @@ function FilterDropdown({
arr.push(f);
}
return groups;
}, [availableFeatures]);
}, [filtered]);
return (
<div ref={ref} className="relative">
<button
className="w-full p-2 border rounded text-sm bg-white dark:bg-warm-800 dark:border-warm-700 text-left text-warm-500 dark:text-warm-400 hover:border-warm-400"
onClick={() => setOpen(!open)}
>
+ Add filter...
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-warm-800 border dark:border-warm-700 rounded shadow-lg max-h-80 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<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">
{group.name}
</div>
{group.features.map((f) => (
<button
key={f.name}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 dark:hover:bg-teal-900/30 hover:text-teal-700 dark:hover:text-teal-400 dark:text-warm-300"
onClick={() => {
onAddFilter(f.name);
setOpen(false);
}}
>
{f.name}
</button>
))}
<>
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<input
type="text"
placeholder="Search features..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
/>
</div>
<div className="flex-1 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<div 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">
{group.name}
</div>
))}
</div>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<span className="text-sm truncate block">{f.name}</span>
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">{f.description}</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0 mt-0.5">
{f.detail && (
<button
onClick={() => setInfoFeature(f)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Feature info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
)}
<button
onClick={() => onTogglePin(f.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onAddFilter(f.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Add filter"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
</button>
</div>
</div>
);
})}
</div>
))}
{grouped.length === 0 && (
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
{search ? 'No matching features' : 'All features are active'}
</div>
)}
</div>
{infoFeature && (
<InfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</div>
</>
);
}
@ -121,128 +259,208 @@ export default memo(function Filters({
pinnedFeature,
onTogglePin,
onCancelPin,
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const containerRef = useRef<HTMLDivElement>(null);
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 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 (
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto">
<div className="text-sm text-warm-500 dark:text-warm-400">Zoom: {zoom.toFixed(1)}</div>
{/* Add filter dropdown */}
{availableFeatures.length > 0 && (
<FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} />
)}
{/* Active filters */}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div key={feature.name} className="space-y-1 p-2 rounded">
<div className="flex items-center justify-between">
<Label className="text-xs">{feature.name}</Label>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
<div className="flex gap-2 text-xs mb-1">
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [...allValues])}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [])}
>
None
</button>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label key={val} className="flex items-center gap-1.5 text-xs cursor-pointer dark:text-warm-300">
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
// Numeric feature
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<Label className="text-xs">
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill={isPinned ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<div ref={containerRef} className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
{/* Top: Active filters — user-resizable, scrollable */}
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
{/* Active Filters header */}
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Active Filters</span>
{enabledFeatureList.length > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{enabledFeatureList.length}
</span>
)}
</div>
);
})}
<span className="text-xs text-warm-500 dark:text-warm-400">Zoom {zoom.toFixed(1)}</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{enabledFeatureList.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<svg className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">No active filters</span>
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">Browse features below and click + to add a filter</span>
</div>
)}
{enabledFeatureList.map((feature) => {
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
return (
<div key={feature.name} className={`space-y-1 p-3 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
<div className="flex items-center justify-between">
<Label>{feature.name}</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={pinnedFeature === feature.name ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={pinnedFeature === feature.name} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<div className="flex gap-2 text-sm mb-1">
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [...allValues])}
>
All
</button>
<button
className="text-teal-600 dark:text-teal-400 hover:underline"
onClick={() => onFilterChange(feature.name, [])}
>
None
</button>
</div>
<div className="space-y-0.5 max-h-40 overflow-y-auto">
{allValues.map((val) => (
<label key={val} className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300">
<input
type="checkbox"
checked={selectedValues.includes(val)}
onChange={() => {
const next = selectedValues.includes(val)
? selectedValues.filter((v) => v !== val)
: [...selectedValues, val];
onFilterChange(feature.name, next);
}}
className="rounded accent-teal-600"
/>
{val}
</label>
))}
</div>
</div>
);
}
// Numeric feature
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
return (
<div
key={feature.name}
className={`space-y-1 p-3 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<Label>
{feature.name}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<div className="flex items-center gap-0.5">
<button
onClick={() => onTogglePin(feature.name)}
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<EyeIcon filled={isPinned} />
</button>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
</div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div>
);
})}
</div>
</div>
{/* Draggable separator */}
<div
className="shrink-0 h-1.5 cursor-row-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700"
onPointerDown={handleSeparatorPointerDown}
onPointerMove={handleSeparatorPointerMove}
onPointerUp={handleSeparatorPointerUp}
>
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
</div>
{/* Bottom: Feature browser — fills remaining space */}
<div className="min-h-0 flex-1 flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div>
<div className="min-h-0 flex-1 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
onAddFilter={onAddFilter}
onTogglePin={onTogglePin}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
/>
</div>
</div>
</div>
);
});

View file

@ -185,7 +185,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900 relative">
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
<div className="relative" style={{ zIndex: 1 }}>
@ -193,7 +193,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-warm-900/60 rounded-2xl p-8 -mx-2"
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
>
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
@ -226,7 +226,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
{/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-warm-800/40 border border-warm-200/50 dark:border-warm-700/50 p-8">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
@ -265,7 +265,7 @@ export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenD
{FILTERS.map((f) => (
<div
key={f.label}
className="rounded-xl bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
>
<div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">{f.label}</div>

View file

@ -19,7 +19,9 @@ interface MapProps {
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
hoveredHexagonId: string | null;
onHexagonClick: (h3: string) => void;
onHexagonHover: (h3: string | null) => void;
initialViewState?: ViewState;
theme?: 'light' | 'dark';
}
@ -74,7 +76,8 @@ function normalizedToColor(t: number): [number, number, number] {
}
function zoomToResolution(zoom: number): number {
if (zoom < 7) return 7;
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
@ -145,13 +148,30 @@ function DeckOverlay({
return null;
}
// Sequential blue scale for count-based coloring
// Vibrant density scale: light cyan → teal → deep indigo
const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, // Light cyan (few)
{ t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate)
{ t: 1, color: [88, 28, 140] }, // Deep indigo (many)
];
function countToColor(t: number): [number, number, number] {
// light blue (209, 226, 243) -> dark blue (33, 102, 172)
const r = Math.round(209 + (33 - 209) * t);
const g = Math.round(226 + (102 - 226) * t);
const b = Math.round(243 + (172 - 243) * t);
return [r, g, b];
if (t <= 0) return DENSITY_GRADIENT[0].color;
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
function PostcodeSearch({
@ -206,7 +226,7 @@ function PostcodeSearch({
setError(null);
}}
placeholder="Search postcode..."
className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-warm-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-warm-100 dark:placeholder-warm-500"
/>
<button
type="submit"
@ -217,7 +237,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 bg-white/90 dark:bg-warm-800/90 rounded px-2 py-0.5 shadow">{error}</span>
<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">{error}</span>
)}
</form>
);
@ -228,11 +248,15 @@ function MapLegend({
range,
showCancel,
onCancel,
mode,
enumValues,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
}) {
const formatVal = (v: number) => {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
@ -241,14 +265,19 @@ function MapLegend({
return v.toFixed(1);
};
const gradientStyle =
mode === 'density'
? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))'
: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))';
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-warm-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-warm-200 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>
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 ml-2"
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view"
>
<svg
@ -265,14 +294,25 @@ function MapLegend({
</div>
<div
className="h-3 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
style={{ background: gradientStyle }}
/>
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-400">
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
{mode === 'density' ? (
<>
<span>Few</span>
<span>Many</span>
</>
) : enumValues && enumValues.length > 0 ? (
<>
<span>{enumValues[0]}</span>
<span>{enumValues[enumValues.length - 1]}</span>
</>
) : (
<>
<span>{formatVal(range[0])}</span>
<span>{formatVal(range[1])}</span>
</>
)}
</div>
</div>
);
@ -289,7 +329,9 @@ export default memo(function Map({
onCancelPin,
features,
selectedHexagonId,
hoveredHexagonId,
onHexagonClick,
onHexagonHover,
initialViewState,
theme = 'light',
}: MapProps) {
@ -433,6 +475,8 @@ export default memo(function Map({
countRangeRef.current = countRange;
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
hoveredHexagonIdRef.current = hoveredHexagonId;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
@ -443,6 +487,17 @@ export default memo(function Map({
}
}, []);
// Stable hover handler using ref
const onHexagonHoverRef = useRef(onHexagonHover);
onHexagonHoverRef.current = onHexagonHover;
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonHoverRef.current(info.object.h3);
} else {
onHexagonHoverRef.current(null);
}
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
handlePoiHoverRef.current = handlePoiHover;
@ -451,7 +506,7 @@ export default memo(function Map({
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
@ -493,14 +548,16 @@ export default memo(function Map({
number,
];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
number,
number,
number,
number,
],
getLineWidth: (d) => (d.h3 === selectedHexagonIdRef.current ? 2 : 0),
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorTrigger],
@ -512,10 +569,11 @@ export default memo(function Map({
opacity: 1,
highPrecision: true,
onClick: handleHexagonClick,
onHover: handleHexagonHover,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick]
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
);
// POI layer — independent, only recreated when POI data changes
@ -576,51 +634,6 @@ export default memo(function Map({
[hexLayer, poiLayer, postcodeLayer]
);
// Tooltip uses refs to avoid being a layer dependency
const featuresRef = useRef(features);
featuresRef.current = features;
const getTooltip = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ object }: { object?: any }) => {
if (!object) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
const minVal = object[`min_${f.name}`];
const maxVal = object[`max_${f.name}`];
if (minVal != null && maxVal != null) {
const minStr =
typeof minVal === 'number'
? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(minVal);
const maxStr =
typeof maxVal === 'number'
? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 })
: String(maxVal);
const highlight = f.name === viewFeatureRef.current ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.name}: ${minStr} - ${maxStr}</div>`);
}
}
const isDark = themeRef.current === 'dark';
return {
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
style: {
backgroundColor: isDark ? '#292524' : 'white',
color: isDark ? '#e7e5e4' : 'inherit',
borderRadius: '4px',
boxShadow: isDark ? '0 2px 4px rgba(0,0,0,0.5)' : '0 2px 4px rgba(0,0,0,0.2)',
},
};
},
[]
);
return (
<div className="flex-1 h-full relative" ref={containerRef}>
<MapGL
@ -635,21 +648,33 @@ export default memo(function Map({
touchPitch={false}
keyboard={true}
pitchWithRotate={false}
minZoom={5}
maxBounds={[-12, 49, 4, 62]}
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
<DeckOverlay layers={layers} getTooltip={null} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && colorRange && colorFeatureMeta && (
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
/>
) : (
<MapLegend
featureLabel="Property density"
range={[0, 0]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
/>
)}
{popupInfo && (
<div
className="absolute pointer-events-none bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
className="absolute pointer-events-none bg-white dark:bg-navy-800 rounded shadow-lg p-2 text-sm dark:text-warm-200"
style={{
left: popupInfo.x,
top: popupInfo.y - 40,

View file

@ -6,6 +6,7 @@ interface POIPaneProps {
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
onNavigateToSource?: (slug: string) => void;
}
export default function POIPane({
@ -13,11 +14,14 @@ export default function POIPane({
selectedCategories,
onCategoriesChange,
poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const infoPopupRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
@ -30,6 +34,18 @@ export default function POIPane({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close info popup when clicking outside
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
@ -95,13 +111,65 @@ export default function POIPane({
const selectedCount = selectedCategories.size;
return (
<div className="w-72 p-4 bg-white dark:bg-warm-900 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Points of Interest
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Points of interest are sourced from OpenStreetMap via Geofabrik extracts.
Categories include public transport stops, shops, restaurants, healthcare
facilities, leisure venues, and more. Data is filtered and mapped to
friendly names with exhaustive category coverage.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('osm-pois');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
<div className="space-y-2" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-warm-700 rounded hover:border-warm-400 bg-white dark:bg-warm-800 dark:text-warm-200"
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
>
<span className="truncate text-left">
{selectedCount === 0
@ -121,8 +189,8 @@ export default function POIPane({
</button>
{dropdownOpen && (
<div className="border border-warm-300 dark:border-warm-700 rounded shadow-lg bg-white dark:bg-warm-800">
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-warm-700">
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button onClick={selectAll} className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300">
All
</button>
@ -131,13 +199,13 @@ export default function POIPane({
None
</button>
</div>
<div className="px-3 py-2 border-b border-warm-200 dark:border-warm-700">
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<input
type="text"
placeholder="Search categories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-warm-700 rounded bg-white dark:bg-warm-900 dark:text-warm-200 dark:placeholder-warm-500"
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
@ -151,7 +219,7 @@ export default function POIPane({
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-warm-900 border-y border-warm-100 dark:border-warm-700">
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
<button
onClick={() => toggleCollapse(group.name)}
className="p-0.5 text-warm-400 hover:text-warm-600"
@ -190,7 +258,7 @@ export default function POIPane({
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-warm-700 cursor-pointer dark:text-warm-300"
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
@ -220,7 +288,7 @@ export default function POIPane({
</div>
)}
<div className="p-3 bg-warm-100 dark:bg-warm-800 rounded text-xs text-warm-600 dark:text-warm-400">
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
<p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
</div>

View file

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { Property } from '../types';
interface PropertiesPaneProps {
@ -8,6 +8,10 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
isHoveredPreview?: boolean;
hoverMode?: boolean;
onHoverModeChange?: (enabled: boolean) => void;
}
type SortBy = 'price' | 'size' | 'energy';
@ -19,9 +23,26 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onClose,
onNavigateToSource,
isHoveredPreview,
hoverMode,
onHoverModeChange,
}: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const infoPopupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showInfo) return;
function handleClickOutside(e: MouseEvent) {
if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) {
setShowInfo(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showInfo]);
// Filter and sort properties
const filteredAndSorted = useMemo(() => {
@ -56,36 +77,112 @@ export function PropertiesPane({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-warm-200 dark:border-warm-700">
<div className="p-4 border-b border-warm-200 dark:border-navy-700">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold dark:text-warm-100">Properties in Hexagon</h2>
<button
onClick={onClose}
className="text-warm-500 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 text-2xl leading-none"
>
×
</button>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold dark:text-warm-100">Properties</h2>
{isHoveredPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
Preview
</span>
)}
<button
onClick={() => setShowInfo(true)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
title="Data source info"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>
</button>
</div>
<div className="flex items-center gap-1">
{onHoverModeChange && (
<button
onClick={() => onHoverModeChange(!hoverMode)}
className={`p-1 rounded ${
hoverMode
? '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)'}
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
)}
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p className="text-sm text-warm-600 dark:text-warm-400">
{search.trim()
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
: `Showing ${properties.length} of ${total} properties`}
</p>
{showInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div
ref={infoPopupRef}
className="bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 rounded-lg shadow-xl max-w-md w-full mx-4 p-5"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
Property Data
</h3>
<button
onClick={() => setShowInfo(false)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land
Registry Price Paid records, fuzzy-matched by address within each postcode.
Includes floor area, energy ratings, construction age, and tenure from EPC
surveys, plus the most recent sale price from the Land Registry.
</p>
{onNavigateToSource && (
<button
onClick={() => {
onNavigateToSource('epc');
setShowInfo(false);
}}
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
>
View data source
</button>
)}
</div>
</div>
)}
</div>
{/* Search and sort controls */}
<div className="p-2 border-b border-warm-200 dark:border-warm-700 space-y-2">
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by address or postcode..."
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="w-full p-2 border border-warm-300 dark:border-warm-700 rounded text-sm bg-white dark:bg-warm-800 dark:text-warm-200"
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200"
>
<option value="price">Price (High to Low)</option>
<option value="size">Size (Large to Small)</option>
@ -156,7 +253,7 @@ function PropertyCard({ property }: { property: Property }) {
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
return (
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
{/* Address & postcode */}
<div className="font-semibold dark:text-warm-100">{property.address || 'Unknown Address'}</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>

View file

@ -11,13 +11,13 @@ export function Slider({ className, ...props }: SliderProps) {
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-warm-700">
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700">
<SliderPrimitive.Range className="absolute h-full bg-teal-600" />
</SliderPrimitive.Track>
{props.value?.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-warm-800 ring-offset-white dark:ring-offset-warm-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>

View file

@ -11,7 +11,7 @@ body,
}
html.dark {
background-color: #1c1917;
background-color: #0a0e1a;
color-scheme: dark;
}

View file

@ -5,8 +5,13 @@ export interface FeatureMeta {
// Numeric-only fields
min?: number;
max?: number;
step?: number;
// Enum-only fields
values?: string[];
// Description fields
description?: string;
detail?: string;
source?: string;
}
export interface FeatureGroup {
@ -100,3 +105,23 @@ export interface HexagonPropertiesResponse {
offset: number;
truncated: boolean;
}
export interface NumericFeatureStats {
name: string;
count: number;
min: number;
max: number;
mean: number;
histogram: { min: number; max: number; bin_width: number; counts: number[] };
}
export interface EnumFeatureStats {
name: string;
counts: Record<string, number>;
}
export interface HexagonStatsResponse {
count: number;
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
}