Good changes
This commit is contained in:
parent
d39d1b15fd
commit
1f68ca0512
23 changed files with 670 additions and 289 deletions
|
|
@ -23,6 +23,7 @@ services:
|
||||||
POCKETBASE_URL: http://pocketbase:8090
|
POCKETBASE_URL: http://pocketbase:8090
|
||||||
SCREENSHOT_URL: http://screenshot:8002
|
SCREENSHOT_URL: http://screenshot:8002
|
||||||
OLLAMA_URL: http://host.docker.internal:11434
|
OLLAMA_URL: http://host.docker.internal:11434
|
||||||
|
R5_URL: http://r5:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
pocketbase:
|
pocketbase:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -84,12 +85,32 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
|
r5:
|
||||||
|
build: ./r5-service
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
networks:
|
||||||
|
- dev-network
|
||||||
|
volumes:
|
||||||
|
- ./property-data/transit:/data/transit
|
||||||
|
- r5-cache:/root/.cache/r5py
|
||||||
|
environment:
|
||||||
|
TRANSIT_DATA_DIR: /data/transit
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8003/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 600s
|
||||||
|
init: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pb-data:
|
pb-data:
|
||||||
cargo-registry:
|
cargo-registry:
|
||||||
cargo-target:
|
cargo-target:
|
||||||
frontend-node-modules:
|
frontend-node-modules:
|
||||||
screenshot-cache:
|
screenshot-cache:
|
||||||
|
r5-cache:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dev-network:
|
dev-network:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import MapPage, { type ExportState } from './components/map/MapPage';
|
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||||
import DataSourcesPage from './components/data-sources/DataSourcesPage';
|
import LearnPage from './components/learn/LearnPage';
|
||||||
import FAQPage from './components/faq/FAQPage';
|
|
||||||
import PricingPage from './components/pricing/PricingPage';
|
import PricingPage from './components/pricing/PricingPage';
|
||||||
import HomePage from './components/home/HomePage';
|
import HomePage from './components/home/HomePage';
|
||||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||||
|
|
@ -27,10 +26,8 @@ function pageToPath(page: Page): string {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return '/dashboard';
|
return '/dashboard';
|
||||||
case 'data-sources':
|
case 'learn':
|
||||||
return '/data-sources';
|
return '/learn';
|
||||||
case 'faq':
|
|
||||||
return '/faq';
|
|
||||||
case 'saved-searches':
|
case 'saved-searches':
|
||||||
return '/saved';
|
return '/saved';
|
||||||
case 'pricing':
|
case 'pricing':
|
||||||
|
|
@ -42,8 +39,7 @@ function pageToPath(page: Page): string {
|
||||||
|
|
||||||
function pathToPage(pathname: string): Page | null {
|
function pathToPage(pathname: string): Page | null {
|
||||||
if (pathname === '/dashboard') return 'dashboard';
|
if (pathname === '/dashboard') return 'dashboard';
|
||||||
if (pathname === '/data-sources') return 'data-sources';
|
if (pathname === '/learn') return 'learn';
|
||||||
if (pathname === '/faq') return 'faq';
|
|
||||||
if (pathname === '/saved') return 'saved-searches';
|
if (pathname === '/saved') return 'saved-searches';
|
||||||
if (pathname === '/pricing') return 'pricing';
|
if (pathname === '/pricing') return 'pricing';
|
||||||
if (pathname === '/') return 'home';
|
if (pathname === '/') return 'home';
|
||||||
|
|
@ -85,7 +81,7 @@ export default function App() {
|
||||||
|
|
||||||
// Backward compat: dashboard params on unknown path
|
// Backward compat: dashboard params on unknown path
|
||||||
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('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f')) {
|
||||||
// Rewrite URL to /dashboard keeping query params
|
// Rewrite URL to /dashboard keeping query params
|
||||||
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
|
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
|
|
@ -240,10 +236,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
{activePage === 'home' ? (
|
{activePage === 'home' ? (
|
||||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
||||||
) : activePage === 'data-sources' ? (
|
) : activePage === 'learn' ? (
|
||||||
<DataSourcesPage />
|
<LearnPage />
|
||||||
) : activePage === 'faq' ? (
|
|
||||||
<FAQPage />
|
|
||||||
) : activePage === 'pricing' ? (
|
) : activePage === 'pricing' ? (
|
||||||
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
||||||
) : activePage === 'saved-searches' ? (
|
) : activePage === 'saved-searches' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import type { PostcodeGeometry } from '../../types';
|
import type { PostcodeGeometry } from '../../types';
|
||||||
import { authHeaders } from '../../lib/api';
|
import { authHeaders } from '../../lib/api';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||||
|
|
||||||
export interface SearchedPostcode {
|
export interface SearchedPostcode {
|
||||||
postcode: string;
|
postcode: string;
|
||||||
|
|
@ -17,6 +19,29 @@ export default function PostcodeSearch({
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click (mobile only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || !expanded) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [isMobile, expanded]);
|
||||||
|
|
||||||
|
// Focus input when expanding on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && expanded) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isMobile, expanded]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
|
|
@ -41,19 +66,39 @@ export default function PostcodeSearch({
|
||||||
onFlyTo(json.latitude, json.longitude, 16);
|
onFlyTo(json.latitude, json.longitude, 16);
|
||||||
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
if (isMobile) setExpanded(false);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Lookup failed');
|
setError('Lookup failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[query, onFlyTo, onPostcodeSearched]
|
[query, onFlyTo, onPostcodeSearched, isMobile]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile collapsed state: just a search icon button
|
||||||
|
if (isMobile && !expanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
||||||
|
aria-label="Search postcode"
|
||||||
|
>
|
||||||
|
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
|
||||||
|
>
|
||||||
<div className="flex shadow-lg rounded overflow-hidden">
|
<div className="flex shadow-lg rounded overflow-hidden">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ export function FeatureActions({
|
||||||
active={isPinned}
|
active={isPinned}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<EyeIcon filled={isPinned} />
|
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
|
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
|
||||||
<PlusIcon />
|
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { AuthUser } from '../../hooks/useAuth';
|
import type { AuthUser } from '../../hooks/useAuth';
|
||||||
|
import { shortenUrl } from '../../lib/api';
|
||||||
import { DownloadIcon } from './icons/DownloadIcon';
|
import { DownloadIcon } from './icons/DownloadIcon';
|
||||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||||
import { LogoIcon } from './icons/LogoIcon';
|
import { LogoIcon } from './icons/LogoIcon';
|
||||||
|
|
@ -12,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
import MobileMenu from './MobileMenu';
|
import MobileMenu from './MobileMenu';
|
||||||
|
|
||||||
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing';
|
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing';
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
activePage,
|
activePage,
|
||||||
|
|
@ -44,6 +45,7 @@ export default function Header({
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Close menu on Escape
|
// Close menu on Escape
|
||||||
|
|
@ -61,17 +63,16 @@ export default function Header({
|
||||||
if (!isMobile) setMenuOpen(false);
|
if (!isMobile) setMenuOpen(false);
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handleShare = useCallback(() => {
|
const copyToClipboard = useCallback((text: string) => {
|
||||||
const url = window.location.href;
|
|
||||||
const onSuccess = () => {
|
const onSuccess = () => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(url).then(onSuccess);
|
navigator.clipboard.writeText(text).then(onSuccess);
|
||||||
} else {
|
} else {
|
||||||
const ta = document.createElement('textarea');
|
const ta = document.createElement('textarea');
|
||||||
ta.value = url;
|
ta.value = text;
|
||||||
ta.style.position = 'fixed';
|
ta.style.position = 'fixed';
|
||||||
ta.style.opacity = '0';
|
ta.style.opacity = '0';
|
||||||
document.body.appendChild(ta);
|
document.body.appendChild(ta);
|
||||||
|
|
@ -82,6 +83,23 @@ export default function Header({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleShare = useCallback(async () => {
|
||||||
|
const params = window.location.search.replace(/^\?/, '');
|
||||||
|
if (!params) {
|
||||||
|
copyToClipboard(window.location.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
const shortUrl = await shortenUrl(params);
|
||||||
|
copyToClipboard(shortUrl);
|
||||||
|
} catch {
|
||||||
|
copyToClipboard(window.location.href);
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
}, [copyToClipboard]);
|
||||||
|
|
||||||
const tabClass = (page: Page) =>
|
const tabClass = (page: Page) =>
|
||||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||||
activePage === page
|
activePage === page
|
||||||
|
|
@ -98,7 +116,7 @@ export default function Header({
|
||||||
onClick={() => onPageChange('home')}
|
onClick={() => onPageChange('home')}
|
||||||
>
|
>
|
||||||
<LogoIcon className="w-5 h-5 text-teal-400" />
|
<LogoIcon className="w-5 h-5 text-teal-400" />
|
||||||
<span className="font-semibold text-lg">Perfect Postcodes</span>
|
<span className="font-semibold text-lg">Perfect Postcode</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
|
|
@ -115,15 +133,6 @@ export default function Header({
|
||||||
Saved
|
Saved
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
className={tabClass('data-sources')}
|
|
||||||
onClick={() => onPageChange('data-sources')}
|
|
||||||
>
|
|
||||||
Data Sources
|
|
||||||
</button>
|
|
||||||
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
|
|
||||||
FAQ
|
|
||||||
</button>
|
|
||||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||||
Pricing
|
Pricing
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -152,9 +161,15 @@ export default function Header({
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
disabled={sharing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{sharing ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||||
|
Sharing...
|
||||||
|
</>
|
||||||
|
) : copied ? (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="w-4 h-4" />
|
<CheckIcon className="w-4 h-4" />
|
||||||
Copied!
|
Copied!
|
||||||
|
|
@ -255,6 +270,13 @@ export default function Header({
|
||||||
copied={copied}
|
copied={copied}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Mobile "Copied" toast */}
|
||||||
|
{isMobile && copied && (
|
||||||
|
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||||
|
<CheckIcon className="w-4 h-4 text-teal-400" />
|
||||||
|
Copied to clipboard
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,9 @@ export default function MobileMenu({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
|
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
|
||||||
|
{mobileNavItem('home', 'Home')}
|
||||||
{mobileNavItem('dashboard', 'Dashboard')}
|
{mobileNavItem('dashboard', 'Dashboard')}
|
||||||
{user && mobileNavItem('saved-searches', 'Saved')}
|
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||||
{mobileNavItem('data-sources', 'Data Sources')}
|
|
||||||
{mobileNavItem('faq', 'FAQ')}
|
|
||||||
{mobileNavItem('pricing', 'Pricing')}
|
{mobileNavItem('pricing', 'Pricing')}
|
||||||
|
|
||||||
{/* Dashboard actions */}
|
{/* Dashboard actions */}
|
||||||
|
|
|
||||||
16
frontend/src/components/ui/PillGroup.tsx
Normal file
16
frontend/src/components/ui/PillGroup.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PillGroupProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PillGroup({ children, className = '' }: PillGroupProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-nowrap overflow-x-auto gap-1.5 md:flex-wrap md:overflow-x-visible scrollbar-hide ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/ui/PillToggle.tsx
Normal file
34
frontend/src/components/ui/PillToggle.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
interface PillToggleProps {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
/** Visual hint for partial selection (e.g. some children selected) */
|
||||||
|
indeterminate?: boolean;
|
||||||
|
size?: 'sm' | 'xs';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PillToggle({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
indeterminate,
|
||||||
|
size = 'sm',
|
||||||
|
}: PillToggleProps) {
|
||||||
|
const sizeClasses = size === 'xs' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm';
|
||||||
|
|
||||||
|
const colorClasses = active
|
||||||
|
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||||
|
: indeterminate
|
||||||
|
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300'
|
||||||
|
: 'bg-warm-100 text-warm-600 hover:bg-warm-200 dark:bg-warm-700 dark:text-warm-300 dark:hover:bg-warm-600';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${sizeClasses} ${colorClasses} rounded-full font-medium whitespace-nowrap cursor-pointer`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>Perfect Postcodes — Every neighbourhood in England & Wales</title>
|
<meta name="theme-color" content="#0f1528" />
|
||||||
|
<title>Perfect Postcode — Every neighbourhood in England & Wales</title>
|
||||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
|
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
|
||||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__" />
|
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
if (localStorage.getItem('theme') === 'dark') {
|
if (localStorage.getItem('theme') === 'dark') {
|
||||||
|
|
|
||||||
135
pipeline/download/greenspace_water.py
Normal file
135
pipeline/download/greenspace_water.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Extract park and water body polygons from OSM PBF for postcode boundary trimming.
|
||||||
|
|
||||||
|
Uses pyosmium's FileProcessor with area assembly to convert OSM ways/relations
|
||||||
|
into Shapely polygons, reprojects to BNG (EPSG:27700), and saves as parquet.
|
||||||
|
|
||||||
|
Reuses the same great-britain-latest.osm.pbf as pois.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import osmium
|
||||||
|
import polars as pl
|
||||||
|
from pyproj import Transformer
|
||||||
|
from shapely import wkb
|
||||||
|
from shapely.geometry import MultiPolygon, Polygon
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from .pois import download_pbf
|
||||||
|
|
||||||
|
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
|
||||||
|
|
||||||
|
# OSM tags that indicate non-residential green/water areas
|
||||||
|
GREENSPACE_TAGS = {
|
||||||
|
"leisure": {"park", "nature_reserve", "garden", "common"},
|
||||||
|
"landuse": {"forest"},
|
||||||
|
"natural": {"wood", "water", "wetland"},
|
||||||
|
"waterway": {"riverbank"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_to_bng = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bng_polygon(geom):
|
||||||
|
"""Reproject a WGS84 Shapely geometry to BNG (EPSG:27700)."""
|
||||||
|
|
||||||
|
def transform_ring(coords):
|
||||||
|
xs, ys = zip(*coords)
|
||||||
|
bng_x, bng_y = _to_bng.transform(list(xs), list(ys))
|
||||||
|
return list(zip(bng_x, bng_y))
|
||||||
|
|
||||||
|
if geom.geom_type == "Polygon":
|
||||||
|
exterior = transform_ring(geom.exterior.coords)
|
||||||
|
holes = [transform_ring(h.coords) for h in geom.interiors]
|
||||||
|
return Polygon(exterior, holes)
|
||||||
|
elif geom.geom_type == "MultiPolygon":
|
||||||
|
parts = []
|
||||||
|
for p in geom.geoms:
|
||||||
|
exterior = transform_ring(p.exterior.coords)
|
||||||
|
holes = [transform_ring(h.coords) for h in p.interiors]
|
||||||
|
parts.append(Polygon(exterior, holes))
|
||||||
|
return MultiPolygon(parts)
|
||||||
|
return geom
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_tags(tags):
|
||||||
|
"""Check if an OSM element's tags match our greenspace/water criteria."""
|
||||||
|
for key, values in GREENSPACE_TAGS.items():
|
||||||
|
if key in tags and tags[key] in values:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class GreenspaceHandler(osmium.SimpleHandler):
|
||||||
|
"""Collects area geometries matching greenspace/water tags."""
|
||||||
|
|
||||||
|
def __init__(self, progress):
|
||||||
|
super().__init__()
|
||||||
|
self._wkb_factory = osmium.geom.WKBFactory()
|
||||||
|
self._progress = progress
|
||||||
|
self.geometries = []
|
||||||
|
|
||||||
|
def area(self, a):
|
||||||
|
self._progress.update(1)
|
||||||
|
if not _matches_tags(a.tags):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wkb_data = self._wkb_factory.create_multipolygon(a)
|
||||||
|
geom = wkb.loads(wkb_data, hex=True)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if geom.is_empty or not geom.is_valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reproject to BNG for area calculation
|
||||||
|
bng_geom = _to_bng_polygon(geom)
|
||||||
|
if bng_geom.area < MIN_AREA_SQM:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.geometries.append(bng_geom.wkb)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Extract park/water polygons from OSM PBF"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, required=True, help="Output parquet file path"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pbf", type=Path, default=None, help="Path to existing PBF file"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.pbf and args.pbf.exists():
|
||||||
|
pbf_file = args.pbf
|
||||||
|
print(f"Using existing PBF: {pbf_file}")
|
||||||
|
else:
|
||||||
|
pbf_file = Path("data/great-britain-latest.osm.pbf")
|
||||||
|
if not pbf_file.exists():
|
||||||
|
download_pbf(pbf_file)
|
||||||
|
else:
|
||||||
|
print(f"Using cached PBF: {pbf_file}")
|
||||||
|
|
||||||
|
print("Extracting greenspace/water areas from PBF (two-pass area assembly)...")
|
||||||
|
with tqdm(unit=" areas", unit_scale=True, desc="Processing", smoothing=0.05) as progress:
|
||||||
|
handler = GreenspaceHandler(progress)
|
||||||
|
handler.apply_file(str(pbf_file), locations=True)
|
||||||
|
|
||||||
|
print(f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm")
|
||||||
|
|
||||||
|
# Merge overlapping geometries per 10km grid cell for efficiency
|
||||||
|
if handler.geometries:
|
||||||
|
# Save WKB geometries directly
|
||||||
|
df = pl.DataFrame({"geometry": handler.geometries})
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
df.write_parquet(args.output)
|
||||||
|
print(f"Saved to {args.output}")
|
||||||
|
else:
|
||||||
|
print("No geometries found — skipping output")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -74,6 +74,18 @@ def _build_wide(
|
||||||
iod = pl.scan_parquet(iod_path)
|
iod = pl.scan_parquet(iod_path)
|
||||||
wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
|
wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left")
|
||||||
|
|
||||||
|
# Invert deprivation scores so that higher values = less deprived (better)
|
||||||
|
iod_score_cols = [
|
||||||
|
"Education, Skills and Training Score",
|
||||||
|
"Income Score (rate)",
|
||||||
|
"Employment Score (rate)",
|
||||||
|
"Health Deprivation and Disability Score",
|
||||||
|
"Living Environment Score",
|
||||||
|
"Indoors Sub-domain Score",
|
||||||
|
"Outdoors Sub-domain Score",
|
||||||
|
]
|
||||||
|
wide = wide.with_columns(*(pl.col(c).max() - pl.col(c) for c in iod_score_cols))
|
||||||
|
|
||||||
ethnicity = pl.scan_parquet(ethnicity_path)
|
ethnicity = pl.scan_parquet(ethnicity_path)
|
||||||
wide = wide.join(
|
wide = wide.join(
|
||||||
ethnicity,
|
ethnicity,
|
||||||
|
|
@ -158,10 +170,20 @@ def _build_wide(
|
||||||
geosure = pl.scan_parquet(geosure_path)
|
geosure = pl.scan_parquet(geosure_path)
|
||||||
wide = wide.join(geosure, on="postcode", how="left")
|
wide = wide.join(geosure, on="postcode", how="left")
|
||||||
|
|
||||||
# Use built_form (Terraced, Semi-detached) when available, otherwise epc_property_type
|
# Derive property_type: prefer EPC data, fall back to price-paid.
|
||||||
|
# For Houses, use built_form (e.g. Semi-Detached, Mid-Terrace) for finer detail.
|
||||||
|
bad_built_form = pl.col("built_form").is_null() | pl.col("built_form").is_in(
|
||||||
|
["NO DATA!", "Not Recorded"]
|
||||||
|
)
|
||||||
|
has_epc = pl.col("epc_property_type").is_not_null()
|
||||||
|
is_house = pl.col("epc_property_type") == "House"
|
||||||
wide = wide.with_columns(
|
wide = wide.with_columns(
|
||||||
pl.when(pl.col("pp_property_type").is_in(["Terraced", "Semi-Detached"]))
|
pl.when(has_epc & is_house & ~bad_built_form)
|
||||||
.then(pl.col("built_form"))
|
.then(pl.col("built_form"))
|
||||||
|
.when(has_epc & is_house)
|
||||||
|
.then(pl.col("pp_property_type"))
|
||||||
|
.when(has_epc)
|
||||||
|
.then(pl.col("epc_property_type"))
|
||||||
.otherwise(pl.col("pp_property_type"))
|
.otherwise(pl.col("pp_property_type"))
|
||||||
.alias("property_type")
|
.alias("property_type")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ dependencies = [
|
||||||
"rasterio>=1.5.0",
|
"rasterio>=1.5.0",
|
||||||
"pyproj>=3.7.2",
|
"pyproj>=3.7.2",
|
||||||
"pyshp>=2.3.0",
|
"pyshp>=2.3.0",
|
||||||
|
"folium>=0.20.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
# r5py needs a JVM to run the R5 routing engine
|
# r5py needs a JVM to run the R5 routing engine
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends openjdk-21-jre-headless curl libexpat1 libgdal-dev && \
|
apt-get install -y --no-install-recommends openjdk-21-jre-headless curl libexpat1 libgdal-dev && \
|
||||||
|
|
@ -7,8 +9,8 @@ RUN apt-get update && \
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN uv pip install --system --no-cache -r pyproject.toml
|
||||||
|
|
||||||
COPY main.py .
|
COPY main.py .
|
||||||
|
|
||||||
|
|
|
||||||
11
r5-service/pyproject.toml
Normal file
11
r5-service/pyproject.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[project]
|
||||||
|
name = "r5-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"r5py>=0.8",
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn>=0.34",
|
||||||
|
"geopandas>=1.0",
|
||||||
|
"shapely>=2.0",
|
||||||
|
]
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
r5py>=0.8
|
|
||||||
fastapi>=0.115
|
|
||||||
uvicorn>=0.34
|
|
||||||
geopandas>=1.0
|
|
||||||
shapely>=2.0
|
|
||||||
|
|
@ -17,39 +17,37 @@ export class ScreenshotCache {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a cache key by quantizing view params and hashing.
|
* Build a cache key by quantizing view params and hashing.
|
||||||
* - lat/lng quantized to 2 decimal places
|
* - lat/lon quantized to 2 decimal places
|
||||||
* - zoom quantized to integer
|
* - zoom quantized to integer
|
||||||
* - filters sorted alphabetically
|
* - filters and POI categories sorted alphabetically
|
||||||
*/
|
*/
|
||||||
buildKey(params: Record<string, string>): string {
|
buildKey(params: URLSearchParams): string {
|
||||||
const normalized: Record<string, string> = {};
|
const normalized: Record<string, string> = {};
|
||||||
|
|
||||||
// Parse and quantize the view param (lat,lng,zoom)
|
// Quantize lat/lon/zoom
|
||||||
if (params.v) {
|
const lat = params.get('lat');
|
||||||
const parts = params.v.split(',');
|
const lon = params.get('lon');
|
||||||
if (parts.length === 3) {
|
const zoom = params.get('zoom');
|
||||||
const lat = parseFloat(parts[0]).toFixed(2);
|
if (lat && lon && zoom) {
|
||||||
const lng = parseFloat(parts[1]).toFixed(2);
|
normalized.lat = parseFloat(lat).toFixed(2);
|
||||||
const zoom = Math.round(parseFloat(parts[2])).toString();
|
normalized.lon = parseFloat(lon).toFixed(2);
|
||||||
normalized.v = `${lat},${lng},${zoom}`;
|
normalized.zoom = Math.round(parseFloat(zoom)).toString();
|
||||||
} else {
|
|
||||||
normalized.v = params.v;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort filters
|
// Sort filters
|
||||||
if (params.f) {
|
const filters = params.getAll('filter').sort();
|
||||||
const segments = params.f.split(',').sort();
|
if (filters.length > 0) {
|
||||||
normalized.f = segments.join(',');
|
normalized.filters = filters.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.poi) {
|
// Sort POI categories
|
||||||
const cats = params.poi.split(',').sort();
|
const pois = params.getAll('poi').sort();
|
||||||
normalized.poi = cats.join(',');
|
if (pois.length > 0) {
|
||||||
|
normalized.poi = pois.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.tab) {
|
if (params.get('tab')) {
|
||||||
normalized.tab = params.tab;
|
normalized.tab = params.get('tab')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = JSON.stringify(normalized);
|
const input = JSON.stringify(normalized);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,18 @@ import { ScreenshotCache } from './cache.js';
|
||||||
import { takeScreenshot, checkWebGL, closeBrowser } from './screenshot.js';
|
import { takeScreenshot, checkWebGL, closeBrowser } from './screenshot.js';
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '8002', 10);
|
const PORT = parseInt(process.env.PORT || '8002', 10);
|
||||||
const APP_URL = process.env.APP_URL || 'http://localhost:8001';
|
const APP_URL = process.env.APP_URL;
|
||||||
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
|
const CACHE_DIR = process.env.CACHE_DIR;
|
||||||
|
|
||||||
|
if (!APP_URL) {
|
||||||
|
console.error('Error: APP_URL environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CACHE_DIR) {
|
||||||
|
console.error('Error: CACHE_DIR environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const cache = new ScreenshotCache(CACHE_DIR);
|
const cache = new ScreenshotCache(CACHE_DIR);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -24,15 +34,26 @@ app.get('/debug', async (_req, res) => {
|
||||||
|
|
||||||
app.get('/screenshot', async (req, res) => {
|
app.get('/screenshot', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const qs = new URLSearchParams();
|
||||||
for (const key of ['v', 'f', 'poi', 'tab', 'og']) {
|
for (const key of ['lat', 'lon', 'zoom', 'tab', 'og']) {
|
||||||
const val = req.query[key];
|
const val = req.query[key];
|
||||||
if (typeof val === 'string' && val) {
|
if (typeof val === 'string' && val) {
|
||||||
params[key] = val;
|
qs.set(key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Repeated params: filter, poi
|
||||||
|
for (const key of ['filter', 'poi']) {
|
||||||
|
const val = req.query[key];
|
||||||
|
if (typeof val === 'string' && val) {
|
||||||
|
qs.append(key, val);
|
||||||
|
} else if (Array.isArray(val)) {
|
||||||
|
for (const v of val) {
|
||||||
|
if (typeof v === 'string' && v) qs.append(key, v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = cache.buildKey(params);
|
const cacheKey = cache.buildKey(qs);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
|
|
@ -45,7 +66,6 @@ app.get('/screenshot', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the URL for the frontend in screenshot mode
|
// Build the URL for the frontend in screenshot mode
|
||||||
const qs = new URLSearchParams(params);
|
|
||||||
qs.set('screenshot', '1');
|
qs.set('screenshot', '1');
|
||||||
const url = `${APP_URL}/?${qs}`;
|
const url = `${APP_URL}/?${qs}`;
|
||||||
|
|
||||||
|
|
|
||||||
1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
|
|
@ -2374,6 +2374,7 @@ dependencies = [
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pmtiles",
|
"pmtiles",
|
||||||
"polars",
|
"polars",
|
||||||
|
"rand 0.9.2",
|
||||||
"rayon",
|
"rayon",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rust_xlsxwriter",
|
"rust_xlsxwriter",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json"] }
|
||||||
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"] }
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
min_ident_chars = "warn"
|
min_ident_chars = "warn"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub struct FeatureConfig {
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
/// Longer description explaining methodology, data source, and caveats
|
/// Longer description explaining methodology, data source, and caveats
|
||||||
pub detail: &'static str,
|
pub detail: &'static str,
|
||||||
/// Data source slug for linking to /data-sources#<slug>
|
/// Data source slug for linking to /learn#<slug>
|
||||||
pub source: &'static str,
|
pub source: &'static str,
|
||||||
/// Display prefix (e.g. "£")
|
/// Display prefix (e.g. "£")
|
||||||
pub prefix: &'static str,
|
pub prefix: &'static str,
|
||||||
|
|
@ -46,7 +46,7 @@ pub struct EnumFeatureConfig {
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
/// Longer description explaining methodology, data source, and caveats
|
/// Longer description explaining methodology, data source, and caveats
|
||||||
pub detail: &'static str,
|
pub detail: &'static str,
|
||||||
/// Data source slug for linking to /data-sources#<slug>
|
/// Data source slug for linking to /learn#<slug>
|
||||||
pub source: &'static str,
|
pub source: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +114,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
suffix: "",
|
suffix: "",
|
||||||
raw: false,
|
raw: false,
|
||||||
},
|
},
|
||||||
|
FeatureConfig {
|
||||||
|
name: "Est. price per sqm",
|
||||||
|
bounds: Bounds::Percentile {
|
||||||
|
low: 0.0,
|
||||||
|
high: 98.0,
|
||||||
|
},
|
||||||
|
step: 100.0,
|
||||||
|
description: "Estimated current price divided by total floor area",
|
||||||
|
detail: "Calculated by dividing the inflation-adjusted estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
|
||||||
|
source: "price-paid",
|
||||||
|
prefix: "£",
|
||||||
|
suffix: "",
|
||||||
|
raw: false,
|
||||||
|
},
|
||||||
FeatureConfig {
|
FeatureConfig {
|
||||||
name: "Total floor area (sqm)",
|
name: "Total floor area (sqm)",
|
||||||
bounds: Bounds::Percentile {
|
bounds: Bounds::Percentile {
|
||||||
|
|
@ -257,8 +271,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
high: 98.0,
|
high: 98.0,
|
||||||
},
|
},
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
description: "IoD education deprivation score for the local area",
|
description: "IoD education score for the local area (higher = better)",
|
||||||
detail: "From the English Indices of Deprivation. Measures deprivation in education, skills and training in the local area (LSOA). Higher scores indicate greater deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
|
detail: "From the English Indices of Deprivation (inverted so higher = better). Measures education, skills and training quality in the local area (LSOA). Higher scores indicate less deprivation. Combines children/young people sub-domain (school attainment, entry to higher education) and adult skills sub-domain (adult qualifications, English language proficiency).",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -301,8 +315,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
name: "Income Score (rate)",
|
name: "Income Score (rate)",
|
||||||
bounds: Bounds::Fixed { min: 0.0, max: 0.6 },
|
bounds: Bounds::Fixed { min: 0.0, max: 0.6 },
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
description: "Proportion of the population experiencing income deprivation",
|
description: "Income deprivation rate, inverted (higher = less deprived)",
|
||||||
detail: "From the English Indices of Deprivation. The proportion of the local population experiencing deprivation relating to low income. Includes people on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
|
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less income deprivation. Based on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -312,8 +326,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
name: "Employment Score (rate)",
|
name: "Employment Score (rate)",
|
||||||
bounds: Bounds::Fixed { min: 0.0, max: 0.4 },
|
bounds: Bounds::Fixed { min: 0.0, max: 0.4 },
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
description: "Proportion of the working-age population involuntarily excluded from work",
|
description: "Employment deprivation rate, inverted (higher = less deprived)",
|
||||||
detail: "From the English Indices of Deprivation. The proportion of the working-age population involuntarily excluded from the labour market. Includes claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
|
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher values indicate less employment deprivation. Based on claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -326,8 +340,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
high: 98.0,
|
high: 98.0,
|
||||||
},
|
},
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
description: "Risk of premature death and quality of life impairment",
|
description: "Health and disability score (higher = better health outcomes)",
|
||||||
detail: "From the English Indices of Deprivation. Measures the risk of premature death and impairment of quality of life through poor physical or mental health. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
|
detail: "From the English Indices of Deprivation (inverted so higher = better). Higher scores indicate lower risk of premature death and better quality of life. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -340,8 +354,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
high: 98.0,
|
high: 98.0,
|
||||||
},
|
},
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
description: "Quality of the local indoor and outdoor environment",
|
description: "Quality of the local indoor and outdoor environment (higher = better)",
|
||||||
detail: "From the English Indices of Deprivation. Measures deprivation in the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate poorer living environments.",
|
detail: "From the English Indices of Deprivation (inverted so higher = better). Measures the quality of the local environment. Combines the Indoors sub-domain (housing quality, central heating, housing conditions) and Outdoors sub-domain (air quality, road traffic accidents). Higher scores indicate better living environments.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -354,8 +368,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
high: 98.0,
|
high: 98.0,
|
||||||
},
|
},
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
description: "Housing quality and conditions in the local area",
|
description: "Housing quality and conditions (higher = better)",
|
||||||
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the quality of housing stock: houses without central heating, housing in poor condition, and houses failing Decent Homes standards. Higher scores indicate worse housing conditions.",
|
detail: "From the English Indices of Deprivation, Living Environment domain (inverted so higher = better). Measures the quality of housing stock: central heating availability, housing condition, and Decent Homes standards. Higher scores indicate better housing conditions.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
@ -368,8 +382,8 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||||
high: 98.0,
|
high: 98.0,
|
||||||
},
|
},
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
description: "Air quality and road safety in the local area",
|
description: "Air quality and road safety (higher = better)",
|
||||||
detail: "From the English Indices of Deprivation, Living Environment domain. Measures the outdoor living environment quality through air quality indicators and road traffic accident casualties involving pedestrians and cyclists. Higher scores indicate poorer outdoor environments.",
|
detail: "From the English Indices of Deprivation, Living Environment domain (inverted so higher = better). Measures the outdoor living environment quality through air quality indicators and road traffic accident casualties involving pedestrians and cyclists. Higher scores indicate better outdoor environments.",
|
||||||
source: "iod",
|
source: "iod",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
suffix: "",
|
suffix: "",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use axum::response::Response;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const OG_PLACEHOLDER: &str = r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>"#;
|
const OG_PLACEHOLDER: &str = r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>"#;
|
||||||
|
|
||||||
pub async fn og_middleware(request: Request, next: Next) -> Response {
|
pub async fn og_middleware(request: Request, next: Next) -> Response {
|
||||||
// Capture the query string before passing the request through
|
// Capture the query string before passing the request through
|
||||||
|
|
@ -47,14 +47,14 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
||||||
};
|
};
|
||||||
|
|
||||||
let og_tags = format!(
|
let og_tags = format!(
|
||||||
r#"<meta property="og:title" content="Perfect Postcodes — Every neighbourhood in England & Wales" />
|
r#"<meta property="og:title" content="Perfect Postcode — Every neighbourhood in England & Wales" />
|
||||||
<meta property="og:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
|
<meta property="og:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="{og_image_url}" />
|
<meta property="og:image" content="{og_image_url}" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="Perfect Postcodes — Every neighbourhood in England & Wales" />
|
<meta name="twitter:title" content="Perfect Postcode — Every neighbourhood in England & Wales" />
|
||||||
<meta name="twitter:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />"#
|
<meta name="twitter:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />"#
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,21 +93,38 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec<String> {
|
||||||
names
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build frontend-style query params for screenshot/dashboard URLs.
|
||||||
|
fn build_frontend_params(
|
||||||
|
center_lat: f64,
|
||||||
|
center_lon: f64,
|
||||||
|
zoom: f64,
|
||||||
|
filters_str: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let mut parts = vec![
|
||||||
|
format!("lat={:.4}", center_lat),
|
||||||
|
format!("lon={:.4}", center_lon),
|
||||||
|
format!("zoom={:.1}", zoom),
|
||||||
|
];
|
||||||
|
if let Some(fs) = filters_str {
|
||||||
|
if !fs.is_empty() {
|
||||||
|
for entry in fs.split(',') {
|
||||||
|
if !entry.is_empty() {
|
||||||
|
parts.push(format!("filter={}", urlencoding::encode(entry.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join("&")
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a screenshot image from the screenshot service for Excel export.
|
/// Fetch a screenshot image from the screenshot service for Excel export.
|
||||||
async fn fetch_screenshot(
|
async fn fetch_screenshot(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
view_param: &str,
|
frontend_params: &str,
|
||||||
filters_str: Option<&str>,
|
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
let screenshot_base = &state.screenshot_url;
|
let screenshot_base = &state.screenshot_url;
|
||||||
|
|
||||||
let mut params = vec![format!("v={}", urlencoding::encode(view_param))];
|
let url = format!("{}/screenshot?{}", screenshot_base, frontend_params);
|
||||||
if let Some(fs) = filters_str {
|
|
||||||
if !fs.is_empty() {
|
|
||||||
params.push(format!("f={}", urlencoding::encode(fs)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let url = format!("{}/screenshot?{}", screenshot_base, params.join("&"));
|
|
||||||
|
|
||||||
match state.http_client.get(&url).send().await {
|
match state.http_client.get(&url).send().await {
|
||||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||||
|
|
@ -147,7 +164,7 @@ pub async fn get_export(
|
||||||
|
|
||||||
let public_url = state.public_url.clone();
|
let public_url = state.public_url.clone();
|
||||||
|
|
||||||
// Compute view param for screenshot and dashboard URL
|
// Compute view center 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;
|
||||||
|
|
@ -156,10 +173,11 @@ pub async fn get_export(
|
||||||
} else {
|
} else {
|
||||||
12.0
|
12.0
|
||||||
};
|
};
|
||||||
let view_param = format!("{:.4},{:.4},{:.1}", center_lat, center_lon, zoom);
|
let frontend_params =
|
||||||
|
build_frontend_params(center_lat, center_lon, zoom, filters_str.as_deref());
|
||||||
|
|
||||||
// Fetch screenshot (async, before spawn_blocking)
|
// Fetch screenshot (async, before spawn_blocking)
|
||||||
let screenshot_bytes = fetch_screenshot(&state, &view_param, filters_str.as_deref()).await;
|
let screenshot_bytes = fetch_screenshot(&state, &frontend_params).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
|
||||||
|
|
@ -273,9 +291,50 @@ pub async fn get_export(
|
||||||
ordered
|
ordered
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build Excel workbook
|
// Filter-only feature indices for the Selected sheet
|
||||||
|
let filter_feature_indices: Vec<usize> = filter_feature_names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| state.feature_name_to_index.get(name.as_str()).copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build feature unit map (feat_idx → (prefix, suffix)) for number formatting
|
||||||
|
let feature_units: FxHashMap<usize, (&str, &str)> = state
|
||||||
|
.features_response
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| &group.features)
|
||||||
|
.filter_map(|feat| match feat {
|
||||||
|
FeatureInfo::Numeric {
|
||||||
|
name,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let idx = state.feature_name_to_index.get(name.as_str())?;
|
||||||
|
Some((*idx, (*prefix, *suffix)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Build Excel number formats per feature index for unit display
|
||||||
|
let mut feat_num_fmts: FxHashMap<usize, Format> = FxHashMap::default();
|
||||||
|
for &feat_idx in &all_feature_indices {
|
||||||
|
if let Some(&(prefix, suffix)) = feature_units.get(&feat_idx) {
|
||||||
|
if prefix.is_empty() && suffix.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let num_fmt_str = if !prefix.is_empty() {
|
||||||
|
format!("\"{}\"#,##0", prefix)
|
||||||
|
} else {
|
||||||
|
format!("#,##0.0\"{}\"", suffix)
|
||||||
|
};
|
||||||
|
feat_num_fmts.insert(feat_idx, Format::new().set_num_format(&num_fmt_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Excel workbook with two sheets
|
||||||
let mut workbook = Workbook::new();
|
let mut workbook = Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
|
||||||
|
|
||||||
// Formats
|
// Formats
|
||||||
let header_fmt = Format::new()
|
let header_fmt = Format::new()
|
||||||
|
|
@ -300,160 +359,184 @@ pub async fn get_export(
|
||||||
.set_font_color("#666666")
|
.set_font_color("#666666")
|
||||||
.set_align(FormatAlign::Left);
|
.set_align(FormatAlign::Left);
|
||||||
|
|
||||||
// Row 0: "View on Perfect Postcodes" link
|
// Dashboard URL
|
||||||
let mut dashboard_url = format!("{}/", public_url);
|
let dashboard_url = format!("{}/?{}", public_url, frontend_params);
|
||||||
let mut query_parts: Vec<String> = Vec::new();
|
|
||||||
query_parts.push(format!("v={}", view_param));
|
|
||||||
if let Some(ref fs) = filters_str {
|
|
||||||
if !fs.is_empty() {
|
|
||||||
query_parts.push(format!("f={}", urlencoding::encode(fs)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !query_parts.is_empty() {
|
|
||||||
dashboard_url.push('?');
|
|
||||||
dashboard_url.push_str(&query_parts.join("&"));
|
|
||||||
}
|
|
||||||
|
|
||||||
sheet
|
// Sheet 1: "Selected" (filter features only) with link + screenshot
|
||||||
.write_url(0, 0, Url::new(&dashboard_url).set_text("View on Perfect Postcodes"))
|
// Sheet 2: "All Data" (all features)
|
||||||
.map_err(|err| format!("Failed to write URL: {err}"))?;
|
let sheet_configs: [(&str, &[usize], bool); 2] = [
|
||||||
sheet
|
("Selected", &filter_feature_indices, true),
|
||||||
.set_row_format(0, &link_fmt)
|
("All Data", &all_feature_indices, false),
|
||||||
.map_err(|err| format!("Failed to set row format: {err}"))?;
|
];
|
||||||
|
|
||||||
// Row 1: screenshot (if available)
|
for (sheet_name, feat_indices, include_header) in &sheet_configs {
|
||||||
let mut current_row = 1u32;
|
let sheet = workbook.add_worksheet();
|
||||||
if let Some(ref img_bytes) = screenshot_bytes {
|
sheet
|
||||||
match Image::new_from_buffer(img_bytes) {
|
.set_name(*sheet_name)
|
||||||
Ok(mut image) => {
|
.map_err(|e| format!("Failed to set sheet name: {e}"))?;
|
||||||
// Scale image to fit: ~400px wide, auto height preserving aspect ratio
|
|
||||||
image = image.set_scale_to_size(400, 300, true);
|
let mut current_row = 0u32;
|
||||||
sheet
|
|
||||||
.insert_image(current_row, 0, &image)
|
if *include_header {
|
||||||
.map_err(|err| format!("Failed to insert screenshot: {err}"))?;
|
// URL row
|
||||||
// Set row height to accommodate the image
|
sheet
|
||||||
sheet
|
.write_url(
|
||||||
.set_row_height(current_row, IMAGE_ROW_HEIGHT)
|
0,
|
||||||
.map_err(|err| format!("Failed to set image row height: {err}"))?;
|
0,
|
||||||
current_row += 1;
|
Url::new(&dashboard_url).set_text("View on Perfect Postcode"),
|
||||||
}
|
)
|
||||||
Err(err) => {
|
.map_err(|e| format!("Failed to write URL: {e}"))?;
|
||||||
warn!("Failed to parse screenshot for export: {err}");
|
sheet
|
||||||
// Skip image row, don't leave a gap
|
.set_row_format(0, &link_fmt)
|
||||||
|
.map_err(|e| format!("Failed to set row format: {e}"))?;
|
||||||
|
current_row = 1;
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
if let Some(ref img_bytes) = screenshot_bytes {
|
||||||
|
match Image::new_from_buffer(img_bytes) {
|
||||||
|
Ok(mut image) => {
|
||||||
|
image = image.set_scale_to_size(400, 300, true);
|
||||||
|
sheet
|
||||||
|
.insert_image(current_row, 0, &image)
|
||||||
|
.map_err(|e| format!("Failed to insert screenshot: {e}"))?;
|
||||||
|
sheet
|
||||||
|
.set_row_height(current_row, IMAGE_ROW_HEIGHT)
|
||||||
|
.map_err(|e| format!("Failed to set image row height: {e}"))?;
|
||||||
|
current_row += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to parse screenshot for export: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blank row between image and header
|
||||||
|
current_row += 1;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Leave a blank row between image and header
|
// Header row
|
||||||
current_row += 1;
|
let header_row = current_row;
|
||||||
|
|
||||||
// Header row
|
|
||||||
let header_row = current_row;
|
|
||||||
sheet
|
|
||||||
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
|
|
||||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
|
||||||
sheet
|
|
||||||
.write_string_with_format(header_row, 1, "Properties", &header_fmt)
|
|
||||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
|
||||||
|
|
||||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
|
||||||
let col = (col_offset + 2) as u16;
|
|
||||||
sheet
|
sheet
|
||||||
.write_string_with_format(header_row, col, &feature_names[feat_idx], &header_fmt)
|
.write_string_with_format(header_row, 0, "Postcode", &header_fmt)
|
||||||
.map_err(|err| format!("Failed to write header: {err}"))?;
|
.map_err(|e| format!("Failed to write header: {e}"))?;
|
||||||
}
|
|
||||||
|
|
||||||
// Description row (below header)
|
|
||||||
let desc_row = header_row + 1;
|
|
||||||
// Empty descriptions for Postcode and Properties columns
|
|
||||||
sheet
|
|
||||||
.write_string_with_format(desc_row, 0, "", &desc_fmt)
|
|
||||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
|
||||||
sheet
|
|
||||||
.write_string_with_format(desc_row, 1, "Count of properties", &desc_fmt)
|
|
||||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
|
||||||
|
|
||||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
|
||||||
let col = (col_offset + 2) as u16;
|
|
||||||
let desc = feature_descriptions
|
|
||||||
.get(&feature_names[feat_idx])
|
|
||||||
.map(String::as_str)
|
|
||||||
.unwrap_or("");
|
|
||||||
sheet
|
sheet
|
||||||
.write_string_with_format(desc_row, col, desc, &desc_fmt)
|
.write_string_with_format(header_row, 1, "Properties", &header_fmt)
|
||||||
.map_err(|err| format!("Failed to write desc: {err}"))?;
|
.map_err(|e| format!("Failed to write header: {e}"))?;
|
||||||
}
|
|
||||||
|
|
||||||
// Write data rows (starting after description row)
|
for (col_offset, &feat_idx) in feat_indices.iter().enumerate() {
|
||||||
let data_start_row = desc_row + 1;
|
|
||||||
for (row_offset, (pc_idx, agg)) in postcode_aggs.iter().enumerate() {
|
|
||||||
let row = data_start_row + row_offset as u32;
|
|
||||||
|
|
||||||
sheet
|
|
||||||
.write_string(row, 0, &postcode_data.postcodes[*pc_idx])
|
|
||||||
.map_err(|err| format!("Failed to write postcode: {err}"))?;
|
|
||||||
|
|
||||||
sheet
|
|
||||||
.write_number(row, 1, agg.count as f64)
|
|
||||||
.map_err(|err| format!("Failed to write count: {err}"))?;
|
|
||||||
|
|
||||||
for (col_offset, &feat_idx) in all_feature_indices.iter().enumerate() {
|
|
||||||
let col = (col_offset + 2) as u16;
|
let col = (col_offset + 2) as u16;
|
||||||
|
sheet
|
||||||
|
.write_string_with_format(
|
||||||
|
header_row,
|
||||||
|
col,
|
||||||
|
&feature_names[feat_idx],
|
||||||
|
&header_fmt,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to write header: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
if enum_indices.contains_key(&feat_idx) {
|
// Description row
|
||||||
if let Some(freqs) = agg.enum_freqs.get(&feat_idx) {
|
let desc_row = header_row + 1;
|
||||||
if let Some((&mode_bits, _)) = freqs.iter().max_by_key(|(_, &count)| count)
|
sheet
|
||||||
{
|
.write_string_with_format(desc_row, 0, "", &desc_fmt)
|
||||||
let mode_f32 = f32::from_bits(mode_bits);
|
.map_err(|e| format!("Failed to write desc: {e}"))?;
|
||||||
let mode_idx = mode_f32 as usize;
|
sheet
|
||||||
if let Some(values) = enum_values.get(&feat_idx) {
|
.write_string_with_format(desc_row, 1, "Count of properties", &desc_fmt)
|
||||||
if mode_idx < values.len() {
|
.map_err(|e| format!("Failed to write desc: {e}"))?;
|
||||||
sheet.write_string(row, col, &values[mode_idx]).map_err(
|
|
||||||
|err| format!("Failed to write enum value: {err}"),
|
for (col_offset, &feat_idx) in feat_indices.iter().enumerate() {
|
||||||
)?;
|
let col = (col_offset + 2) as u16;
|
||||||
|
let desc = feature_descriptions
|
||||||
|
.get(&feature_names[feat_idx])
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
sheet
|
||||||
|
.write_string_with_format(desc_row, col, desc, &desc_fmt)
|
||||||
|
.map_err(|e| format!("Failed to write desc: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
let data_start_row = desc_row + 1;
|
||||||
|
for (row_offset, (pc_idx, agg)) in postcode_aggs.iter().enumerate() {
|
||||||
|
let row = data_start_row + row_offset as u32;
|
||||||
|
|
||||||
|
sheet
|
||||||
|
.write_string(row, 0, &postcode_data.postcodes[*pc_idx])
|
||||||
|
.map_err(|e| format!("Failed to write postcode: {e}"))?;
|
||||||
|
|
||||||
|
sheet
|
||||||
|
.write_number(row, 1, agg.count as f64)
|
||||||
|
.map_err(|e| format!("Failed to write count: {e}"))?;
|
||||||
|
|
||||||
|
for (col_offset, &feat_idx) in feat_indices.iter().enumerate() {
|
||||||
|
let col = (col_offset + 2) as u16;
|
||||||
|
|
||||||
|
if enum_indices.contains_key(&feat_idx) {
|
||||||
|
if let Some(freqs) = agg.enum_freqs.get(&feat_idx) {
|
||||||
|
if let Some((&mode_bits, _)) =
|
||||||
|
freqs.iter().max_by_key(|(_, &count)| count)
|
||||||
|
{
|
||||||
|
let mode_f32 = f32::from_bits(mode_bits);
|
||||||
|
let mode_idx = mode_f32 as usize;
|
||||||
|
if let Some(values) = enum_values.get(&feat_idx) {
|
||||||
|
if mode_idx < values.len() {
|
||||||
|
sheet
|
||||||
|
.write_string(row, col, &values[mode_idx])
|
||||||
|
.map_err(|e| {
|
||||||
|
format!("Failed to write enum value: {e}")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
let fc = agg.finite_counts[feat_idx];
|
||||||
let fc = agg.finite_counts[feat_idx];
|
if fc > 0 {
|
||||||
if fc > 0 {
|
let mean = (agg.sums[feat_idx] / fc as f64 * 100.0).round() / 100.0;
|
||||||
let mean = agg.sums[feat_idx] / fc as f64;
|
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
|
||||||
sheet
|
sheet
|
||||||
.write_number(row, col, mean)
|
.write_number_with_format(row, col, mean, fmt)
|
||||||
.map_err(|err| format!("Failed to write numeric value: {err}"))?;
|
.map_err(|e| {
|
||||||
|
format!("Failed to write numeric value: {e}")
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
sheet.write_number(row, col, mean).map_err(|e| {
|
||||||
|
format!("Failed to write numeric value: {e}")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If sampled, add a note at the bottom
|
// Sample note
|
||||||
if was_sampled {
|
if was_sampled {
|
||||||
let note_row = data_start_row + postcode_aggs.len() as u32 + 1;
|
let note_row = data_start_row + postcode_aggs.len() as u32 + 1;
|
||||||
let total_cols = (all_feature_indices.len() + 2) as u16;
|
let total_cols = (feat_indices.len() + 2) as u16;
|
||||||
sheet
|
sheet
|
||||||
.merge_range(
|
.merge_range(
|
||||||
note_row,
|
note_row,
|
||||||
0,
|
0,
|
||||||
note_row,
|
note_row,
|
||||||
total_cols.saturating_sub(1),
|
total_cols.saturating_sub(1),
|
||||||
&format!(
|
&format!(
|
||||||
"Only the first {} postcodes shown (randomly sampled from results)",
|
"Only the first {} postcodes shown (randomly sampled from results)",
|
||||||
MAX_EXPORT_POSTCODES
|
MAX_EXPORT_POSTCODES
|
||||||
),
|
),
|
||||||
¬e_fmt,
|
¬e_fmt,
|
||||||
)
|
)
|
||||||
.map_err(|err| format!("Failed to write note: {err}"))?;
|
.map_err(|e| format!("Failed to write note: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Column widths
|
// Column widths
|
||||||
sheet.set_column_width(0, 12).ok();
|
sheet.set_column_width(0, 12).ok();
|
||||||
sheet.set_column_width(1, 12).ok();
|
sheet.set_column_width(1, 12).ok();
|
||||||
for col_offset in 0..all_feature_indices.len() {
|
for col_offset in 0..feat_indices.len() {
|
||||||
let col = (col_offset + 2) as u16;
|
let col = (col_offset + 2) as u16;
|
||||||
let feat_name = &feature_names[all_feature_indices[col_offset]];
|
let feat_name = &feature_names[feat_indices[col_offset]];
|
||||||
let width = (feat_name.len() as f64 * 1.1).clamp(10.0, 30.0);
|
let width = (feat_name.len() as f64 * 1.1).clamp(10.0, 30.0);
|
||||||
sheet.set_column_width(col, width).ok();
|
sheet.set_column_width(col, width).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let buf = workbook
|
let buf = workbook
|
||||||
|
|
@ -485,7 +568,7 @@ pub async fn get_export(
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
"attachment; filename=\"perfect-postcodes-export.xlsx\"",
|
"attachment; filename=\"perfect-postcode-export.xlsx\"",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bytes,
|
bytes,
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,19 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::Query;
|
use axum::http::{header, StatusCode, Uri};
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
pub async fn get_screenshot(state: Arc<AppState>, uri: Uri) -> impl IntoResponse {
|
||||||
pub struct ScreenshotQuery {
|
|
||||||
#[serde(rename = "v")]
|
|
||||||
view: Option<String>,
|
|
||||||
#[serde(rename = "f")]
|
|
||||||
filters: Option<String>,
|
|
||||||
poi: Option<String>,
|
|
||||||
tab: Option<String>,
|
|
||||||
/// When "1", renders the OG heading overlay on the screenshot
|
|
||||||
og: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_screenshot(
|
|
||||||
state: Arc<AppState>,
|
|
||||||
Query(query): Query<ScreenshotQuery>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let screenshot_base = &state.screenshot_url;
|
let screenshot_base = &state.screenshot_url;
|
||||||
|
|
||||||
let mut params = Vec::new();
|
let qs = uri
|
||||||
if query.og.as_deref() == Some("1") {
|
.query()
|
||||||
params.push("og=1".to_string());
|
.map(|q| format!("?{q}"))
|
||||||
}
|
.unwrap_or_default();
|
||||||
if let Some(ref val) = query.view {
|
let url = format!("{screenshot_base}/screenshot{qs}");
|
||||||
params.push(format!("v={}", urlencoding::encode(val)));
|
|
||||||
}
|
|
||||||
if let Some(ref val) = query.filters {
|
|
||||||
params.push(format!("f={}", urlencoding::encode(val)));
|
|
||||||
}
|
|
||||||
if let Some(ref val) = query.poi {
|
|
||||||
params.push(format!("poi={}", urlencoding::encode(val)));
|
|
||||||
}
|
|
||||||
if let Some(ref val) = query.tab {
|
|
||||||
params.push(format!("tab={}", urlencoding::encode(val)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let qs = if params.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("?{}", params.join("&"))
|
|
||||||
};
|
|
||||||
let url = format!("{}/screenshot{}", screenshot_base, qs);
|
|
||||||
info!("Proxying screenshot request to: {}", url);
|
info!("Proxying screenshot request to: {}", url);
|
||||||
|
|
||||||
match state.http_client.get(&url).send().await {
|
match state.http_client.get(&url).send().await {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue