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