Lots of frontend changes

This commit is contained in:
Andras Schmelczer 2026-02-07 19:10:53 +00:00
parent ec29631c44
commit 555ba7cf53
38 changed files with 1508 additions and 648 deletions

View file

@ -1,15 +1,17 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { trackPageview } from './hooks/usePlausible'; import { trackPageview } from './hooks/usePlausible';
import MapPage from './components/map/MapPage'; import MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage'; import FAQPage from './components/faq/FAQPage';
import HomePage from './components/home/HomePage'; import HomePage from './components/home/HomePage';
import Header, { type Page } from './components/ui/Header'; import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api'; import { fetchWithRetry, apiUrl } from './lib/api';
import { parseUrlState } from './lib/url-state'; import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts'; import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme'; import { useTheme } from './hooks/useTheme';
import { useAuth } from './hooks/useAuth';
declare global { declare global {
interface Window { interface Window {
@ -43,6 +45,16 @@ export default function App() {
}); });
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const {
user,
loading: authLoading,
error: authError,
login,
register,
logout,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
// Load features and POI categories on mount // Load features and POI categories on mount
useEffect(() => { useEffect(() => {
@ -116,6 +128,8 @@ export default function App() {
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const [exportState, setExportState] = useState<ExportState | null>(null);
if (isScreenshotMode) { if (isScreenshotMode) {
return ( return (
<MapPage <MapPage
@ -142,6 +156,11 @@ export default function App() {
onPageChange={navigateTo} onPageChange={navigateTo}
theme={theme} theme={theme}
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
onExport={exportState?.onExport ?? null}
exporting={exportState?.exporting ?? false}
user={user}
onLoginClick={() => setShowAuthModal(true)}
onLogout={logout}
/> />
{activePage === 'home' ? ( {activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} /> <HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
@ -162,6 +181,17 @@ export default function App() {
pendingInfoFeature={pendingInfoFeature} pendingInfoFeature={pendingInfoFeature}
onClearPendingInfoFeature={() => setPendingInfoFeature(null)} onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo} onNavigateTo={navigateTo}
onExportStateChange={setExportState}
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
loading={authLoading}
error={authError}
onClearError={clearError}
/> />
)} )}
</div> </div>

View file

@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
return ( return (
<button <button
onClick={onNavigate} onClick={onNavigate}
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" 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"
> >
Data Sources Data Sources
</button> </button>

View file

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
interface FAQItem { interface FAQItem {
question: string; question: string;
@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) {
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span> <span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<svg <ChevronIcon
direction="down"
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`} 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> </button>
{open && ( {open && (
<div className="px-5 pb-4"> <div className="px-5 pb-4">

View file

@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
import type { HexagonLocation } from '../../lib/external-search'; import type { HexagonLocation } from '../../lib/external-search';
import { formatValue, calculateHistogramMean } from '../../lib/format'; import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features'; import { groupFeaturesByCategory } from '../../lib/features';
import { STACKED_GROUPS } from '../../lib/consts'; import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart'; import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart'; import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart'; import PriceHistoryChart from './PriceHistoryChart';
import ExternalSearchLinks from './ExternalSearchLinks'; import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from '../ui/icons'; import { InfoIcon, CloseIcon } from '../ui/icons';
@ -126,6 +127,14 @@ export default function AreaPane({
if (!hasData) return null; if (!hasData) return null;
const stackedCharts = STACKED_GROUPS[group.name]; const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean)
) as string[] ?? []
);
return ( return (
<div key={group.name}> <div key={group.name}>
@ -183,75 +192,157 @@ export default function AreaPane({
</div> </div>
); );
}) })
: // Default: render each feature individually : // Default: render each feature individually (skip stacked enum features)
group.features.map((feature) => { group.features
const numericStats = numericByName.get(feature.name); .filter((f) => !stackedEnumFeatureNames.has(f.name))
const enumStats = enumByName.get(feature.name); .map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) { if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name); const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram; const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram) ? calculateHistogramMean(globalHistogram)
: undefined; : undefined;
return ( return (
<div <div
key={feature.name} key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<FeatureLabel <FeatureLabel
feature={feature} feature={feature}
onShowInfo={setInfoFeature} onShowInfo={setInfoFeature}
className="mr-2" className="mr-2"
/> />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap"> <span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)} {formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
</span> </span>
</div> </div>
{numericStats.histogram && ( {numericStats.histogram && (
<> globalHistogram ? (
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.histogram.min)}</span>
<span>{formatValue(numericStats.histogram.max)}</span>
</div>
{globalHistogram ? (
<DualHistogram <DualHistogram
localCounts={numericStats.histogram.counts} localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts} globalCounts={globalHistogram.counts}
min={numericStats.histogram.min} p1={numericStats.histogram.p1}
max={numericStats.histogram.max} p99={numericStats.histogram.p99}
globalMean={globalMean} globalMean={globalMean}
formatLabel={formatFilterValue}
/> />
) : ( ) : (
<DualHistogram <DualHistogram
localCounts={numericStats.histogram.counts} localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts} globalCounts={numericStats.histogram.counts}
min={numericStats.histogram.min} p1={numericStats.histogram.p1}
max={numericStats.histogram.max} p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/> />
)} )
</> )}
)} </div>
</div> );
); }
}
if (enumStats) { if (enumStats) {
return ( return (
<div <div
key={feature.name} key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2" className="bg-warm-50 dark:bg-warm-800 rounded p-2"
> >
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} /> <FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} /> <EnumBarChart counts={enumStats.counts} />
</div> </div>
); );
} }
return null; return null;
})} })}
{/* Stacked enum charts */}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
// Single component: render as a stacked bar (like crime charts)
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
}
// Multi-component: render as compact multi-row chart (like risk features)
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{chart.label}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div> </div>
</div> </div>
); );

View file

@ -11,18 +11,37 @@ function downsampleBars(counts: number[], targetBars: number): number[] {
return bars; return bars;
} }
function pickTicks(min: number, max: number, count: number): number[] {
if (max <= min) return [min];
const range = max - min;
const rawStep = range / (count - 1);
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10;
const step = nice * magnitude;
const start = Math.ceil(min / step) * step;
const ticks: number[] = [];
for (let v = start; v <= max + step * 0.01; v += step) {
ticks.push(v);
}
// Ensure at least min and max are represented
if (ticks.length === 0) return [min, max];
return ticks;
}
export function DualHistogram({ export function DualHistogram({
localCounts, localCounts,
globalCounts, globalCounts,
min, p1,
max, p99,
globalMean, globalMean,
formatLabel,
}: { }: {
localCounts: number[]; localCounts: number[];
globalCounts: number[]; globalCounts: number[];
min: number; p1: number;
max: number; p99: number;
globalMean?: number; globalMean?: number;
formatLabel?: (value: number) => string;
}) { }) {
const targetBars = 25; const targetBars = 25;
const localBars = downsampleBars(localCounts, targetBars); const localBars = downsampleBars(localCounts, targetBars);
@ -32,7 +51,37 @@ export function DualHistogram({
const localMax = Math.max(...localBars, 1); const localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 1); const globalMax = Math.max(...globalBars, 1);
const meanFraction = globalMean != null && max > min ? (globalMean - min) / (max - min) : null; const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
if (i === 0) return p1; // outlier bin, label as p1
if (i === barCount - 1) return p99; // outlier bin, label as p99
return p1 + (i - 1 + 0.5) * middleWidth;
});
// Pick nice tick values and assign each to the nearest bar
const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : [];
const tickBars = new Map<number, string>(); // bar index → label
for (const v of ticks) {
let bestBar = 1;
let bestDist = Infinity;
for (let i = 1; i < barCount - 1; i++) {
const dist = Math.abs(barCenters[i] - v);
if (dist < bestDist) { bestDist = dist; bestBar = i; }
}
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
}
// Mean line: position as fraction across the bar area
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
// Account for outlier bins: middle region spans bars 1..n-2
const meanPct = meanFrac != null
? ((1 + meanFrac * middleBins) / barCount) * 100
: null;
return ( return (
<div className="mt-1"> <div className="mt-1">
@ -56,13 +105,26 @@ export function DualHistogram({
</div> </div>
); );
})} })}
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && ( {meanPct != null && meanPct >= 0 && meanPct <= 100 && (
<div <div
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500" className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
style={{ left: `${meanFraction * 100}%` }} style={{ left: `${meanPct}%` }}
/> />
)} )}
</div> </div>
{tickBars.size > 0 && (
<div className="flex gap-px mt-0.5">
{Array.from({ length: barCount }).map((_, index) => (
<div key={index} className="flex-1 min-w-[2px] text-center">
{tickBars.has(index) && (
<span className="text-[9px] leading-none text-warm-400 dark:text-warm-500">
{tickBars.get(index)}
</span>
)}
</div>
))}
</div>
)}
</div> </div>
); );
} }

View file

@ -1,8 +1,8 @@
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { memo, useState, useMemo, useEffect } from 'react';
import { Slider } from '../ui/Slider'; import { Slider } from '../ui/Slider';
import { Label } from '../ui/Label'; import { Label } from '../ui/Label';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { FilterIcon, LightbulbIcon } from '../ui/icons'; import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons';
import { EmptyState } from '../ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../../types'; import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format'; import { formatFilterValue } from '../../lib/format';
@ -56,6 +56,15 @@ function FeatureBrowser({
}) { }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null); const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const toggleGroup = (name: string) =>
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
useEffect(() => { useEffect(() => {
if (openInfoFeature) { if (openInfoFeature) {
@ -73,50 +82,70 @@ function FeatureBrowser({
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
return ( return (
<> <>
<div className="p-2 border-b border-warm-200 dark:border-navy-700"> <div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." /> <SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
{grouped.map((group) => ( {grouped.map((group) => {
<div key={group.name}> const isExpanded = isSearching || expandedGroups.has(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"> return (
{group.name} <div key={group.name} className="shrink-0">
</div> <button
{group.features.map((f) => { onClick={() => toggleGroup(group.name)}
const isPinned = pinnedFeature === f.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"
return ( >
<div <span>{group.name}</span>
key={f.name} <div className="flex items-center gap-1">
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" <span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
> {group.features.length}
<div className="min-w-0 mr-2"> </span>
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" /> <ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div> </div>
); </button>
})} {isExpanded &&
</div> group.features.map((f) => {
))} const isPinned = pinnedFeature === f.name;
{grouped.length === 0 && ( 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">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
})}
</div>
);
})}
{grouped.length === 0 ? (
<EmptyState <EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />} icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'} title={search ? 'No matching features' : 'All features are active'}
description={search ? 'Try a different search term' : 'Remove a filter to see available features'} description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
className="px-3 py-4" className="px-3 py-4"
/> />
) : (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
)} )}
</div> </div>
{infoFeature && ( {infoFeature && (
@ -155,38 +184,12 @@ export default memo(function Filters({
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
const containerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [splitFraction, setSplitFraction] = useState(0.65);
const draggingRef = useRef(false);
const [showPhilosophy, setShowPhilosophy] = useState(false); const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null); const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
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 headerHeight = headerRef.current?.offsetHeight ?? 0;
const y = e.clientY - rect.top;
const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height));
setSplitFraction(fraction);
}, []);
const handleSeparatorPointerUp = useCallback(() => {
draggingRef.current = false;
}, []);
return ( return (
<div <div className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full">
ref={containerRef} <div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
>
<div ref={headerRef} className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<button <button
onClick={() => setShowPhilosophy(true)} onClick={() => setShowPhilosophy(true)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2" className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
@ -195,7 +198,7 @@ export default memo(function Filters({
Finding the Perfect Postcode Finding the Perfect Postcode
</button> </button>
</div> </div>
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}> <div className="min-h-0 flex flex-col max-h-[65%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700"> <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"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100"> <span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
@ -279,13 +282,11 @@ export default memo(function Filters({
key={feature.name} 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' : ''}`} 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' : ''}`}
> >
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0"> <span className="text-sm text-warm-500 dark:text-warm-400">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" /> {formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
<span className="text-sm text-warm-500 dark:text-warm-400"> </span>
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
</span>
</div>
<FeatureActions <FeatureActions
feature={feature} feature={feature}
isPinned={isPinned} isPinned={isPinned}
@ -308,16 +309,7 @@ export default memo(function Filters({
</div> </div>
</div> </div>
<div <div className="min-h-0 flex-1 flex flex-col border-t border-warm-200 dark:border-warm-700">
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>
<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"> <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> <span className="text-sm font-semibold text-navy-950 dark:text-warm-100">Add Filter</span>
</div> </div>

View file

@ -23,6 +23,8 @@ import {
getBoundsFromViewState, getBoundsFromViewState,
emojiToTwemojiUrl, emojiToTwemojiUrl,
getMapStyle, getMapStyle,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
} from '../../lib/map-utils'; } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
@ -161,7 +163,7 @@ export default memo(function Map({
const handleMapLoad = useCallback( const handleMapLoad = useCallback(
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
// Hexagons render below roads/buildings/labels so map features show on top // Road opacity is set in getMapStyle
}, },
[] []
); );
@ -297,8 +299,15 @@ export default memo(function Map({
} }
}, []); }, []);
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`; const isDark = theme === 'dark';
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const densityGradientRef = useRef(densityGradient);
densityGradientRef.current = densityGradient;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`;
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`;
const hexLayer = useMemo( const hexLayer = useMemo(
() => () =>
@ -311,14 +320,15 @@ export default memo(function Map({
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current; const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) { if (vf && clr && cfm) {
const val = d[`min_${vf}`]; const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (fr) { if (fr) {
const minVal = d[`min_${vf}`] as number; const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number; const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) { if (maxVal < fr[0] || minVal > fr[1]) {
return [180, 180, 180, 60] as [number, number, number, number]; return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
} }
} }
const range = clr[1] - clr[0]; const range = clr[1] - clr[0];
@ -330,7 +340,7 @@ export default memo(function Map({
const cr = countRangeRef.current; const cr = countRangeRef.current;
const c = d.count as number; const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min); const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [ return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
number, number,
number, number,
number, number,
@ -378,14 +388,15 @@ export default memo(function Map({
const clr = colorRangeRef.current; const clr = colorRangeRef.current;
const fr = filterRangeRef.current; const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current; const cfm = colorFeatureMetaRef.current;
const dark = isDarkRef.current;
if (vf && clr && cfm) { if (vf && clr && cfm) {
const val = d[`min_${vf}`]; const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
if (fr) { if (fr) {
const minVal = d[`min_${vf}`] as number; const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number; const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) { if (maxVal < fr[0] || minVal > fr[1]) {
return [180, 180, 180, 60] as [number, number, number, number]; return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
} }
} }
const range = clr[1] - clr[0]; const range = clr[1] - clr[0];
@ -397,7 +408,7 @@ export default memo(function Map({
const cr = postcodeCountRangeRef.current; const cr = postcodeCountRangeRef.current;
const c = d.count; const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min); const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [ return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
number, number,
number, number,
number, number,
@ -406,11 +417,12 @@ export default memo(function Map({
}, },
getLineColor: (f) => { getLineColor: (f) => {
const pc = f.properties.postcode; const pc = f.properties.postcode;
const dark = isDarkRef.current;
if (pc === selectedPostcodeRef.current) if (pc === selectedPostcodeRef.current)
return [255, 255, 255, 255] as [number, number, number, number]; return [255, 255, 255, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current) if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number]; return [29, 228, 195, 200] as [number, number, number, number];
return [100, 100, 100, 150] as [number, number, number, number]; return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number];
}, },
getLineWidth: (f) => { getLineWidth: (f) => {
const pc = f.properties.postcode; const pc = f.properties.postcode;
@ -570,6 +582,7 @@ export default memo(function Map({
onCancel={onCancelPin} onCancel={onCancelPin}
mode="feature" mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined} enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/> />
) : ( ) : (
<MapLegend <MapLegend
@ -578,6 +591,7 @@ export default memo(function Map({
showCancel={false} showCancel={false}
onCancel={onCancelPin} onCancel={onCancelPin}
mode="density" mode="density"
theme={theme}
/> />
)} )}
{popupInfo && ( {popupInfo && (

View file

@ -1,4 +1,7 @@
import { formatValue } from '../../lib/format'; import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
export default function MapLegend({ export default function MapLegend({
featureLabel, featureLabel,
@ -7,6 +10,7 @@ export default function MapLegend({
onCancel, onCancel,
mode, mode,
enumValues, enumValues,
theme = 'light',
}: { }: {
featureLabel: string; featureLabel: string;
range: [number, number]; range: [number, number];
@ -14,11 +18,10 @@ export default function MapLegend({
onCancel: () => void; onCancel: () => void;
mode: 'feature' | 'density'; mode: 'feature' | 'density';
enumValues?: string[]; enumValues?: string[];
theme?: 'light' | 'dark';
}) { }) {
const gradientStyle = const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
mode === 'density' const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
? '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 ( 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-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
@ -30,15 +33,7 @@ export default function MapLegend({
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2" className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
title="Clear color view" title="Clear color view"
> >
<svg <CloseIcon className="w-4 h-4" />
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
)} )}
</div> </div>

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types'; import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
import type { SearchedPostcode } from './PostcodeSearch'; import type { SearchedPostcode } from './PostcodeSearch';
import type { Page } from '../ui/Header'; import type { Page } from '../ui/Header';
@ -14,6 +14,13 @@ import { usePOIData } from '../../hooks/usePOIData';
import { useFilters } from '../../hooks/useFilters'; import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection'; import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize'; import { usePaneResize } from '../../hooks/usePaneResize';
import { apiUrl, buildFilterString } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
export interface ExportState {
onExport: () => void;
exporting: boolean;
}
interface MapPageProps { interface MapPageProps {
features: FeatureMeta[]; features: FeatureMeta[];
@ -27,6 +34,7 @@ interface MapPageProps {
pendingInfoFeature: string | null; pendingInfoFeature: string | null;
onClearPendingInfoFeature: () => void; onClearPendingInfoFeature: () => void;
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
screenshotMode?: boolean; screenshotMode?: boolean;
} }
@ -42,6 +50,7 @@ export default function MapPage({
pendingInfoFeature, pendingInfoFeature,
onClearPendingInfoFeature, onClearPendingInfoFeature,
onNavigateTo, onNavigateTo,
onExportStateChange,
screenshotMode, screenshotMode,
}: MapPageProps) { }: MapPageProps) {
if (screenshotMode) { if (screenshotMode) {
@ -142,15 +151,46 @@ export default function MapPage({
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]); }, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]);
// Export to Excel
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return;
const { south, west, north, east } = mapData.bounds;
const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`,
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const url = apiUrl('export', params);
setExporting(true);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'narrowit-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
})
.catch((err) => console.error('Export failed:', err))
.finally(() => setExporting(false));
}, [mapData.bounds, filters, features, exporting]);
// Report export state to parent (Header)
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
return ( return (
<div className="flex-1 flex overflow-hidden relative"> <div className="flex-1 flex overflow-hidden relative">
{initialLoading && ( {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="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"> <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"> <SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<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> <p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
</div> </div>
</div> </div>

View file

@ -1,6 +1,5 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { POICategoryGroup } from '../../types'; import type { POICategoryGroup } from '../../types';
import { useClickOutside } from '../../hooks/useClickOutside';
import InfoPopup from '../ui/InfoPopup'; import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { InfoIcon, ChevronIcon } from '../ui/icons'; import { InfoIcon, ChevronIcon } from '../ui/icons';
@ -21,13 +20,9 @@ export default function POIPane({
poiCount, poiCount,
onNavigateToSource, onNavigateToSource,
}: POIPaneProps) { }: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, () => setDropdownOpen(false));
const allCategories = groups.flatMap((g) => g.categories); const allCategories = groups.flatMap((g) => g.categories);
@ -93,139 +88,129 @@ export default function POIPane({
const selectedCount = selectedCategories.size; const selectedCount = selectedCategories.size;
return ( return (
<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 flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
<div className="flex items-center gap-2"> <div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2> <div className="flex items-center gap-2">
<IconButton onClick={() => setShowInfo(true)} title="Data source info"> <h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<InfoIcon /> <IconButton onClick={() => setShowInfo(true)} title="Data source info">
</IconButton> <InfoIcon />
</div> </IconButton>
</div>
{showInfo && ( {showInfo && (
<InfoPopup <InfoPopup
title="Points of Interest" title="Points of Interest"
onClose={() => setShowInfo(false)} onClose={() => setShowInfo(false)}
sourceLink={ sourceLink={
onNavigateToSource onNavigateToSource
? { ? {
label: 'View data source', label: 'View data source',
onClick: () => { onClick: () => {
onNavigateToSource('osm-pois'); onNavigateToSource('osm-pois');
setShowInfo(false); setShowInfo(false);
}, },
} }
: undefined : undefined
} }
> >
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed"> <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 Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories
include public transport stops, shops, restaurants, healthcare facilities, leisure include public transport stops, shops, restaurants, healthcare facilities, leisure
venues, and more. Data is filtered and mapped to friendly names with exhaustive category venues, and more. Data is filtered and mapped to friendly names with exhaustive
coverage. category coverage.
</p> </p>
</InfoPopup> </InfoPopup>
)} )}
<div className="space-y-2" ref={dropdownRef}> <SearchInput
<button value={searchTerm}
onClick={() => setDropdownOpen(!dropdownOpen)} onChange={setSearchTerm}
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" placeholder="Search categories..."
> />
<span className="truncate text-left">
{selectedCount === 0 <div className="flex items-center justify-between">
? 'Select categories...' <div className="flex gap-1">
: selectedCount === allCategories.length <button
? 'All categories' onClick={selectAll}
: `${selectedCount} selected`} className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
All
</button>
<button
onClick={selectNone}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
None
</button>
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{selectedCount}/{allCategories.length} selected
</span> </span>
<ChevronIcon </div>
direction={dropdownOpen ? 'up' : 'down'}
className="w-4 h-4 ml-2 flex-shrink-0"
/>
</button>
{dropdownOpen && ( {selectedCount > 0 && (
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800"> <div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700"> <span className="font-medium text-teal-900 dark:text-teal-300">
<SearchInput {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
value={searchTerm} </span>
onChange={setSearchTerm}
placeholder="Search categories..."
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
return (
<div key={group.name}>
<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 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
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-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div> </div>
)} )}
</div> </div>
{selectedCount > 0 && ( <div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm"> {filteredGroups.map((group) => {
<div className="font-medium text-teal-900 dark:text-teal-300"> const groupSelected = group.categories.filter((c) =>
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible selectedCategories.has(c)
</div> ).length;
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1"> const allInGroupSelected = groupSelected === group.categories.length;
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
</div> const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
</div>
)}
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400"> return (
<p>Select categories to display POIs on the map.</p> <div key={group.name}>
<p className="mt-2">Zoom in for better visibility of individual locations.</p> <div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
<button
onClick={() => toggleCollapse(group.name)}
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
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-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { PostcodeGeometry } from '../../types'; import type { PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
export interface SearchedPostcode { export interface SearchedPostcode {
postcode: string; postcode: string;
@ -26,7 +27,7 @@ export default function PostcodeSearch({
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`); const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders());
if (!res.ok) { if (!res.ok) {
setError('Postcode not found'); setError('Postcode not found');
return; return;

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types'; import type { PricePoint } from '../../types';
import { formatValue } from '../../lib/format'; import { formatValue, FEATURE_FORMATS } from '../../lib/format';
interface PriceHistoryChartProps { interface PriceHistoryChartProps {
points: PricePoint[]; points: PricePoint[];
@ -8,141 +8,159 @@ interface PriceHistoryChartProps {
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 }; const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const HEIGHT = 120; const HEIGHT = 120;
const priceFmt = FEATURE_FORMATS['Last known price'];
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => { const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const w = entries[0].contentRect.width;
if (w > 0) setWidth(w);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
let yMin = Infinity, let yMin = Infinity,
yMax = -Infinity, yMax = -Infinity;
pMin = Infinity,
pMax = -Infinity;
for (const p of points) { for (const p of points) {
if (p.year < yMin) yMin = p.year; if (p.year < yMin) yMin = p.year;
if (p.year > yMax) yMax = p.year; if (p.year > yMax) yMax = p.year;
if (p.price < pMin) pMin = p.price;
if (p.price > pMax) pMax = p.price;
} }
// Add 5% padding to price range
const pRange = pMax - pMin || 1;
pMin = Math.max(0, pMin - pRange * 0.05);
pMax = pMax + pRange * 0.05;
// Yearly averages // Use p5/p95 to clip outliers
const byYear = new Map<number, { sum: number; count: number }>(); const sorted = points.map((p) => p.price).sort((a, b) => a - b);
const p5 = sorted[Math.floor(sorted.length * 0.05)];
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
const pRange = p95 - p5 || 1;
const pMin = Math.max(0, p5 - pRange * 0.1);
const pMax = p95 + pRange * 0.1;
// Yearly medians (robust to outliers)
const byYear = new Map<number, number[]>();
for (const p of points) { for (const p of points) {
const yr = Math.floor(p.year); const yr = Math.floor(p.year);
const entry = byYear.get(yr); const arr = byYear.get(yr);
if (entry) { if (arr) arr.push(p.price);
entry.sum += p.price; else byYear.set(yr, [p.price]);
entry.count += 1;
} else {
byYear.set(yr, { sum: p.price, count: 1 });
}
} }
const avgs = Array.from(byYear.entries()) const meds = Array.from(byYear.entries())
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count })) .map(([yr, prices]) => {
prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2);
const median =
prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median };
})
.sort((a, b) => a.year - b.year); .sort((a, b) => a.year - b.year);
// Price ticks (3-5 nice round numbers)
const ticks = niceTicksForRange(pMin, pMax, 4); const ticks = niceTicksForRange(pMin, pMax, 4);
return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks }; return {
yearMin: yMin,
yearMax: yMax,
priceMin: pMin,
priceMax: pMax,
medians: meds,
priceTicks: ticks,
};
}, [points]); }, [points]);
const scaleY = (price: number) => { const plotW = width - PADDING.left - PADDING.right;
const ratio = (price - priceMin) / (priceMax - priceMin || 1); const plotH = HEIGHT - PADDING.top - PADDING.bottom;
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
};
const yearRange = yearMax - yearMin || 1; const yearRange = yearMax - yearMin || 1;
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
const scaleY = (price: number) => {
const t = (price - priceMin) / (priceMax - priceMin || 1);
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
};
// Year labels: every 5 years // Year labels: every 5 years
const yearStart = Math.ceil(yearMin / 5) * 5; const yearStart = Math.ceil(yearMin / 5) * 5;
const yearLabels: number[] = []; const yearLabels: number[] = [];
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y); for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
const VB_W = 1000; const medianPolyline = medians
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
const scaleX = (year: number) => { .join(' ');
const ratio = (year - yearMin) / yearRange;
return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right);
};
const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
return ( return (
<svg <div ref={containerRef} style={{ height: HEIGHT }}>
viewBox={`0 0 ${VB_W} ${HEIGHT}`} {width > 0 && (
preserveAspectRatio="none" <svg width={width} height={HEIGHT}>
className="w-full" {/* Grid lines */}
style={{ height: HEIGHT }} {priceTicks.map((tick) => (
> <line
{/* Grid lines */} key={tick}
{priceTicks.map((tick) => ( x1={PADDING.left}
<line y1={scaleY(tick)}
key={tick} x2={width - PADDING.right}
x1={PADDING.left} y2={scaleY(tick)}
y1={scaleY(tick)} className="stroke-warm-200 dark:stroke-warm-700"
x2={VB_W - PADDING.right} strokeWidth={1}
y2={scaleY(tick)} />
className="stroke-warm-200 dark:stroke-warm-700" ))}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{/* Dots */} {/* Dots (clamp outliers to visible range) */}
{points.map((p, i) => ( {points.map((p, i) => (
<circle <circle
key={i} key={i}
cx={scaleX(p.year)} cx={scaleX(p.year)}
cy={scaleY(p.price)} cy={scaleY(p.price)}
r={4} r={3}
className="fill-teal-500 dark:fill-teal-400" className="fill-teal-500 dark:fill-teal-400"
opacity={0.35} opacity={0.35}
/> />
))} ))}
{/* Average line */} {/* Median line */}
{averages.length > 1 && ( {medians.length > 1 && (
<polyline <polyline
points={avgPolyline} points={medianPolyline}
fill="none" fill="none"
className="stroke-teal-600 dark:stroke-teal-400" className="stroke-teal-600 dark:stroke-teal-400"
strokeWidth={3} strokeWidth={2}
vectorEffect="non-scaling-stroke" strokeLinejoin="round"
strokeLinejoin="round" />
/> )}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{yr}
</text>
))}
</svg>
)} )}
</div>
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{formatValue(tick)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{yr}
</text>
))}
</svg>
); );
} }
@ -151,7 +169,6 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
const range = max - min; const range = max - min;
if (range <= 0) return [min]; if (range <= 0) return [min];
const rough = range / count; const rough = range / count;
// Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc.
const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
let step: number; let step: number;
const normalized = rough / magnitude; const normalized = rough / magnitude;

View file

@ -17,8 +17,6 @@ interface PropertiesPaneProps {
onNavigateToSource?: (slug: string) => void; onNavigateToSource?: (slug: string) => void;
} }
type SortBy = 'price' | 'size' | 'energy';
export function PropertiesPane({ export function PropertiesPane({
properties, properties,
total, total,
@ -28,30 +26,19 @@ export function PropertiesPane({
onClose, onClose,
onNavigateToSource, onNavigateToSource,
}: PropertiesPaneProps) { }: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const filteredAndSorted = useMemo(() => { const filtered = useMemo(() => {
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
const filtered = query return query
? properties.filter((p) => { ? properties.filter((p) => {
const addr = (p.address || '').toLowerCase(); const addr = (p.address || '').toLowerCase();
const pc = (p.postcode || '').toLowerCase(); const pc = (p.postcode || '').toLowerCase();
return addr.includes(query) || pc.includes(query); return addr.includes(query) || pc.includes(query);
}) })
: properties; : properties;
return [...filtered].sort((a, b) => { }, [properties, search]);
switch (sortBy) {
case 'price':
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
case 'size':
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
case 'energy':
return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z');
}
});
}, [properties, sortBy, search]);
if (!hexagonId) { if (!hexagonId) {
return ( return (
@ -91,22 +78,13 @@ export function PropertiesPane({
</InfoPopup> </InfoPopup>
)} )}
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2"> <div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput <SearchInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder="Search by address or postcode..." placeholder="Search by address or postcode..."
className="p-2" className="p-2"
/> />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
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>
<option value="energy">Energy Rating (Best to Worst)</option>
</select>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
@ -114,7 +92,7 @@ export function PropertiesPane({
<div className="p-4 dark:text-warm-400">Loading...</div> <div className="p-4 dark:text-warm-400">Loading...</div>
) : ( ) : (
<> <>
{filteredAndSorted.map((property, idx) => ( {filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} /> <PropertyCard key={idx} property={property} />
))} ))}
{properties.length < total && ( {properties.length < total && (

View file

@ -10,6 +10,8 @@ interface Segment {
interface StackedBarChartProps { interface StackedBarChartProps {
segments: Segment[]; segments: Segment[];
total: number; total: number;
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
colorMap?: Record<string, string>;
} }
/** Strip common suffixes/prefixes to produce short legend labels */ /** Strip common suffixes/prefixes to produce short legend labels */
@ -26,7 +28,7 @@ function shortenLabel(name: string): string {
.trim(); .trim();
} }
export default function StackedBarChart({ segments, total }: StackedBarChartProps) { export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo( const sortedSegments = useMemo(
() => [...segments].sort((a, b) => b.value - a.value), () => [...segments].sort((a, b) => b.value - a.value),
[segments] [segments]
@ -51,7 +53,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
className="h-full" className="h-full"
style={{ style={{
width: `${pct}%`, width: `${pct}%`,
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length], backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}} }}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`} title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
/> />
@ -66,7 +68,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
<span <span
className="w-2 h-2 rounded-sm shrink-0" className="w-2 h-2 rounded-sm shrink-0"
style={{ style={{
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length], backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}} }}
/> />
<span className="text-[10px] text-warm-600 dark:text-warm-400"> <span className="text-[10px] text-warm-600 dark:text-warm-400">

View file

@ -0,0 +1,78 @@
import type { EnumFeatureStats } from '../../types';
interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
}
/** Strip common suffixes to produce short row labels */
function shortenLabel(name: string): string {
return name.replace(/ risk$/, '');
}
export default function StackedEnumChart({
components,
valueOrder,
valueColors,
}: StackedEnumChartProps) {
const visibleRows = components.filter(({ stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
if (total === 0) return false;
const lowCount = stats.counts[valueOrder[0]] ?? 0;
return total - lowCount > 0;
});
if (visibleRows.length === 0) {
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>
);
}
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{shortenLabel(label)}
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
const count = stats.counts[value] ?? 0;
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={value}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
/>
);
})}
</div>
</div>
);
})}
{/* Legend */}
<div className="flex gap-x-3 gap-y-0.5 justify-center">
{valueOrder.map((value, i) => (
<div key={value} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: valueColors[i] }}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,154 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
type Tab = 'login' | 'register';
export default function AuthModal({
onClose,
onLogin,
onRegister,
loading,
error,
onClearError,
}: {
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string, name?: string) => Promise<void>;
loading: boolean;
error: string | null;
onClearError: () => void;
}) {
const [tab, setTab] = useState<Tab>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const switchTab = useCallback(
(newTab: Tab) => {
setTab(newTab);
onClearError();
},
[onClearError]
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
try {
if (tab === 'login') {
await onLogin(email, password);
} else {
await onRegister(email, password, name || undefined);
}
onClose();
} catch {
// Error is handled by the hook
}
},
[tab, email, password, name, onLogin, onRegister, onClose]
);
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="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>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<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'
? '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')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === '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')}
>
Register
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{tab === 'register' && (
<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-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
placeholder="Your name (optional)"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
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"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
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'}
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<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"
>
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
</button>
</form>
</div>
</div>
);
}

View file

@ -17,8 +17,8 @@ export function FeatureLabel({
const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
return ( return (
<div className={`flex items-center gap-1 min-w-0 ${className}`}> <div className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}>
<span className={`${textClass} text-warm-700 dark:text-warm-300 truncate`}> <span className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}>
{feature.name} {feature.name}
</span> </span>
{feature.detail && onShowInfo && ( {feature.detail && onShowInfo && (

View file

@ -1,4 +1,12 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
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 UserMenu from './UserMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq'; export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq';
@ -7,11 +15,21 @@ export default function Header({
onPageChange, onPageChange,
theme, theme,
onToggleTheme, onToggleTheme,
onExport,
exporting,
user,
onLoginClick,
onLogout,
}: { }: {
activePage: Page; activePage: Page;
onPageChange: (page: Page) => void; onPageChange: (page: Page) => void;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
onToggleTheme: () => void; onToggleTheme: () => void;
onExport: (() => void) | null;
exporting: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onLogout: () => void;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -36,25 +54,7 @@ export default function Header({
className="flex items-center gap-2 hover:opacity-80 transition-opacity" className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')} onClick={() => onPageChange('home')}
> >
<svg <MapPinIcon className="w-5 h-5 text-teal-400" />
className="w-5 h-5 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="font-semibold text-lg">Narrowit</span> <span className="font-semibold text-lg">Narrowit</span>
</button> </button>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
@ -70,73 +70,52 @@ export default function Header({
</nav> </nav>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activePage === 'dashboard' && (
<>
<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>
<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"
>
{copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied!
</>
) : (
<>
<ClipboardIcon className="w-4 h-4" />
Share
</>
)}
</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 <button
onClick={onToggleTheme} onClick={onToggleTheme}
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors" className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
title={`Theme: ${theme}`} title={`Theme: ${theme}`}
> >
{theme === 'light' ? ( {theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button> </button>
{activePage === 'dashboard' && (
<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"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
Share
</>
)}
</button>
)}
</div> </div>
</header> </header>
); );

View file

@ -0,0 +1,57 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const initial = (user.name || user.email)[0].toUpperCase();
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((prev) => !prev)}
className="flex items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
title={user.name || user.email}
>
{initial}
</button>
{open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
{user.name && (
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.name}
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 truncate">{user.email}</p>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onLogout();
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Log out
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
);
}

View file

@ -0,0 +1,12 @@
interface IconProps {
className?: string;
}
export function DownloadIcon({ 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="M12 5v14m0 0l-6-6m6 6l6-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function MapPinIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function MoonIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
}

View file

@ -0,0 +1,12 @@
interface IconProps {
className?: string;
}
export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} 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>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function SunIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}

View file

@ -0,0 +1,108 @@
import { useState, useEffect, useCallback } from 'react';
import pb from '../lib/pocketbase';
export interface AuthUser {
id: string;
email: string;
name: string;
avatar: string;
verified: boolean;
}
// PocketBase RecordModel stores user fields as dynamic properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function recordToUser(record: any): AuthUser {
return {
id: record.id || '',
email: record.email || '',
name: record.name || '',
avatar: record.avatar || '',
verified: record.verified || false,
};
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(() => {
if (pb.authStore.isValid && pb.authStore.record) {
return recordToUser(pb.authStore.record);
}
return null;
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Sync with authStore changes (cross-tab, external updates)
useEffect(() => {
const unsubscribe = pb.authStore.onChange(() => {
if (pb.authStore.isValid && pb.authStore.record) {
setUser(recordToUser(pb.authStore.record));
} else {
setUser(null);
}
});
return unsubscribe;
}, []);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const register = useCallback(async (email: string, password: string, name?: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').create({
email,
password,
passwordConfirm: password,
name: name || '',
});
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const loginWithOAuth = useCallback(async (provider: string) => {
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authWithOAuth2({ provider });
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
pb.authStore.clear();
setUser(null);
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return { user, loading, error, login, register, loginWithOAuth, logout, clearError };
}

View file

@ -8,7 +8,7 @@ import type {
HexagonStatsResponse, HexagonStatsResponse,
NumericFeatureStats, NumericFeatureStats,
} from '../types'; } from '../types';
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
interface SelectedHexagon { interface SelectedHexagon {
id: string; id: string;
@ -50,7 +50,7 @@ export function useHexagonSelection({
if (fields) { if (fields) {
params.set('fields', fields.join(',')); params.set('fields', fields.join(','));
} }
const response = await fetch(apiUrl('hexagon-stats', params), { signal }); const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal }));
return (await response.json()) as HexagonStatsResponse; return (await response.json()) as HexagonStatsResponse;
}, },
[filters, features] [filters, features]
@ -96,7 +96,7 @@ export function useHexagonSelection({
const filterStr = buildFilterString(filters, features); const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr); if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('hexagon-properties', params)); const response = await fetch(apiUrl('hexagon-properties', params), authHeaders());
const data: HexagonPropertiesResponse = await response.json(); const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) { if (offset === 0) {

View file

@ -8,7 +8,7 @@ import type {
ViewChangeParams, ViewChangeParams,
ApiResponse, ApiResponse,
} from '../types'; } from '../types';
import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
@ -76,9 +76,12 @@ export function useMapData({
const params = new URLSearchParams({ bounds: boundsStr }); const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || ''); params.set('fields', viewFeature || '');
const res = await fetch(apiUrl('postcodes', params), { const res = await fetch(
signal: abortControllerRef.current.signal, apiUrl('postcodes', params),
}); authHeaders({
signal: abortControllerRef.current.signal,
})
);
const json: { features: PostcodeFeature[] } = await res.json(); const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features || []); setPostcodeData(json.features || []);
setRawData([]); setRawData([]);
@ -89,9 +92,12 @@ export function useMapData({
}); });
if (filtersStr) params.set('filters', filtersStr); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', viewFeature || ''); params.set('fields', viewFeature || '');
const res = await fetch(apiUrl('hexagons', params), { const res = await fetch(
signal: abortControllerRef.current.signal, apiUrl('hexagons', params),
}); authHeaders({
signal: abortControllerRef.current.signal,
})
);
const json: ApiResponse = await res.json(); const json: ApiResponse = await res.json();
setRawData(json.features || []); setRawData(json.features || []);
setPostcodeData([]); setPostcodeData([]);
@ -162,7 +168,13 @@ export function useMapData({
}, [viewFeature, features, dataRange, activeFeature, dragValue]); }, [viewFeature, features, dataRange, activeFeature, dragValue]);
const handleViewChange = useCallback( const handleViewChange = useCallback(
({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => { ({
resolution: newRes,
bounds: newBounds,
zoom: newZoom,
latitude,
longitude,
}: ViewChangeParams) => {
const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`;
if (boundsKey !== prevBoundsRef.current) { if (boundsKey !== prevBoundsRef.current) {
prevBoundsRef.current = boundsKey; prevBoundsRef.current = boundsKey;
@ -175,10 +187,13 @@ export function useMapData({
[] []
); );
const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => { const setInitialView = useCallback(
setCurrentView(view); (view: { latitude: number; longitude: number; zoom: number }) => {
setZoom(view.zoom); setCurrentView(view);
}, []); setZoom(view.zoom);
},
[]
);
return { return {
data, data,

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import type { Bounds, POI, POIResponse } from '../types'; import type { Bounds, POI, POIResponse } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api'; import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
@ -32,9 +32,12 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
categories: categoriesStr, categories: categoriesStr,
bounds: boundsStr, bounds: boundsStr,
}); });
const res = await fetch(apiUrl('pois', params), { const res = await fetch(
signal: abortControllerRef.current.signal, apiUrl('pois', params),
}); authHeaders({
signal: abortControllerRef.current.signal,
})
);
const json: POIResponse = await res.json(); const json: POIResponse = await res.json();
setPois(json.pois || []); setPois(json.pois || []);
} catch (err) { } catch (err) {

View file

@ -8,6 +8,7 @@ body,
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
overscroll-behavior: none;
} }
html.dark { html.dark {

View file

@ -1,25 +1,29 @@
import type { FeatureMeta, FeatureFilters } from '../types'; import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
const INITIAL_RETRY_MS = 1000; import pb from './pocketbase';
const MAX_RETRY_MS = 10000;
// Error handling utilities
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export function logNonAbortError(label: string, error: unknown): void { export function logNonAbortError(label: string, error: unknown): void {
if (!isAbortError(error)) { if (error instanceof Error && error.name === 'AbortError') {
console.error(`${label}:`, error); return;
} }
console.error(`${label}:`, error);
}
export function authHeaders(init?: RequestInit): RequestInit {
const headers: Record<string, string> = {};
if (pb.authStore.isValid && pb.authStore.token) {
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
}
if (!init) return { headers };
const existing = init.headers as Record<string, string> | undefined;
return { ...init, headers: { ...existing, ...headers } };
} }
// API URL helper
export function apiUrl(endpoint: string, params?: URLSearchParams): string { export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const base = getApiBaseUrl();
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`; const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString(); const query = params?.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`; return query ? `${path}?${query}` : path;
} }
export async function fetchWithRetry<T>( export async function fetchWithRetry<T>(
@ -30,7 +34,7 @@ export async function fetchWithRetry<T>(
let delay = INITIAL_RETRY_MS; let delay = INITIAL_RETRY_MS;
while (!signal.aborted) { while (!signal.aborted) {
try { try {
const res = await fetch(url, { signal }); const res = await fetch(url, authHeaders({ signal }));
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json(); const json = await res.json();
onSuccess(json); onSuccess(json);
@ -44,26 +48,6 @@ export async function fetchWithRetry<T>(
} }
} }
export function getApiBaseUrl(): string {
if (process.env.NODE_ENV === 'production') {
return '';
}
const { pathname, href } = window.location;
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
if (pathMatch) {
return `${pathMatch[1]}8001`;
}
const hrefMatch = href.match(/(\/proxy\/)\d+/);
if (hrefMatch) {
return `${hrefMatch[1]}8001`;
}
return '';
}
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string { export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
const entries = Object.entries(filters); const entries = Object.entries(filters);
if (entries.length === 0) return ''; if (entries.length === 0) return '';

View file

@ -1,12 +1,14 @@
import type { ViewState } from '../types'; import type { ViewState } from '../types';
export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57]; export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
export const MAP_MIN_ZOOM = 5.5; export const MAP_MIN_ZOOM = 5.5;
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
export const TILE_MAX_ZOOM = 15;
/** Initial map view state */
export const INITIAL_VIEW_STATE: ViewState = { export const INITIAL_VIEW_STATE: ViewState = {
longitude: -1.5, longitude: -1.5,
latitude: 53.5, latitude: 53.5,
@ -27,14 +29,11 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 13, resolution: 9 }, { maxZoom: 13, resolution: 9 },
{ maxZoom: Infinity, resolution: 10 }, { maxZoom: Infinity, resolution: 10 },
] as const; ] as const;
export const POSTCODE_ZOOM_THRESHOLD = 15;
export const POSTCODE_ZOOM_THRESHOLD = 17.5;
// =============================================================================
// Color Gradients
// =============================================================================
/** Feature value gradient (green → yellow → red → purple) */
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] }, { t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] }, { t: 0.33, color: [241, 196, 15] },
@ -42,34 +41,37 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [142, 68, 173] }, { t: 1, color: [142, 68, 173] },
]; ];
/** Property density gradient (teal → blue → purple) */ /** Property density gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] }, { t: 0, color: [255, 255, 255] },
{ t: 0.5, color: [20, 140, 180] }, { t: 0.1, color: [248, 233, 211] },
{ t: 1, color: [88, 28, 140] }, { t: 0.5, color: [255, 221, 173] },
{ t: 0.8, color: [251, 171, 60] },
{ t: 1, color: [255, 162, 31] },
]; ];
// ============================================================================= /** Property density gradient — dark mode (dark warm → bright amber) */
// External URLs export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
// ============================================================================= { t: 0, color: [55, 45, 35] },
{ t: 0.1, color: [85, 65, 40] },
{ t: 0.5, color: [170, 115, 50] },
{ t: 0.8, color: [230, 155, 45] },
{ t: 1, color: [255, 170, 40] },
];
/** Protomaps font glyphs URL */ /** Protomaps font glyphs URL (served locally from public/assets/) */
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf'; export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
/** Protomaps sprite base URL */ /** Twemoji base URL (served locally from public/assets/) */
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4'; export const TWEMOJI_BASE = '/assets/twemoji/';
/** Twemoji CDN base URL */
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
/** OpenStreetMap attribution HTML */
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
// ============================================================================= /**
// Stacked Chart Groups * Groups whose features should be collapsed into stacked bar charts.
// ============================================================================= * Keyed by feature group name. Each entry defines one stacked chart.
*/
export interface StackedChartConfig { export const STACKED_GROUPS: Record<string, {
/** Display label for the chart */ /** Display label for the chart */
label: string; label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */ /** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
@ -78,13 +80,7 @@ export interface StackedChartConfig {
unit?: string; unit?: string;
/** Feature names that make up the segments */ /** Feature names that make up the segments */
components: string[]; components: string[];
} }[]> = {
/**
* Groups whose features should be collapsed into stacked bar charts.
* Keyed by feature group name. Each entry defines one stacked chart.
*/
export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
Crime: [ Crime: [
{ {
label: 'Serious crime', label: 'Serious crime',
@ -124,6 +120,56 @@ export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
], ],
}; };
/**
* Groups whose enum features should be collapsed into compact multi-row charts.
* Keyed by feature group name. Each entry defines one stacked enum chart.
*/
export const STACKED_ENUM_GROUPS: Record<string, {
/** Display label for the chart */
label: string;
/** If set, use this feature for the info popup */
feature?: string;
/** Enum feature names that make up the rows */
components: string[];
/** Value order for consistent segment ordering */
valueOrder: string[];
/** Colors for each value (matches valueOrder) */
valueColors: string[];
}[]> = {
Property: [
{
label: 'Property type',
feature: 'Property type',
components: ['Property type'],
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flat'],
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b'],
},
{
label: 'Leasehold/Freehold',
feature: 'Leashold/Freehold',
components: ['Leashold/Freehold'],
valueOrder: ['Freehold', 'Leasehold'],
valueColors: ['#3b82f6', '#f59e0b'],
},
],
Environment: [
{
label: 'Ground Risk',
feature: 'Environmental risk',
components: [
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
],
valueOrder: ['Low', 'Moderate', 'Significant'],
valueColors: ['#22c55e', '#eab308', '#ef4444'],
},
],
};
/** Colors for stacked bar segments */ /** Colors for stacked bar segments */
export const SEGMENT_COLORS = [ export const SEGMENT_COLORS = [
'#ef4444', // red-500 '#ef4444', // red-500

View file

@ -1,10 +1,62 @@
export function formatValue(value: number): string { export interface ValueFormat {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; prefix?: string;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; suffix?: string;
if (Number.isInteger(value)) return value.toLocaleString(); /** Show full integer (no k/M abbreviation) */
return value.toFixed(1); raw?: boolean;
} }
export function formatValue(value: number, fmt?: ValueFormat): string {
const p = fmt?.prefix ?? '';
const s = fmt?.suffix ?? '';
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
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 { 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_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
@ -29,20 +81,31 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString(); return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
} }
// Calculate weighted mean from histogram // 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: { export function calculateHistogramMean(histogram: {
min: number; min: number;
bin_width: number; max: number;
p1: number;
p99: number;
counts: number[]; counts: number[];
}): number | undefined { }): number | undefined {
if (!histogram.counts.length) return undefined; const n = histogram.counts.length;
if (n === 0) return undefined;
const totalCount = histogram.counts.reduce((a, b) => a + b, 0); const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
if (totalCount === 0) return undefined; if (totalCount === 0) return undefined;
const { min, max, p1, p99 } = histogram;
const middleBins = Math.max(n - 2, 0);
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
let weightedSum = 0; let weightedSum = 0;
for (let i = 0; i < histogram.counts.length; i++) { for (let i = 0; i < n; i++) {
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width; let center: number;
weightedSum += binCenter * histogram.counts[i]; if (i === 0) center = (min + p1) / 2;
else if (i === n - 1) center = (p99 + max) / 2;
else center = p1 + (i - 0.5) * middleWidth;
weightedSum += center * histogram.counts[i];
} }
return weightedSum / totalCount; return weightedSum / totalCount;
} }

View file

@ -3,40 +3,94 @@ import type { StyleSpecification } from 'maplibre-gl';
import { layers, namedFlavor } from '@protomaps/basemaps'; import { layers, namedFlavor } from '@protomaps/basemaps';
import { import {
GLYPHS_URL, GLYPHS_URL,
SPRITE_URL_BASE,
TILE_MAX_ZOOM,
OSM_ATTRIBUTION,
FEATURE_GRADIENT, FEATURE_GRADIENT,
DENSITY_GRADIENT, DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ZOOM_TO_RESOLUTION_THRESHOLDS, ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE, TWEMOJI_BASE,
POSTCODE_ZOOM_THRESHOLD,
} from './consts'; } from './consts';
// Re-export constants for backwards compatibility // Re-export constants for backwards compatibility
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts'; export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, POSTCODE_ZOOM_THRESHOLD } from './consts';
const ROAD_OPACITY = 0.4;
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification { export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
const flavor = namedFlavor(theme); const flavor = namedFlavor(theme);
// Use absolute URL for tiles - required by MapLibre // Use absolute URL for tiles - required by MapLibre
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`; const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
const baseLayers = layers('protomaps', flavor, { lang: 'en' });
// Reduce road layer opacity so hexagons are more visible
const modifiedLayers = baseLayers.map((layer) => {
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
} else if (layer.type === 'fill') {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
}
}
return layer;
});
return { return {
version: 8, version: 8,
glyphs: GLYPHS_URL, glyphs: GLYPHS_URL,
sprite: `${SPRITE_URL_BASE}/${theme}`,
sources: { sources: {
protomaps: { protomaps: {
type: 'vector', type: 'vector',
tiles: [tileUrl], tiles: [tileUrl],
maxzoom: TILE_MAX_ZOOM, maxzoom: POSTCODE_ZOOM_THRESHOLD,
attribution: OSM_ATTRIBUTION,
}, },
}, },
layers: layers('protomaps', flavor, { lang: 'en' }), layers: modifiedLayers,
} as StyleSpecification; } as StyleSpecification;
} }
type GradientStop = { t: number; color: [number, number, number] }; type GradientStop = { t: number; color: [number, number, number] };
// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
const v = c / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function linearToSrgb(c: number): number {
const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(Math.max(0, Math.min(255, v * 255)));
}
function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
const r = srgbToLinear(rgb[0]);
const g = srgbToLinear(rgb[1]);
const b = srgbToLinear(rgb[2]);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
];
}
function oklabToRgb(lab: [number, number, number]): [number, number, number] {
const L = lab[0], a = lab[1], b = lab[2];
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
const s = Math.pow(L - 0.0894841775 * a - 1.2914855480 * b, 3);
return [
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
];
}
function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] { function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] {
if (t <= 0) return gradient[0].color; if (t <= 0) return gradient[0].color;
if (t >= 1) return gradient[gradient.length - 1].color; if (t >= 1) return gradient[gradient.length - 1].color;
@ -46,11 +100,14 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
const hi = gradient[i + 1]; const hi = gradient[i + 1];
if (t >= lo.t && t <= hi.t) { if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t); const frac = (t - lo.t) / (hi.t - lo.t);
return [ const loLab = rgbToOklab(lo.color);
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), const hiLab = rgbToOklab(hi.color);
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), const interpLab: [number, number, number] = [
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), loLab[0] + (hiLab[0] - loLab[0]) * frac,
loLab[1] + (hiLab[1] - loLab[1]) * frac,
loLab[2] + (hiLab[2] - loLab[2]) * frac,
]; ];
return oklabToRgb(interpLab);
} }
} }
return gradient[gradient.length - 1].color; return gradient[gradient.length - 1].color;
@ -60,8 +117,8 @@ export function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT); return interpolateGradient(t, FEATURE_GRADIENT);
} }
export function countToColor(t: number): [number, number, number] { export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
return interpolateGradient(t, DENSITY_GRADIENT); return interpolateGradient(t, gradient);
} }
export function zoomToResolution(zoom: number): number { export function zoomToResolution(zoom: number): number {

View file

@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('/pb');
export default pb;

View file

@ -0,0 +1,7 @@
/**
* Converts a gradient definition to CSS linear-gradient string
*/
export function gradientToCss(gradient: { t: number; color: [number, number, number] }[]): string {
const stops = gradient.map(({ t, color }) => `rgb(${color.join(',')}) ${t * 100}%`).join(', ');
return `linear-gradient(in oklch to right, ${stops})`;
}

View file

@ -6,7 +6,7 @@ export interface FeatureMeta {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
histogram?: { min: number; max: number; bin_width: number; counts: number[] }; histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
// Enum-only fields // Enum-only fields
values?: string[]; values?: string[];
// Description fields // Description fields
@ -124,7 +124,7 @@ export interface NumericFeatureStats {
min: number; min: number;
max: number; max: number;
mean: number; mean: number;
histogram?: { min: number; max: number; bin_width: number; counts: number[] }; histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] };
} }
export interface EnumFeatureStats { export interface EnumFeatureStats {