Format and lint
This commit is contained in:
parent
42ee2d4c51
commit
04a78e7bfe
75 changed files with 1290 additions and 719 deletions
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
|||
"@deck.gl/layers": "^9.0.0",
|
||||
"@deck.gl/mapbox": "^9.2.6",
|
||||
"@deck.gl/react": "^9.0.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
|
|
@ -3325,6 +3326,11 @@
|
|||
"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": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.2.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"@deck.gl/layers": "^9.0.0",
|
||||
"@deck.gl/mapbox": "^9.2.6",
|
||||
"@deck.gl/react": "^9.0.0",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@protomaps/basemaps": "^5.7.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { trackPageview } from './hooks/usePlausible';
|
||||
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
||||
import FAQPage from './components/faq/FAQPage';
|
||||
|
|
@ -19,17 +18,22 @@ import { useSavedSearches } from './hooks/useSavedSearches';
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
__og_ready?: boolean;
|
||||
__screenshot_ready?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
function pageToPath(page: Page): string {
|
||||
switch (page) {
|
||||
case 'dashboard': return '/dashboard';
|
||||
case 'data-sources': return '/data-sources';
|
||||
case 'faq': return '/faq';
|
||||
case 'saved-searches': return '/saved';
|
||||
default: return '/';
|
||||
case 'dashboard':
|
||||
return '/dashboard';
|
||||
case 'data-sources':
|
||||
return '/data-sources';
|
||||
case 'faq':
|
||||
return '/faq';
|
||||
case 'saved-searches':
|
||||
return '/saved';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +48,10 @@ function pathToPage(pathname: string): Page | null {
|
|||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []);
|
||||
const initialViewState = useMemo(
|
||||
() => urlState.viewState || INITIAL_VIEW_STATE,
|
||||
[urlState.viewState]
|
||||
);
|
||||
|
||||
const isScreenshotMode = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -76,11 +83,7 @@ export default function App() {
|
|||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) {
|
||||
// Rewrite URL to /dashboard keeping query params
|
||||
window.history.replaceState(
|
||||
{ page: 'dashboard' },
|
||||
'',
|
||||
`/dashboard${window.location.search}`
|
||||
);
|
||||
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +144,7 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Screenshot mode ready signal — MapPage sets __og_ready once map data loads
|
||||
// Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads
|
||||
|
||||
// Navigation
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
|
|
@ -152,7 +155,6 @@ export default function App() {
|
|||
const url = hash ? `${path}#${hash}` : path;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
trackPageview();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -180,11 +182,12 @@ export default function App() {
|
|||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch saved searches when page becomes active
|
||||
const { fetchSearches } = savedSearches;
|
||||
useEffect(() => {
|
||||
if (activePage === 'saved-searches') {
|
||||
savedSearches.fetchSearches();
|
||||
fetchSearches();
|
||||
}
|
||||
}, [activePage, savedSearches.fetchSearches]);
|
||||
}, [activePage, fetchSearches]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
HexagonStatsResponse,
|
||||
PostcodeFeature,
|
||||
} from '../../types';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
|
|
@ -33,7 +38,6 @@ interface AreaPaneProps {
|
|||
aiSummary?: string;
|
||||
aiSummaryLoading?: boolean;
|
||||
aiSummaryError?: string | null;
|
||||
onRetryAiSummary?: () => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -51,7 +55,6 @@ export default function AreaPane({
|
|||
aiSummary,
|
||||
aiSummaryLoading,
|
||||
aiSummaryError,
|
||||
onRetryAiSummary,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
|
|
@ -145,7 +148,9 @@ export default function AreaPane({
|
|||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={aiSummaryExpanded ? 'down' : 'right'}
|
||||
|
|
@ -156,15 +161,7 @@ export default function AreaPane({
|
|||
<>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
<span>Failed to generate summary. </span>
|
||||
{onRetryAiSummary && (
|
||||
<button
|
||||
onClick={onRetryAiSummary}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
Failed to generate summary.
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -191,19 +188,24 @@ export default function AreaPane({
|
|||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span>{' '}
|
||||
show the distribution in this selected area
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span>{' '}
|
||||
show the overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">
|
||||
Dashed line
|
||||
</span>{' '}
|
||||
indicates the global average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -219,9 +221,9 @@ export default function AreaPane({
|
|||
|
||||
// Features that are part of a stacked enum config (rendered as compact charts)
|
||||
const stackedEnumFeatureNames = new Set(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
(stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter(Boolean)
|
||||
) as string[] ?? []
|
||||
) as string[]) ?? []
|
||||
);
|
||||
|
||||
const isExpanded = !collapsedGroups.has(group.name);
|
||||
|
|
@ -234,40 +236,156 @@ export default function AreaPane({
|
|||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && <div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' && stats.price_history && (() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' &&
|
||||
stats.price_history &&
|
||||
(() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -278,7 +396,7 @@ export default function AreaPane({
|
|||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
|
|
@ -288,166 +406,57 @@ export default function AreaPane({
|
|||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
}
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -51,15 +51,16 @@ export function DualHistogram({
|
|||
const localMax = Math.max(...localBars, 1);
|
||||
const globalMax = Math.max(...globalBars, 1);
|
||||
|
||||
const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
|
||||
const fmt =
|
||||
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
|
||||
|
||||
// Compute center value for each bar.
|
||||
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
|
||||
const middleBins = Math.max(barCount - 2, 0);
|
||||
const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0;
|
||||
const barCenters: number[] = Array.from({ length: barCount }, (_, i) => {
|
||||
if (i === 0) return p1; // outlier bin, label as p1
|
||||
if (i === barCount - 1) return p99; // outlier bin, label as p99
|
||||
if (i === 0) return p1; // outlier bin, label as p1
|
||||
if (i === barCount - 1) return p99; // outlier bin, label as p99
|
||||
return p1 + (i - 1 + 0.5) * middleWidth;
|
||||
});
|
||||
|
||||
|
|
@ -71,7 +72,10 @@ export function DualHistogram({
|
|||
let bestDist = Infinity;
|
||||
for (let i = 1; i < barCount - 1; i++) {
|
||||
const dist = Math.abs(barCenters[i] - v);
|
||||
if (dist < bestDist) { bestDist = dist; bestBar = i; }
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestBar = i;
|
||||
}
|
||||
}
|
||||
if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v));
|
||||
}
|
||||
|
|
@ -79,9 +83,7 @@ export function DualHistogram({
|
|||
// Mean line: position as fraction across the bar area
|
||||
const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null;
|
||||
// Account for outlier bins: middle region spans bars 1..n-2
|
||||
const meanPct = meanFrac != null
|
||||
? ((1 + meanFrac * middleBins) / barCount) * 100
|
||||
: null;
|
||||
const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { memo, useState, useMemo, useEffect } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { Label } from '../ui/Label';
|
||||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { FilterIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
|
|
@ -138,7 +137,9 @@ function FeatureBrowser({
|
|||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
description={search ? 'Try a different search term' : 'Remove a filter to see available features'}
|
||||
description={
|
||||
search ? 'Try a different search term' : 'Remove a filter to see available features'
|
||||
}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -334,9 +335,9 @@ export default memo(function Filters({
|
|||
Be intentional, not reactive
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale. Don't let a
|
||||
seemingly good deal turn into lifelong regret. Instead of waiting for listings to
|
||||
appear, define what you actually want and go find it.
|
||||
Your future home isn't a box of cereal you grab because it's on sale.
|
||||
Don't let a seemingly good deal turn into lifelong regret. Instead of waiting
|
||||
for listings to appear, define what you actually want and go find it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -347,7 +348,7 @@ export default memo(function Filters({
|
|||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Current listings show only a fraction of the market. There are too few to give you a
|
||||
complete picture, yet too many to evaluate one by one. We aggregate millions of
|
||||
historical sales so you can understand what's truly available in any area.
|
||||
historical sales so you can understand what's truly available in any area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -367,18 +368,19 @@ export default memo(function Filters({
|
|||
Find the right place, not just the right listing
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help you
|
||||
identify where you should be looking, so when something does come up, you're ready.
|
||||
The best areas to live don't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you're ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
Know what's possible
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
FeatureMeta,
|
||||
Bounds,
|
||||
} from '../../types';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
|
|
@ -66,9 +66,9 @@ interface MapProps {
|
|||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
}
|
||||
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -118,6 +118,7 @@ export default memo(function Map({
|
|||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
|
|
@ -204,8 +205,13 @@ export default memo(function Map({
|
|||
let max = -Infinity;
|
||||
for (const d of data) {
|
||||
if (viewportBounds) {
|
||||
const [lat, lng] = cellToLatLng(d.h3);
|
||||
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
|
||||
if (
|
||||
d.lat < viewportBounds.south ||
|
||||
d.lat > viewportBounds.north ||
|
||||
d.lon < viewportBounds.west ||
|
||||
d.lon > viewportBounds.east
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
|
|
@ -270,7 +276,13 @@ export default memo(function Map({
|
|||
for (const d of postcodeData) {
|
||||
if (viewportBounds) {
|
||||
const [lng, lat] = d.properties.centroid as [number, number];
|
||||
if (lat < viewportBounds.south || lat > viewportBounds.north || lng < viewportBounds.west || lng > viewportBounds.east) continue;
|
||||
if (
|
||||
lat < viewportBounds.south ||
|
||||
lat > viewportBounds.north ||
|
||||
lng < viewportBounds.west ||
|
||||
lng > viewportBounds.east
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const c = d.properties.count;
|
||||
if (c < min) min = c;
|
||||
|
|
@ -339,12 +351,23 @@ export default memo(function Map({
|
|||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (val == null)
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
|
|
@ -356,12 +379,10 @@ export default memo(function Map({
|
|||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return [
|
||||
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
|
||||
255,
|
||||
] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.h3 === selectedHexagonIdRef.current)
|
||||
|
|
@ -407,12 +428,23 @@ export default memo(function Map({
|
|||
const dark = isDarkRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
if (val == null) return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
if (val == null)
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
|
|
@ -424,12 +456,10 @@ export default memo(function Map({
|
|||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 180] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return [
|
||||
...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current),
|
||||
180,
|
||||
] as [number, number, number, number];
|
||||
},
|
||||
getLineColor: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
|
|
@ -438,7 +468,12 @@ export default memo(function Map({
|
|||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
|
|
@ -546,7 +581,14 @@ export default memo(function Map({
|
|||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
}, [
|
||||
usePostcodeView,
|
||||
hexLayer,
|
||||
postcodeLayer,
|
||||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
searchedPostcodeHighlightLayer,
|
||||
]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverPosition(null);
|
||||
|
|
@ -588,26 +630,35 @@ export default memo(function Map({
|
|||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={viewSource === 'eye' ? `Previewing \u201c${colorFeatureMeta.name}\u201d` : colorFeatureMeta.name}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={usePostcodeView ? [postcodeCountRange.min, postcodeCountRange.max] : [countRange.min, countRange.max]}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
{!hideLegend &&
|
||||
(viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded shadow-lg p-2 text-sm dark:text-white"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default function MapLegend({
|
|||
mode,
|
||||
enumValues,
|
||||
theme = 'light',
|
||||
inline = false,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -19,12 +20,20 @@ export default function MapLegend({
|
|||
mode: 'feature' | 'density';
|
||||
enumValues?: string[];
|
||||
theme?: 'light' | 'dark';
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
const gradientStyle =
|
||||
mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT);
|
||||
|
||||
return (
|
||||
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]">
|
||||
<div
|
||||
className={
|
||||
inline
|
||||
? 'bg-white dark:bg-warm-800 dark:text-white p-3 text-xs border-b border-warm-200 dark:border-warm-700'
|
||||
: 'absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[160px]'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">{featureLabel}</span>
|
||||
{showCancel && (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { PropertiesPane } from './PropertiesPane';
|
|||
import AreaPane from './AreaPane';
|
||||
import MobileDrawer from './MobileDrawer';
|
||||
import DataSources from '../data-sources/DataSources';
|
||||
import MapLegend from './MapLegend';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
|
|
@ -25,7 +26,7 @@ export interface ExportState {
|
|||
exporting: boolean;
|
||||
}
|
||||
|
||||
type MobileBottomTab = 'filters' | 'pois';
|
||||
type MobileBottomTab = 'filters' | 'pois' | 'area';
|
||||
|
||||
interface MapPageProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -63,7 +64,8 @@ export default function MapPage({
|
|||
isMobile = false,
|
||||
}: MapPageProps) {
|
||||
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(initialPOICategories);
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
|
||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
|
@ -116,7 +118,6 @@ export default function MapPage({
|
|||
const selection = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
postcodeData: mapData.postcodeData,
|
||||
resolution: mapData.resolution,
|
||||
});
|
||||
|
||||
|
|
@ -133,13 +134,17 @@ export default function MapPage({
|
|||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On mobile, open drawer and switch tab when hexagon is clicked
|
||||
const handleMobileHexagonClick = useCallback((id: string, isPostcode?: boolean) => {
|
||||
selection.handleHexagonClick(id, isPostcode);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
setMobileBottomTab('area');
|
||||
}
|
||||
}, [selection.handleHexagonClick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean) => {
|
||||
handleHexagonClick(id, isPostcode);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
setMobileBottomTab('area');
|
||||
}
|
||||
},
|
||||
[handleHexagonClick]
|
||||
);
|
||||
|
||||
// Compute hexagon location for external links
|
||||
const hexagonLocation = useMemo(() => {
|
||||
|
|
@ -158,7 +163,13 @@ export default function MapPage({
|
|||
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
|
||||
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
|
||||
}
|
||||
}, [selection.selectedHexagon?.id, selection.selectedHexagon?.type, mapData.data, mapData.postcodeData, mapData.resolution]);
|
||||
}, [
|
||||
selection.selectedHexagon?.id,
|
||||
selection.selectedHexagon?.type,
|
||||
mapData.data,
|
||||
mapData.postcodeData,
|
||||
mapData.resolution,
|
||||
]);
|
||||
|
||||
// AI area summary
|
||||
const aiSummary = useAreaSummary({
|
||||
|
|
@ -203,10 +214,33 @@ export default function MapPage({
|
|||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
);
|
||||
const mobileDensityRange = useMemo((): [number, number] => {
|
||||
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
|
||||
if (items.length === 0) return [0, 1];
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of items) {
|
||||
const c =
|
||||
'count' in d
|
||||
? (d as { count: number }).count
|
||||
: (d as { properties: { count: number } }).properties.count;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === Infinity) return [0, 1];
|
||||
if (min === max) return [min, min + 1];
|
||||
return [min, max];
|
||||
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
|
||||
|
||||
// Signal screenshot readiness once map data has loaded
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
|
||||
window.__og_ready = true;
|
||||
window.__screenshot_ready = true;
|
||||
}
|
||||
}, [screenshotMode, mapData.loading, mapData.data.length]);
|
||||
|
||||
|
|
@ -249,7 +283,9 @@ export default function MapPage({
|
|||
isPostcode={selection.selectedHexagon?.type === 'postcode'}
|
||||
postcodeData={
|
||||
selection.selectedHexagon?.type === 'postcode'
|
||||
? mapData.postcodeData.find((f) => f.properties.postcode === selection.selectedHexagon?.id) || null
|
||||
? mapData.postcodeData.find(
|
||||
(f) => f.properties.postcode === selection.selectedHexagon?.id
|
||||
) || null
|
||||
: null
|
||||
}
|
||||
onViewProperties={selection.handleViewPropertiesFromArea}
|
||||
|
|
@ -260,7 +296,6 @@ export default function MapPage({
|
|||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
onRetryAiSummary={aiSummary.retry}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -319,7 +354,9 @@ export default function MapPage({
|
|||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -348,6 +385,7 @@ export default function MapPage({
|
|||
searchedPostcode={searchedPostcode}
|
||||
onPostcodeSearched={setSearchedPostcode}
|
||||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
|
|
@ -358,19 +396,55 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
<div className="bg-white dark:bg-navy-950 border-t border-warm-200 dark:border-navy-700 overflow-hidden flex flex-col" style={{ flex: '55 0 0' }}>
|
||||
<div
|
||||
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
|
||||
style={{ flex: '55 0 0' }}
|
||||
>
|
||||
{/* Legend */}
|
||||
{viewFeature && mapData.colorRange && mobileLegendMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
|
||||
: mobileLegendMeta.name
|
||||
}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
)}
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton label="Filters" isActive={mobileBottomTab === 'filters'} onClick={() => setMobileBottomTab('filters')} />
|
||||
<TabButton label="POIs" isActive={mobileBottomTab === 'pois'} onClick={() => setMobileBottomTab('pois')} />
|
||||
<div className="flex shrink-0 border-b border-warm-200 dark:border-warm-700 text-sm">
|
||||
<TabButton
|
||||
label="Filters"
|
||||
isActive={mobileBottomTab === 'filters'}
|
||||
onClick={() => setMobileBottomTab('filters')}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={mobileBottomTab === 'pois'}
|
||||
onClick={() => setMobileBottomTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileBottomTab === 'pois' ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{renderPOIPane()}
|
||||
</div>
|
||||
<div className="h-full overflow-y-auto">{renderPOIPane()}</div>
|
||||
) : (
|
||||
renderFilters()
|
||||
)}
|
||||
|
|
@ -397,16 +471,19 @@ export default function MapPage({
|
|||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">Connecting to server...</p>
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Pane */}
|
||||
<div className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden" style={{ width: leftPaneWidth }}>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{renderFilters()}
|
||||
</div>
|
||||
<div
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
style={{ width: leftPaneWidth }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...leftPaneHandlers}
|
||||
|
|
@ -449,7 +526,10 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
<div className="flex bg-white dark:bg-navy-950 shadow-lg z-10" style={{ width: rightPaneWidth }}>
|
||||
<div
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...rightPaneHandlers}
|
||||
|
|
@ -458,19 +538,29 @@ export default function MapPage({
|
|||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
<TabButton label="Area" isActive={selection.rightPaneTab === 'area'} onClick={() => selection.setRightPaneTab('area')} />
|
||||
<TabButton label="Properties" isActive={selection.rightPaneTab === 'properties'} onClick={selection.handlePropertiesTabClick} />
|
||||
<TabButton label="POIs" isActive={selection.rightPaneTab === 'pois'} onClick={() => selection.setRightPaneTab('pois')} />
|
||||
<TabButton
|
||||
label="Area"
|
||||
isActive={selection.rightPaneTab === 'area'}
|
||||
onClick={() => selection.setRightPaneTab('area')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Properties"
|
||||
isActive={selection.rightPaneTab === 'properties'}
|
||||
onClick={selection.handlePropertiesTabClick}
|
||||
/>
|
||||
<TabButton
|
||||
label="POIs"
|
||||
isActive={selection.rightPaneTab === 'pois'}
|
||||
onClick={() => selection.setRightPaneTab('pois')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'area' ? (
|
||||
renderAreaPane()
|
||||
) : selection.rightPaneTab === 'properties' ? (
|
||||
renderPropertiesPane()
|
||||
) : (
|
||||
renderPOIPane()
|
||||
)}
|
||||
{selection.rightPaneTab === 'area'
|
||||
? renderAreaPane()
|
||||
: selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderPOIPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ export default function MobileDrawer({
|
|||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton label="Area" isActive={tab === 'area'} onClick={() => setTab('area')} />
|
||||
<TabButton label="Properties" isActive={tab === 'properties'} onClick={() => setTab('properties')} />
|
||||
<TabButton
|
||||
label="Properties"
|
||||
isActive={tab === 'properties'}
|
||||
onClick={() => setTab('properties')}
|
||||
/>
|
||||
<TabButton label="POIs" isActive={tab === 'pois'} onClick={() => setTab('pois')} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
|
|||
|
|
@ -159,9 +159,7 @@ export default function POIPane({
|
|||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
{filteredGroups.map((group) => {
|
||||
const groupSelected = group.categories.filter((c) =>
|
||||
selectedCategories.has(c)
|
||||
).length;
|
||||
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
|
||||
const allInGroupSelected = groupSelected === group.categories.length;
|
||||
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
|
||||
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
|
||||
|
|
|
|||
|
|
@ -53,8 +53,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
.map(([yr, prices]) => {
|
||||
prices.sort((a, b) => a - b);
|
||||
const mid = Math.floor(prices.length / 2);
|
||||
const median =
|
||||
prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
return { year: yr + 0.5, price: median };
|
||||
})
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
|
@ -86,9 +85,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
const yearLabels: number[] = [];
|
||||
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
||||
|
||||
const medianPolyline = medians
|
||||
.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`)
|
||||
.join(' ');
|
||||
const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function PropertiesPane({
|
|||
loading,
|
||||
hexagonId,
|
||||
onLoadMore,
|
||||
onClose,
|
||||
onClose: _onClose,
|
||||
onNavigateToSource,
|
||||
}: PropertiesPaneProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -70,10 +70,10 @@ export function PropertiesPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
|
||||
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
|
||||
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
|
||||
recent sale price from the Land Registry.
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
|
||||
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
|
||||
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
|
||||
from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -122,10 +122,7 @@ function PropertyLoadingSkeleton() {
|
|||
return (
|
||||
<div className="space-y-0">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse"
|
||||
>
|
||||
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
|
||||
{/* Address */}
|
||||
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
|
||||
{/* Postcode */}
|
||||
|
|
@ -196,7 +193,8 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
|
||||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
|
|
|
|||
|
|
@ -29,15 +29,10 @@ function shortenLabel(name: string): string {
|
|||
}
|
||||
|
||||
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
|
||||
const sortedSegments = useMemo(
|
||||
() => [...segments].sort((a, b) => b.value - a.value),
|
||||
[segments]
|
||||
);
|
||||
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>
|
||||
);
|
||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +48,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor:
|
||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
|
|
@ -68,7 +64,8 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
|
|||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor:
|
||||
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ export default function StackedEnumChart({
|
|||
});
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>
|
||||
);
|
||||
return <div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -127,7 +127,10 @@ export default function SavedSearchesPage({
|
|||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteConfirmId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setDeleteConfirmId(null)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function AuthModal({
|
|||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -115,7 +115,7 @@ export default function AuthModal({
|
|||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -131,7 +131,7 @@ export default function AuthModal({
|
|||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
/>
|
||||
{view === 'login' && (
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ export function CollapsibleGroupHeader({
|
|||
children,
|
||||
}: CollapsibleGroupHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between ${className}`}
|
||||
>
|
||||
<button onClick={onToggle} className={`w-full flex items-center justify-between ${className}`}>
|
||||
<span>{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function EmptyState({
|
|||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center ${centered ? 'h-full px-4' : 'py-8'} ${className}`}
|
||||
className={`flex flex-col items-center justify-center text-center ${centered ? 'h-full px-4' : 'py-3 md:py-8'} ${className}`}
|
||||
>
|
||||
<div className="mb-2">{icon}</div>
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,16 @@ export function FeatureActions({
|
|||
<InfoIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onTogglePin(feature.name)} title={isPinned ? 'Unpin color view' : 'Color map by this feature'} active={isPinned}>
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
active={isPinned}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter">
|
||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ export function FeatureLabel({
|
|||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
|
||||
return (
|
||||
<div className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}>
|
||||
<span className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}>
|
||||
<div
|
||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
||||
>
|
||||
<span
|
||||
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && onShowInfo && (
|
||||
|
|
|
|||
|
|
@ -93,7 +93,9 @@ export default function Header({
|
|||
<button
|
||||
key={page}
|
||||
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
|
||||
activePage === page ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onPageChange(page);
|
||||
|
|
@ -123,11 +125,17 @@ export default function Header({
|
|||
Dashboard
|
||||
</button>
|
||||
{user && (
|
||||
<button className={tabClass('saved-searches')} onClick={() => onPageChange('saved-searches')}>
|
||||
<button
|
||||
className={tabClass('saved-searches')}
|
||||
onClick={() => onPageChange('saved-searches')}
|
||||
>
|
||||
Saved
|
||||
</button>
|
||||
)}
|
||||
<button className={tabClass('data-sources')} onClick={() => onPageChange('data-sources')}>
|
||||
<button
|
||||
className={tabClass('data-sources')}
|
||||
onClick={() => onPageChange('data-sources')}
|
||||
>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
||||
|
|
@ -245,10 +253,7 @@ export default function Header({
|
|||
{isMobile && menuOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={() => setMenuOpen(false)} />
|
||||
{/* Menu panel */}
|
||||
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
|
||||
|
|
@ -272,23 +277,40 @@ export default function Header({
|
|||
<div className="mt-3 pt-3 border-t border-navy-700 flex flex-col gap-1">
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
onClick={() => { onSaveSearch(); setMenuOpen(false); }}
|
||||
onClick={() => {
|
||||
onSaveSearch();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
disabled={savingSearch}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
|
||||
>
|
||||
{savingSearch ? <SpinnerIcon className="w-5 h-5 animate-spin" /> : <BookmarkIcon className="w-5 h-5" />}
|
||||
{savingSearch ? (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<BookmarkIcon className="w-5 h-5" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handleShare(); setMenuOpen(false); }}
|
||||
onClick={() => {
|
||||
handleShare();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-5 h-5" /> : <ClipboardIcon className="w-5 h-5" />}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-5 h-5" />
|
||||
)}
|
||||
{copied ? 'Copied!' : 'Share'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onExport?.(); setMenuOpen(false); }}
|
||||
onClick={() => {
|
||||
onExport?.();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
disabled={!onExport || exporting}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded disabled:opacity-50"
|
||||
>
|
||||
|
|
@ -303,41 +325,56 @@ export default function Header({
|
|||
<div className="p-3 border-t border-navy-700 flex flex-col gap-3">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => { onToggleTheme(); }}
|
||||
onClick={() => {
|
||||
onToggleTheme();
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-base text-warm-300 hover:bg-navy-800 hover:text-white rounded transition-colors"
|
||||
>
|
||||
{theme === 'light' ? <SunIcon className="w-5 h-5" /> : <MoonIcon className="w-5 h-5" />}
|
||||
{theme === 'light' ? (
|
||||
<SunIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<MoonIcon className="w-5 h-5" />
|
||||
)}
|
||||
<span>Theme: {theme === 'light' ? 'Light' : 'Dark'}</span>
|
||||
</button>
|
||||
|
||||
{/* Auth buttons */}
|
||||
<div>
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-sm text-warm-300 truncate">{user.email}</span>
|
||||
<button
|
||||
onClick={() => { onLogout(); setMenuOpen(false); }}
|
||||
className="text-sm text-warm-400 hover:text-white"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onLoginClick(); setMenuOpen(false); }}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onRegisterClick(); setMenuOpen(false); }}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-sm text-warm-300 truncate">{user.email}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="text-sm text-warm-400 hover:text-white"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLoginClick();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm text-center"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onRegisterClick();
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className="flex-1 px-3 py-2.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium text-center"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,28 @@ interface IconButtonProps {
|
|||
children: ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) {
|
||||
const baseClasses = 'p-0.5 rounded';
|
||||
export function IconButton({
|
||||
onClick,
|
||||
title,
|
||||
children,
|
||||
active,
|
||||
className,
|
||||
size = 'sm',
|
||||
}: IconButtonProps) {
|
||||
const padClasses = size === 'md' ? 'p-1' : 'p-0.5';
|
||||
const colorClasses = active
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
|
||||
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default function SaveSearchModal({
|
|||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
|
|
@ -63,7 +63,7 @@ export default function SaveSearchModal({
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="My search"
|
||||
autoFocus
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
|
|||
{open && (
|
||||
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">{user.email}</p>
|
||||
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -4,8 +4,18 @@ interface IconProps {
|
|||
|
||||
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ export function ChevronIcon({
|
|||
down: 'M6 9l6 6 6-6',
|
||||
};
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l-6-6m6 6l6-6" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ interface IconProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { filled: boolean }) {
|
||||
export function EyeIcon({ filled, className = 'w-7 h-7' }: IconProps & { filled: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
|
|
@ -11,8 +11,17 @@ export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { fil
|
|||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" fill={filled ? 'currentColor' : 'none'} />
|
||||
<circle cx="12" cy="12" r="3" fill={filled ? 'currentColor' : 'none'} stroke={filled ? 'white' : 'currentColor'} />
|
||||
<path
|
||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke={filled ? 'white' : 'currentColor'}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@ interface IconProps {
|
|||
|
||||
export function MapPinIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function MoonIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@ interface IconProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
export function PlusIcon({ className = 'w-7 h-7' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
|
|||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ interface IconProps {
|
|||
|
||||
export function SunIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,18 @@ interface IconProps {
|
|||
|
||||
export function TrashIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,21 @@ interface UseAreaSummaryResult {
|
|||
summary: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
const FORBIDDEN_FEATURES = ['% White', '% Black', '% Asian', '% Mixed', '% Other',
|
||||
'Environmental risk', 'Collapsible deposits risk', 'Compressible ground risk', 'Landslide risk', 'Running sand risk', 'Shrink-swell risk', 'Soluble rocks risk'
|
||||
const FORBIDDEN_FEATURES = [
|
||||
'% White',
|
||||
'% Black',
|
||||
'% Asian',
|
||||
'% Mixed',
|
||||
'% Other',
|
||||
'Environmental risk',
|
||||
'Collapsible deposits risk',
|
||||
'Compressible ground risk',
|
||||
'Landslide risk',
|
||||
'Running sand risk',
|
||||
'Shrink-swell risk',
|
||||
'Soluble rocks risk',
|
||||
];
|
||||
|
||||
export function useAreaSummary({
|
||||
|
|
@ -61,23 +71,30 @@ export function useAreaSummary({
|
|||
location: hexagonId,
|
||||
is_postcode: isPostcode,
|
||||
filters: filterDescriptions,
|
||||
numeric_stats: stats.numeric_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
|
||||
name: f.name,
|
||||
mean: f.mean,
|
||||
})),
|
||||
enum_stats: stats.enum_features.filter(f => !FORBIDDEN_FEATURES.includes(f.name)).map((f) => ({
|
||||
name: f.name,
|
||||
counts: f.counts,
|
||||
})),
|
||||
numeric_stats: stats.numeric_features
|
||||
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
mean: f.mean,
|
||||
})),
|
||||
enum_stats: stats.enum_features
|
||||
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
counts: f.counts,
|
||||
})),
|
||||
};
|
||||
|
||||
const url = apiUrl('area-summary');
|
||||
const response = await fetch(url, authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}));
|
||||
const response = await fetch(
|
||||
url,
|
||||
authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
|
@ -102,9 +119,5 @@ export function useAreaSummary({
|
|||
};
|
||||
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const retry = useCallback(() => {
|
||||
fetchSummary();
|
||||
}, [fetchSummary]);
|
||||
|
||||
return { summary, loading, error, retry };
|
||||
return { summary, loading, error };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,5 +113,15 @@ export function useAuth() {
|
|||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { user, loading, error, login, register, loginWithOAuth, logout, requestPasswordReset, clearError };
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}
|
||||
|
||||
const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`;
|
||||
const params = new URLSearchParams({ resolution: resolutionRef.current.toString(), bounds: boundsStr });
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolutionRef.current.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', name);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import { useState, useCallback } from 'react';
|
|||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
PostcodeFeature,
|
||||
Property,
|
||||
HexagonPropertiesResponse,
|
||||
HexagonStatsResponse,
|
||||
NumericFeatureStats,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
|
||||
|
|
@ -19,16 +17,10 @@ interface SelectedHexagon {
|
|||
interface UseHexagonSelectionOptions {
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
postcodeData,
|
||||
resolution,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
export function useHexagonSelection({ filters, features, resolution }: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
|
|
@ -56,31 +48,15 @@ export function useHexagonSelection({
|
|||
[filters, features]
|
||||
);
|
||||
|
||||
const buildPostcodeStats = useCallback(
|
||||
(postcode: string): HexagonStatsResponse | null => {
|
||||
const feat = postcodeData.find((f) => f.properties.postcode === postcode);
|
||||
if (!feat) return null;
|
||||
const props = feat.properties;
|
||||
|
||||
const numeric_features: NumericFeatureStats[] = [];
|
||||
for (const f of features) {
|
||||
if (f.type !== 'numeric') continue;
|
||||
const minVal = props[`min_${f.name}`];
|
||||
const maxVal = props[`max_${f.name}`];
|
||||
if (typeof minVal !== 'number' || typeof maxVal !== 'number') continue;
|
||||
const avgVal = props[`avg_${f.name}`];
|
||||
numeric_features.push({
|
||||
name: f.name,
|
||||
count: props.count,
|
||||
min: minVal,
|
||||
max: maxVal,
|
||||
mean: typeof avgVal === 'number' ? avgVal : (minVal + maxVal) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: props.count, numeric_features, enum_features: [] };
|
||||
const fetchPostcodeStats = useCallback(
|
||||
async (postcode: string, signal?: AbortSignal) => {
|
||||
const params = new URLSearchParams({ postcode });
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.append('filters', filterStr);
|
||||
const response = await fetch(apiUrl('postcode-stats', params), authHeaders({ signal }));
|
||||
return (await response.json()) as HexagonStatsResponse;
|
||||
},
|
||||
[postcodeData, features]
|
||||
[filters, features]
|
||||
);
|
||||
|
||||
const fetchHexagonProperties = useCallback(
|
||||
|
|
@ -131,8 +107,11 @@ export function useHexagonSelection({
|
|||
setRightPaneTab('area');
|
||||
|
||||
if (isPostcode) {
|
||||
setAreaStats(buildPostcodeStats(id));
|
||||
setLoadingAreaStats(false);
|
||||
setLoadingAreaStats(true);
|
||||
fetchPostcodeStats(id)
|
||||
.then((stats) => setAreaStats(stats))
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => setLoadingAreaStats(false));
|
||||
} else {
|
||||
setLoadingAreaStats(true);
|
||||
fetchHexagonStats(id, resolution)
|
||||
|
|
@ -142,7 +121,7 @@ export function useHexagonSelection({
|
|||
}
|
||||
}
|
||||
},
|
||||
[selectedHexagon, resolution, fetchHexagonStats, buildPostcodeStats]
|
||||
[selectedHexagon, resolution, fetchHexagonStats, fetchPostcodeStats]
|
||||
);
|
||||
|
||||
const handleHexagonHover = useCallback((h3: string | null) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { cellToLatLng } from 'h3-js';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
|
|
@ -147,7 +146,8 @@ export function useMapData({
|
|||
for (const feat of postcodeData) {
|
||||
if (bounds) {
|
||||
const [lng, lat] = feat.properties.centroid as [number, number];
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
|
||||
continue;
|
||||
}
|
||||
const val = feat.properties[`avg_${viewFeature}`] ?? feat.properties[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
|
|
@ -156,8 +156,9 @@ export function useMapData({
|
|||
if (data.length === 0) return null;
|
||||
for (const item of data) {
|
||||
if (bounds) {
|
||||
const [lat, lng] = cellToLatLng(item.h3);
|
||||
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) continue;
|
||||
const { lat, lon } = item;
|
||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||
continue;
|
||||
}
|
||||
const val = item[`avg_${viewFeature}`] ?? item[`min_${viewFeature}`];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
|
|
@ -166,7 +167,10 @@ export function useMapData({
|
|||
|
||||
if (vals.length === 0) return null;
|
||||
vals.sort((a, b) => a - b);
|
||||
return [percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE)];
|
||||
return [
|
||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
];
|
||||
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]);
|
||||
|
||||
// Color range for the legend and hex coloring
|
||||
|
|
|
|||
|
|
@ -1,77 +1,11 @@
|
|||
const DOMAIN = 'narrowit.schmelczer.dev';
|
||||
const ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
import { init as plausibleInit } from '@plausible-analytics/tracker';
|
||||
|
||||
type EventOptions = {
|
||||
props?: Record<string, string | number | boolean>;
|
||||
revenue?: { currency: string; amount: number };
|
||||
};
|
||||
|
||||
function sendEvent(name: string, options?: EventOptions) {
|
||||
if (IS_DEV) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
n: name,
|
||||
u: window.location.href,
|
||||
d: DOMAIN,
|
||||
r: document.referrer || null,
|
||||
};
|
||||
if (options?.props) {
|
||||
payload.p = JSON.stringify(options.props);
|
||||
}
|
||||
if (options?.revenue) {
|
||||
payload.$ = JSON.stringify(options.revenue);
|
||||
}
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
ENDPOINT,
|
||||
new Blob([JSON.stringify(payload)], { type: 'application/json' })
|
||||
);
|
||||
} else {
|
||||
fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
}).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Tracks pageview on first call and returns a trackEvent function.
|
||||
* Tracks outbound link clicks automatically.
|
||||
*/
|
||||
export function initPlausible() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Initial pageview
|
||||
sendEvent('pageview');
|
||||
|
||||
// Track outbound link clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = (e.target as HTMLElement).closest?.('a');
|
||||
if (!link) return;
|
||||
const href = link.getAttribute('href');
|
||||
if (!href) return;
|
||||
try {
|
||||
const url = new URL(href, window.location.origin);
|
||||
if (url.hostname !== window.location.hostname) {
|
||||
sendEvent('Outbound Link: Click', { props: { url: href } });
|
||||
}
|
||||
} catch {
|
||||
// invalid URL, ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function trackPageview(options?: EventOptions) {
|
||||
sendEvent('pageview', options);
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, options?: EventOptions) {
|
||||
sendEvent(name, options);
|
||||
}
|
||||
plausibleInit({
|
||||
domain: 'narrowit.schmelczer.dev',
|
||||
endpoint: 'https://stats.schmelczer.dev/status',
|
||||
autoCapturePageviews: true,
|
||||
captureOnLocalhost: true,
|
||||
logging: true,
|
||||
fileDownloads: true,
|
||||
hashBasedRouting: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@ export function useSavedSearches(userId: string | null) {
|
|||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Try to capture a screenshot via the OG image endpoint
|
||||
// Try to capture a screenshot via the screenshot endpoint
|
||||
let screenshotBlob: Blob | null = null;
|
||||
try {
|
||||
const ogUrl = apiUrl('og-image', new URLSearchParams(params));
|
||||
const res = await fetch(ogUrl);
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const res = await fetch(screenshotUrl);
|
||||
if (res.ok) {
|
||||
screenshotBlob = await res.blob();
|
||||
}
|
||||
|
|
@ -84,18 +84,15 @@ export function useSavedSearches(userId: string | null) {
|
|||
[userId, fetchSearches]
|
||||
);
|
||||
|
||||
const deleteSearch = useCallback(
|
||||
async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete search');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const deleteSearch = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_searches').delete(id);
|
||||
setSearches((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete search');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,4 +53,3 @@ h3 {
|
|||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { initPlausible } from './hooks/usePlausible';
|
||||
|
||||
initPlausible();
|
||||
import './hooks/usePlausible';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import type { ViewState } from '../types';
|
||||
|
||||
|
||||
export const INITIAL_RETRY_MS = 1000;
|
||||
export const MAX_RETRY_MS = 10000;
|
||||
|
||||
|
||||
/** Lower percentile for color-range clipping (0–100) */
|
||||
export const COLOR_RANGE_LOW_PERCENTILE = 5;
|
||||
/** Upper percentile for color-range clipping (0–100) */
|
||||
|
|
@ -22,7 +20,6 @@ export const INITIAL_VIEW_STATE: ViewState = {
|
|||
pitch: 0,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Zoom to H3 resolution mapping thresholds.
|
||||
* Returns the H3 resolution to use for a given zoom level.
|
||||
|
|
@ -38,8 +35,6 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
|
||||
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
{ t: 0.33, color: [241, 196, 15] },
|
||||
|
|
@ -71,22 +66,23 @@ export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf';
|
|||
/** Twemoji base URL (served locally from public/assets/) */
|
||||
export const TWEMOJI_BASE = '/assets/twemoji/';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Groups whose features should be collapsed into stacked bar charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked chart.
|
||||
*/
|
||||
export const STACKED_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
feature?: string;
|
||||
/** Suffix shown after the total value (e.g. "avg/yr") */
|
||||
unit?: string;
|
||||
/** Feature names that make up the segments */
|
||||
components: string[];
|
||||
}[]> = {
|
||||
export const STACKED_GROUPS: Record<
|
||||
string,
|
||||
{
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
|
||||
feature?: string;
|
||||
/** Suffix shown after the total value (e.g. "avg/yr") */
|
||||
unit?: string;
|
||||
/** Feature names that make up the segments */
|
||||
components: string[];
|
||||
}[]
|
||||
> = {
|
||||
Crime: [
|
||||
{
|
||||
label: 'Serious crime',
|
||||
|
|
@ -130,18 +126,21 @@ export const STACKED_GROUPS: Record<string, {
|
|||
* Groups whose enum features should be collapsed into compact multi-row charts.
|
||||
* Keyed by feature group name. Each entry defines one stacked enum chart.
|
||||
*/
|
||||
export const STACKED_ENUM_GROUPS: Record<string, {
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature for the info popup */
|
||||
feature?: string;
|
||||
/** Enum feature names that make up the rows */
|
||||
components: string[];
|
||||
/** Value order for consistent segment ordering */
|
||||
valueOrder: string[];
|
||||
/** Colors for each value (matches valueOrder) */
|
||||
valueColors: string[];
|
||||
}[]> = {
|
||||
export const STACKED_ENUM_GROUPS: Record<
|
||||
string,
|
||||
{
|
||||
/** Display label for the chart */
|
||||
label: string;
|
||||
/** If set, use this feature for the info popup */
|
||||
feature?: string;
|
||||
/** Enum feature names that make up the rows */
|
||||
components: string[];
|
||||
/** Value order for consistent segment ordering */
|
||||
valueOrder: string[];
|
||||
/** Colors for each value (matches valueOrder) */
|
||||
valueColors: string[];
|
||||
}[]
|
||||
> = {
|
||||
Property: [
|
||||
{
|
||||
label: 'Property type',
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@ import {
|
|||
GLYPHS_URL,
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ZOOM_TO_RESOLUTION_THRESHOLDS,
|
||||
TWEMOJI_BASE,
|
||||
BUFFER_MULTIPLIER,
|
||||
} from './consts';
|
||||
|
||||
// Re-export constants for backwards compatibility
|
||||
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, POSTCODE_ZOOM_THRESHOLD } from './consts';
|
||||
export {
|
||||
FEATURE_GRADIENT as GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from './consts';
|
||||
|
||||
const ROAD_OPACITY = 0.4;
|
||||
|
||||
|
|
@ -25,31 +29,33 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
|
||||
// Reduce road layer opacity so hexagons are more visible
|
||||
// In dark mode, make all text white with dark outline
|
||||
const modifiedLayers = baseLayers.map((layer) => {
|
||||
// Modify road opacity
|
||||
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
|
||||
if (layer.type === 'line') {
|
||||
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
|
||||
} else if (layer.type === 'fill') {
|
||||
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
|
||||
const modifiedLayers = baseLayers
|
||||
.filter((layer) => !layer.id.includes('buildings'))
|
||||
.map((layer) => {
|
||||
// Modify road opacity
|
||||
if (layer.id.includes('roads_') || layer.id.includes('road_')) {
|
||||
if (layer.type === 'line') {
|
||||
return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } };
|
||||
} else if (layer.type === 'fill') {
|
||||
return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modify text colors in dark mode
|
||||
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Modify text colors in dark mode
|
||||
if (isDark && layer.type === 'symbol' && layer.paint?.['text-color']) {
|
||||
return {
|
||||
...layer,
|
||||
paint: {
|
||||
...layer.paint,
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#1a1a1a',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return layer;
|
||||
});
|
||||
return layer;
|
||||
});
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
|
|
@ -88,23 +94,25 @@ function rgbToOklab(rgb: [number, number, number]): [number, number, number] {
|
|||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
|
||||
return [
|
||||
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
||||
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function oklabToRgb(lab: [number, number, number]): [number, number, number] {
|
||||
const L = lab[0], a = lab[1], b = lab[2];
|
||||
const L = lab[0],
|
||||
a = lab[1],
|
||||
b = lab[2];
|
||||
|
||||
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
|
||||
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
|
||||
const s = Math.pow(L - 0.0894841775 * a - 1.2914855480 * b, 3);
|
||||
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
|
||||
|
||||
return [
|
||||
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
|
||||
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +142,10 @@ export function normalizedToColor(t: number): [number, number, number] {
|
|||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
}
|
||||
|
||||
export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] {
|
||||
export function countToColor(
|
||||
t: number,
|
||||
gradient: GradientStop[] = DENSITY_GRADIENT
|
||||
): [number, number, number] {
|
||||
return interpolateGradient(t, gradient);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,15 +111,16 @@ export function summarizeParams(queryString: string): string {
|
|||
|
||||
const f = params.get('f');
|
||||
if (f) {
|
||||
const filterNames = f.split(',').map((seg) => {
|
||||
const colonIdx = seg.indexOf(':');
|
||||
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
|
||||
}).filter(Boolean);
|
||||
const filterNames = f
|
||||
.split(',')
|
||||
.map((seg) => {
|
||||
const colonIdx = seg.indexOf(':');
|
||||
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2
|
||||
? filterNames.join(', ')
|
||||
: `${filterNames.length} filters`
|
||||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export type FeatureFilters = Record<string, [number, number] | string[]>;
|
|||
export interface HexagonData {
|
||||
h3: string;
|
||||
count: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
[key: string]: string | number | null;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +42,10 @@ export interface PostcodeProperties {
|
|||
[key: string]: string | number | [number, number] | null;
|
||||
}
|
||||
|
||||
export type PostcodeFeature = GeoJSON.Feature<GeoJSON.Polygon | GeoJSON.MultiPolygon, PostcodeProperties>;
|
||||
export type PostcodeFeature = GeoJSON.Feature<
|
||||
GeoJSON.Polygon | GeoJSON.MultiPolygon,
|
||||
PostcodeProperties
|
||||
>;
|
||||
|
||||
export type PostcodeGeometry = GeoJSON.Polygon | GeoJSON.MultiPolygon;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ from pathlib import Path
|
|||
from shapely.geometry import MultiPolygon, Polygon
|
||||
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 .oa_boundaries import load_oa_boundaries
|
||||
from .output import merge_fragments, write_district_geojson
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None:
|
|||
if len(polys) == 1:
|
||||
return polys[0]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -167,9 +167,7 @@ class TestVoronoiDeduplication:
|
|||
|
||||
def test_int64_coords_jitter_works(self, square_boundary):
|
||||
"""Int64 coords (production dtype) must still jitter correctly."""
|
||||
points = np.array(
|
||||
[[500050, 180050], [500050, 180050]], dtype=np.int64
|
||||
)
|
||||
points = np.array([[500050, 180050], [500050, 180050]], dtype=np.int64)
|
||||
postcodes = ["A", "B"]
|
||||
result = compute_voronoi_regions(points, postcodes, square_boundary)
|
||||
assert "A" in result, "Postcode A missing with int64 coords"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ def compute_voronoi_regions(
|
|||
else:
|
||||
# Tiny jitter so Voronoi sees distinct points (0.01m per step)
|
||||
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[1] += 0.01 * np.sin(angle)
|
||||
unique_pts.append(jittered)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ description = "Add your description here"
|
|||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"attrs>=22.2.0",
|
||||
"httpx[socks]>=0.28.1",
|
||||
"ipywidgets>=8.0.0",
|
||||
"jupyter>=1.0.0",
|
||||
|
|
@ -14,7 +13,6 @@ dependencies = [
|
|||
"plotly>=6.5.2",
|
||||
"polars>=1.37.1",
|
||||
"pyarrow>=15.0.0",
|
||||
"python-dateutil>=2.8.0",
|
||||
"tqdm>=4.67.1",
|
||||
"fastexcel>=0.19.0",
|
||||
"osmium>=4.0.0",
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
|
|||
});
|
||||
|
||||
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, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
|
|
@ -143,12 +143,12 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
|
|||
|
||||
// Wait for the frontend to signal readiness
|
||||
try {
|
||||
await page.waitForFunction('window.__og_ready === true', {
|
||||
await page.waitForFunction('window.__screenshot_ready === true', {
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
console.log('Frontend signalled ready');
|
||||
} 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
|
||||
|
|
|
|||
1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
|
|
@ -2375,7 +2375,6 @@ dependencies = [
|
|||
"pmtiles",
|
||||
"polars",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rust_xlsxwriter",
|
||||
"rustc-hash",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
|||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
||||
regex = "1"
|
||||
urlencoding = "2"
|
||||
rust_xlsxwriter = "0.79"
|
||||
pmtiles = { version = "0.12", features = ["mmap-async-tokio"] }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
|||
|
||||
pub const GRID_CELL_SIZE: f32 = 0.01;
|
||||
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 MAX_PROPERTIES_LIMIT: usize = 500;
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
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());
|
||||
|
||||
|
|
@ -273,10 +276,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
let state_poi_categories = state.clone();
|
||||
let state_hexagon_properties = 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_crawler = state.clone();
|
||||
let state_pb = state.clone();
|
||||
let state_postcode_stats = state.clone();
|
||||
let state_area_summary = state.clone();
|
||||
|
||||
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)),
|
||||
)
|
||||
.route(
|
||||
"/api/og-image",
|
||||
get(move |query| routes::get_og_image(state_og_image.clone(), query)),
|
||||
"/api/postcode-stats",
|
||||
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(
|
||||
"/api/export",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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 {
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use axum::http::StatusCode;
|
|||
/// Check if two bounding boxes intersect.
|
||||
/// Both boxes are (south, west, north, east) / (min_lat, min_lon, max_lat, max_lon).
|
||||
#[inline]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bounds_intersect(
|
||||
a_south: f64,
|
||||
a_west: f64,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ mod features;
|
|||
mod hexagon_stats;
|
||||
pub(crate) mod hexagons;
|
||||
mod me;
|
||||
mod og_image;
|
||||
mod pb_proxy;
|
||||
mod pois;
|
||||
mod postcode_stats;
|
||||
mod postcodes;
|
||||
pub(crate) mod properties;
|
||||
mod screenshot;
|
||||
mod tiles;
|
||||
|
||||
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 hexagons::get_hexagons;
|
||||
pub use me::get_me;
|
||||
pub use og_image::get_og_image;
|
||||
pub use pb_proxy::proxy_to_pocketbase;
|
||||
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 properties::get_hexagon_properties;
|
||||
pub use screenshot::get_screenshot;
|
||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use crate::routes::FeatureInfo;
|
|||
use crate::state::AppState;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -152,7 +152,7 @@ pub async fn get_export(
|
|||
|
||||
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_lon = (west + east) / 2.0;
|
||||
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);
|
||||
|
||||
// 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
|
||||
let feature_descriptions: FxHashMap<String, String> = state
|
||||
|
|
@ -335,16 +335,16 @@ pub async fn get_export(
|
|||
.set_row_format(0, &link_fmt)
|
||||
.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;
|
||||
if let Some(ref img_bytes) = og_image_bytes {
|
||||
if let Some(ref img_bytes) = screenshot_bytes {
|
||||
match Image::new_from_buffer(img_bytes) {
|
||||
Ok(mut image) => {
|
||||
// Scale image to fit: ~400px wide, auto height preserving aspect ratio
|
||||
image = image.set_scale_to_size(400, 300, true);
|
||||
sheet
|
||||
.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
|
||||
sheet
|
||||
.set_row_height(current_row, IMAGE_ROW_HEIGHT)
|
||||
|
|
@ -352,7 +352,7 @@ pub async fn get_export(
|
|||
current_row += 1;
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -479,7 +479,7 @@ pub async fn get_export(
|
|||
postcodes = postcode_aggs.len(),
|
||||
sampled = was_sampled,
|
||||
features = all_feature_indices.len(),
|
||||
has_og_image = og_image_bytes.is_some(),
|
||||
has_screenshot = screenshot_bytes.is_some(),
|
||||
bytes = buf.len(),
|
||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||
"GET /api/export"
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ use crate::data::{Histogram, PropertyData};
|
|||
use crate::features::{ENUM_FEATURE_GROUPS, FEATURE_GROUPS};
|
||||
use crate::state::AppState;
|
||||
|
||||
fn is_empty(v: &str) -> bool {
|
||||
v.is_empty()
|
||||
fn is_empty(val: &str) -> bool {
|
||||
val.is_empty()
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!v
|
||||
fn is_false(val: &bool) -> bool {
|
||||
!val
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
|
|
|
|||
|
|
@ -14,44 +14,44 @@ use crate::state::AppState;
|
|||
|
||||
#[derive(Serialize)]
|
||||
pub struct HistogramStats {
|
||||
min: f64,
|
||||
max: f64,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
/// 1st percentile (left edge of main distribution)
|
||||
p1: f64,
|
||||
pub p1: f64,
|
||||
/// 99th percentile (right edge of main distribution)
|
||||
p99: f64,
|
||||
counts: Vec<u64>,
|
||||
pub p99: f64,
|
||||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NumericFeatureStats {
|
||||
name: String,
|
||||
count: usize,
|
||||
min: f64,
|
||||
max: f64,
|
||||
mean: f64,
|
||||
histogram: HistogramStats,
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub mean: f64,
|
||||
pub histogram: HistogramStats,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EnumFeatureStats {
|
||||
name: String,
|
||||
counts: HashMap<String, u64>,
|
||||
pub name: String,
|
||||
pub counts: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PricePoint {
|
||||
year: f32,
|
||||
price: f32,
|
||||
pub year: f32,
|
||||
pub price: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HexagonStatsResponse {
|
||||
count: usize,
|
||||
numeric_features: Vec<NumericFeatureStats>,
|
||||
enum_features: Vec<EnumFeatureStats>,
|
||||
pub count: usize,
|
||||
pub numeric_features: Vec<NumericFeatureStats>,
|
||||
pub enum_features: Vec<EnumFeatureStats>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
price_history: Vec<PricePoint>,
|
||||
pub price_history: Vec<PricePoint>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -158,7 +158,10 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
// Collect price history (year, price) pairs
|
||||
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();
|
||||
match (year_idx, price_idx) {
|
||||
(Some(yi), Some(pi)) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Map, Value};
|
||||
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::{
|
||||
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 features = build_feature_maps(
|
||||
let mut features = build_feature_maps(
|
||||
&groups,
|
||||
min_keys,
|
||||
max_keys,
|
||||
|
|
@ -303,11 +303,17 @@ pub async fn get_hexagons(
|
|||
(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();
|
||||
info!(
|
||||
resolution,
|
||||
cells_before_filter = groups.len(),
|
||||
cells_after_filter = features.len(),
|
||||
truncated,
|
||||
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
|
|
|
|||
268
server-rs/src/routes/postcode_stats.rs
Normal file
268
server-rs/src/routes/postcode_stats.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Map, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::consts::MAX_CELLS_PER_REQUEST;
|
||||
use crate::parsing::{bounds_intersect, parse_bounds, parse_filters, row_passes_filters};
|
||||
use crate::state::AppState;
|
||||
|
||||
|
|
@ -282,7 +283,8 @@ pub async fn get_postcodes(
|
|||
|
||||
for feat_index in iter {
|
||||
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)) = (
|
||||
serde_json::Number::from_f64(aggregation.mins[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));
|
||||
|
||||
features.push(feature);
|
||||
|
||||
if features.len() >= MAX_CELLS_PER_REQUEST {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let truncated = features.len() >= MAX_CELLS_PER_REQUEST;
|
||||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
postcodes_before_filter,
|
||||
postcodes_after_filter = features.len(),
|
||||
filtered_out,
|
||||
truncated,
|
||||
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use tracing::{info, warn};
|
|||
use crate::state::AppState;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct OgImageQuery {
|
||||
pub struct ScreenshotQuery {
|
||||
#[serde(rename = "v")]
|
||||
view: Option<String>,
|
||||
#[serde(rename = "f")]
|
||||
|
|
@ -19,9 +19,9 @@ pub struct OgImageQuery {
|
|||
og: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_og_image(
|
||||
pub async fn get_screenshot(
|
||||
state: Arc<AppState>,
|
||||
Query(query): Query<OgImageQuery>,
|
||||
Query(query): Query<ScreenshotQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let screenshot_base = &state.screenshot_url;
|
||||
|
||||
|
|
@ -12,9 +12,9 @@ pub type TileReader = AsyncPmTilesReader<MmapBackend>;
|
|||
|
||||
pub async fn get_tile(
|
||||
State(reader): State<Arc<TileReader>>,
|
||||
Path((z, x, y)): Path<(u8, u32, u32)>,
|
||||
Path((zoom, col, row)): Path<(u8, u32, u32)>,
|
||||
) -> 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)) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
|
|
@ -27,7 +27,7 @@ pub async fn get_tile(
|
|||
.into_response(),
|
||||
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ pub async fn get_style(
|
|||
|
||||
// Parse the JSON string
|
||||
let metadata: serde_json::Value = match serde_json::from_str(&metadata_str) {
|
||||
Ok(v) => v,
|
||||
Ok(val) => val,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to parse PMTiles metadata JSON");
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
|
|
@ -67,14 +67,14 @@ pub async fn get_style(
|
|||
// Extract tilestats for layer info if available
|
||||
let layers: Vec<serde_json::Value> = metadata
|
||||
.get("vector_layers")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|vl| vl.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build absolute tile URL using the request host
|
||||
let host = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|hv| hv.to_str().ok())
|
||||
.unwrap_or("localhost:8001");
|
||||
let tile_url = format!("http://{}/api/tiles/{{z}}/{{x}}/{{y}}", host);
|
||||
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
|
||||
let layer_ids: Vec<&str> = layers
|
||||
.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();
|
||||
|
||||
let mut style_layers = vec![serde_json::json!({
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -1339,7 +1339,6 @@ name = "property-map"
|
|||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
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 = "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'" },
|
||||
|
|
@ -1353,7 +1352,6 @@ dependencies = [
|
|||
{ 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 = "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 = "scipy", 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]
|
||||
requires-dist = [
|
||||
{ name = "attrs", specifier = ">=22.2.0" },
|
||||
{ name = "fastexcel", specifier = ">=0.19.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
||||
{ name = "ipywidgets", specifier = ">=8.0.0" },
|
||||
|
|
@ -1384,7 +1381,6 @@ requires-dist = [
|
|||
{ name = "pyarrow", specifier = ">=15.0.0" },
|
||||
{ name = "pyproj", specifier = ">=3.7.2" },
|
||||
{ name = "pyshp", specifier = ">=2.3.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.8.0" },
|
||||
{ name = "rasterio", specifier = ">=1.5.0" },
|
||||
{ name = "scipy", specifier = ">=1.17.0" },
|
||||
{ name = "shapely", specifier = ">=2.0.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue