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

@ -13,6 +13,7 @@
"@deck.gl/layers": "^9.0.0", "@deck.gl/layers": "^9.0.0",
"@deck.gl/mapbox": "^9.2.6", "@deck.gl/mapbox": "^9.2.6",
"@deck.gl/react": "^9.0.0", "@deck.gl/react": "^9.0.0",
"@plausible-analytics/tracker": "^0.4.4",
"@protomaps/basemaps": "^5.7.0", "@protomaps/basemaps": "^5.7.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.0", "@radix-ui/react-slider": "^1.1.0",
@ -3325,6 +3326,11 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.4.tgz",
"integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": { "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.2.tgz", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.2.tgz",

View file

@ -18,6 +18,7 @@
"@deck.gl/layers": "^9.0.0", "@deck.gl/layers": "^9.0.0",
"@deck.gl/mapbox": "^9.2.6", "@deck.gl/mapbox": "^9.2.6",
"@deck.gl/react": "^9.0.0", "@deck.gl/react": "^9.0.0",
"@plausible-analytics/tracker": "^0.4.4",
"@protomaps/basemaps": "^5.7.0", "@protomaps/basemaps": "^5.7.0",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.0", "@radix-ui/react-slider": "^1.1.0",

View file

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

View file

@ -1,5 +1,10 @@
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, formatFilterValue, calculateHistogramMean } from '../../lib/format'; import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features'; import { groupFeaturesByCategory } from '../../lib/features';
@ -33,7 +38,6 @@ interface AreaPaneProps {
aiSummary?: string; aiSummary?: string;
aiSummaryLoading?: boolean; aiSummaryLoading?: boolean;
aiSummaryError?: string | null; aiSummaryError?: string | null;
onRetryAiSummary?: () => void;
} }
export default function AreaPane({ export default function AreaPane({
@ -51,7 +55,6 @@ export default function AreaPane({
aiSummary, aiSummary,
aiSummaryLoading, aiSummaryLoading,
aiSummaryError, aiSummaryError,
onRetryAiSummary,
}: AreaPaneProps) { }: AreaPaneProps) {
// For postcodes, use local data for count // For postcodes, use local data for count
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.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"> <div className="flex items-center gap-1.5">
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" /> <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> </div>
<ChevronIcon <ChevronIcon
direction={aiSummaryExpanded ? 'down' : 'right'} direction={aiSummaryExpanded ? 'down' : 'right'}
@ -156,15 +161,7 @@ export default function AreaPane({
<> <>
{aiSummaryError ? ( {aiSummaryError ? (
<div className="text-xs text-warm-600 dark:text-warm-400"> <div className="text-xs text-warm-600 dark:text-warm-400">
<span>Failed to generate summary. </span> Failed to generate summary.
{onRetryAiSummary && (
<button
onClick={onRetryAiSummary}
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
>
Retry
</button>
)}
</div> </div>
) : aiSummaryLoading ? ( ) : aiSummaryLoading ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -191,19 +188,24 @@ export default function AreaPane({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" /> <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="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> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" /> <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="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> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" /> <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="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> </span>
</div> </div>
</div> </div>
@ -219,9 +221,9 @@ export default function AreaPane({
// Features that are part of a stacked enum config (rendered as compact charts) // Features that are part of a stacked enum config (rendered as compact charts)
const stackedEnumFeatureNames = new Set( const stackedEnumFeatureNames = new Set(
stackedEnumCharts?.flatMap((c) => (stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter(Boolean) [c.feature, ...c.components].filter(Boolean)
) as string[] ?? [] ) as string[]) ?? []
); );
const isExpanded = !collapsedGroups.has(group.name); const isExpanded = !collapsedGroups.has(group.name);
@ -234,40 +236,156 @@ export default function AreaPane({
onToggle={() => toggleGroup(group.name)} 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" 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"> {isExpanded && (
{/* Price History in Property group */} <div className="px-3 py-2 space-y-3">
{group.name === 'Property' && stats.price_history && (() => { {/* Price History in Property group */}
// Only show chart if there are at least 2 unique years {group.name === 'Property' &&
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year))); stats.price_history &&
return uniqueYears.size > 1; (() => {
})() && ( // Only show chart if there are at least 2 unique years
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2"> const uniqueYears = new Set(
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span> stats.price_history.map((p) => Math.floor(p.year))
<PriceHistoryChart points={stats.price_history} /> );
</div> return uniqueYears.size > 1;
)} })() && (
{stackedCharts <div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
? // Render stacked charts for this group <span className="text-xs text-warm-700 dark:text-warm-300">
stackedCharts.map((chart) => { Price History
const segments = chart.components </span>
.map((name) => ({ <PriceHistoryChart points={stats.price_history} />
name, </div>
value: numericByName.get(name)?.mean ?? 0, )}
})) {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); .filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + 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; if (total === 0) return null;
return ( return (
@ -278,7 +396,7 @@ export default function AreaPane({
<div className="flex justify-between items-baseline mb-1.5"> <div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? ( {featureMeta ? (
<FeatureLabel <FeatureLabel
feature={{ ...featureMeta, name: chart.label }} feature={featureMeta}
onShowInfo={setInfoFeature} onShowInfo={setInfoFeature}
className="mr-2" className="mr-2"
/> />
@ -288,166 +406,57 @@ export default function AreaPane({
</span> </span>
)} )}
<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(total)} {total.toLocaleString()}
{chart.unit ? ` ${chart.unit}` : ''}
</span> </span>
</div> </div>
<StackedBarChart segments={segments} total={total} /> <StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div> </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) { // Multi-component: render as compact multi-row chart (like risk features)
const globalFeature = globalFeatureByName.get(feature.name); const components = chart.components
const globalHistogram = globalFeature?.histogram; .map((name) => {
const globalMean = globalHistogram const stats = enumByName.get(name);
? calculateHistogramMean(globalHistogram) return stats ? { label: name, stats } : null;
: undefined; })
.filter((c): c is NonNullable<typeof c> => c !== null);
return ( if (components.length === 0) return null;
<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;
return ( return (
<div <div
key={chart.label} key={chart.label}
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 mb-1.5"> <div className="mb-1.5">
{featureMeta ? ( {featureMeta ? (
<FeatureLabel <FeatureLabel
feature={featureMeta} feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature} 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} {chart.label}
</span> </span>
)} )}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div> </div>
<StackedBarChart <StackedEnumChart
segments={segments} components={components}
total={total} valueOrder={chart.valueOrder}
colorMap={Object.fromEntries( valueColors={chart.valueColors}
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/> />
</div> </div>
); );
} })}
</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 localMax = Math.max(...localBars, 1);
const globalMax = Math.max(...globalBars, 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. // Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier. // 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 middleBins = Math.max(barCount - 2, 0);
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => { const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
if (i === 0) return p1; // outlier bin, label as p1 if (i === 0) return p1; // outlier bin, label as p1
if (i === barCount - 1) return p99; // outlier bin, label as p99 if (i === barCount - 1) return p99; // outlier bin, label as p99
return p1 + (i - 1 + 0.5) * middleWidth; return p1 + (i - 1 + 0.5) * middleWidth;
}); });
@ -71,7 +72,10 @@ export function DualHistogram({
let bestDist = Infinity; let bestDist = Infinity;
for (let i = 1; i < barCount - 1; i++) { for (let i = 1; i < barCount - 1; i++) {
const dist = Math.abs(barCenters[i] - v); 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)); 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 // Mean line: position as fraction across the bar area
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null; const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
// Account for outlier bins: middle region spans bars 1..n-2 // Account for outlier bins: middle region spans bars 1..n-2
const meanPct = meanFrac != null const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
? ((1 + meanFrac * middleBins) / barCount) * 100
: null;
return ( return (
<div className="mt-1"> <div className="mt-1">

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane'; import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer'; import MobileDrawer from './MobileDrawer';
import DataSources from '../data-sources/DataSources'; import DataSources from '../data-sources/DataSources';
import MapLegend from './MapLegend';
import { TabButton } from '../ui/TabButton'; import { TabButton } from '../ui/TabButton';
import { useMapData } from '../../hooks/useMapData'; import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData'; import { usePOIData } from '../../hooks/usePOIData';
@ -25,7 +26,7 @@ export interface ExportState {
exporting: boolean; exporting: boolean;
} }
type MobileBottomTab = 'filters' | 'pois'; type MobileBottomTab = 'filters' | 'pois' | 'area';
interface MapPageProps { interface MapPageProps {
features: FeatureMeta[]; features: FeatureMeta[];
@ -63,7 +64,8 @@ export default function MapPage({
isMobile = false, isMobile = false,
}: MapPageProps) { }: MapPageProps) {
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null); 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 [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
@ -116,7 +118,6 @@ export default function MapPage({
const selection = useHexagonSelection({ const selection = useHexagonSelection({
filters, filters,
features, features,
postcodeData: mapData.postcodeData,
resolution: mapData.resolution, resolution: mapData.resolution,
}); });
@ -133,13 +134,17 @@ export default function MapPage({
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// On mobile, open drawer and switch tab when hexagon is clicked // On mobile, open drawer and switch tab when hexagon is clicked
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => { const { handleHexagonClick } = selection;
selection.handleHexagonClick(id, isPostcode); const handleMobileHexagonClick = useCallback(
if (id) { (id: string, isPostcode?: boolean) => {
setMobileDrawerOpen(true); handleHexagonClick(id, isPostcode);
setMobileBottomTab('area'); if (id) {
} setMobileDrawerOpen(true);
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps setMobileBottomTab('area');
}
},
[handleHexagonClick]
);
// Compute hexagon location for external links // Compute hexagon location for external links
const hexagonLocation = useMemo(() => { const hexagonLocation = useMemo(() => {
@ -158,7 +163,13 @@ export default function MapPage({
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; 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 // AI area summary
const aiSummary = useAreaSummary({ const aiSummary = useAreaSummary({
@ -203,10 +214,33 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting }); onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]); }, [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 // Signal screenshot readiness once map data has loaded
useEffect(() => { useEffect(() => {
if (screenshotMode && !mapData.loading && mapData.data.length > 0) { if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
window.__og_ready = true; window.__screenshot_ready = true;
} }
}, [screenshotMode, mapData.loading, mapData.data.length]); }, [screenshotMode, mapData.loading, mapData.data.length]);
@ -249,7 +283,9 @@ export default function MapPage({
isPostcode={selection.selectedHexagon?.type === 'postcode'} isPostcode={selection.selectedHexagon?.type === 'postcode'}
postcodeData={ postcodeData={
selection.selectedHexagon?.type === 'postcode' 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 : null
} }
onViewProperties={selection.handleViewPropertiesFromArea} onViewProperties={selection.handleViewPropertiesFromArea}
@ -260,7 +296,6 @@ export default function MapPage({
aiSummary={aiSummary.summary} aiSummary={aiSummary.summary}
aiSummaryLoading={aiSummary.loading} aiSummaryLoading={aiSummary.loading}
aiSummaryError={aiSummary.error} 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="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">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" /> <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>
</div> </div>
)} )}
@ -348,6 +385,7 @@ export default function MapPage({
searchedPostcode={searchedPostcode} searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode} onPostcodeSearched={setSearchedPostcode}
bounds={mapData.bounds} bounds={mapData.bounds}
hideLegend
/> />
{mapData.loading && ( {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"> <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> </div>
{/* Bottom panel — 55% */} {/* 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 */} {/* Tab bar */}
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm"> <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
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} /> label="Filters"
isActive={mobileBottomTab === 'filters'}
onClick={() => setMobileBottomTab('filters')}
/>
<TabButton
label="POIs"
isActive={mobileBottomTab === 'pois'}
onClick={() => setMobileBottomTab('pois')}
/>
</div> </div>
{/* Tab content */} {/* Tab content */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{mobileBottomTab === 'pois' ? ( {mobileBottomTab === 'pois' ? (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">{renderPOIPane()}</div>
{renderPOIPane()}
</div>
) : ( ) : (
renderFilters() 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="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">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" /> <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>
</div> </div>
)} )}
{/* Left Pane */} {/* Left Pane */}
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}> <div
<div className="flex-1 flex flex-col overflow-hidden"> className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
{renderFilters()} style={{ width: leftPaneWidth }}
</div> >
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
<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" 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} {...leftPaneHandlers}
@ -449,7 +526,10 @@ export default function MapPage({
</div> </div>
{/* Right Pane */} {/* 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 <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" 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} {...rightPaneHandlers}
@ -458,19 +538,29 @@ export default function MapPage({
</div> </div>
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm"> <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
<TabButton label="Properties" isActive={selection.rightPaneTab === 'properties'} onClick={selection.handlePropertiesTabClick} /> label="Area"
<TabButton label="POIs" isActive={selection.rightPaneTab === 'pois'} onClick={() => selection.setRightPaneTab('pois')} /> 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>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'area' ? ( {selection.rightPaneTab === 'area'
renderAreaPane() ? renderAreaPane()
) : selection.rightPaneTab === 'properties' ? ( : selection.rightPaneTab === 'properties'
renderPropertiesPane() ? renderPropertiesPane()
) : ( : renderPOIPane()}
renderPOIPane()
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -38,7 +38,11 @@ export default function MobileDrawer({
{/* Tab bar + close */} {/* Tab bar + close */}
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0"> <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="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')} /> <TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
<button <button
onClick={onClose} 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"> <div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
{filteredGroups.map((group) => { {filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) => const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
selectedCategories.has(c)
).length;
const allInGroupSelected = groupSelected === group.categories.length; const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;

View file

@ -53,8 +53,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
.map(([yr, prices]) => { .map(([yr, prices]) => {
prices.sort((a, b) => a - b); prices.sort((a, b) => a - b);
const mid = Math.floor(prices.length / 2); const mid = Math.floor(prices.length / 2);
const median = const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
return { year: yr + 0.5, price: median }; return { year: yr + 0.5, price: median };
}) })
.sort((a, b) => a.year - b.year); .sort((a, b) => a.year - b.year);
@ -86,9 +85,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
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 medianPolyline = medians const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
.join(' ');
return ( return (
<div ref={containerRef} style={{ height: HEIGHT }}> <div ref={containerRef} style={{ height: HEIGHT }}>

View file

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

View file

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

View file

@ -127,7 +127,10 @@ export default function SavedSearchesPage({
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
{deleteConfirmId && ( {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="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div <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-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="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="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div <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()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
@ -115,7 +115,7 @@ export default function AuthModal({
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required 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" placeholder="you@example.com"
/> />
</div> </div>
@ -131,7 +131,7 @@ export default function AuthModal({
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} 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'} placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/> />
{view === 'login' && ( {view === 'login' && (

View file

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

View file

@ -17,7 +17,7 @@ export function EmptyState({
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<div <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> <div className="mb-2">{icon}</div>
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span> <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 /> <InfoIcon />
</IconButton> </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} /> <EyeIcon filled={isPinned} />
</IconButton> </IconButton>
{onAdd && ( {onAdd && (
<IconButton onClick={() => onAdd(feature.name)} title="Add filter"> <IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
<PlusIcon /> <PlusIcon />
</IconButton> </IconButton>
)} )}

View file

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

View file

@ -93,7 +93,9 @@ export default function Header({
<button <button
key={page} key={page}
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${ 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={() => { onClick={() => {
onPageChange(page); onPageChange(page);
@ -123,11 +125,17 @@ export default function Header({
Dashboard Dashboard
</button> </button>
{user && ( {user && (
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}> <button
className={tabClass('saved-searches')}
onClick={() => onPageChange('saved-searches')}
>
Saved Saved
</button> </button>
)} )}
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}> <button
className={tabClass('data-sources')}
onClick={() => onPageChange('data-sources')}
>
Data Sources Data Sources
</button> </button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}> <button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
@ -245,10 +253,7 @@ export default function Header({
{isMobile && menuOpen && ( {isMobile && menuOpen && (
<> <>
{/* Backdrop */} {/* Backdrop */}
<div <div className="fixed inset-0 bg-black/50 z-40" onClick={() => setMenuOpen(false)} />
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setMenuOpen(false)}
/>
{/* Menu panel */} {/* 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="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"> <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"> <div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
{onSaveSearch && ( {onSaveSearch && (
<button <button
onClick={() => { onSaveSearch(); setMenuOpen(false); }} onClick={() => {
onSaveSearch();
setMenuOpen(false);
}}
disabled={savingSearch} 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" 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 Save
</button> </button>
)} )}
<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" 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'} {copied ? 'Copied!' : 'Share'}
</button> </button>
<button <button
onClick={() => { onExport?.(); setMenuOpen(false); }} onClick={() => {
onExport?.();
setMenuOpen(false);
}}
disabled={!onExport || exporting} 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" 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"> <div className="p-3 border-t border-navy-700 flex flex-col gap-3">
{/* Theme toggle */} {/* Theme toggle */}
<button <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" 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> <span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
</button> </button>
{/* Auth buttons */} {/* Auth buttons */}
<div> <div>
{user ? ( {user ? (
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<span className="text-sm text-warm-300 truncate">{user.email}</span> <span className="text-sm text-warm-300 truncate">{user.email}</span>
<button <button
onClick={() => { onLogout(); setMenuOpen(false); }} onClick={() => {
className="text-sm text-warm-400 hover:text-white" onLogout();
> setMenuOpen(false);
Log out }}
</button> className="text-sm text-warm-400 hover:text-white"
</div> >
) : ( Log out
<div className="flex gap-2"> </button>
<button </div>
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" <div className="flex gap-2">
> <button
Log in onClick={() => {
</button> onLoginClick();
<button setMenuOpen(false);
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" className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
> >
Register Log in
</button> </button>
</div> <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> </div>
</div> </div>

View file

@ -6,16 +6,28 @@ interface IconButtonProps {
children: ReactNode; children: ReactNode;
active?: boolean; active?: boolean;
className?: string; className?: string;
size?: 'sm' | 'md';
} }
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) { export function IconButton({
const baseClasses = 'p-0.5 rounded'; onClick,
title,
children,
active,
className,
size = 'sm',
}: IconButtonProps) {
const padClasses = size === 'md' ? 'p-1' : 'p-0.5';
const colorClasses = active const colorClasses = active
? 'text-teal-600 dark:text-teal-400' ? 'text-teal-600 dark:text-teal-400'
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'; : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
return ( return (
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}> <button
onClick={onClick}
title={title}
className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
>
{children} {children}
</button> </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="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="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div <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()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between px-5 pt-5 pb-3"> <div className="flex items-center justify-between px-5 pt-5 pb-3">
@ -63,7 +63,7 @@ export default function SaveSearchModal({
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} 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" placeholder="My search"
autoFocus autoFocus
/> />

View file

@ -32,7 +32,9 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
{open && ( {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="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"> <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>
<div className="p-1"> <div className="p-1">
<button <button

View file

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

View file

@ -4,7 +4,13 @@ interface IconProps {
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) { export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
return ( 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
); );

View file

@ -13,7 +13,13 @@ export function ChevronIcon({
down: 'M6 9l6 6 6-6', down: 'M6 9l6 6 6-6',
}; };
return ( 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]} /> <path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
</svg> </svg>
); );

View file

@ -4,7 +4,13 @@ interface IconProps {
export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) { export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) {
return ( 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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

@ -4,7 +4,13 @@ interface IconProps {
export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) { export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return ( 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="M12 5v14m0 0l-6-6m6 6l6-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
</svg> </svg>

View file

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

View file

@ -4,7 +4,13 @@ interface IconProps {
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) { export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
return ( 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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

@ -4,7 +4,13 @@ interface IconProps {
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) { export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return ( 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" /> <circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" /> <path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
</svg> </svg>

View file

@ -4,7 +4,13 @@ interface IconProps {
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) { export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
return ( 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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

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

View file

@ -4,7 +4,13 @@ interface IconProps {
export function MoonIcon({ className = 'w-4 h-4' }: IconProps) { export function MoonIcon({ className = 'w-4 h-4' }: IconProps) {
return ( 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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

@ -2,9 +2,15 @@ interface IconProps {
className?: string; className?: string;
} }
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) { export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
return ( 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
</svg> </svg>
); );

View file

@ -6,7 +6,11 @@ export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
return ( return (
<svg className={className} fill="none" viewBox="0 0 24 24"> <svg className={className} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <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> </svg>
); );
} }

View file

@ -4,7 +4,13 @@ interface IconProps {
export function SunIcon({ className = 'w-4 h-4' }: IconProps) { export function SunIcon({ className = 'w-4 h-4' }: IconProps) {
return ( 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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View file

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

View file

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

View file

@ -113,5 +113,15 @@ export function useAuth() {
setError(null); 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 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); if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name); params.set('fields', name);

View file

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

View file

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

View file

@ -1,77 +1,11 @@
const DOMAIN = 'narrowit.schmelczer.dev'; import { init as plausibleInit } from '@plausible-analytics/tracker';
const ENDPOINT = 'https://stats.schmelczer.dev/status';
const IS_DEV = process.env.NODE_ENV !== 'production';
type EventOptions = { plausibleInit({
props?: Record<string, string | number | boolean>; domain: 'narrowit.schmelczer.dev',
revenue?: { currency: string; amount: number }; endpoint: 'https://stats.schmelczer.dev/status',
}; autoCapturePageviews: true,
captureOnLocalhost: true,
function sendEvent(name: string, options?: EventOptions) { logging: true,
if (IS_DEV) return; fileDownloads: true,
hashBasedRouting: true,
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);
}

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
import type { ViewState } from '../types'; import type { ViewState } from '../types';
export const INITIAL_RETRY_MS = 1000; export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000; export const MAX_RETRY_MS = 10000;
/** Lower percentile for color-range clipping (0100) */ /** Lower percentile for color-range clipping (0100) */
export const COLOR_RANGE_LOW_PERCENTILE = 5; export const COLOR_RANGE_LOW_PERCENTILE = 5;
/** Upper percentile for color-range clipping (0100) */ /** Upper percentile for color-range clipping (0100) */
@ -22,7 +20,6 @@ export const INITIAL_VIEW_STATE: ViewState = {
pitch: 0, pitch: 0,
}; };
/** /**
* Zoom to H3 resolution mapping thresholds. * Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level. * 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 POSTCODE_ZOOM_THRESHOLD = 16;
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] },
@ -71,22 +66,23 @@ export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
/** Twemoji base URL (served locally from public/assets/) */ /** Twemoji base URL (served locally from public/assets/) */
export const TWEMOJI_BASE = '/assets/twemoji/'; export const TWEMOJI_BASE = '/assets/twemoji/';
/** /**
* Groups whose features should be collapsed into stacked bar charts. * Groups whose features should be collapsed into stacked bar charts.
* Keyed by feature group name. Each entry defines one stacked chart. * Keyed by feature group name. Each entry defines one stacked chart.
*/ */
export const STACKED_GROUPS: Record<string, { export const STACKED_GROUPS: Record<
/** Display label for the chart */ string,
label: string; {
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */ /** Display label for the chart */
feature?: string; label: string;
/** Suffix shown after the total value (e.g. "avg/yr") */ /** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
unit?: string; feature?: string;
/** Feature names that make up the segments */ /** Suffix shown after the total value (e.g. "avg/yr") */
components: string[]; unit?: string;
}[]> = { /** Feature names that make up the segments */
components: string[];
}[]
> = {
Crime: [ Crime: [
{ {
label: 'Serious 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. * Groups whose enum features should be collapsed into compact multi-row charts.
* Keyed by feature group name. Each entry defines one stacked enum chart. * Keyed by feature group name. Each entry defines one stacked enum chart.
*/ */
export const STACKED_ENUM_GROUPS: Record<string, { export const STACKED_ENUM_GROUPS: Record<
/** Display label for the chart */ string,
label: string; {
/** If set, use this feature for the info popup */ /** Display label for the chart */
feature?: string; label: string;
/** Enum feature names that make up the rows */ /** If set, use this feature for the info popup */
components: string[]; feature?: string;
/** Value order for consistent segment ordering */ /** Enum feature names that make up the rows */
valueOrder: string[]; components: string[];
/** Colors for each value (matches valueOrder) */ /** Value order for consistent segment ordering */
valueColors: string[]; valueOrder: string[];
}[]> = { /** Colors for each value (matches valueOrder) */
valueColors: string[];
}[]
> = {
Property: [ Property: [
{ {
label: 'Property type', label: 'Property type',

View file

@ -5,14 +5,18 @@ import {
GLYPHS_URL, GLYPHS_URL,
FEATURE_GRADIENT, FEATURE_GRADIENT,
DENSITY_GRADIENT, DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ZOOM_TO_RESOLUTION_THRESHOLDS, ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE, TWEMOJI_BASE,
BUFFER_MULTIPLIER, BUFFER_MULTIPLIER,
} from './consts'; } from './consts';
// Re-export constants for backwards compatibility // 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; 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 // Reduce road layer opacity so hexagons are more visible
// In dark mode, make all text white with dark outline // In dark mode, make all text white with dark outline
const modifiedLayers = baseLayers.map((layer) => { const modifiedLayers = baseLayers
// Modify road opacity .filter((layer) => !layer.id.includes('buildings'))
if (layer.id.includes('roads_') || layer.id.includes('road_')) { .map((layer) => {
if (layer.type === 'line') { // Modify road opacity
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } }; if (layer.id.includes('roads_') || layer.id.includes('road_')) {
} else if (layer.type === 'fill') { if (layer.type === 'line') {
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } }; 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 // Modify text colors in dark mode
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) { if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
return { return {
...layer, ...layer,
paint: { paint: {
...layer.paint, ...layer.paint,
'text-color': '#ffffff', 'text-color': '#ffffff',
'text-halo-color': '#1a1a1a', 'text-halo-color': '#1a1a1a',
'text-halo-width': 1.5, 'text-halo-width': 1.5,
}, },
}; };
} }
return layer; return layer;
}); });
return { return {
version: 8, 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); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [ return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
]; ];
} }
function oklabToRgb(lab: [number, number, number]): [number, number, number] { 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 l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * 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 [ return [
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * 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); 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); return interpolateGradient(t, gradient);
} }

View file

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

View file

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

View file

@ -4,7 +4,12 @@ from pathlib import Path
from shapely.geometry import MultiPolygon, Polygon from shapely.geometry import MultiPolygon, Polygon
from tqdm import tqdm from tqdm import tqdm
from .inspire import cache_inspire, get_inspire_candidates, inspire_cache_exists, load_inspire from .inspire import (
cache_inspire,
get_inspire_candidates,
inspire_cache_exists,
load_inspire,
)
from .memory import release_memory from .memory import release_memory
from .oa_boundaries import load_oa_boundaries from .oa_boundaries import load_oa_boundaries
from .output import merge_fragments, write_district_geojson from .output import merge_fragments, write_district_geojson

View file

@ -128,6 +128,10 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
if len(polys) == 1: if len(polys) == 1:
return polys[0] return polys[0]
return MultiPolygon( return MultiPolygon(
[p for g in polys for p in (g.geoms if g.geom_type == "MultiPolygon" else [g])] [
p
for g in polys
for p in (g.geoms if g.geom_type == "MultiPolygon" else [g])
]
) )
return None return None

View file

@ -167,9 +167,7 @@ class TestVoronoiDeduplication:
def test_int64_coords_jitter_works(self, square_boundary): def test_int64_coords_jitter_works(self, square_boundary):
"""Int64 coords (production dtype) must still jitter correctly.""" """Int64 coords (production dtype) must still jitter correctly."""
points = np.array( points = np.array([[500050, 180050], [500050, 180050]], dtype=np.int64)
[[500050, 180050], [500050, 180050]], dtype=np.int64
)
postcodes = ["A", "B"] postcodes = ["A", "B"]
result = compute_voronoi_regions(points, postcodes, square_boundary) result = compute_voronoi_regions(points, postcodes, square_boundary)
assert "A" in result, "Postcode A missing with int64 coords" assert "A" in result, "Postcode A missing with int64 coords"

View file

@ -39,7 +39,9 @@ def compute_voronoi_regions(
else: else:
# Tiny jitter so Voronoi sees distinct points (0.01m per step) # Tiny jitter so Voronoi sees distinct points (0.01m per step)
jittered = points[i].copy() jittered = points[i].copy()
angle = 2 * np.pi * jitter_idx / max(coord_counts[coord], jitter_idx + 1) angle = (
2 * np.pi * jitter_idx / max(coord_counts[coord], jitter_idx + 1)
)
jittered[0] += 0.01 * np.cos(angle) jittered[0] += 0.01 * np.cos(angle)
jittered[1] += 0.01 * np.sin(angle) jittered[1] += 0.01 * np.sin(angle)
unique_pts.append(jittered) unique_pts.append(jittered)

View file

@ -5,7 +5,6 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"attrs>=22.2.0",
"httpx[socks]>=0.28.1", "httpx[socks]>=0.28.1",
"ipywidgets>=8.0.0", "ipywidgets>=8.0.0",
"jupyter>=1.0.0", "jupyter>=1.0.0",
@ -14,7 +13,6 @@ dependencies = [
"plotly>=6.5.2", "plotly>=6.5.2",
"polars>=1.37.1", "polars>=1.37.1",
"pyarrow>=15.0.0", "pyarrow>=15.0.0",
"python-dateutil>=2.8.0",
"tqdm>=4.67.1", "tqdm>=4.67.1",
"fastexcel>=0.19.0", "fastexcel>=0.19.0",
"osmium>=4.0.0", "osmium>=4.0.0",

View file

@ -132,7 +132,7 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
}); });
try { try {
// Use domcontentloaded instead of networkidle - let __og_ready handle readiness // Use domcontentloaded instead of networkidle - let __screenshot_ready handle readiness
const response = await page.goto(url, { const response = await page.goto(url, {
waitUntil: 'domcontentloaded', waitUntil: 'domcontentloaded',
timeout: NAVIGATION_TIMEOUT, timeout: NAVIGATION_TIMEOUT,
@ -143,12 +143,12 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
// Wait for the frontend to signal readiness // Wait for the frontend to signal readiness
try { try {
await page.waitForFunction('window.__og_ready === true', { await page.waitForFunction('window.__screenshot_ready === true', {
timeout: NAVIGATION_TIMEOUT, timeout: NAVIGATION_TIMEOUT,
}); });
console.log('Frontend signalled ready'); console.log('Frontend signalled ready');
} catch { } catch {
console.warn('Timed out waiting for __og_ready, proceeding with partial screenshot'); console.warn('Timed out waiting for __screenshot_ready, proceeding with partial screenshot');
} }
// Extra buffer for map tiles to finish rendering // Extra buffer for map tiles to finish rendering

1
server-rs/Cargo.lock generated
View file

@ -2375,7 +2375,6 @@ dependencies = [
"pmtiles", "pmtiles",
"polars", "polars",
"rayon", "rayon",
"regex",
"reqwest", "reqwest",
"rust_xlsxwriter", "rust_xlsxwriter",
"rustc-hash", "rustc-hash",

View file

@ -22,7 +22,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
metrics = "0.24" metrics = "0.24"
metrics-exporter-prometheus = "0.16" metrics-exporter-prometheus = "0.16"
reqwest = { version = "0.12", features = ["rustls-tls", "json"] } reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
regex = "1"
urlencoding = "2" urlencoding = "2"
rust_xlsxwriter = "0.79" rust_xlsxwriter = "0.79"
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] } pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }

View file

@ -8,6 +8,7 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
pub const GRID_CELL_SIZE: f32 = 0.01; pub const GRID_CELL_SIZE: f32 = 0.01;
pub const MAX_POIS_PER_REQUEST: usize = 2500; pub const MAX_POIS_PER_REQUEST: usize = 2500;
pub const MAX_CELLS_PER_REQUEST: usize = 5000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100; pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const MAX_PROPERTIES_LIMIT: usize = 500; pub const MAX_PROPERTIES_LIMIT: usize = 500;

View file

@ -233,7 +233,10 @@ async fn main() -> anyhow::Result<()> {
); );
info!("PocketBase configured: {}", cli.pocketbase_url); info!("PocketBase configured: {}", cli.pocketbase_url);
info!("Ollama configured: {} (model: {})", cli.ollama_url, cli.ollama_model); info!(
"Ollama configured: {} (model: {})",
cli.ollama_url, cli.ollama_model
);
let token_cache = Arc::new(auth::TokenCache::new()); let token_cache = Arc::new(auth::TokenCache::new());
@ -273,10 +276,11 @@ async fn main() -> anyhow::Result<()> {
let state_poi_categories = state.clone(); let state_poi_categories = state.clone();
let state_hexagon_properties = state.clone(); let state_hexagon_properties = state.clone();
let state_hexagon_stats = state.clone(); let state_hexagon_stats = state.clone();
let state_og_image = state.clone(); let state_screenshot = state.clone();
let state_export = state.clone(); let state_export = state.clone();
let state_crawler = state.clone(); let state_crawler = state.clone();
let state_pb = state.clone(); let state_pb = state.clone();
let state_postcode_stats = state.clone();
let state_area_summary = state.clone(); let state_area_summary = state.clone();
let api = Router::new() let api = Router::new()
@ -315,8 +319,12 @@ async fn main() -> anyhow::Result<()> {
get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)), get(move |query| routes::get_hexagon_stats(state_hexagon_stats.clone(), query)),
) )
.route( .route(
"/api/og-image", "/api/postcode-stats",
get(move |query| routes::get_og_image(state_og_image.clone(), query)), get(move |query| routes::get_postcode_stats(state_postcode_stats.clone(), query)),
)
.route(
"/api/screenshot",
get(move |query| routes::get_screenshot(state_screenshot.clone(), query)),
) )
.route( .route(
"/api/export", "/api/export",

View file

@ -41,9 +41,9 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot) // Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
let og_image_url = if query_string.is_empty() { let og_image_url = if query_string.is_empty() {
format!("{}/api/og-image?og=1", state.public_url) format!("{}/api/screenshot?og=1", state.public_url)
} else { } else {
format!("{}/api/og-image?og=1&{}", state.public_url, query_string) format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
}; };
let og_tags = format!( let og_tags = format!(

View file

@ -3,6 +3,7 @@ use axum::http::StatusCode;
/// Check if two bounding boxes intersect. /// Check if two bounding boxes intersect.
/// Both boxes are (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon). /// Both boxes are (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon).
#[inline] #[inline]
#[allow(clippy::too_many_arguments)]
pub fn bounds_intersect( pub fn bounds_intersect(
a_south: f64, a_south: f64,
a_west: f64, a_west: f64,

View file

@ -4,11 +4,12 @@ mod features;
mod hexagon_stats; mod hexagon_stats;
pub(crate) mod hexagons; pub(crate) mod hexagons;
mod me; mod me;
mod og_image;
mod pb_proxy; mod pb_proxy;
mod pois; mod pois;
mod postcode_stats;
mod postcodes; mod postcodes;
pub(crate) mod properties; pub(crate) mod properties;
mod screenshot;
mod tiles; mod tiles;
pub use area_summary::post_area_summary; pub use area_summary::post_area_summary;
@ -17,9 +18,10 @@ pub use features::{build_features_response, get_features, FeatureInfo, FeaturesR
pub use hexagon_stats::get_hexagon_stats; pub use hexagon_stats::get_hexagon_stats;
pub use hexagons::get_hexagons; pub use hexagons::get_hexagons;
pub use me::get_me; pub use me::get_me;
pub use og_image::get_og_image;
pub use pb_proxy::proxy_to_pocketbase; pub use pb_proxy::proxy_to_pocketbase;
pub use pois::{get_poi_categories, get_pois}; pub use pois::{get_poi_categories, get_pois};
pub use postcode_stats::get_postcode_stats;
pub use postcodes::{get_postcode_lookup, get_postcodes}; pub use postcodes::{get_postcode_lookup, get_postcodes};
pub use properties::get_hexagon_properties; pub use properties::get_hexagon_properties;
pub use screenshot::get_screenshot;
pub use tiles::{get_style, get_tile, init_tile_reader}; pub use tiles::{get_style, get_tile, init_tile_reader};

View file

@ -15,7 +15,7 @@ use crate::routes::FeatureInfo;
use crate::state::AppState; use crate::state::AppState;
const MAX_EXPORT_POSTCODES: usize = 250; const MAX_EXPORT_POSTCODES: usize = 250;
/// Height (in pixels) reserved for the OG image row /// Height (in pixels) reserved for the screenshot row
const IMAGE_ROW_HEIGHT: f64 = 225.0; const IMAGE_ROW_HEIGHT: f64 = 225.0;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -152,7 +152,7 @@ pub async fn get_export(
let public_url = state.public_url.clone(); let public_url = state.public_url.clone();
// Compute view param for OG image and dashboard URL // Compute view param for screenshot and dashboard URL
let center_lat = (south + north) / 2.0; let center_lat = (south + north) / 2.0;
let center_lon = (west + east) / 2.0; let center_lon = (west + east) / 2.0;
let lat_span = north - south; let lat_span = north - south;
@ -164,7 +164,7 @@ pub async fn get_export(
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom); let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
// Fetch screenshot (async, before spawn_blocking) // Fetch screenshot (async, before spawn_blocking)
let og_image_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await; let screenshot_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await;
// Build feature name → description map from the precomputed features response // Build feature name → description map from the precomputed features response
let feature_descriptions: FxHashMap<String, String> = state let feature_descriptions: FxHashMap<String, String> = state
@ -335,16 +335,16 @@ pub async fn get_export(
.set_row_format(0, &link_fmt) .set_row_format(0, &link_fmt)
.map_err(|err| format!("Failed to set row format: {err}"))?; .map_err(|err| format!("Failed to set row format: {err}"))?;
// Row 1: OG image (if available) // Row 1: screenshot (if available)
let mut current_row = 1u32; let mut current_row = 1u32;
if let Some(ref img_bytes) = og_image_bytes { if let Some(ref img_bytes) = screenshot_bytes {
match Image::new_from_buffer(img_bytes) { match Image::new_from_buffer(img_bytes) {
Ok(mut image) => { Ok(mut image) => {
// Scale image to fit: ~400px wide, auto height preserving aspect ratio // Scale image to fit: ~400px wide, auto height preserving aspect ratio
image = image.set_scale_to_size(400, 300, true); image = image.set_scale_to_size(400, 300, true);
sheet sheet
.insert_image(current_row, 0, &image) .insert_image(current_row, 0, &image)
.map_err(|err| format!("Failed to insert OG image: {err}"))?; .map_err(|err| format!("Failed to insert screenshot: {err}"))?;
// Set row height to accommodate the image // Set row height to accommodate the image
sheet sheet
.set_row_height(current_row, IMAGE_ROW_HEIGHT) .set_row_height(current_row, IMAGE_ROW_HEIGHT)
@ -352,7 +352,7 @@ pub async fn get_export(
current_row += 1; current_row += 1;
} }
Err(err) => { Err(err) => {
warn!("Failed to parse OG image for export: {err}"); warn!("Failed to parse screenshot for export: {err}");
// Skip image row, don't leave a gap // Skip image row, don't leave a gap
} }
} }
@ -479,7 +479,7 @@ pub async fn get_export(
postcodes = postcode_aggs.len(), postcodes = postcode_aggs.len(),
sampled = was_sampled, sampled = was_sampled,
features = all_feature_indices.len(), features = all_feature_indices.len(),
has_og_image = og_image_bytes.is_some(), has_screenshot = screenshot_bytes.is_some(),
bytes = buf.len(), bytes = buf.len(),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0), total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
"GET /api/export" "GET /api/export"

View file

@ -8,12 +8,12 @@ use crate::data::{Histogram, PropertyData};
use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS}; use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS};
use crate::state::AppState; use crate::state::AppState;
fn is_empty(v: &str) -> bool { fn is_empty(val: &str) -> bool {
v.is_empty() val.is_empty()
} }
fn is_false(v: &bool) -> bool { fn is_false(val: &bool) -> bool {
!v !val
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]

View file

@ -14,44 +14,44 @@ use crate::state::AppState;
#[derive(Serialize)] #[derive(Serialize)]
pub struct HistogramStats { pub struct HistogramStats {
min: f64, pub min: f64,
max: f64, pub max: f64,
/// 1st percentile (left edge of main distribution) /// 1st percentile (left edge of main distribution)
p1: f64, pub p1: f64,
/// 99th percentile (right edge of main distribution) /// 99th percentile (right edge of main distribution)
p99: f64, pub p99: f64,
counts: Vec<u64>, pub counts: Vec<u64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct NumericFeatureStats { pub struct NumericFeatureStats {
name: String, pub name: String,
count: usize, pub count: usize,
min: f64, pub min: f64,
max: f64, pub max: f64,
mean: f64, pub mean: f64,
histogram: HistogramStats, pub histogram: HistogramStats,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct EnumFeatureStats { pub struct EnumFeatureStats {
name: String, pub name: String,
counts: HashMap<String, u64>, pub counts: HashMap<String, u64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct PricePoint { pub struct PricePoint {
year: f32, pub year: f32,
price: f32, pub price: f32,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct HexagonStatsResponse { pub struct HexagonStatsResponse {
count: usize, pub count: usize,
numeric_features: Vec<NumericFeatureStats>, pub numeric_features: Vec<NumericFeatureStats>,
enum_features: Vec<EnumFeatureStats>, pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
price_history: Vec<PricePoint>, pub price_history: Vec<PricePoint>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -158,7 +158,10 @@ pub async fn get_hexagon_stats(
// Collect price history (year, price) pairs // Collect price history (year, price) pairs
let price_history = { let price_history = {
let year_idx = state.feature_name_to_index.get("Date of last transaction").copied(); let year_idx = state
.feature_name_to_index
.get("Date of last transaction")
.copied();
let price_idx = state.feature_name_to_index.get("Last known price").copied(); let price_idx = state.feature_name_to_index.get("Last known price").copied();
match (year_idx, price_idx) { match (year_idx, price_idx) {
(Some(yi), Some(pi)) => { (Some(yi), Some(pi)) => {

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::consts::{H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN}; use crate::consts::{H3_PRECOMPUTE_MAX, H3_REQUEST_MAX, H3_REQUEST_MIN, MAX_CELLS_PER_REQUEST};
use crate::parsing::{ use crate::parsing::{
bounds_intersect, h3_cell_bounds, parse_bounds, parse_filters, row_passes_filters, bounds_intersect, h3_cell_bounds, parse_bounds, parse_filters, row_passes_filters,
}; };
@ -293,7 +293,7 @@ pub async fn get_hexagons(
let t_agg = t0.elapsed(); let t_agg = t0.elapsed();
let features = build_feature_maps( let mut features = build_feature_maps(
&groups, &groups,
min_keys, min_keys,
max_keys, max_keys,
@ -303,11 +303,17 @@ pub async fn get_hexagons(
(south, west, north, east), (south, west, north, east),
); );
let truncated = features.len() > MAX_CELLS_PER_REQUEST;
if truncated {
features.truncate(MAX_CELLS_PER_REQUEST);
}
let t_total = t0.elapsed(); let t_total = t0.elapsed();
info!( info!(
resolution, resolution,
cells_before_filter = groups.len(), cells_before_filter = groups.len(),
cells_after_filter = features.len(), cells_after_filter = features.len(),
truncated,
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east), bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
filters = num_filters, filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"), filters_raw = filters_str.as_deref().unwrap_or("-"),

View file

@ -0,0 +1,268 @@
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::Json;
use serde::Deserialize;
use tracing::{info, warn};
use crate::parsing::{parse_filters, row_passes_filters};
use crate::state::AppState;
use super::hexagon_stats::{
EnumFeatureStats, HexagonStatsResponse, HistogramStats, NumericFeatureStats, PricePoint,
};
#[derive(Deserialize)]
pub struct PostcodeStatsParams {
pub postcode: String,
pub filters: Option<String>,
/// Comma-separated feature names to include in stats response.
/// Only listed features are computed; if absent or empty, no features are returned.
pub fields: Option<String>,
}
pub async fn get_postcode_stats(
state: Arc<AppState>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, (StatusCode, String)> {
// Normalize postcode: uppercase, collapse whitespace
let normalized = params
.postcode
.to_uppercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
// Look up postcode centroid for spatial search
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
Some(&idx) => idx,
None => {
warn!(postcode = %normalized, "Postcode not found");
return Err((
StatusCode::NOT_FOUND,
format!("Postcode not found: {}", normalized),
));
}
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
let filters_str = params.filters.clone();
let (parsed_filters, parsed_enum_filters) = parse_filters(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
);
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
let fields_specified = params.fields.is_some();
let field_set: std::collections::HashSet<String> = params
.fields
.as_ref()
.map(|fields_str| {
fields_str
.split(',')
.map(|field| field.trim().to_string())
.filter(|field| !field.is_empty())
.collect()
})
.unwrap_or_default();
let postcode_str = normalized.clone();
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
// Search ±0.02° around centroid (~2km, generous for a postcode)
let offset: f64 = 0.02;
let min_lat = centroid_lat as f64 - offset;
let max_lat = centroid_lat as f64 + offset;
let min_lon = centroid_lon as f64 - offset;
let max_lon = centroid_lon as f64 + offset;
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
let row_postcode = state.data.postcode(row);
if row_postcode == postcode_str
&& row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
)
{
matching_rows.push(row);
}
});
let total_count = matching_rows.len();
// Collect price history (year, price) pairs
let price_history = {
let year_idx = state
.feature_name_to_index
.get("Date of last transaction")
.copied();
let price_idx = state.feature_name_to_index.get("Last known price").copied();
match (year_idx, price_idx) {
(Some(yi), Some(pi)) => {
let mut points: Vec<PricePoint> = matching_rows
.iter()
.filter_map(|&row| {
let year = feature_data[row * num_features + yi];
let price = feature_data[row * num_features + pi];
if year.is_finite() && price.is_finite() {
Some(PricePoint { year, price })
} else {
None
}
})
.collect();
// Cap at 5000 points by evenly sampling
if points.len() > 5000 {
let step = points.len() as f64 / 5000.0;
points = (0..5000)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {
year: points[idx].year,
price: points[idx].price,
}
})
.collect();
}
points
}
_ => Vec::new(),
}
};
let mut numeric_features = Vec::new();
let mut enum_features_out = Vec::new();
for (feature_index, feature_name) in state.data.feature_names.iter().enumerate() {
if fields_specified && !field_set.contains(feature_name.as_str()) {
continue;
}
if let Some(enum_values) = state.data.enum_values.get(&feature_index) {
// Enum feature: count occurrences of each value
let mut value_counts = vec![0u64; enum_values.len()];
for &row in &matching_rows {
let value = feature_data[row * num_features + feature_index];
if value.is_finite() {
let idx = value as usize;
if idx < value_counts.len() {
value_counts[idx] += 1;
}
}
}
let counts: HashMap<String, u64> = value_counts
.iter()
.enumerate()
.filter(|(_, &count)| count > 0)
.map(|(idx, &count)| (enum_values[idx].clone(), count))
.collect();
if !counts.is_empty() {
enum_features_out.push(EnumFeatureStats {
name: feature_name.clone(),
counts,
});
}
} else {
// Numeric feature: compute stats and histogram
let global_hist = &state.data.feature_stats[feature_index].histogram;
let p1 = global_hist.p1;
let p99 = global_hist.p99;
let num_bins = global_hist.counts.len();
let mut count = 0usize;
let mut min_value = f32::INFINITY;
let mut max_value = f32::NEG_INFINITY;
let mut sum = 0.0f64;
let mut bins = vec![0u64; num_bins];
let middle_bins = num_bins.saturating_sub(2);
let middle_width = if middle_bins > 0 && p99 > p1 {
(p99 - p1) / middle_bins as f32
} else {
0.0
};
for &row in &matching_rows {
let value = feature_data[row * num_features + feature_index];
if value.is_finite() {
count += 1;
if value < min_value {
min_value = value;
}
if value > max_value {
max_value = value;
}
sum += value as f64;
let bin = if value < p1 {
0
} else if value >= p99 {
num_bins - 1
} else if middle_width > 0.0 {
let middle_bin = ((value - p1) / middle_width) as usize;
(1 + middle_bin).min(num_bins - 2)
} else {
num_bins / 2
};
bins[bin] += 1;
}
}
if count > 0 {
numeric_features.push(NumericFeatureStats {
name: feature_name.clone(),
count,
min: min_value as f64,
max: max_value as f64,
mean: sum / count as f64,
histogram: HistogramStats {
min: global_hist.min as f64,
max: global_hist.max as f64,
p1: p1 as f64,
p99: p99 as f64,
counts: bins,
},
});
}
}
}
let elapsed = start_time.elapsed();
info!(
postcode = %postcode_str,
total_count,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/postcode-stats"
);
Ok(HexagonStatsResponse {
count: total_count,
numeric_features,
enum_features: enum_features_out,
price_history,
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
Ok(Json(response))
}

View file

@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tracing::info; use tracing::info;
use crate::consts::MAX_CELLS_PER_REQUEST;
use crate::parsing::{bounds_intersect, parse_bounds, parse_filters, row_passes_filters}; use crate::parsing::{bounds_intersect, parse_bounds, parse_filters, row_passes_filters};
use crate::state::AppState; use crate::state::AppState;
@ -282,7 +283,8 @@ pub async fn get_postcodes(
for feat_index in iter { for feat_index in iter {
if aggregation.feat_counts[feat_index] > 0 { if aggregation.feat_counts[feat_index] > 0 {
let avg = aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64; let avg =
aggregation.sums[feat_index] / aggregation.feat_counts[feat_index] as f64;
if let (Some(min_num), Some(max_num), Some(avg_num)) = ( if let (Some(min_num), Some(max_num), Some(avg_num)) = (
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64), serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64), serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
@ -302,13 +304,19 @@ pub async fn get_postcodes(
feature.insert("properties".into(), Value::Object(props)); feature.insert("properties".into(), Value::Object(props));
features.push(feature); features.push(feature);
if features.len() >= MAX_CELLS_PER_REQUEST {
break;
}
} }
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
let t_total = t0.elapsed(); let t_total = t0.elapsed();
info!( info!(
postcodes_before_filter, postcodes_before_filter,
postcodes_after_filter = features.len(), postcodes_after_filter = features.len(),
filtered_out, filtered_out,
truncated,
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east), bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
filters = num_filters, filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"), filters_raw = filters_str.as_deref().unwrap_or("-"),

View file

@ -8,7 +8,7 @@ use tracing::{info, warn};
use crate::state::AppState; use crate::state::AppState;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct OgImageQuery { pub struct ScreenshotQuery {
#[serde(rename = "v")] #[serde(rename = "v")]
view: Option<String>, view: Option<String>,
#[serde(rename = "f")] #[serde(rename = "f")]
@ -19,9 +19,9 @@ pub struct OgImageQuery {
og: Option<String>, og: Option<String>,
} }
pub async fn get_og_image( pub async fn get_screenshot(
state: Arc<AppState>, state: Arc<AppState>,
Query(query): Query<OgImageQuery>, Query(query): Query<ScreenshotQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let screenshot_base = &state.screenshot_url; let screenshot_base = &state.screenshot_url;

View file

@ -12,9 +12,9 @@ pub type TileReader = AsyncPmTilesReader<MmapBackend>;
pub async fn get_tile( pub async fn get_tile(
State(reader): State<Arc<TileReader>>, State(reader): State<Arc<TileReader>>,
Path((z, x, y)): Path<(u8, u32, u32)>, Path((zoom, col, row)): Path<(u8, u32, u32)>,
) -> Response { ) -> Response {
match reader.get_tile(z, x as u64, y as u64).await { match reader.get_tile(zoom, col as u64, row as u64).await {
Ok(Some(tile_bytes)) => ( Ok(Some(tile_bytes)) => (
StatusCode::OK, StatusCode::OK,
[ [
@ -27,7 +27,7 @@ pub async fn get_tile(
.into_response(), .into_response(),
Ok(None) => StatusCode::NO_CONTENT.into_response(), Ok(None) => StatusCode::NO_CONTENT.into_response(),
Err(err) => { Err(err) => {
warn!(z, x, y, error = %err, "Failed to get tile"); warn!(zoom, col, row, error = %err, "Failed to get tile");
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
} }
} }
@ -57,7 +57,7 @@ pub async fn get_style(
// Parse the JSON string // Parse the JSON string
let metadata: serde_json::Value = match serde_json::from_str(&metadata_str) { let metadata: serde_json::Value = match serde_json::from_str(&metadata_str) {
Ok(v) => v, Ok(val) => val,
Err(err) => { Err(err) => {
warn!(error = %err, "Failed to parse PMTiles metadata JSON"); warn!(error = %err, "Failed to parse PMTiles metadata JSON");
serde_json::Value::Object(serde_json::Map::new()) serde_json::Value::Object(serde_json::Map::new())
@ -67,14 +67,14 @@ pub async fn get_style(
// Extract tilestats for layer info if available // Extract tilestats for layer info if available
let layers: Vec<serde_json::Value> = metadata let layers: Vec<serde_json::Value> = metadata
.get("vector_layers") .get("vector_layers")
.and_then(|v| v.as_array()) .and_then(|vl| vl.as_array())
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
// Build absolute tile URL using the request host // Build absolute tile URL using the request host
let host = headers let host = headers
.get(header::HOST) .get(header::HOST)
.and_then(|h| h.to_str().ok()) .and_then(|hv| hv.to_str().ok())
.unwrap_or("localhost:8001"); .unwrap_or("localhost:8001");
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host); let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
let style = build_style(is_dark, &layers, &tile_url); let style = build_style(is_dark, &layers, &tile_url);
@ -101,7 +101,7 @@ fn build_style(is_dark: bool, layers: &[serde_json::Value], tile_url: &str) -> s
// Build layer list from metadata // Build layer list from metadata
let layer_ids: Vec<&str> = layers let layer_ids: Vec<&str> = layers
.iter() .iter()
.filter_map(|l| l.get("id").and_then(|v| v.as_str())) .filter_map(|ly| ly.get("id").and_then(|id| id.as_str()))
.collect(); .collect();
let mut style_layers = vec![serde_json::json!({ let mut style_layers = vec![serde_json::json!({

4
uv.lock generated
View file

@ -1339,7 +1339,6 @@ name = "property-map"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "attrs", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "fastexcel", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "httpx", extra = ["socks"], marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "ipywidgets", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
@ -1353,7 +1352,6 @@ dependencies = [
{ name = "pyarrow", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "pyarrow", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyproj", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "pyproj", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "pyshp", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "pyshp", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "python-dateutil", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "rasterio", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "rasterio", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "scipy", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "scipy", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
{ name = "shapely", marker = "python_full_version < '3.14' and sys_platform == 'linux'" }, { name = "shapely", marker = "python_full_version < '3.14' and sys_platform == 'linux'" },
@ -1370,7 +1368,6 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "attrs", specifier = ">=22.2.0" },
{ name = "fastexcel", specifier = ">=0.19.0" }, { name = "fastexcel", specifier = ">=0.19.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "ipywidgets", specifier = ">=8.0.0" }, { name = "ipywidgets", specifier = ">=8.0.0" },
@ -1384,7 +1381,6 @@ requires-dist = [
{ name = "pyarrow", specifier = ">=15.0.0" }, { name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyproj", specifier = ">=3.7.2" }, { name = "pyproj", specifier = ">=3.7.2" },
{ name = "pyshp", specifier = ">=2.3.0" }, { name = "pyshp", specifier = ">=2.3.0" },
{ name = "python-dateutil", specifier = ">=2.8.0" },
{ name = "rasterio", specifier = ">=1.5.0" }, { name = "rasterio", specifier = ">=1.5.0" },
{ name = "scipy", specifier = ">=1.17.0" }, { name = "scipy", specifier = ">=1.17.0" },
{ name = "shapely", specifier = ">=2.0.0" }, { name = "shapely", specifier = ">=2.0.0" },