Format and lint

This commit is contained in:
Andras Schmelczer 2026-02-08 12:37:07 +00:00
parent 42ee2d4c51
commit 04a78e7bfe
75 changed files with 1290 additions and 719 deletions

View file

@ -1,5 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { trackPageview } from './hooks/usePlausible';
import MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage';
@ -19,17 +18,22 @@ import { useSavedSearches } from './hooks/useSavedSearches';
declare global {
interface Window {
__og_ready?: boolean;
__screenshot_ready?: boolean;
}
}
function pageToPath(page: Page): string {
switch (page) {
case 'dashboard': return '/dashboard';
case 'data-sources': return '/data-sources';
case 'faq': return '/faq';
case 'saved-searches': return '/saved';
default: return '/';
case 'dashboard':
return '/dashboard';
case 'data-sources':
return '/data-sources';
case 'faq':
return '/faq';
case 'saved-searches':
return '/saved';
default:
return '/';
}
}
@ -44,7 +48,10 @@ function pathToPage(pathname: string): Page | null {
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
const initialViewState = useMemo(
() => urlState.viewState || INITIAL_VIEW_STATE,
[urlState.viewState]
);
const isScreenshotMode = useMemo(() => {
const params = new URLSearchParams(window.location.search);
@ -76,11 +83,7 @@ export default function App() {
const params = new URLSearchParams(window.location.search);
if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) {
// Rewrite URL to /dashboard keeping query params
window.history.replaceState(
{ page: 'dashboard' },
'',
`/dashboard${window.location.search}`
);
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
return 'dashboard';
}
@ -141,7 +144,7 @@ export default function App() {
return () => controller.abort();
}, []);
// Screenshot mode ready signal — MapPage sets __og_ready once map data loads
// Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads
// Navigation
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
@ -152,7 +155,6 @@ export default function App() {
const url = hash ? `${path}#${hash}` : path;
window.history.pushState({ page }, '', url);
setActivePage(page);
trackPageview();
}, []);
useEffect(() => {
@ -180,11 +182,12 @@ export default function App() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch saved searches when page becomes active
const { fetchSearches } = savedSearches;
useEffect(() => {
if (activePage === 'saved-searches') {
savedSearches.fetchSearches();
fetchSearches();
}
}, [activePage, savedSearches.fetchSearches]);
}, [activePage, fetchSearches]);
const [exportState, setExportState] = useState<ExportState | null>(null);

View file

@ -1,5 +1,10 @@
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 { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
@ -33,7 +38,6 @@ interface AreaPaneProps {
aiSummary?: string;
aiSummaryLoading?: boolean;
aiSummaryError?: string | null;
onRetryAiSummary?: () => void;
}
export default function AreaPane({
@ -51,7 +55,6 @@ export default function AreaPane({
aiSummary,
aiSummaryLoading,
aiSummaryError,
onRetryAiSummary,
}: AreaPaneProps) {
// For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
@ -145,7 +148,9 @@ export default function AreaPane({
>
<div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
AI Summary
</span>
</div>
<ChevronIcon
direction={aiSummaryExpanded ? 'down' : 'right'}
@ -156,15 +161,7 @@ export default function AreaPane({
<>
{aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span>
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
Failed to generate summary.
</div>
) : aiSummaryLoading ? (
<div className="space-y-1.5">
@ -191,19 +188,24 @@ export default function AreaPane({
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span>{' '}
show the distribution in this selected area
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span>{' '}
show the overall distribution across all areas
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
<span className="text-warm-700 dark:text-warm-300">
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
<span className="font-medium text-warm-900 dark:text-warm-100">
Dashed line
</span>{' '}
indicates the global average
</span>
</div>
</div>
@ -219,9 +221,9 @@ export default function AreaPane({
// Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set(
stackedEnumCharts?.flatMap((c) =>
(stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean)
) as string[] ?? []
) as string[]) ?? []
);
const isExpanded = !collapsedGroups.has(group.name);
@ -234,40 +236,156 @@ export default function AreaPane({
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && <div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' && stats.price_history && (() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
{isExpanded && (
<div className="px-3 py-2 space-y-3">
{/* Price History in Property group */}
{group.name === 'Property' &&
stats.price_history &&
(() => {
// Only show chart if there are at least 2 unique years
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
return uniqueYears.size > 1;
})() && (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
Price History
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}
{stackedCharts
? // Render stacked charts for this group
stackedCharts.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
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, name: chart.label }}
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">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
</div>
<StackedBarChart segments={segments} total={total} />
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
))}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
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);
// Use aggregate feature stats if available, otherwise sum components
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: segments.reduce((sum, s) => sum + s.value, 0);
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
@ -278,7 +396,7 @@ export default function AreaPane({
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
@ -288,166 +406,57 @@ export default function AreaPane({
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(total)}
{chart.unit ? ` ${chart.unit}` : ''}
{total.toLocaleString()}
</span>
</div>
<StackedBarChart segments={segments} total={total} />
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
})
: // Default: render each feature individually (skip stacked enum features)
group.features
.filter((f) => !stackedEnumFeatureNames.has(f.name))
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
}
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
// 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);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram && (
globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
)
)}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
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;
if (components.length === 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">
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{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]])
)}
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</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>
);
})}

View file

@ -51,15 +51,16 @@ export function DualHistogram({
const localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 1);
const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
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
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;
});
@ -71,7 +72,10 @@ export function DualHistogram({
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 (dist < bestDist) {
bestDist = dist;
bestBar = i;
}
}
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
}
@ -79,9 +83,7 @@ export function DualHistogram({
// 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;
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
return (
<div className="mt-1">

View file

@ -1,6 +1,5 @@
import { memo, useState, useMemo, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { Label } from '../ui/Label';
import { SearchInput } from '../ui/SearchInput';
import { FilterIcon, LightbulbIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
@ -138,7 +137,9 @@ function FeatureBrowser({
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
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"
/>
) : (
@ -334,9 +335,9 @@ export default memo(function Filters({
Be intentional, not reactive
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn't a box of cereal you grab because it's on sale. Don't let a
seemingly good deal turn into lifelong regret. Instead of waiting for listings to
appear, define what you actually want and go find it.
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
</p>
</div>
@ -347,7 +348,7 @@ export default memo(function Filters({
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what's truly available in any area.
historical sales so you can understand what&apos;s truly available in any area.
</p>
</div>
@ -367,18 +368,19 @@ export default memo(function Filters({
Find the right place, not just the right listing
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don't always have properties listed right now. We help you
identify where you should be looking, so when something does come up, you're ready.
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what's possible
Know what&apos;s possible
</h4>
<p className="text-warm-600 dark:text-warm-300">
We'd rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn't exist.
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
</p>
</div>
</div>

View file

@ -16,7 +16,7 @@ import type {
FeatureMeta,
Bounds,
} from '../../types';
import { cellToLatLng } from 'h3-js';
import {
GRADIENT,
normalizedToColor,
@ -66,9 +66,9 @@ interface MapProps {
searchedPostcode?: SearchedPostcode | null;
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
}
interface Dimensions {
width: number;
height: number;
@ -118,6 +118,7 @@ export default memo(function Map({
searchedPostcode,
onPostcodeSearched,
bounds: viewportBounds,
hideLegend = false,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -204,8 +205,13 @@ export default memo(function Map({
let max = -Infinity;
for (const d of data) {
if (viewportBounds) {
const [lat, lng] = cellToLatLng(d.h3);
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
if (
d.lat < viewportBounds.south ||
d.lat > viewportBounds.north ||
d.lon < viewportBounds.west ||
d.lon > viewportBounds.east
)
continue;
}
const c = d.count as number;
if (c < min) min = c;
@ -270,7 +276,13 @@ export default memo(function Map({
for (const d of postcodeData) {
if (viewportBounds) {
const [lng, lat] = d.properties.centroid as [number, number];
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
if (
lat < viewportBounds.south ||
lat > viewportBounds.north ||
lng < viewportBounds.west ||
lng > viewportBounds.east
)
continue;
}
const c = d.properties.count;
if (c < min) min = c;
@ -339,12 +351,23 @@ export default memo(function Map({
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [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) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [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];
@ -356,12 +379,10 @@ export default memo(function Map({
const cr = countRangeRef.current;
const c = d.count as number;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
number,
number,
number,
number,
];
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
255,
] as [number, number, number, number];
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current)
@ -407,12 +428,23 @@ export default memo(function Map({
const dark = isDarkRef.current;
if (vf && clr && cfm) {
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
if (val == null) return (dark ? [80, 70, 65, 80] : [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) {
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < fr[0] || minVal > fr[1]) {
return (dark ? [60, 55, 50, 60] : [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];
@ -424,12 +456,10 @@ export default memo(function Map({
const cr = postcodeCountRangeRef.current;
const c = d.count;
const t = (c - cr.min) / (cr.max - cr.min);
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 180] as [
number,
number,
number,
number,
];
return [
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
180,
] as [number, number, number, number];
},
getLineColor: (f) => {
const pc = f.properties.postcode;
@ -438,7 +468,12 @@ export default memo(function Map({
return [255, 255, 255, 255] as [number, number, number, number];
if (pc === hoveredPostcodeRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number];
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
number,
number,
number,
number,
];
},
getLineWidth: (f) => {
const pc = f.properties.postcode;
@ -546,7 +581,14 @@ export default memo(function Map({
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
}, [
usePostcodeView,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
]);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
@ -588,26 +630,35 @@ export default memo(function Map({
) : (
<>
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
{viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={viewSource === 'eye' ? `Previewing \u201c${colorFeatureMeta.name}\u201d` : colorFeatureMeta.name}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={usePostcodeView ? [postcodeCountRange.min, postcodeCountRange.max] : [countRange.min, countRange.max]}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
)}
{!hideLegend &&
(viewFeature && colorRange && colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
: colorFeatureMeta.name
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
theme={theme}
/>
) : (
<MapLegend
featureLabel="Property density"
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
))}
{popupInfo && (
<div
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"

View file

@ -11,6 +11,7 @@ export default function MapLegend({
mode,
enumValues,
theme = 'light',
inline = false,
}: {
featureLabel: string;
range: [number, number];
@ -19,12 +20,20 @@ export default function MapLegend({
mode: 'feature' | 'density';
enumValues?: string[];
theme?: 'light' | 'dark';
inline?: boolean;
}) {
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
const gradientStyle =
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
return (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
<div
className={
inline
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
}
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
{showCancel && (

View file

@ -9,6 +9,7 @@ import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
@ -25,7 +26,7 @@ export interface ExportState {
exporting: boolean;
}
type MobileBottomTab = 'filters' | 'pois';
type MobileBottomTab = 'filters' | 'pois' | 'area';
interface MapPageProps {
features: FeatureMeta[];
@ -63,7 +64,8 @@ export default function MapPage({
isMobile = false,
}: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
@ -116,7 +118,6 @@ export default function MapPage({
const selection = useHexagonSelection({
filters,
features,
postcodeData: mapData.postcodeData,
resolution: mapData.resolution,
});
@ -133,13 +134,17 @@ export default function MapPage({
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => {
selection.handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps
const { handleHexagonClick } = selection;
const handleMobileHexagonClick = useCallback(
(id: string, isPostcode?: boolean) => {
handleHexagonClick(id, isPostcode);
if (id) {
setMobileDrawerOpen(true);
setMobileBottomTab('area');
}
},
[handleHexagonClick]
);
// Compute hexagon location for external links
const hexagonLocation = useMemo(() => {
@ -158,7 +163,13 @@ export default function MapPage({
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
}
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
}, [
selection.selectedHexagon?.id,
selection.selectedHexagon?.type,
mapData.data,
mapData.postcodeData,
mapData.resolution,
]);
// AI area summary
const aiSummary = useAreaSummary({
@ -203,10 +214,33 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
if (items.length === 0) return [0, 1];
let min = Infinity;
let max = -Infinity;
for (const d of items) {
const c =
'count' in d
? (d as { count: number }).count
: (d as { properties: { count: number } }).properties.count;
if (c < min) min = c;
if (c > max) max = c;
}
if (min === Infinity) return [0, 1];
if (min === max) return [min, min + 1];
return [min, max];
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
// Signal screenshot readiness once map data has loaded
useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__og_ready = true;
window.__screenshot_ready = true;
}
}, [screenshotMode, mapData.loading, mapData.data.length]);
@ -249,7 +283,9 @@ export default function MapPage({
isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={
selection.selectedHexagon?.type === 'postcode'
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
? mapData.postcodeData.find(
(f) => f.properties.postcode === selection.selectedHexagon?.id
) || null
: null
}
onViewProperties={selection.handleViewPropertiesFromArea}
@ -260,7 +296,6 @@ export default function MapPage({
aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error}
onRetryAiSummary={aiSummary.retry}
/>
);
@ -319,7 +354,9 @@ export default function MapPage({
<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">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<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>
)}
@ -348,6 +385,7 @@ export default function MapPage({
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds}
hideLegend
/>
{mapData.loading && (
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
@ -358,19 +396,55 @@ export default function MapPage({
</div>
{/* Bottom panel — 55% */}
<div className="bg-white dark:bg-navy-950 border-t border-warm-200 dark:border-navy-700 overflow-hidden flex flex-col" style={{ flex: '55 0 0' }}>
<div
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
style={{ flex: '55 0 0' }}
>
{/* Legend */}
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
: mobileLegendMeta.name
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
theme={theme}
inline
/>
) : (
<MapLegend
featureLabel="Property density"
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
{/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Filters" isActive={mobileBottomTab === 'filters'} onClick={() => setMobileBottomTab('filters')} />
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} />
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
<TabButton
label="Filters"
isActive={mobileBottomTab === 'filters'}
onClick={() => setMobileBottomTab('filters')}
/>
<TabButton
label="POIs"
isActive={mobileBottomTab === 'pois'}
onClick={() => setMobileBottomTab('pois')}
/>
</div>
{/* Tab content */}
<div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto">
{renderPOIPane()}
</div>
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
) : (
renderFilters()
)}
@ -397,16 +471,19 @@ export default function MapPage({
<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">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<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>
)}
{/* Left Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
<div className="flex-1 flex flex-col overflow-hidden">
{renderFilters()}
</div>
<div
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
style={{ width: leftPaneWidth }}
>
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
@ -449,7 +526,10 @@ export default function MapPage({
</div>
{/* Right Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg z-10" style={{ width: rightPaneWidth }}>
<div
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
@ -458,19 +538,29 @@ export default function MapPage({
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton label="Area" isActive={selection.rightPaneTab === 'area'} onClick={() => selection.setRightPaneTab('area')} />
<TabButton label="Properties" isActive={selection.rightPaneTab === 'properties'} onClick={selection.handlePropertiesTabClick} />
<TabButton label="POIs" isActive={selection.rightPaneTab === 'pois'} onClick={() => selection.setRightPaneTab('pois')} />
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<TabButton
label="POIs"
isActive={selection.rightPaneTab === 'pois'}
onClick={() => selection.setRightPaneTab('pois')}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area' ? (
renderAreaPane()
) : selection.rightPaneTab === 'properties' ? (
renderPropertiesPane()
) : (
renderPOIPane()
)}
{selection.rightPaneTab === 'area'
? renderAreaPane()
: selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderPOIPane()}
</div>
</div>
</div>

View file

@ -38,7 +38,11 @@ export default function MobileDrawer({
{/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
<TabButton label="Properties" isActive={tab === 'properties'} onClick={() => setTab('properties')} />
<TabButton
label="Properties"
isActive={tab === 'properties'}
onClick={() => setTab('properties')}
/>
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
<button
onClick={onClose}

View file

@ -159,9 +159,7 @@ export default function POIPane({
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
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;

View file

@ -53,8 +53,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
.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;
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);
@ -86,9 +85,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const yearLabels: number[] = [];
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
const medianPolyline = medians
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
.join(' ');
const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
return (
<div ref={containerRef} style={{ height: HEIGHT }}>

View file

@ -23,7 +23,7 @@ export function PropertiesPane({
loading,
hexagonId,
onLoadMore,
onClose,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
@ -70,10 +70,10 @@ export function PropertiesPane({
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
recent sale price from the Land Registry.
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
)}
@ -122,10 +122,7 @@ function PropertyLoadingSkeleton() {
return (
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div
key={idx}
className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse"
>
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
@ -196,7 +193,8 @@ function PropertyCard({ property }: { property: Property }) {
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
{formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (

View file

@ -29,15 +29,10 @@ function shortenLabel(name: string): string {
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(
() => [...segments].sort((a, b) => b.value - a.value),
[segments]
);
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
if (total === 0) {
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>
);
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
}
return (
@ -53,7 +48,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: colorMap?.[segment.name] ?? 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)}%)`}
/>
@ -68,7 +64,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: colorMap?.[segment.name] ?? 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">

View file

@ -24,9 +24,7 @@ export default function StackedEnumChart({
});
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="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
}
return (

View file

@ -127,7 +127,10 @@ export default function SavedSearchesPage({
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setDeleteConfirmId(null)}>
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setDeleteConfirmId(null)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"

View file

@ -64,7 +64,7 @@ export default function AuthModal({
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
@ -115,7 +115,7 @@ export default function AuthModal({
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
@ -131,7 +131,7 @@ export default function AuthModal({
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (

View file

@ -16,10 +16,7 @@ export function CollapsibleGroupHeader({
children,
}: CollapsibleGroupHeaderProps) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-between ${className}`}
>
<button onClick={onToggle} className={`w-full flex items-center justify-between ${className}`}>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}

View file

@ -17,7 +17,7 @@ export function EmptyState({
}: EmptyStateProps) {
return (
<div
className={`flex flex-col items-center justify-center text-center ${centered ? 'h-full px-4' : 'py-8'} ${className}`}
className={`flex flex-col items-center justify-center text-center ${centered ? 'h-full px-4' : 'py-3 md:py-8'} ${className}`}
>
<div className="mb-2">{icon}</div>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>

View file

@ -26,11 +26,16 @@ export function FeatureActions({
<InfoIcon />
</IconButton>
)}
<IconButton onClick={() => onTogglePin(feature.name)} title={isPinned ? 'Unpin color view' : 'Color map by this feature'} active={isPinned}>
<IconButton
onClick={() => onTogglePin(feature.name)}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
active={isPinned}
size="md"
>
<EyeIcon filled={isPinned} />
</IconButton>
{onAdd && (
<IconButton onClick={() => onAdd(feature.name)} title="Add filter">
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
<PlusIcon />
</IconButton>
)}

View file

@ -17,8 +17,12 @@ export function FeatureLabel({
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
return (
<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 ${size === 'xs' ? 'truncate' : ''}`}>
<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 ${size === 'xs' ? 'truncate' : ''}`}
>
{feature.name}
</span>
{feature.detail && onShowInfo && (

View file

@ -93,7 +93,9 @@ export default function Header({
<button
key={page}
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
activePage === page ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={() => {
onPageChange(page);
@ -123,11 +125,17 @@ export default function Header({
Dashboard
</button>
{user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
<button
className={tabClass('saved-searches')}
onClick={() => onPageChange('saved-searches')}
>
Saved
</button>
)}
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
<button
className={tabClass('data-sources')}
onClick={() => onPageChange('data-sources')}
>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
@ -245,10 +253,7 @@ export default function Header({
{isMobile && menuOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="fixed inset-0 bg-black/50 z-40" onClick={() => setMenuOpen(false)} />
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
@ -272,23 +277,40 @@ export default function Header({
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
{onSaveSearch && (
<button
onClick={() => { onSaveSearch(); setMenuOpen(false); }}
onClick={() => {
onSaveSearch();
setMenuOpen(false);
}}
disabled={savingSearch}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
{savingSearch ? <SpinnerIcon className="w-5 h-5 animate-spin" /> : <BookmarkIcon className="w-5 h-5" />}
{savingSearch ? (
<SpinnerIcon className="w-5 h-5 animate-spin" />
) : (
<BookmarkIcon className="w-5 h-5" />
)}
Save
</button>
)}
<button
onClick={() => { handleShare(); setMenuOpen(false); }}
onClick={() => {
handleShare();
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
>
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
{copied ? (
<CheckIcon className="w-5 h-5" />
) : (
<ClipboardIcon className="w-5 h-5" />
)}
{copied ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => { onExport?.(); setMenuOpen(false); }}
onClick={() => {
onExport?.();
setMenuOpen(false);
}}
disabled={!onExport || exporting}
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
>
@ -303,41 +325,56 @@ export default function Header({
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
{/* Theme toggle */}
<button
onClick={() => { onToggleTheme(); }}
onClick={() => {
onToggleTheme();
}}
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
>
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
{theme === 'light' ? (
<SunIcon className="w-5 h-5" />
) : (
<MoonIcon className="w-5 h-5" />
)}
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
</button>
{/* Auth buttons */}
<div>
{user ? (
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-warm-300 truncate">{user.email}</span>
<button
onClick={() => { onLogout(); setMenuOpen(false); }}
className="text-sm text-warm-400 hover:text-white"
>
Log out
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => { onLoginClick(); setMenuOpen(false); }}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
</button>
<button
onClick={() => { onRegisterClick(); setMenuOpen(false); }}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Register
</button>
</div>
)}
{user ? (
<div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-warm-300 truncate">{user.email}</span>
<button
onClick={() => {
onLogout();
setMenuOpen(false);
}}
className="text-sm text-warm-400 hover:text-white"
>
Log out
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => {
onLoginClick();
setMenuOpen(false);
}}
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
>
Log in
</button>
<button
onClick={() => {
onRegisterClick();
setMenuOpen(false);
}}
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
>
Register
</button>
</div>
)}
</div>
</div>
</div>

View file

@ -6,16 +6,28 @@ interface IconButtonProps {
children: ReactNode;
active?: boolean;
className?: string;
size?: 'sm' | 'md';
}
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) {
const baseClasses = 'p-0.5 rounded';
export function IconButton({
onClick,
title,
children,
active,
className,
size = 'sm',
}: IconButtonProps) {
const padClasses = size === 'md' ? 'p-1' : 'p-0.5';
const colorClasses = active
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
return (
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}>
<button
onClick={onClick}
title={title}
className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
>
{children}
</button>
);

View file

@ -41,7 +41,7 @@ export default function SaveSearchModal({
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
@ -63,7 +63,7 @@ export default function SaveSearchModal({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>

View file

@ -32,7 +32,9 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
{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">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">{user.email}</p>
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
</div>
<div className="p-1">
<button

View file

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

View file

@ -4,7 +4,13 @@ interface IconProps {
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<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

@ -13,7 +13,13 @@ export function ChevronIcon({
down: 'M6 9l6 6 6-6',
};
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
</svg>
);

View file

@ -4,7 +4,13 @@ interface IconProps {
export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -4,7 +4,13 @@ interface IconProps {
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}>
<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

@ -2,7 +2,7 @@ interface IconProps {
className?: string;
}
export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { filled: boolean }) {
export function EyeIcon({ filled, className = 'w-7 h-7' }: IconProps & { filled: boolean }) {
return (
<svg
className={className}
@ -11,8 +11,17 @@ export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { fil
stroke="currentColor"
strokeWidth={2}
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" fill={filled ? 'currentColor' : 'none'} />
<circle cx="12" cy="12" r="3" fill={filled ? 'currentColor' : 'none'} stroke={filled ? 'white' : 'currentColor'} />
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
fill={filled ? 'currentColor' : 'none'}
/>
<circle
cx="12"
cy="12"
r="3"
fill={filled ? 'currentColor' : 'none'}
stroke={filled ? 'white' : 'currentColor'}
/>
</svg>
);
}

View file

@ -4,7 +4,13 @@ interface IconProps {
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -4,7 +4,13 @@ interface IconProps {
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg>

View file

@ -4,7 +4,13 @@ interface IconProps {
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -4,17 +4,19 @@ interface IconProps {
export function MapPinIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<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"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}

View file

@ -4,7 +4,13 @@ interface IconProps {
export function MoonIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

@ -2,9 +2,15 @@ interface IconProps {
className?: string;
}
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg>
);

View file

@ -6,7 +6,11 @@ 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" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}

View file

@ -4,7 +4,13 @@ interface IconProps {
export function SunIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View file

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

View file

@ -14,11 +14,21 @@ interface UseAreaSummaryResult {
summary: string;
loading: boolean;
error: string | null;
retry: () => void;
}
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other',
'Environmental risk', 'Collapsible deposits risk', 'Compressible ground risk', 'Landslide risk', 'Running sand risk', 'Shrink-swell risk', 'Soluble rocks risk'
const FORBIDDEN_FEATURES = [
'% White',
'% Black',
'% Asian',
'% Mixed',
'% Other',
'Environmental risk',
'Collapsible deposits risk',
'Compressible ground risk',
'Landslide risk',
'Running sand risk',
'Shrink-swell risk',
'Soluble rocks risk',
];
export function useAreaSummary({
@ -61,23 +71,30 @@ export function useAreaSummary({
location: hexagonId,
is_postcode: isPostcode,
filters: filterDescriptions,
numeric_stats: stats.numeric_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
name: f.name,
counts: f.counts,
})),
numeric_stats: stats.numeric_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
mean: f.mean,
})),
enum_stats: stats.enum_features
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
.map((f) => ({
name: f.name,
counts: f.counts,
})),
};
const url = apiUrl('area-summary');
const response = await fetch(url, authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
}));
const response = await fetch(
url,
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
);
if (!response.ok) {
const text = await response.text();
@ -102,9 +119,5 @@ export function useAreaSummary({
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
const retry = useCallback(() => {
fetchSummary();
}, [fetchSummary]);
return { summary, loading, error, retry };
return { summary, loading, error };
}

View file

@ -113,5 +113,15 @@ export function useAuth() {
setError(null);
}, []);
return { user, loading, error, login, register, loginWithOAuth, logout, requestPasswordReset, clearError };
return {
user,
loading,
error,
login,
register,
loginWithOAuth,
logout,
requestPasswordReset,
clearError,
};
}

View file

@ -84,7 +84,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr });
const params = new URLSearchParams({
resolution: resolutionRef.current.toString(),
bounds: boundsStr,
});
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);

View file

@ -2,11 +2,9 @@ import { useState, useCallback } from 'react';
import type {
FeatureMeta,
FeatureFilters,
PostcodeFeature,
Property,
HexagonPropertiesResponse,
HexagonStatsResponse,
NumericFeatureStats,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
@ -19,16 +17,10 @@ interface SelectedHexagon {
interface UseHexagonSelectionOptions {
filters: FeatureFilters;
features: FeatureMeta[];
postcodeData: PostcodeFeature[];
resolution: number;
}
export function useHexagonSelection({
filters,
features,
postcodeData,
resolution,
}: UseHexagonSelectionOptions) {
export function useHexagonSelection({ filters, features, resolution }: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
@ -56,31 +48,15 @@ export function useHexagonSelection({
[filters, features]
);
const buildPostcodeStats = useCallback(
(postcode: string): HexagonStatsResponse | null => {
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
if (!feat) return null;
const props = feat.properties;
const numeric_features: NumericFeatureStats[] = [];
for (const f of features) {
if (f.type !== 'numeric') continue;
const minVal = props[`min_${f.name}`];
const maxVal = props[`max_${f.name}`];
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
const avgVal = props[`avg_${f.name}`];
numeric_features.push({
name: f.name,
count: props.count,
min: minVal,
max: maxVal,
mean: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
});
}
return { count: props.count, numeric_features, enum_features: [] };
const fetchPostcodeStats = useCallback(
async (postcode: string, signal?: AbortSignal) => {
const params = new URLSearchParams({ postcode });
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
return (await response.json()) as HexagonStatsResponse;
},
[postcodeData, features]
[filters, features]
);
const fetchHexagonProperties = useCallback(
@ -131,8 +107,11 @@ export function useHexagonSelection({
setRightPaneTab('area');
if (isPostcode) {
setAreaStats(buildPostcodeStats(id));
setLoadingAreaStats(false);
setLoadingAreaStats(true);
fetchPostcodeStats(id)
.then((stats) => setAreaStats(stats))
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
.finally(() => setLoadingAreaStats(false));
} else {
setLoadingAreaStats(true);
fetchHexagonStats(id, resolution)
@ -142,7 +121,7 @@ export function useHexagonSelection({
}
}
},
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
);
const handleHexagonHover = useCallback((h3: string | null) => {

View file

@ -11,7 +11,6 @@ import type {
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { cellToLatLng } from 'h3-js';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -147,7 +146,8 @@ export function useMapData({
for (const feat of postcodeData) {
if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
@ -156,8 +156,9 @@ export function useMapData({
if (data.length === 0) return null;
for (const item of data) {
if (bounds) {
const [lat, lng] = cellToLatLng(item.h3);
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
const { lat, lon } = item;
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
@ -166,7 +167,10 @@ export function useMapData({
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
return [percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE)];
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
// Color range for the legend and hex coloring

View file

@ -1,77 +1,11 @@
const DOMAIN = 'narrowit.schmelczer.dev';
const ENDPOINT = 'https://stats.schmelczer.dev/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
import { init as plausibleInit } from '@plausible-analytics/tracker';
type EventOptions = {
props?: Record<string, string | number | boolean>;
revenue?: { currency: string; amount: number };
};
function sendEvent(name: string, options?: EventOptions) {
if (IS_DEV) return;
const payload: Record<string, unknown> = {
n: name,
u: window.location.href,
d: DOMAIN,
r: document.referrer || null,
};
if (options?.props) {
payload.p = JSON.stringify(options.props);
}
if (options?.revenue) {
payload.$ = JSON.stringify(options.revenue);
}
if (navigator.sendBeacon) {
navigator.sendBeacon(
ENDPOINT,
new Blob([JSON.stringify(payload)], { type: 'application/json' })
);
} else {
fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'omit',
keepalive: true,
}).catch(() => { });
}
}
let initialized = false;
/**
* Tracks pageview on first call and returns a trackEvent function.
* Tracks outbound link clicks automatically.
*/
export function initPlausible() {
if (initialized) return;
initialized = true;
// Initial pageview
sendEvent('pageview');
// Track outbound link clicks
document.addEventListener('click', (e) => {
const link = (e.target as HTMLElement).closest?.('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
try {
const url = new URL(href, window.location.origin);
if (url.hostname !== window.location.hostname) {
sendEvent('Outbound Link: Click', { props: { url: href } });
}
} catch {
// invalid URL, ignore
}
});
}
export function trackPageview(options?: EventOptions) {
sendEvent('pageview', options);
}
export function trackEvent(name: string, options?: EventOptions) {
sendEvent(name, options);
}
plausibleInit({
domain: 'narrowit.schmelczer.dev',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
hashBasedRouting: true,
});

View file

@ -51,11 +51,11 @@ export function useSavedSearches(userId: string | null) {
try {
const params = window.location.search.replace(/^\?/, '');
// Try to capture a screenshot via the OG image endpoint
// Try to capture a screenshot via the screenshot endpoint
let screenshotBlob: Blob | null = null;
try {
const ogUrl = apiUrl('og-image', new URLSearchParams(params));
const res = await fetch(ogUrl);
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const res = await fetch(screenshotUrl);
if (res.ok) {
screenshotBlob = await res.blob();
}
@ -84,18 +84,15 @@ export function useSavedSearches(userId: string | null) {
[userId, fetchSearches]
);
const deleteSearch = useCallback(
async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
}
},
[]
);
const deleteSearch = useCallback(async (id: string) => {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');
}
}, []);
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
}

View file

@ -53,4 +53,3 @@ h3 {
opacity: 1;
transform: translateY(0);
}

View file

@ -1,9 +1,7 @@
import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App';
import './index.css';
import { initPlausible } from './hooks/usePlausible';
initPlausible();
import './hooks/usePlausible';
const container = document.getElementById('root');
if (!container) {

View file

@ -1,10 +1,8 @@
import type { ViewState } from '../types';
export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
/** Lower percentile for color-range clipping (0100) */
export const COLOR_RANGE_LOW_PERCENTILE = 5;
/** Upper percentile for color-range clipping (0100) */
@ -22,7 +20,6 @@ export const INITIAL_VIEW_STATE: ViewState = {
pitch: 0,
};
/**
* Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level.
@ -38,8 +35,6 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
export const POSTCODE_ZOOM_THRESHOLD = 16;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] },
@ -71,22 +66,23 @@ export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
/** Twemoji base URL (served locally from public/assets/) */
export const TWEMOJI_BASE = '/assets/twemoji/';
/**
* 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, {
/** Display label for the chart */
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
/** Feature names that make up the segments */
components: string[];
}[]> = {
export const STACKED_GROUPS: Record<
string,
{
/** Display label for the chart */
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
/** Feature names that make up the segments */
components: string[];
}[]
> = {
Crime: [
{
label: 'Serious crime',
@ -130,18 +126,21 @@ export const STACKED_GROUPS: Record<string, {
* 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[];
}[]> = {
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',

View file

@ -5,14 +5,18 @@ import {
GLYPHS_URL,
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
} from './consts';
// Re-export constants for backwards compatibility
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, 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;
@ -25,31 +29,33 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
// Reduce road layer opacity so hexagons are more visible
// In dark mode, make all text white with dark outline
const modifiedLayers = baseLayers.map((layer) => {
// Modify road opacity
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
} else if (layer.type === 'fill') {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
const modifiedLayers = baseLayers
.filter((layer) => !layer.id.includes('buildings'))
.map((layer) => {
// Modify road opacity
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
} else if (layer.type === 'fill') {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
}
}
}
// Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
return {
...layer,
paint: {
...layer.paint,
'text-color': '#ffffff',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1.5,
},
};
}
// Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
return {
...layer,
paint: {
...layer.paint,
'text-color': '#ffffff',
'text-halo-color': '#1a1a1a',
'text-halo-width': 1.5,
},
};
}
return layer;
});
return layer;
});
return {
version: 8,
@ -88,23 +94,25 @@ function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
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,
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
];
}
function oklabToRgb(lab: [number, number, number]): [number, number, number] {
const L = lab[0], a = lab[1], b = lab[2];
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);
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * 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),
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
];
}
@ -134,7 +142,10 @@ export function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT);
}
export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
export function countToColor(
t: number,
gradient: GradientStop[] = DENSITY_GRADIENT
): [number, number, number] {
return interpolateGradient(t, gradient);
}

View file

@ -111,15 +111,16 @@ export function summarizeParams(queryString: string): string {
const f = params.get('f');
if (f) {
const filterNames = f.split(',').map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
}).filter(Boolean);
const filterNames = f
.split(',')
.map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
})
.filter(Boolean);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2
? filterNames.join(', ')
: `${filterNames.length} filters`
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
);
}
}

View file

@ -30,6 +30,8 @@ export type FeatureFilters = Record<string, [number, number] | string[]>;
export interface HexagonData {
h3: string;
count: number;
lat: number;
lon: number;
[key: string]: string | number | null;
}
@ -40,7 +42,10 @@ export interface PostcodeProperties {
[key: string]: string | number | [number, number] | null;
}
export type PostcodeFeature = GeoJSON.Feature<GeoJSON.Polygon | GeoJSON.MultiPolygon, PostcodeProperties>;
export type PostcodeFeature = GeoJSON.Feature<
GeoJSON.Polygon | GeoJSON.MultiPolygon,
PostcodeProperties
>;
export type PostcodeGeometry = GeoJSON.Polygon | GeoJSON.MultiPolygon;