vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
94
.github/workflows/ci.yml
vendored
Normal file
94
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python:
|
||||
name: Python (lint + test)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Ruff check
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Deptry (unused dependencies)
|
||||
run: uv run deptry .
|
||||
|
||||
- name: Tests
|
||||
run: |
|
||||
uv run pytest pipeline/utils/test_haversine.py
|
||||
uv run pytest pipeline/utils/test_poi_counts.py
|
||||
uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
||||
|
||||
frontend:
|
||||
name: Frontend (lint + typecheck)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier check
|
||||
run: npm run format:check
|
||||
|
||||
- name: TypeScript typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
rust:
|
||||
name: Rust (lint + test)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: server-rs
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Install cargo-machete
|
||||
run: cargo install cargo-machete
|
||||
|
||||
- name: Unused dependencies check
|
||||
run: cargo machete
|
||||
|
||||
- name: Tests
|
||||
run: cargo test
|
||||
|
|
@ -112,7 +112,7 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist`
|
|||
- All features (numeric and enum): row-major flat `Vec<f32>`, NaN = null
|
||||
- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap<usize, Vec<String>>` mapping feature index → string values
|
||||
- String fields (address, postcode): interned/packed for memory efficiency
|
||||
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `ollama_model` and `public_url` must be explicitly provided via env or CLI.
|
||||
- All CLI args are required (no hidden defaults). Optional services use `Option<String>`: `r5_url` (travel time disabled when None), `pocketbase_admin_email`/`password` (collection auto-creation skipped when None). Required config like `gemini_model` and `public_url` must be explicitly provided via env or CLI.
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
|
||||
|
|
|
|||
19
Taskfile.yml
19
Taskfile.yml
|
|
@ -24,10 +24,14 @@ tasks:
|
|||
|
||||
test:python:
|
||||
cmds:
|
||||
- uv run -m pipeline.utils.test_fuzzy_join
|
||||
- uv run pytest pipeline/utils/test_haversine.py
|
||||
- uv run pytest pipeline/utils/test_poi_counts.py
|
||||
- uv run pytest pipeline/transform/postcode_boundaries/test_postcode_boundaries.py
|
||||
|
||||
test:python:fuzzy-join:
|
||||
desc: Run fuzzy join test (requires data files in data/)
|
||||
cmds:
|
||||
- uv run -m pipeline.utils.test_fuzzy_join
|
||||
|
||||
test:server:
|
||||
desc: Run Rust backend tests
|
||||
|
|
@ -107,6 +111,19 @@ tasks:
|
|||
cmds:
|
||||
- cargo fmt --all
|
||||
|
||||
ci:
|
||||
desc: Run CI checks locally (lint + typecheck + test, no builds)
|
||||
cmds:
|
||||
- task: lint
|
||||
- task: typecheck
|
||||
- task: test
|
||||
|
||||
typecheck:
|
||||
desc: TypeScript typecheck only
|
||||
dir: frontend
|
||||
cmds:
|
||||
- npm run typecheck
|
||||
|
||||
check:
|
||||
desc: Run all checks (lint, typecheck, build)
|
||||
cmds:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"import numpy as np\n",
|
||||
"import plotly.express as px\n",
|
||||
"import plotly.graph_objects as go\n",
|
||||
"from plotly.subplots import make_subplots\n",
|
||||
"\n",
|
||||
"pl.Config.set_tbl_rows(20)\n",
|
||||
"pl.Config.set_fmt_str_lengths(50)\n",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"import polars as pl\n",
|
||||
"import plotly.express as px\n",
|
||||
"import plotly.graph_objects as go\n",
|
||||
"from plotly.subplots import make_subplots\n",
|
||||
"\n",
|
||||
"pl.Config.set_tbl_rows(20)\n",
|
||||
"pl.Config.set_fmt_str_lengths(80)\n",
|
||||
|
|
@ -265,7 +264,7 @@
|
|||
],
|
||||
"source": [
|
||||
"price = clean[\"price\"]\n",
|
||||
"print(f\"Price stats:\")\n",
|
||||
"print(\"Price stats:\")\n",
|
||||
"print(f\" Min: £{price.min():,}\")\n",
|
||||
"print(f\" P5: £{price.quantile(0.05):,.0f}\")\n",
|
||||
"print(f\" P25: £{price.quantile(0.25):,.0f}\")\n",
|
||||
|
|
@ -590891,7 +590890,7 @@
|
|||
")\n",
|
||||
"\n",
|
||||
"age = with_age[\"days_on_market\"].drop_nulls()\n",
|
||||
"print(f\"Days on market stats:\")\n",
|
||||
"print(\"Days on market stats:\")\n",
|
||||
"print(f\" Median: {age.median():.0f} days\")\n",
|
||||
"print(f\" Mean: {age.mean():.0f} days\")\n",
|
||||
"print(f\" P25: {age.quantile(0.25):.0f} days\")\n",
|
||||
|
|
@ -594786,20 +594785,20 @@
|
|||
}
|
||||
],
|
||||
"source": [
|
||||
"print(f\"=== Rightmove Buy Listings Summary ===\")\n",
|
||||
"print(\"=== Rightmove Buy Listings Summary ===\")\n",
|
||||
"print(f\"Total listings: {len(clean):,}\")\n",
|
||||
"print(f\"Outcodes covered: {clean['outcode'].n_unique():,}\")\n",
|
||||
"print(f\"\")\n",
|
||||
"print(\"\")\n",
|
||||
"print(f\"Price: median £{clean['price'].median():,.0f}, mean £{clean['price'].mean():,.0f}\")\n",
|
||||
"print(f\"Bedrooms: median {clean['bedrooms'].median():.0f}, mean {clean['bedrooms'].mean():.1f}\")\n",
|
||||
"print(f\"\")\n",
|
||||
"print(\"\")\n",
|
||||
"print(f\"Tenure known: {(len(clean) - clean['tenure'].null_count())/len(clean)*100:.1f}%\")\n",
|
||||
"print(f\" Freehold: {len(clean.filter(pl.col('tenure') == 'Freehold')):,}\")\n",
|
||||
"print(f\" Leasehold: {len(clean.filter(pl.col('tenure') == 'Leasehold')):,}\")\n",
|
||||
"print(f\"\")\n",
|
||||
"print(\"\")\n",
|
||||
"print(f\"Floorspace available: {clean['floorspace_sqm'].drop_nulls().len()/len(clean)*100:.1f}%\")\n",
|
||||
"print(f\"\")\n",
|
||||
"print(f\"Property types:\")\n",
|
||||
"print(\"\")\n",
|
||||
"print(\"Property types:\")\n",
|
||||
"for row in clean['property_type'].value_counts().sort('count', descending=True).iter_rows():\n",
|
||||
" print(f\" {row[0]}: {row[1]:,} ({row[1]/len(clean)*100:.1f}%)\")"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@
|
|||
"CROSS_DEDUP_BUY = 2_220\n",
|
||||
"hk_buy_total = len(buy.filter(pl.col(\"source\") == \"Home.co.uk\")) + CROSS_DEDUP_BUY\n",
|
||||
"hk_buy_unique = len(buy.filter(pl.col(\"source\") == \"Home.co.uk\"))\n",
|
||||
"print(f\"\\n--- BUY overlap analysis ---\")\n",
|
||||
"print(\"\\n--- BUY overlap analysis ---\")\n",
|
||||
"print(f\"Home.co.uk scraped (before dedup): {hk_buy_total:,}\")\n",
|
||||
"print(f\"Home.co.uk unique (after dedup): {hk_buy_unique:,}\")\n",
|
||||
"print(f\"Cross-source duplicates removed: {CROSS_DEDUP_BUY:,}\")\n",
|
||||
|
|
@ -1114,7 +1114,7 @@
|
|||
"\n",
|
||||
"print(f\"Outcodes with home.co.uk listings: {len(oc_comparison)}\")\n",
|
||||
"print(f\"Total outcodes in dataset: {buy_oc['outcode'].drop_nulls().n_unique()}\")\n",
|
||||
"print(f\"\\nHome.co.uk coverage by outcode:\")\n",
|
||||
"print(\"\\nHome.co.uk coverage by outcode:\")\n",
|
||||
"oc_comparison"
|
||||
]
|
||||
},
|
||||
|
|
@ -7315,7 +7315,7 @@
|
|||
"print(f\"Covered outcodes: {covered_count}\")\n",
|
||||
"print(f\"Total outcodes: {total_outcodes}\")\n",
|
||||
"print()\n",
|
||||
"print(f\"In covered outcodes:\")\n",
|
||||
"print(\"In covered outcodes:\")\n",
|
||||
"print(f\" Rightmove: {rm_in_covered:,} listings\")\n",
|
||||
"print(f\" Home.co.uk: {hk_buy_unique:,} unique listings\")\n",
|
||||
"print(f\" HK/RM ratio: {ratio_in_covered:.2f}\")\n",
|
||||
|
|
@ -7326,13 +7326,13 @@
|
|||
"projected_dedup = int(projected_hk * CROSS_DEDUP_BUY / hk_buy_total)\n",
|
||||
"projected_unique = projected_hk - projected_dedup\n",
|
||||
"\n",
|
||||
"print(f\"--- Projected full-coverage estimates ---\")\n",
|
||||
"print(\"--- Projected full-coverage estimates ---\")\n",
|
||||
"print(f\" Projected home.co.uk total: ~{projected_hk:,}\")\n",
|
||||
"print(f\" Projected cross-dedup: ~{projected_dedup:,}\")\n",
|
||||
"print(f\" Projected unique additions: ~{projected_unique:,}\")\n",
|
||||
"print(f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique/rm_buy*100:.1f}% increase)\")\n",
|
||||
"print()\n",
|
||||
"print(f\"⚠️ These are rough estimates — the covered outcodes may not be representative\")"
|
||||
"print(\"⚠️ These are rough estimates — the covered outcodes may not be representative\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -94,9 +94,6 @@ def transform_property(prop: dict, outcode: str, pc_index: PostcodeSpatialIndex)
|
|||
|
||||
key_features = [kf.get("description", "") for kf in prop.get("keyFeatures", []) if kf.get("description")]
|
||||
|
||||
listing_update = prop.get("listingUpdate", {})
|
||||
update_date = listing_update.get("listingUpdateDate", "")
|
||||
|
||||
postcode = pc_index.nearest(lat, lng)
|
||||
if not postcode:
|
||||
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
|
||||
|
|
|
|||
|
|
@ -178,15 +178,18 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const path = pageToPath(page, inviteCode ?? undefined);
|
||||
const url = hash ? `${path}#${hash}` : path;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
}, [inviteCode]);
|
||||
const navigateTo = useCallback(
|
||||
(page: Page, hash?: string, infoFeature?: string) => {
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const path = pageToPath(page, inviteCode ?? undefined);
|
||||
const url = hash ? `${path}#${hash}` : path;
|
||||
window.history.pushState({ page }, '', url);
|
||||
setActivePage(page);
|
||||
},
|
||||
[inviteCode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state?.page) {
|
||||
|
|
@ -225,7 +228,8 @@ export default function App() {
|
|||
}
|
||||
}, [activePage, fetchSearches, fetchSavedProperties, user]);
|
||||
|
||||
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites';
|
||||
const isAuthRequiredPage =
|
||||
activePage === 'account' || activePage === 'saved' || activePage === 'invites';
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (isAuthRequiredPage && !user) {
|
||||
|
|
@ -266,8 +270,8 @@ export default function App() {
|
|||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
onClearPendingInfoFeature={() => { }}
|
||||
onNavigateTo={() => { }}
|
||||
onClearPendingInfoFeature={() => {}}
|
||||
onNavigateTo={() => {}}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
|
|
@ -306,7 +310,13 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} />
|
||||
<HomePage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
onOpenPricing={() => navigateTo('pricing')}
|
||||
theme={theme}
|
||||
features={features}
|
||||
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
||||
/>
|
||||
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
||||
<PricingPage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
|
|
@ -412,13 +422,21 @@ export default function App() {
|
|||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }}
|
||||
onViewSearches={() => {
|
||||
setShowSaveModal(false);
|
||||
navigateTo('saved');
|
||||
}}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
)}
|
||||
{showLicenseSuccess && (
|
||||
<LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
|
||||
<LicenseSuccessModal
|
||||
onClose={() => {
|
||||
setShowLicenseSuccess(false);
|
||||
navigateTo('dashboard');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ function PageLayout({ children }: { children: React.ReactNode }) {
|
|||
return (
|
||||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -38,10 +36,7 @@ function DeleteDialog({
|
|||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onCancel}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
|
|
@ -122,17 +117,20 @@ function SavedSearchesTab({
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback(async (params: string, id: string) => {
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
doCopy(shortUrl, id);
|
||||
} catch {
|
||||
doCopy(`${window.location.origin}/?${params}`, id);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
}, [doCopy]);
|
||||
const handleShare = useCallback(
|
||||
async (params: string, id: string) => {
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
doCopy(shortUrl, id);
|
||||
} catch {
|
||||
doCopy(`${window.location.origin}/?${params}`, id);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
},
|
||||
[doCopy]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -201,7 +199,11 @@ function SavedSearchesTab({
|
|||
>
|
||||
{sharingId === search.id ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : copiedId === search.id ? 'Copied!' : 'Share'}
|
||||
) : copiedId === search.id ? (
|
||||
'Copied!'
|
||||
) : (
|
||||
'Share'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(search.id)}
|
||||
|
|
@ -452,8 +454,12 @@ function InviteTable({
|
|||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Status</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Created</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
|
||||
|
|
@ -718,10 +724,11 @@ export default function AccountPage({
|
|||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${user.verified
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
user.verified
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{user.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
|
|
@ -732,7 +739,9 @@ export default function AccountPage({
|
|||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
|
||||
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
|
||||
<span
|
||||
className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}
|
||||
>
|
||||
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -47,14 +47,16 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
|
|||
>
|
||||
<div
|
||||
className="bg-teal-500"
|
||||
style={{
|
||||
width: hex.size,
|
||||
height: hex.size * 2 / Math.sqrt(3),
|
||||
opacity: hex.opacity * (isDark ? 0.6 : 1),
|
||||
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
|
||||
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
|
||||
'--bob': `${hex.bobAmount}px`,
|
||||
} as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
width: hex.size,
|
||||
height: (hex.size * 2) / Math.sqrt(3),
|
||||
opacity: hex.opacity * (isDark ? 0.6 : 1),
|
||||
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
|
||||
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
|
||||
'--bob': `${hex.bobAmount}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import type { FeatureMeta } from '../../types';
|
|||
|
||||
export default function HomePage({
|
||||
onOpenDashboard,
|
||||
onOpenPricing,
|
||||
onOpenPricing: _onOpenPricing,
|
||||
theme = 'light',
|
||||
features = [],
|
||||
hidePricing,
|
||||
hidePricing: _hidePricing,
|
||||
}: {
|
||||
onOpenDashboard: () => void;
|
||||
onOpenPricing: () => void;
|
||||
|
|
@ -79,8 +79,8 @@ export default function HomePage({
|
|||
House hunting? Make your biggest investment your smartest move.
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-xl">
|
||||
So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
|
||||
fit.
|
||||
So many options - choosing the right one can feel overwhelming. Our interactive map
|
||||
makes it simple: select your must-haves and instantly see the areas that fit.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
|
|
@ -100,7 +100,11 @@ export default function HomePage({
|
|||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
if (!scroller) return;
|
||||
const start = scroller.scrollTop;
|
||||
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
|
||||
const end =
|
||||
start +
|
||||
target.getBoundingClientRect().top -
|
||||
scroller.getBoundingClientRect().top -
|
||||
48;
|
||||
const distance = end - start;
|
||||
const duration = 1200;
|
||||
let startTime: number;
|
||||
|
|
@ -146,7 +150,8 @@ export default function HomePage({
|
|||
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
|
||||
if (!scroller) return;
|
||||
const start = scroller.scrollTop;
|
||||
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
const end =
|
||||
start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
const distance = end - start;
|
||||
const duration = 1200;
|
||||
let startTime: number;
|
||||
|
|
@ -176,13 +181,13 @@ export default function HomePage({
|
|||
</h2>
|
||||
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
|
||||
<p>
|
||||
Listings show what's available, not what's possible — fragments without context.
|
||||
Traditional tools force you to begin with a location, separating area insight from property detail.
|
||||
You search, cross-reference, and repeat per location.
|
||||
Listings show what's available, not what's possible — fragments
|
||||
without context. Traditional tools force you to begin with a location, separating area
|
||||
insight from property detail. You search, cross-reference, and repeat per location.
|
||||
</p>
|
||||
<p>
|
||||
We take a different approach. Start with what matters to you, and the right places reveal themselves.
|
||||
No context lost. No property missed.
|
||||
We take a different approach. Start with what matters to you, and the right places
|
||||
reveal themselves. No context lost. No property missed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,66 +222,75 @@ export default function HomePage({
|
|||
{/* Right: Comparison table */}
|
||||
<div id="comparison">
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
|
||||
Others vs{' '}<span className="inline-flex items-baseline gap-3 whitespace-nowrap">Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" /></span>
|
||||
Others vs{' '}
|
||||
<span className="inline-flex items-baseline gap-3 whitespace-nowrap">
|
||||
Perfect Postcode{' '}
|
||||
<LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
|
||||
</span>
|
||||
</h2>
|
||||
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Listing portals
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
{'\u201CCheck my postcode\u201D'}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Area guides
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
|
||||
Perfect Postcode
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURE_ROWS.map((row, i) => (
|
||||
<tr
|
||||
key={row.feature}
|
||||
className={
|
||||
i < FEATURE_ROWS.length - 1
|
||||
? 'border-b border-warm-100 dark:border-warm-800'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
|
||||
{row.feature}
|
||||
{row.subtitle && (
|
||||
<div className="italic text-warm-500 dark:text-warm-400">{row.subtitle}</div>
|
||||
)}
|
||||
</td>
|
||||
{[row.listings, row.postcode, row.guides].map((has, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
|
||||
>
|
||||
{has ? '\u2713' : '\u2717'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500 bg-teal-50 dark:bg-teal-900/30">
|
||||
✓
|
||||
</td>
|
||||
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Listing portals
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
{'\u201CCheck my postcode\u201D'}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Area guides
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
|
||||
Perfect Postcode
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURE_ROWS.map((row, i) => (
|
||||
<tr
|
||||
key={row.feature}
|
||||
className={
|
||||
i < FEATURE_ROWS.length - 1
|
||||
? 'border-b border-warm-100 dark:border-warm-800'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
|
||||
{row.feature}
|
||||
{row.subtitle && (
|
||||
<div className="italic text-warm-500 dark:text-warm-400">
|
||||
{row.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{[row.listings, row.postcode, row.guides].map((has, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
|
||||
>
|
||||
{has ? '\u2713' : '\u2717'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500 bg-teal-50 dark:bg-teal-900/30">
|
||||
✓
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollytelling: Problem + Solution + Demo map */}
|
||||
<h2 id="demo" className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-16 mb-8">
|
||||
<h2
|
||||
id="demo"
|
||||
className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-16 mb-8"
|
||||
>
|
||||
See It in Action
|
||||
</h2>
|
||||
<ScrollStory features={features} theme={theme} />
|
||||
|
|
@ -311,27 +325,48 @@ export default function HomePage({
|
|||
|
||||
const FEATURE_ROWS = [
|
||||
// listings postcode guides
|
||||
{ feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
|
||||
{ feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
|
||||
{ feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
|
||||
{ feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
|
||||
{
|
||||
feature: 'Search without choosing an area first',
|
||||
subtitle: '(start with needs, not a location)',
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: 'Area data',
|
||||
subtitle: '(crime, schools, noise, broadband)',
|
||||
listings: false,
|
||||
postcode: true,
|
||||
guides: true,
|
||||
},
|
||||
{
|
||||
feature: 'Property-specific data',
|
||||
subtitle: '(price, EPC, floor area)',
|
||||
listings: true,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: '56 combinable filters in one place',
|
||||
subtitle: '(all insights, one interactive map)',
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
];
|
||||
|
||||
const HOW_STEPS = [
|
||||
{
|
||||
title: 'Set your must-haves',
|
||||
description:
|
||||
'Budget, commute, schools \u2014 the map shows only what qualifies.',
|
||||
description: 'Budget, commute, schools \u2014 the map shows only what qualifies.',
|
||||
},
|
||||
{
|
||||
title: 'Explore areas and discover hidden gems',
|
||||
description:
|
||||
'Zoom in, dig into details and nice to haves.',
|
||||
description: 'Zoom in, dig into details and nice to haves.',
|
||||
},
|
||||
{
|
||||
title: 'Drill into postcodes',
|
||||
description:
|
||||
'See individual properties, sale prices, floor area, and compare.',
|
||||
description: 'See individual properties, sale prices, floor area, and compare.',
|
||||
},
|
||||
{
|
||||
title: 'Shortlist with confidence',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const DEMO_FEATURE_NAMES = [
|
|||
'Good+ primary schools within 5km',
|
||||
'Number of restaurants within 2km',
|
||||
];
|
||||
const noop = () => { };
|
||||
const noop = () => {};
|
||||
|
||||
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
|
||||
// 0 = feature.min, 1 = feature.max
|
||||
|
|
@ -75,10 +75,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
</p>
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
You're about to spend{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
up to £500k
|
||||
</strong>{' '}
|
||||
on a home.
|
||||
<strong className="text-navy-950 dark:text-warm-100">up to £500k</strong> on a home.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
|
@ -91,7 +88,9 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
<div className="shrink-0 w-7 h-7 md:w-8 md:h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-xs md:text-sm">
|
||||
1
|
||||
</div>
|
||||
<h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3>
|
||||
<h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">
|
||||
Set your must-haves
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
Say you want a home{' '}
|
||||
|
|
@ -127,8 +126,8 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
body: (
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
…all within{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong>{' '}
|
||||
by public transport.
|
||||
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong> by
|
||||
public transport.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
|
|
@ -137,11 +136,13 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
body: (
|
||||
<>
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed mb-2 md:mb-4 font-semibold text-navy-950 dark:text-warm-100">
|
||||
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
|
||||
No area chosen. No listings browsed. Yet you already know exactly where your needs are
|
||||
met.
|
||||
</p>
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
That's just 4 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
||||
covering commute times, crime, broadband, noise, schools, amenities, and more.
|
||||
That's just 4 filters. We've built{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">56</strong> — covering commute
|
||||
times, crime, broadband, noise, schools, amenities, and more.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
|
@ -337,9 +338,13 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
);
|
||||
})}
|
||||
{/* Travel time indicator */}
|
||||
<div className={`transition-opacity duration-700 ${STAGES[stage].travel ? 'opacity-100' : 'opacity-30'}`}>
|
||||
<div
|
||||
className={`transition-opacity duration-700 ${STAGES[stage].travel ? 'opacity-100' : 'opacity-30'}`}
|
||||
>
|
||||
<div className="flex justify-between items-baseline text-xs md:text-sm mb-1 md:mb-1.5 gap-1.5 md:gap-2">
|
||||
<span className={`font-medium truncate ${STAGES[stage].travel ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}>
|
||||
<span
|
||||
className={`font-medium truncate ${STAGES[stage].travel ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
Commute to Manchester
|
||||
</span>
|
||||
{STAGES[stage].travel && (
|
||||
|
|
@ -369,7 +374,11 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</div>
|
||||
<div
|
||||
className="h-1.5 md:h-2.5 rounded-full overflow-hidden"
|
||||
style={{ background: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT) }}
|
||||
style={{
|
||||
background: gradientToCss(
|
||||
theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-[10px] md:text-xs text-warm-500 dark:text-warm-400">
|
||||
<span>Fewer</span>
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export default function InvitePage({
|
|||
<h2 className="text-[144px] leading-tight font-bold text-white mb-6">
|
||||
{isValid
|
||||
? isAdminInvite
|
||||
? "You\u2019re invited!"
|
||||
? 'You\u2019re invited!'
|
||||
: 'Special offer!'
|
||||
: 'Perfect Postcode'}
|
||||
</h2>
|
||||
|
|
@ -256,12 +256,8 @@ export default function InvitePage({
|
|||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-8 h-8 text-teal-400" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-white mb-2">
|
||||
License activated!
|
||||
</p>
|
||||
<p className="text-warm-400">
|
||||
You now have full access to Perfect Postcode.
|
||||
</p>
|
||||
<p className="text-lg font-medium text-white mb-2">License activated!</p>
|
||||
<p className="text-warm-400">You now have full access to Perfect Postcode.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -306,8 +302,12 @@ export default function InvitePage({
|
|||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<p className="text-warm-700 dark:text-warm-300 font-medium">You already have a license</p>
|
||||
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">Your account already has full access.</p>
|
||||
<p className="text-warm-700 dark:text-warm-300 font-medium">
|
||||
You already have a license
|
||||
</p>
|
||||
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">
|
||||
Your account already has full access.
|
||||
</p>
|
||||
</div>
|
||||
) : user ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ const DATA_SOURCES = [
|
|||
name: 'Energy Performance Certificates (EPC)',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
|
||||
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
optOutUrl:
|
||||
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
|
|
@ -158,7 +159,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
'There are a few common reasons. If a property has never been sold (or was last sold before Land Registry digital records began in 1995), there will be no price record. EPC data may be missing if the property has never had an energy assessment, or if the owner has opted out of public disclosure. Floor area, number of rooms, and energy ratings all come from EPC records, so a missing EPC means those fields will be blank. Finally, the fuzzy address matching between EPC and Land Registry records occasionally fails for unusual addresses.',
|
||||
},
|
||||
{
|
||||
question: 'How do I find areas that match what I\'m looking for?',
|
||||
question: "How do I find areas that match what I'm looking for?",
|
||||
answer:
|
||||
'Use the Filters panel on the left. Add filters for the features you care about - for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source - this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
|
||||
},
|
||||
|
|
@ -168,7 +169,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
'Click the travel time icon in the filters panel, search for a destination (any address or postcode in England), and choose a transport mode (car, bicycle, walking, or public transport). The map will colour hexagons by average journey time to that destination. You can add a time range filter to only show areas within, say, 30 minutes. Multiple destinations can be added simultaneously to find areas that are well-connected to several places.',
|
||||
},
|
||||
{
|
||||
question: 'Can I export the data I\'m looking at?',
|
||||
question: "Can I export the data I'm looking at?",
|
||||
answer:
|
||||
'Yes. Use the export button to download the currently filtered properties within your map view as an Excel spreadsheet. The export respects all your active filters, so you can narrow down to exactly the properties you want before downloading.',
|
||||
},
|
||||
|
|
@ -313,10 +314,11 @@ export default function LearnPage() {
|
|||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-warm-700'
|
||||
}`}
|
||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-warm-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ export default memo(function AiFilterInput({
|
|||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">— describe what you're looking for</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
— describe what you're looking for
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
|
|
@ -141,11 +143,7 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
</button>
|
||||
</form>
|
||||
{loading && (
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
|
||||
{loadingMessage}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
|
||||
{showExamples && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
|
|
@ -162,12 +160,13 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
{error && errorType === 'verification' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
|
||||
Please verify your email address to use AI-powered search. Check your inbox for a
|
||||
verification link.
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'limit' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'error' && (
|
||||
|
|
@ -176,14 +175,10 @@ export default memo(function AiFilterInput({
|
|||
</p>
|
||||
)}
|
||||
{summary && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
|
||||
{summary}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{summary}</p>
|
||||
)}
|
||||
{notes && !error && !loading && (
|
||||
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
|
||||
{notes}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">{notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
|
|
@ -107,8 +107,8 @@ export default function AreaPane({
|
|||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties
|
||||
in this {isPostcode ? 'postcode' : 'area'}
|
||||
Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
|
||||
{isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
</p>
|
||||
{stats && stats.count > 0 && (
|
||||
|
|
@ -142,15 +142,11 @@ export default function AreaPane({
|
|||
<HistogramLegend />
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,7 @@ export default function ExternalSearchLinks({
|
|||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{urls.rightmove ? (
|
||||
<a
|
||||
href={urls.rightmove}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
|
|
@ -58,20 +53,10 @@ export default function ExternalSearchLinks({
|
|||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={urls.onthemarket}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.onthemarket} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
OnTheMarket
|
||||
</a>
|
||||
<a
|
||||
href={urls.zoopla}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
Zoopla
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ import { FeatureActions } from '../ui/FeatureIcons';
|
|||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import {
|
||||
TRANSPORT_MODES,
|
||||
MODE_LABELS,
|
||||
MODE_DESCRIPTIONS,
|
||||
type TransportMode,
|
||||
type TravelTimeEntry,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
|
|
@ -45,7 +51,7 @@ export default function FeatureBrowser({
|
|||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
travelTimeEntries: _travelTimeEntries,
|
||||
onAddTravelTimeEntry,
|
||||
isLicensed,
|
||||
onUpgradeClick,
|
||||
|
|
@ -77,12 +83,13 @@ export default function FeatureBrowser({
|
|||
// Only show modes that have precomputed travel time data
|
||||
const visibleModes = useMemo(
|
||||
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
|
||||
[availableTravelModes],
|
||||
[availableTravelModes]
|
||||
);
|
||||
|
||||
const showTravelModes =
|
||||
visibleModes.length > 0 &&
|
||||
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||
(!search ||
|
||||
'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -102,36 +109,40 @@ export default function FeatureBrowser({
|
|||
{visibleModes.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) &&
|
||||
visibleModes.map((mode) => {
|
||||
const ModeIcon = MODE_ICONS[mode];
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
>
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => onAddTravelTimeEntry(mode)}
|
||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
||||
>
|
||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{grouped.map((group) => {
|
||||
|
|
@ -203,8 +214,15 @@ export default function FeatureBrowser({
|
|||
>
|
||||
Upgrade to full map
|
||||
</button>
|
||||
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
|
||||
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
|
|
|
|||
|
|
@ -49,16 +49,10 @@ function SliderLabels({
|
|||
const labels = displayValues || value;
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${leftPct}%` }}
|
||||
>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
|
||||
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
|
||||
</span>
|
||||
<span
|
||||
className="absolute -translate-x-1/2"
|
||||
style={{ left: `${rightPct}%` }}
|
||||
>
|
||||
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
|
||||
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -175,7 +169,8 @@ export default memo(function Filters({
|
|||
}, [filters]);
|
||||
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
() =>
|
||||
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType, isAllowed]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
|
|
@ -271,7 +266,10 @@ export default memo(function Filters({
|
|||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -287,7 +285,16 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
|
||||
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} summary={aiFilterSummary} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
error={aiFilterError}
|
||||
errorType={aiFilterErrorType}
|
||||
notes={aiFilterNotes}
|
||||
summary={aiFilterSummary}
|
||||
onSubmit={onAiFilterSubmit}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
|
|
@ -332,19 +339,21 @@ export default memo(function Filters({
|
|||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
useBest={entry.useBest}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) =>
|
||||
onTravelTimeSetDestination(index, slug, label)
|
||||
}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -385,7 +394,11 @@ export default memo(function Filters({
|
|||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
size="sm"
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
|
|
@ -419,7 +432,10 @@ export default memo(function Filters({
|
|||
const displayValue =
|
||||
isActive && dragValue
|
||||
? dragValue
|
||||
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
|
||||
: (filters[feature.name] as [number, number]) || [
|
||||
hist?.min ?? feature.min!,
|
||||
hist?.max ?? feature.max!,
|
||||
];
|
||||
const scale = percentileScales.get(feature.name);
|
||||
const dataMin = hist?.min ?? feature.min!;
|
||||
const dataMax = hist?.max ?? feature.max!;
|
||||
|
|
@ -442,7 +458,12 @@ export default memo(function Filters({
|
|||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
|
|
@ -454,7 +475,9 @@ export default memo(function Filters({
|
|||
<Slider
|
||||
min={scale ? 0 : feature.min!}
|
||||
max={scale ? 100 : feature.max!}
|
||||
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
|
||||
step={
|
||||
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
|
||||
}
|
||||
value={sliderValue}
|
||||
onValueChange={
|
||||
scale
|
||||
|
|
@ -462,14 +485,19 @@ export default memo(function Filters({
|
|||
const step = feature.step ?? 1;
|
||||
const snap = (v: number) => Math.round(v / step) * step;
|
||||
onDragChange([
|
||||
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
|
||||
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
|
||||
pMin <= 0
|
||||
? (hist?.min ?? feature.min!)
|
||||
: snap(scale.toValue(pMin)),
|
||||
pMax >= 100
|
||||
? (hist?.max ?? feature.max!)
|
||||
: snap(scale.toValue(pMax)),
|
||||
]);
|
||||
}
|
||||
: ([min, max]) => onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
: ([min, max]) =>
|
||||
onDragChange([
|
||||
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
|
||||
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
|
||||
])
|
||||
}
|
||||
onPointerDown={() => onDragStart(feature.name)}
|
||||
onPointerUp={() => onDragEnd()}
|
||||
|
|
@ -521,8 +549,8 @@ export default memo(function Filters({
|
|||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Start with your must-haves, then layer on nice-to-haves.
|
||||
The map narrows down as you add filters — the areas that survive are your best matches.
|
||||
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
|
||||
add filters — the areas that survive are your best matches.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
@ -530,9 +558,9 @@ export default memo(function Filters({
|
|||
1. Budget & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Set your price range, minimum floor area, and property type.
|
||||
If you need a lease over freehold (or vice versa), filter for that too.
|
||||
This eliminates most of the map immediately.
|
||||
Set your price range, minimum floor area, and property type. If you need a lease
|
||||
over freehold (or vice versa), filter for that too. This eliminates most of the map
|
||||
immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -541,9 +569,9 @@ export default memo(function Filters({
|
|||
2. Commute & transport
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Add a travel time filter to your workplace — choose public transport or cycling
|
||||
and set your maximum tolerable commute. You can also filter by
|
||||
how many stations are within walking distance.
|
||||
Add a travel time filter to your workplace — choose public transport or
|
||||
cycling and set your maximum tolerable commute. You can also filter by how many
|
||||
stations are within walking distance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -552,9 +580,9 @@ export default memo(function Filters({
|
|||
3. Safety & environment
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Use the crime filters to cap serious or minor crime rates.
|
||||
Check road noise levels if you're a light sleeper, and
|
||||
environmental risk filters for ground stability concerns.
|
||||
Use the crime filters to cap serious or minor crime rates. Check road noise levels
|
||||
if you're a light sleeper, and environmental risk filters for ground stability
|
||||
concerns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -563,9 +591,9 @@ export default memo(function Filters({
|
|||
4. Schools & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and
|
||||
secondary schools nearby. The education deprivation score captures
|
||||
broader area-level attainment.
|
||||
Filter by the number of Ofsted-rated Good or Outstanding primary and secondary
|
||||
schools nearby. The education deprivation score captures broader area-level
|
||||
attainment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -574,9 +602,8 @@ export default memo(function Filters({
|
|||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Want restaurants, parks, or grocery shops within walking distance?
|
||||
Filter by nearby amenity counts. Broadband speed filters help if
|
||||
you work from home.
|
||||
Want restaurants, parks, or grocery shops within walking distance? Filter by nearby
|
||||
amenity counts. Broadband speed filters help if you work from home.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -585,16 +612,15 @@ export default memo(function Filters({
|
|||
6. Energy & running costs
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
EPC ratings from A to G indicate energy efficiency.
|
||||
Filter for better ratings to find homes with lower bills and
|
||||
fewer upgrade headaches.
|
||||
EPC ratings from A to G indicate energy efficiency. Filter for better ratings to
|
||||
find homes with lower bills and fewer upgrade headaches.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-warm-500 dark:text-warm-400 italic">
|
||||
Tip: if nothing survives your filters, relax one constraint at a time
|
||||
to see which compromise unlocks the most options.
|
||||
Tip: if nothing survives your filters, relax one constraint at a time to see which
|
||||
compromise unlocks the most options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,18 @@ interface HoverCardProps {
|
|||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
|
||||
export default memo(function HoverCard({
|
||||
x,
|
||||
y,
|
||||
id,
|
||||
isPostcode,
|
||||
data,
|
||||
filters,
|
||||
features,
|
||||
}: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(
|
||||
() => new Map(features.map((f) => [f.name, f])),
|
||||
[features]
|
||||
);
|
||||
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
|
|
|
|||
|
|
@ -116,9 +116,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
|
||||
)}
|
||||
{!isLast && <div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />}
|
||||
</div>
|
||||
<div className="pb-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
|
|
@ -135,7 +133,11 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
|
||||
export default function JourneyInstructions({
|
||||
postcode,
|
||||
entries,
|
||||
label,
|
||||
}: JourneyInstructionsProps) {
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
|
|
@ -192,9 +194,7 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
|
|||
)
|
||||
.catch((err) => {
|
||||
logNonAbortError('journey', err);
|
||||
setJourneys((prev) =>
|
||||
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
|
||||
);
|
||||
setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -78,10 +78,7 @@ export default function LocationSearch({
|
|||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.label)}`,
|
||||
authHeaders(),
|
||||
);
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
|
|
@ -102,7 +99,7 @@ export default function LocationSearch({
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[onFlyTo, onLocationSearched, isMobile, search],
|
||||
[onFlyTo, onLocationSearched, isMobile, search]
|
||||
);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
|
|
@ -120,7 +117,12 @@ export default function LocationSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-tutorial="search"
|
||||
className="absolute top-3 left-3 z-10 flex flex-col"
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<PlaceSearchInput
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import type {
|
|||
} from '../../types';
|
||||
|
||||
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR } from '../../lib/consts';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -114,8 +120,7 @@ export default memo(function Map({
|
|||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||
const viewState =
|
||||
screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -245,10 +250,7 @@ export default memo(function Map({
|
|||
Transport
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-teal-600 font-semibold"
|
||||
style={{ fontSize: '1rem' }}
|
||||
>
|
||||
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
|
||||
perfect-postcode.co.uk
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -256,7 +258,11 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
onLocationSearched={onLocationSearched}
|
||||
onMouseEnter={handleMouseLeave}
|
||||
/>
|
||||
{!hideLegend &&
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
|
|
@ -280,7 +286,9 @@ export default memo(function Map({
|
|||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
enumValues={
|
||||
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
|
||||
}
|
||||
theme={theme}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts';
|
||||
import {
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
DENSITY_GRADIENT_DARK,
|
||||
ENUM_PALETTE,
|
||||
} from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
|
|
@ -34,7 +39,9 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
|
|||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">{label}</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -106,7 +113,10 @@ export default function MapLegend({
|
|||
) : (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
|
||||
{rangeMin}
|
||||
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
|
||||
<div
|
||||
className="h-2.5 rounded flex-1 min-w-[40px]"
|
||||
style={{ background: gradientStyle }}
|
||||
/>
|
||||
{rangeMax}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
POICategoryGroup,
|
||||
ViewState,
|
||||
PostcodeGeometry,
|
||||
Property,
|
||||
} from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
import Map from './Map';
|
||||
|
|
@ -103,9 +110,7 @@ export default function MapPage({
|
|||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||
const bookmarkToastDismissed = useRef(
|
||||
localStorage.getItem('bookmark_toast_dismissed') === '1'
|
||||
);
|
||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
||||
|
||||
const handleSavePropertyWithToast = useCallback(
|
||||
(property: Property) => {
|
||||
|
|
@ -158,8 +163,7 @@ export default function MapPage({
|
|||
max: entry.timeRange?.[1],
|
||||
})),
|
||||
};
|
||||
const hasContext =
|
||||
Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
|
||||
|
||||
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
|
||||
if (!result) return;
|
||||
|
|
@ -174,7 +178,13 @@ export default function MapPage({
|
|||
}));
|
||||
travelTime.handleSetEntries(newEntries);
|
||||
},
|
||||
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters]
|
||||
[
|
||||
aiFilters.fetchAiFilters,
|
||||
handleSetFilters,
|
||||
travelTime.handleSetEntries,
|
||||
travelTime.activeEntries,
|
||||
filters,
|
||||
]
|
||||
);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
|
|
@ -246,7 +256,14 @@ export default function MapPage({
|
|||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
||||
useUrlSync(
|
||||
mapData.currentView,
|
||||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
selection.rightPaneTab,
|
||||
travelTime.entries
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
|
|
@ -268,11 +285,18 @@ export default function MapPage({
|
|||
if (!res.ok) throw new Error('Postcode not found');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
|
||||
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
|
||||
selection.handleLocationSearch(data.postcode, data.geometry);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
})
|
||||
.then(
|
||||
(data: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
}) => {
|
||||
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
|
||||
selection.handleLocationSearch(data.postcode, data.geometry);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
// Silently fail — postcode might not exist
|
||||
});
|
||||
|
|
@ -397,7 +421,13 @@ export default function MapPage({
|
|||
window.__screenshot_ready = true;
|
||||
}
|
||||
}
|
||||
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
|
||||
}, [
|
||||
screenshotMode,
|
||||
mapData.loading,
|
||||
mapData.data.length,
|
||||
mapData.postcodeData.length,
|
||||
mapData.usePostcodeView,
|
||||
]);
|
||||
|
||||
const bookmarkToast = showBookmarkToast && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
|
|
@ -580,7 +610,9 @@ export default function MapPage({
|
|||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -641,9 +673,7 @@ export default function MapPage({
|
|||
inline
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderFilters()}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">{renderFilters()}</div>
|
||||
</div>
|
||||
|
||||
{mobileDrawerOpen && selection.selectedHexagon && (
|
||||
|
|
@ -746,7 +776,9 @@ export default function MapPage({
|
|||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -794,9 +826,7 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selection.rightPaneTab === 'properties'
|
||||
? renderPropertiesPane()
|
||||
: renderAreaPane()}
|
||||
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default function MobileDrawer({
|
|||
tab,
|
||||
onTabChange,
|
||||
}: MobileDrawerProps) {
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function POIPane({
|
|||
groups,
|
||||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
poiCount,
|
||||
poiCount: _poiCount,
|
||||
onNavigateToSource,
|
||||
}: POIPaneProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -136,7 +136,6 @@ export default function POIPane({
|
|||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -234,7 +234,9 @@ function PropertyCard({
|
|||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
|
||||
<div
|
||||
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
|
||||
>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
Last sold: £{formatNumber(price)}
|
||||
|
|
@ -265,9 +267,7 @@ function PropertyCard({
|
|||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(estimatedPrice)}
|
||||
</span>
|
||||
{estPricePerSqm !== undefined && (
|
||||
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
|
||||
)}
|
||||
{estPricePerSqm !== undefined && <span> (£{formatNumber(estPricePerSqm)}/m²)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function TravelTimeCard({
|
|||
(selectedSlug: string, selectedLabel: string) => {
|
||||
onSetDestination(selectedSlug, selectedLabel);
|
||||
},
|
||||
[onSetDestination],
|
||||
[onSetDestination]
|
||||
);
|
||||
|
||||
const sliderMin = 0;
|
||||
|
|
@ -68,7 +68,9 @@ export function TravelTimeCard({
|
|||
const ModeIcon = MODE_ICONS[mode];
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
<div
|
||||
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -86,7 +88,11 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Stop previewing' : 'Preview on map'}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -126,8 +132,8 @@ export function TravelTimeCard({
|
|||
? ' by car, based on typical road speeds and the road network.'
|
||||
: mode === 'bicycle'
|
||||
? ' by bicycle, using cycle-friendly routes.'
|
||||
: ' on foot, using pedestrian paths and pavements.'}
|
||||
{' '}Use the slider to filter areas within your preferred commute time.
|
||||
: ' on foot, using pedestrian paths and pavements.'}{' '}
|
||||
Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -135,8 +141,8 @@ export function TravelTimeCard({
|
|||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
|
||||
if you time your departure to catch optimal connections. The default uses the{' '}
|
||||
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey if
|
||||
you time your departure to catch optimal connections. The default uses the{' '}
|
||||
<strong>median</strong>, representing a typical journey regardless of when you leave.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
|
|
@ -156,12 +162,8 @@ export function TravelTimeCard({
|
|||
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
|
||||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute left-0">
|
||||
{formatFilterValue(displayRange[0])} min
|
||||
</span>
|
||||
<span className="absolute right-0">
|
||||
{formatFilterValue(displayRange[1])} min
|
||||
</span>
|
||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
|
||||
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function tierLabel(tier: PricingTier, index: number): string {
|
|||
export default function PricingPage({
|
||||
onOpenDashboard,
|
||||
user,
|
||||
onLoginClick,
|
||||
onLoginClick: _onLoginClick,
|
||||
onRegisterClick,
|
||||
}: {
|
||||
onOpenDashboard: () => void;
|
||||
|
|
@ -87,8 +87,7 @@ export default function PricingPage({
|
|||
const tier = pricing.tiers[i];
|
||||
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
|
||||
currentTierIndex = i;
|
||||
spotsRemaining =
|
||||
tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
||||
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +98,8 @@ export default function PricingPage({
|
|||
if (currentTierIndex === 0) return;
|
||||
const container = scrollRef.current;
|
||||
const card = activeCardRef.current;
|
||||
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
const scrollLeft =
|
||||
card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollLeft = Math.max(0, scrollLeft);
|
||||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
|
|
@ -117,9 +117,7 @@ export default function PricingPage({
|
|||
disabled={license.checkingOut}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
)}
|
||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
|
|
@ -143,7 +141,8 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[90vw] h-[80vh] -top-[10%] -left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 20s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -151,7 +150,8 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[80vw] h-[70vh] top-[5%] left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-2 18s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -159,7 +159,8 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[85vw] h-[90vh] -top-[5%] -right-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-4 25s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -167,7 +168,8 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[75vw] h-[70vh] -bottom-[5%] right-[5%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-3 22s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -175,7 +177,8 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[80vw] h-[75vh] -bottom-[10%] -left-[10%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-5 24s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
|
@ -183,16 +186,15 @@ export default function PricingPage({
|
|||
<div
|
||||
className="absolute w-[70vw] h-[60vh] top-[20%] left-[20%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
|
||||
background:
|
||||
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 16s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">Early access pricing</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
Pay once, access forever. The earlier you join, the less you pay.
|
||||
</p>
|
||||
|
|
@ -200,9 +202,9 @@ export default function PricingPage({
|
|||
|
||||
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
|
||||
<p className="text-warm-400 text-sm leading-relaxed mb-2">
|
||||
Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees,
|
||||
£500 for a survey. Get the wrong area and you're stuck with a long
|
||||
commute, bad schools, or a road you didn't know about.
|
||||
Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500
|
||||
for a survey. Get the wrong area and you're stuck with a long commute, bad schools,
|
||||
or a road you didn't know about.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
Less than your survey costs. Vastly more useful.
|
||||
|
|
@ -216,145 +218,151 @@ export default function PricingPage({
|
|||
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : pricing ? (
|
||||
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
|
||||
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled =
|
||||
tier.up_to !== null &&
|
||||
pricing.licensed_count >= tier.up_to;
|
||||
const filledInTier = isCurrent
|
||||
? pricing.licensed_count -
|
||||
(i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||
: 0;
|
||||
const tierSlots = tier.slots;
|
||||
const fillPercent = isFilled
|
||||
? 100
|
||||
: isCurrent && tierSlots > 0
|
||||
? (filledInTier / tierSlots) * 100
|
||||
<div
|
||||
className="relative mb-12"
|
||||
style={{
|
||||
marginLeft: 'calc(-50vw + 50%)',
|
||||
marginRight: 'calc(-50vw + 50%)',
|
||||
width: '100vw',
|
||||
}}
|
||||
>
|
||||
{scrolledLeft && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled = tier.up_to !== null && pricing.licensed_count >= tier.up_to;
|
||||
const filledInTier = isCurrent
|
||||
? pricing.licensed_count - (i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||
: 0;
|
||||
const tierSlots = tier.slots;
|
||||
const fillPercent = isFilled
|
||||
? 100
|
||||
: isCurrent && tierSlots > 0
|
||||
? (filledInTier / tierSlots) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? activeCardRef : undefined}
|
||||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||
isCurrent
|
||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||
: 'border-warm-700 shadow-md'
|
||||
} ${isFilled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
|
||||
Current tier
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-8 text-center ${
|
||||
key={i}
|
||||
ref={isCurrent ? activeCardRef : undefined}
|
||||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||
isCurrent
|
||||
? 'bg-gradient-to-br from-navy-950 to-teal-900'
|
||||
: 'bg-white dark:bg-warm-800'
|
||||
}`}
|
||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||
: 'border-warm-700 shadow-md'
|
||||
} ${isFilled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
|
||||
{isCurrent && (
|
||||
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
|
||||
Current tier
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`px-6 py-8 text-center ${
|
||||
isCurrent
|
||||
? 'text-teal-300'
|
||||
: 'text-warm-500 dark:text-warm-400'
|
||||
? 'bg-gradient-to-br from-navy-950 to-teal-900'
|
||||
: 'bg-white dark:bg-warm-800'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span
|
||||
className={`text-4xl font-extrabold ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-navy-950 dark:text-warm-100'
|
||||
<p
|
||||
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
|
||||
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{formatPrice(tier.price_pence)}
|
||||
</span>
|
||||
{tier.price_pence > 0 && (
|
||||
{tierLabel(tier, i)}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span
|
||||
className={`text-lg ${
|
||||
className={`text-4xl font-extrabold ${
|
||||
isCurrent
|
||||
? 'text-warm-400'
|
||||
: 'text-warm-400 dark:text-warm-500'
|
||||
? 'text-white'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-navy-950 dark:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
/lifetime
|
||||
{formatPrice(tier.price_pence)}
|
||||
</span>
|
||||
{tier.price_pence > 0 && (
|
||||
<span
|
||||
className={`text-lg ${
|
||||
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
|
||||
}`}
|
||||
>
|
||||
/lifetime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot
|
||||
{spotsRemaining !== 1 ? 's' : ''} remaining
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> Filled
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot
|
||||
{spotsRemaining !== 1 ? 's' : ''} remaining
|
||||
</p>
|
||||
{/* Progress bar for current tier */}
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
|
||||
<div className="h-full bg-teal-500" style={{ width: `${fillPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> Filled
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for current tier */}
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
|
||||
<div
|
||||
className="h-full bg-teal-500"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<>
|
||||
{ctaButton}
|
||||
{license.error && (
|
||||
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
{isCurrent ? (
|
||||
<>
|
||||
{ctaButton}
|
||||
{license.error && (
|
||||
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{isFree ? 'No credit card required' : '30-day money-back guarantee'}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{isFree
|
||||
? 'No credit card required'
|
||||
: '30-day money-back guarantee'}
|
||||
</p>
|
||||
</>
|
||||
) : isFilled ? (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
|
||||
Sold out
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
|
||||
Upcoming
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : isFilled ? (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
|
||||
Sold out
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
|
||||
Upcoming
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -363,7 +371,6 @@ export default function PricingPage({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ export function CollapsibleGroupHeader({
|
|||
children,
|
||||
}: CollapsibleGroupHeaderProps) {
|
||||
return (
|
||||
<button onClick={onToggle} className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Destination } from '../../hooks/useTravelDestinations';
|
||||
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
|
||||
|
|
@ -42,9 +36,7 @@ export function DestinationDropdown({
|
|||
if (!filter) return destinations;
|
||||
const lower = filter.toLowerCase();
|
||||
return destinations.filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(lower) ||
|
||||
d.city?.toLowerCase().includes(lower),
|
||||
(d) => d.name.toLowerCase().includes(lower) || d.city?.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [destinations, filter]);
|
||||
|
||||
|
|
@ -79,16 +71,14 @@ export function DestinationDropdown({
|
|||
setFilter('');
|
||||
setActiveIndex(-1);
|
||||
},
|
||||
[onSelect],
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev < filtered.length - 1 ? prev + 1 : prev,
|
||||
);
|
||||
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
|
|
@ -102,7 +92,7 @@ export function DestinationDropdown({
|
|||
setFilter('');
|
||||
}
|
||||
},
|
||||
[filtered, activeIndex, handleSelect],
|
||||
[filtered, activeIndex, handleSelect]
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
|
|
@ -170,10 +160,7 @@ export function DestinationDropdown({
|
|||
<span className="text-warm-700 dark:text-warm-200 truncate">
|
||||
{dest.name}
|
||||
{dest.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
({dest.city})
|
||||
</span>
|
||||
<span className="text-warm-400 dark:text-warm-500"> ({dest.city})</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -185,7 +172,9 @@ export function DestinationDropdown({
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}>
|
||||
<div
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
|
|
@ -194,9 +183,13 @@ export function DestinationDropdown({
|
|||
{loading ? (
|
||||
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
|
||||
) : (
|
||||
<MapPinIcon className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`} />
|
||||
<MapPinIcon
|
||||
className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}>
|
||||
<span
|
||||
className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,15 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite';
|
||||
export type Page =
|
||||
| 'home'
|
||||
| 'dashboard'
|
||||
| 'learn'
|
||||
| 'pricing'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invites'
|
||||
| 'invite';
|
||||
|
||||
export const PAGE_PATHS: Record<Page, string> = {
|
||||
home: '/',
|
||||
|
|
@ -128,27 +136,51 @@ export default function Header({
|
|||
{/* Desktop nav */}
|
||||
{!isMobile && (
|
||||
<nav className="flex items-center gap-2">
|
||||
<a href={PAGE_PATHS.dashboard} className={tabClass('dashboard')} onClick={(e) => navLink('dashboard', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.dashboard}
|
||||
className={tabClass('dashboard')}
|
||||
onClick={(e) => navLink('dashboard', e)}
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
{user && (
|
||||
<>
|
||||
<a href={PAGE_PATHS.saved} className={tabClass('saved')} onClick={(e) => navLink('saved', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.saved}
|
||||
className={tabClass('saved')}
|
||||
onClick={(e) => navLink('saved', e)}
|
||||
>
|
||||
Saved
|
||||
</a>
|
||||
<a href={PAGE_PATHS.invites} className={tabClass('invites')} onClick={(e) => navLink('invites', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.invites}
|
||||
className={tabClass('invites')}
|
||||
onClick={(e) => navLink('invites', e)}
|
||||
>
|
||||
Invite
|
||||
</a>
|
||||
<a href={PAGE_PATHS.account} className={tabClass('account')} onClick={(e) => navLink('account', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.account}
|
||||
className={tabClass('account')}
|
||||
onClick={(e) => navLink('account', e)}
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<a href={PAGE_PATHS.learn} className={tabClass('learn')} onClick={(e) => navLink('learn', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.learn}
|
||||
className={tabClass('learn')}
|
||||
onClick={(e) => navLink('learn', e)}
|
||||
>
|
||||
Learn
|
||||
</a>
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && (
|
||||
<a href={PAGE_PATHS.pricing} className={tabClass('pricing')} onClick={(e) => navLink('pricing', e)}>
|
||||
<a
|
||||
href={PAGE_PATHS.pricing}
|
||||
className={tabClass('pricing')}
|
||||
onClick={(e) => navLink('pricing', e)}
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="text-5xl mb-3">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">
|
||||
Your lifetime access is now active.
|
||||
</p>
|
||||
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ export default function MobileMenu({
|
|||
{mobileNavItem('home', 'Home')}
|
||||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
|
||||
{user?.subscription !== 'licensed' &&
|
||||
!user?.isAdmin &&
|
||||
mobileNavItem('pricing', 'Pricing')}
|
||||
{user && mobileNavItem('saved', 'Saved')}
|
||||
{user && mobileNavItem('invites', 'Invite')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ interface SearchHook {
|
|||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleInputChange: (value: string) => void;
|
||||
handleKeyDown: (
|
||||
e: React.KeyboardEvent,
|
||||
onSelect: (result: SearchResult) => void,
|
||||
) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||
}
|
||||
|
||||
interface PlaceSearchInputProps {
|
||||
|
|
@ -56,16 +53,20 @@ export function PlaceSearchInput({
|
|||
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
|
||||
style={
|
||||
portal && dropdownPos
|
||||
? { position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 50 }
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: dropdownPos.top,
|
||||
left: dropdownPos.left,
|
||||
width: dropdownPos.width,
|
||||
zIndex: 50,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{search.results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer ${
|
||||
|
|
@ -83,23 +84,16 @@ export function PlaceSearchInput({
|
|||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
({result.city})
|
||||
</span>
|
||||
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -133,10 +127,12 @@ export function PlaceSearchInput({
|
|||
/>
|
||||
)}
|
||||
|
||||
{showDropdown && (portal
|
||||
? createPortal(dropdown, document.body)
|
||||
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
|
||||
)}
|
||||
{showDropdown &&
|
||||
(portal ? (
|
||||
createPortal(dropdown, document.body)
|
||||
) : (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ function Digit({ char, delay, active }: { char: string; delay: number; active: b
|
|||
}}
|
||||
>
|
||||
{DIGITS.split('').map((d) => (
|
||||
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
|
||||
<span
|
||||
key={d}
|
||||
className="block text-center"
|
||||
style={{ height: `${H}em`, lineHeight: `${H}em` }}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,7 @@ export default function UpgradeModal({
|
|||
}, []);
|
||||
|
||||
const priceLabel =
|
||||
pricePence === null
|
||||
? '...'
|
||||
: pricePence === 0
|
||||
? 'Free'
|
||||
: `\u00A3${pricePence / 100}`;
|
||||
pricePence === null ? '...' : pricePence === 0 ? 'Free' : `\u00A3${pricePence / 100}`;
|
||||
const isFree = pricePence === 0;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
|
|
@ -76,9 +72,7 @@ export default function UpgradeModal({
|
|||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||
{priceLabel}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>
|
||||
)}
|
||||
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>}
|
||||
</div>
|
||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||
{isFree
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
||||
export default function UserMenu({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ export function LogoIcon({ className = 'w-4 h-4' }: IconProps) {
|
|||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 2L20.7 7v10L12 22l-8.7-5V7z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2L20.7 7v10L12 22l-8.7-5V7z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.5 12.5l2.5 2.5 4.5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) {
|
|||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
|
||||
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
|
||||
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
|
||||
<path
|
||||
d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z"
|
||||
opacity=".7"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z"
|
||||
opacity=".4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { FeatureFilters } from '../types';
|
||||
import type { TransportMode, TravelTimeEntry } from './useTravelTime';
|
||||
import type { TransportMode } from './useTravelTime';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
export interface AiTravelTimeFilter {
|
||||
|
|
@ -37,10 +37,7 @@ interface UseAiFiltersResult {
|
|||
}
|
||||
|
||||
/** Build a human-readable summary of the AI result. */
|
||||
function buildSummary(
|
||||
filters: FeatureFilters,
|
||||
travelTimeFilters: AiTravelTimeFilter[]
|
||||
): string {
|
||||
function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
|
||||
export function useCollapsibleGroups(): [
|
||||
Set<string>,
|
||||
(name: string) => void,
|
||||
(name: string) => void,
|
||||
] {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = useCallback((name: string) => {
|
||||
|
|
|
|||
|
|
@ -24,13 +24,9 @@ import {
|
|||
POI_CLUSTER_MAX_ZOOM,
|
||||
} from '../lib/consts';
|
||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||
import {
|
||||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from './useTravelTime';
|
||||
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
|
||||
|
||||
interface UseDeckLayersProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeFeature[];
|
||||
|
|
@ -314,8 +310,17 @@ export function useDeckLayers({
|
|||
if (!entry.timeRange || !entry.slug) continue;
|
||||
const fk = travelFieldKey(entry);
|
||||
const modeVal = d[`avg_${fk}`];
|
||||
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
if (
|
||||
modeVal == null ||
|
||||
(modeVal as number) < entry.timeRange[0] ||
|
||||
(modeVal as number) > entry.timeRange[1]
|
||||
) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +334,12 @@ export function useDeckLayers({
|
|||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
|
|
@ -423,8 +433,17 @@ export function useDeckLayers({
|
|||
if (!entry.timeRange || !entry.slug) continue;
|
||||
const fk = travelFieldKey(entry);
|
||||
const modeVal = d[`avg_${fk}`];
|
||||
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
if (
|
||||
modeVal == null ||
|
||||
(modeVal as number) < entry.timeRange[0] ||
|
||||
(modeVal as number) > entry.timeRange[1]
|
||||
) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,7 +457,12 @@ export function useDeckLayers({
|
|||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
|
|
@ -673,8 +697,7 @@ export function useDeckLayers({
|
|||
id: 'poi-cluster-text',
|
||||
data: clusters,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getText: (d) =>
|
||||
d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count),
|
||||
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 255],
|
||||
fontWeight: 700,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
export function useDropdownPosition(
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
) {
|
||||
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ interface UseHexagonSelectionOptions {
|
|||
journeyDest?: JourneyDest | null;
|
||||
}
|
||||
|
||||
export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) {
|
||||
export function useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution,
|
||||
journeyDest,
|
||||
}: UseHexagonSelectionOptions) {
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
const [propertiesTotal, setPropertiesTotal] = useState(0);
|
||||
|
|
@ -39,8 +44,9 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
|
|||
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
|
||||
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
|
||||
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
|
||||
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
|
||||
useState<PostcodeGeometry | null>(null);
|
||||
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
|
||||
|
|
@ -204,7 +210,13 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
|
|||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||
}, [
|
||||
selectedHexagon,
|
||||
properties.length,
|
||||
loadingProperties,
|
||||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
]);
|
||||
|
||||
const handleLoadMoreProperties = useCallback(() => {
|
||||
if (!selectedHexagon) return;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ function normalizePostcode(s: string): string {
|
|||
|
||||
export type SearchResult =
|
||||
| { type: 'postcode'; label: string }
|
||||
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
|
||||
| {
|
||||
type: 'place';
|
||||
name: string;
|
||||
slug: string;
|
||||
place_type: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
city?: string;
|
||||
};
|
||||
|
||||
export function useLocationSearch(mode?: string) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
|
@ -29,60 +37,63 @@ export function useLocationSearch(mode?: string) {
|
|||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setQuery(value);
|
||||
setActiveIndex(-1);
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value);
|
||||
setActiveIndex(-1);
|
||||
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
||||
if (mode) params.set('mode', mode);
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal }),
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
place_type: p.place_type,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
city: p.city,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
}, 200);
|
||||
}, [mode]);
|
||||
|
||||
if (!mode && looksLikePostcode(trimmed)) {
|
||||
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.length < 2) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmed, limit: '7' });
|
||||
if (mode) params.set('mode', mode);
|
||||
const res = await fetch(
|
||||
`/api/places?${params}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const json: { places: PlaceResult[] } = await res.json();
|
||||
const placeResults: SearchResult[] = json.places.map((p) => ({
|
||||
type: 'place' as const,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
place_type: p.place_type,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
city: p.city,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
} catch (err) {
|
||||
logNonAbortError('places search', err);
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
[mode]
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
|
|
@ -112,7 +123,7 @@ export function useLocationSearch(mode?: string) {
|
|||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[results, activeIndex, query],
|
||||
[results, activeIndex, query]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
|
|
|||
|
|
@ -8,7 +8,14 @@ import type {
|
|||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
} from '../types';
|
||||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import {
|
||||
buildFilterString,
|
||||
apiUrl,
|
||||
assertOk,
|
||||
logNonAbortError,
|
||||
authHeaders,
|
||||
isAbortError,
|
||||
} from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
|
|
@ -243,8 +250,11 @@ export function useMapData({
|
|||
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
|
||||
|
||||
// Use drag data when it matches the current view feature, otherwise fall back to rawData
|
||||
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
|
||||
const data =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
|
||||
const effectivePostcodeData =
|
||||
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
|
||||
postcodeData;
|
||||
|
||||
// Compute p5/p95 from committed data for the viewed feature.
|
||||
// Always uses rawData/postcodeData (not drag preview data) so the color
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ export function useSavedProperties(userId: string | null) {
|
|||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
|
||||
data =
|
||||
typeof raw.data === 'string'
|
||||
? JSON.parse(raw.data)
|
||||
: (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ export function useTravelDestinations(mode: TransportMode) {
|
|||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
fetch(
|
||||
`/api/travel-destinations?mode=${mode}`,
|
||||
authHeaders({ signal: controller.signal }),
|
||||
)
|
||||
fetch(`/api/travel-destinations?mode=${mode}`, authHeaders({ signal: controller.signal }))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function useTravelModes() {
|
|||
})
|
||||
.then((data: { modes: TravelModeInfo[] }) => {
|
||||
const modes = new Set<TransportMode>(
|
||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
|
||||
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
|
||||
);
|
||||
setAvailableModes(modes);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,58 +40,39 @@ export function useTravelTime(initial?: TravelTimeInitial) {
|
|||
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
|
||||
|
||||
const handleAddEntry = useCallback((mode: TransportMode) => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ mode, slug: '', label: '', timeRange: null, useBest: false },
|
||||
]);
|
||||
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveEntry = useCallback((index: number) => {
|
||||
setEntries((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, timeRange: range } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleToggleBest = useCallback(
|
||||
(index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) =>
|
||||
i === index ? { ...entry, useBest: !entry.useBest } : entry
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleToggleBest = useCallback((index: number) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
|
||||
setEntries(newEntries);
|
||||
}, []);
|
||||
|
||||
/** Entries that have a destination selected (slug is set) */
|
||||
const activeEntries = useMemo(
|
||||
() => entries.filter((e) => e.slug !== ''),
|
||||
[entries]
|
||||
);
|
||||
const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ const STEPS: Step[] = [
|
|||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: 'Search Locations',
|
||||
content:
|
||||
'Search for a place name or postcode to jump directly to that area on the map.',
|
||||
content: 'Search for a place name or postcode to jump directly to that area on the map.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -47,13 +47,22 @@ h3 {
|
|||
|
||||
/* Hexagon background animations */
|
||||
@keyframes hex-drift {
|
||||
from { transform: translateX(-5vw); }
|
||||
to { transform: translateX(105vw); }
|
||||
from {
|
||||
transform: translateX(-5vw);
|
||||
}
|
||||
to {
|
||||
transform: translateX(105vw);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hex-bob {
|
||||
0%, 100% { transform: translateY(var(--bob)); }
|
||||
50% { transform: translateY(calc(var(--bob) * -1)); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(var(--bob));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(calc(var(--bob) * -1));
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade-in animation for homepage sections */
|
||||
|
|
@ -131,32 +140,65 @@ h3 {
|
|||
|
||||
/* Aurora gradient animation for pricing hero */
|
||||
@keyframes aurora-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -20px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 15px) scale(0.9); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -20px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 15px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(-40px, 20px) scale(1.15); }
|
||||
66% { transform: translate(25px, -30px) scale(0.95); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(-40px, 20px) scale(1.15);
|
||||
}
|
||||
66% {
|
||||
transform: translate(25px, -30px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
50% { transform: translate(20px, 25px) scale(1.1) rotate(3deg); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(20px, 25px) scale(1.1) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-4 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
40% { transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); }
|
||||
70% { transform: translate(15px, 20px) scale(0.9) rotate(1deg); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1) rotate(0deg);
|
||||
}
|
||||
40% {
|
||||
transform: translate(-35px, -15px) scale(1.2) rotate(-2deg);
|
||||
}
|
||||
70% {
|
||||
transform: translate(15px, 20px) scale(0.9) rotate(1deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora-5 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
30% { transform: translate(25px, 30px) scale(1.15); }
|
||||
60% { transform: translate(-30px, -10px) scale(0.95); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: translate(25px, 30px) scale(1.15);
|
||||
}
|
||||
60% {
|
||||
transform: translate(-30px, -10px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar for pill groups on mobile */
|
||||
|
|
@ -168,4 +210,3 @@ h3 {
|
|||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
|
|||
return `${window.location.origin}${data.url}`;
|
||||
}
|
||||
|
||||
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string {
|
||||
export function buildFilterString(
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
exclude?: string
|
||||
): string {
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) return '';
|
||||
return entries
|
||||
|
|
|
|||
|
|
@ -87,12 +87,7 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
|||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
||||
|
||||
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
|
||||
export const MINOR_POI_CATEGORIES = new Set([
|
||||
'Bus stop',
|
||||
'Taxi rank',
|
||||
'EV Charging',
|
||||
'Playground',
|
||||
]);
|
||||
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
|
||||
|
||||
/** Zoom level below which minor POI categories are hidden */
|
||||
export const MINOR_POI_ZOOM_THRESHOLD = 14;
|
||||
|
|
@ -217,16 +212,16 @@ export const STACKED_ENUM_GROUPS: Record<
|
|||
* 10 colors chosen for perceptual distinctness in both light and dark modes.
|
||||
*/
|
||||
export const ENUM_PALETTE: [number, number, number][] = [
|
||||
[59, 130, 246], // blue-500
|
||||
[249, 115, 22], // orange-500
|
||||
[139, 92, 246], // violet-500
|
||||
[34, 197, 94], // green-500
|
||||
[239, 68, 68], // red-500
|
||||
[6, 182, 212], // cyan-500
|
||||
[236, 72, 153], // pink-500
|
||||
[245, 158, 11], // amber-500
|
||||
[20, 184, 166], // teal-500
|
||||
[107, 114, 128], // gray-500
|
||||
[59, 130, 246], // blue-500
|
||||
[249, 115, 22], // orange-500
|
||||
[139, 92, 246], // violet-500
|
||||
[34, 197, 94], // green-500
|
||||
[239, 68, 68], // red-500
|
||||
[6, 182, 212], // cyan-500
|
||||
[236, 72, 153], // pink-500
|
||||
[245, 158, 11], // amber-500
|
||||
[20, 184, 166], // teal-500
|
||||
[107, 114, 128], // gray-500
|
||||
];
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
|
|||
|
||||
// Rightmove only accepts these specific price values
|
||||
const RIGHTMOVE_PRICES = [
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
|
||||
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000,
|
||||
270000, 280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000,
|
||||
550000, 600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000,
|
||||
2500000, 3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
|
||||
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
|
||||
170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000, 270000,
|
||||
280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000,
|
||||
600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, 2500000,
|
||||
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
|
||||
];
|
||||
|
||||
function nearestRadius(target: number, allowed: number[]): number {
|
||||
|
|
@ -99,15 +99,23 @@ export function buildPropertySearchUrls({
|
|||
|
||||
const bedroomFilter = filters['Bedrooms'];
|
||||
const minBedrooms =
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number' ? bedroomFilter[0] : undefined;
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number'
|
||||
? bedroomFilter[0]
|
||||
: undefined;
|
||||
const maxBedrooms =
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number' ? bedroomFilter[1] : undefined;
|
||||
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number'
|
||||
? bedroomFilter[1]
|
||||
: undefined;
|
||||
|
||||
const bathroomFilter = filters['Bathrooms'];
|
||||
const minBathrooms =
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number' ? bathroomFilter[0] : undefined;
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number'
|
||||
? bathroomFilter[0]
|
||||
: undefined;
|
||||
const maxBathrooms =
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
|
||||
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number'
|
||||
? bathroomFilter[1]
|
||||
: undefined;
|
||||
|
||||
const tenureFilter = filters['Leasehold/Freehold'];
|
||||
const selectedTenures =
|
||||
|
|
@ -123,8 +131,10 @@ export function buildPropertySearchUrls({
|
|||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
|
||||
if (minPrice !== undefined) rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
|
||||
if (maxPrice !== undefined) rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
|
||||
if (minPrice !== undefined)
|
||||
rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
|
||||
if (maxPrice !== undefined)
|
||||
rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
|
||||
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
|
||||
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
|
||||
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));
|
||||
|
|
|
|||
|
|
@ -494,10 +494,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
/**
|
||||
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
|
||||
*/
|
||||
export function getFeatureIcon(
|
||||
featureName: string,
|
||||
className: string,
|
||||
): ReactElement | null {
|
||||
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
|
||||
const paths = FEATURE_ICON_PATHS[featureName];
|
||||
if (!paths) return null;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -30,8 +30,18 @@ export function formatDuration(d: string): string {
|
|||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
export function formatTransactionDate(fractionalYear: number): string {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
|||
Property: TagIcon,
|
||||
};
|
||||
|
||||
export function getGroupIcon(
|
||||
group: string,
|
||||
): ComponentType<{ className?: string }> | null {
|
||||
export function getGroupIcon(group: string): ComponentType<{ className?: string }> | null {
|
||||
return GROUP_ICONS[group] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ import polars as pl
|
|||
from shapely.geometry import Point
|
||||
from tqdm import tqdm
|
||||
|
||||
from pipeline.download.pois import (
|
||||
from pipeline.utils.england_geometry import (
|
||||
ENGLAND_BBOX_EAST,
|
||||
ENGLAND_BBOX_NORTH,
|
||||
ENGLAND_BBOX_SOUTH,
|
||||
ENGLAND_BBOX_WEST,
|
||||
load_england_polygon,
|
||||
)
|
||||
from pipeline.utils.england_geometry import load_england_polygon
|
||||
|
||||
PLACE_TYPES = {"city"}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,42 @@ BEDROOM_SHEETS = {
|
|||
# Local authority district codes in England, https://en.wikipedia.org/wiki/ONS_coding_system
|
||||
LA_PREFIXES = ("E06", "E07", "E08", "E09")
|
||||
|
||||
# April 2021 + April 2023 LA reorganizations: old district codes → new unitary authority codes.
|
||||
# The ONS rental data (Oct 2022 – Sep 2023) uses the old codes; IoD 2025 uses the new ones.
|
||||
# We remap old → new and average the medians so the join in merge.py works.
|
||||
LA_CONSOLIDATION = {
|
||||
# North Northamptonshire (April 2021)
|
||||
"E07000150": "E06000061", # Corby
|
||||
"E07000152": "E06000061", # East Northamptonshire
|
||||
"E07000153": "E06000061", # Kettering
|
||||
"E07000156": "E06000061", # Wellingborough
|
||||
# West Northamptonshire (April 2021)
|
||||
"E07000151": "E06000062", # Daventry
|
||||
"E07000154": "E06000062", # Northampton
|
||||
"E07000155": "E06000062", # South Northamptonshire
|
||||
# Cumberland (April 2023)
|
||||
"E07000026": "E06000063", # Allerdale
|
||||
"E07000028": "E06000063", # Carlisle
|
||||
"E07000029": "E06000063", # Copeland
|
||||
# Westmorland and Furness (April 2023)
|
||||
"E07000027": "E06000064", # Barrow-in-Furness
|
||||
"E07000030": "E06000064", # Eden
|
||||
"E07000031": "E06000064", # South Lakeland
|
||||
# North Yorkshire (April 2023)
|
||||
"E07000163": "E06000065", # Craven
|
||||
"E07000164": "E06000065", # Hambleton
|
||||
"E07000165": "E06000065", # Harrogate
|
||||
"E07000166": "E06000065", # Richmondshire
|
||||
"E07000167": "E06000065", # Ryedale
|
||||
"E07000168": "E06000065", # Scarborough
|
||||
"E07000169": "E06000065", # Selby
|
||||
# Somerset (April 2023)
|
||||
"E07000187": "E06000066", # Mendip
|
||||
"E07000188": "E06000066", # Sedgemoor
|
||||
"E07000189": "E06000066", # South Somerset
|
||||
"E07000246": "E06000066", # Somerset West and Taunton
|
||||
}
|
||||
|
||||
|
||||
def _read_sheet(xls_path: Path, sheet_id: int, bedrooms: int) -> pl.DataFrame:
|
||||
"""Read one bedroom category sheet, extract LA-level median rents."""
|
||||
|
|
@ -61,6 +97,14 @@ def convert_to_parquet(xls_path: Path, parquet_path: Path) -> None:
|
|||
frames.append(df)
|
||||
|
||||
combined = pl.concat(frames)
|
||||
|
||||
# Remap old LA codes to new unitary authority codes and average medians
|
||||
combined = combined.with_columns(
|
||||
pl.col("area_code").replace(LA_CONSOLIDATION),
|
||||
).group_by("area_code", "bedrooms").agg(
|
||||
pl.col("median_monthly_rent").mean(),
|
||||
)
|
||||
|
||||
print(f"Combined: {combined.shape}")
|
||||
print(f"Non-null medians: {combined['median_monthly_rent'].drop_nulls().len()}")
|
||||
print(combined.head(10))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""England boundary polygon for accurate point-in-country filtering.
|
||||
"""England boundary polygon and bounding box for geographic filtering.
|
||||
|
||||
Uses shapely prepared geometry for fast single-point checks (osmium handlers)
|
||||
and vectorized shapely.contains for batch checks (Polars DataFrames).
|
||||
|
|
@ -12,6 +12,12 @@ import shapely
|
|||
from shapely.geometry import shape
|
||||
from shapely.prepared import PreparedGeometry, prep
|
||||
|
||||
# Bounding box for fast pre-filtering before the precise polygon check
|
||||
ENGLAND_BBOX_WEST = -6.45
|
||||
ENGLAND_BBOX_SOUTH = 49.85
|
||||
ENGLAND_BBOX_EAST = 1.77
|
||||
ENGLAND_BBOX_NORTH = 55.82
|
||||
|
||||
|
||||
def load_england_polygon(geojson_path: Path) -> PreparedGeometry:
|
||||
"""Load England boundary as a prepared shapely polygon for fast contains checks."""
|
||||
|
|
|
|||
|
|
@ -42,9 +42,14 @@ dev = [
|
|||
"ruff>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.deptry]
|
||||
# finder/ has its own pyproject.toml; analyses/ and scripts/ use transitive deps
|
||||
exclude = ["\\.venv", "finder", "analyses", "scripts"]
|
||||
|
||||
[tool.deptry.per_rule_ignores]
|
||||
# pyarrow/fastexcel: runtime backends for polars parquet/Excel I/O
|
||||
# jupyter/ipywidgets/pandas: needed to run analysis notebooks
|
||||
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas"]
|
||||
# jupyter/ipywidgets/pandas/plotly/folium: needed for analysis notebooks (excluded from scan)
|
||||
# flask/fake-useragent: used in finder/ (excluded, has own pyproject.toml)
|
||||
DEP002 = ["pyarrow", "fastexcel", "jupyter", "ipywidgets", "pandas", "plotly", "folium", "flask", "fake-useragent"]
|
||||
# pytest is a dev dependency, not a missing one
|
||||
DEP004 = ["pytest"]
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export class ScreenshotCache {
|
|||
}
|
||||
|
||||
getPath(key: string): string {
|
||||
return join(this.dir, `${key}.png`);
|
||||
return join(this.dir, `${key}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
58
screenshot/src/network-cache.ts
Normal file
58
screenshot/src/network-cache.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
interface CacheEntry {
|
||||
body: Buffer;
|
||||
headers: Record<string, string>;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 2000;
|
||||
|
||||
/**
|
||||
* In-memory cache for network responses (tiles, JS/CSS bundles, font glyphs).
|
||||
*
|
||||
* This is the single biggest performance win for non-GPU environments:
|
||||
* cached tiles and assets are served in microseconds instead of making
|
||||
* HTTP roundtrips, saving 1-3+ seconds per screenshot after warm-up.
|
||||
*/
|
||||
export class NetworkCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
|
||||
shouldCache(url: string): boolean {
|
||||
// Vector map tiles (protobuf)
|
||||
if (url.includes('/api/tiles/')) return true;
|
||||
// Static assets by extension
|
||||
if (/\.(js|css|woff2?|ttf|png|jpe?g|svg|ico|pbf|json)(\?|$)/i.test(url)) return true;
|
||||
// Font glyphs and emoji sprites under /assets/
|
||||
if (url.includes('/assets/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
get(url: string): CacheEntry | undefined {
|
||||
const entry = this.cache.get(url);
|
||||
if (entry) {
|
||||
this.hits++;
|
||||
return entry;
|
||||
}
|
||||
this.misses++;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
set(url: string, entry: CacheEntry): void {
|
||||
if (this.cache.size >= MAX_ENTRIES) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey) this.cache.delete(firstKey);
|
||||
}
|
||||
this.cache.set(url, entry);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
stats(): string {
|
||||
const total = this.hits + this.misses;
|
||||
const rate = total > 0 ? ((this.hits / total) * 100).toFixed(0) : '0';
|
||||
return `${this.size} entries, ${rate}% hit rate (${this.hits}/${total})`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,23 @@
|
|||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { chromium, type Browser, type BrowserContext, type Page, type Route } from 'playwright';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { NetworkCache } from './network-cache.js';
|
||||
|
||||
const VIEWPORT = { width: 1200, height: 630 };
|
||||
const NAVIGATION_TIMEOUT = 15_000;
|
||||
const TILE_BUFFER_MS = 500;
|
||||
const READY_TIMEOUT = 15_000;
|
||||
const RENDER_BUFFER_MS = 200;
|
||||
const POOL_SIZE = 3;
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let sharedContext: BrowserContext | null = null;
|
||||
const pagePool: Page[] = [];
|
||||
let warmingUp = false;
|
||||
const networkCache = new NetworkCache();
|
||||
|
||||
/**
|
||||
* Detect if a GPU is available for hardware-accelerated rendering.
|
||||
* Checks for DRI devices on Linux which indicate GPU + driver availability.
|
||||
*/
|
||||
function detectGpu(): boolean {
|
||||
try {
|
||||
// Check for DRI render nodes (Linux)
|
||||
if (existsSync('/dev/dri')) {
|
||||
const devices = readdirSync('/dev/dri');
|
||||
// Look for renderD* (render nodes) or card* (display cards)
|
||||
const hasGpu = devices.some((d) => d.startsWith('renderD') || d.startsWith('card'));
|
||||
if (hasGpu) {
|
||||
console.log(`GPU detected: /dev/dri contains ${devices.join(', ')}`);
|
||||
|
|
@ -27,7 +25,7 @@ function detectGpu(): boolean {
|
|||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - fall back to software rendering
|
||||
// Fall through to software rendering
|
||||
}
|
||||
console.log('No GPU detected, using SwiftShader software rendering');
|
||||
return false;
|
||||
|
|
@ -41,14 +39,31 @@ function getBrowserArgs(): string[] {
|
|||
'--disable-dev-shm-usage',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blocklist',
|
||||
// Prevent timer/renderer throttling in headless mode
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
// Ensure compositor finishes all stages before screenshot capture
|
||||
'--run-all-compositor-stages-before-draw',
|
||||
// Skip color profile conversion overhead
|
||||
'--force-color-profile=srgb',
|
||||
// Reduce non-essential browser features
|
||||
'--disable-domain-reliability',
|
||||
'--disable-features=TranslateUI',
|
||||
];
|
||||
|
||||
if (hasGpu) {
|
||||
// Use hardware GPU acceleration
|
||||
return [...baseArgs, '--enable-gpu', '--use-gl=egl'];
|
||||
} else {
|
||||
// Fall back to SwiftShader software rendering
|
||||
return [...baseArgs, '--disable-gpu', '--use-gl=swiftshader'];
|
||||
return [
|
||||
...baseArgs,
|
||||
'--disable-gpu',
|
||||
'--use-gl=swiftshader',
|
||||
// Run the GPU (SwiftShader) work in the browser process to eliminate
|
||||
// IPC overhead between browser and GPU processes — significant win
|
||||
// when all GL calls are CPU-bound anyway
|
||||
'--in-process-gpu',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,22 +76,71 @@ async function ensureBrowser(): Promise<Browser> {
|
|||
return browser;
|
||||
}
|
||||
|
||||
async function createPage(): Promise<Page> {
|
||||
async function ensureContext(): Promise<BrowserContext> {
|
||||
if (sharedContext) return sharedContext;
|
||||
|
||||
const instance = await ensureBrowser();
|
||||
const context = await instance.newContext({
|
||||
sharedContext = await instance.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
// Set up response caching at context level — all pages share the cache.
|
||||
// This means tiles fetched during pre-warm or a previous screenshot are
|
||||
// served instantly for subsequent screenshots.
|
||||
await sharedContext.route('**/*', async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
// Non-cacheable requests (API data, etc.) pass through directly
|
||||
if (!networkCache.shouldCache(url)) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache hit — fulfill from memory, zero network
|
||||
const cached = networkCache.get(url);
|
||||
if (cached) {
|
||||
await route.fulfill({
|
||||
status: cached.status,
|
||||
headers: cached.headers,
|
||||
body: cached.body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache miss — fetch, cache, fulfill
|
||||
try {
|
||||
const response = await route.fetch();
|
||||
const body = await response.body();
|
||||
const entry = {
|
||||
body,
|
||||
headers: response.headers(),
|
||||
status: response.status(),
|
||||
};
|
||||
networkCache.set(url, entry);
|
||||
await route.fulfill({
|
||||
status: entry.status,
|
||||
headers: entry.headers,
|
||||
body: entry.body,
|
||||
});
|
||||
} catch {
|
||||
await route.continue().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
return sharedContext;
|
||||
}
|
||||
|
||||
async function createPage(): Promise<Page> {
|
||||
const context = await ensureContext();
|
||||
return context.newPage();
|
||||
}
|
||||
|
||||
async function warmPool(): Promise<void> {
|
||||
if (warmingUp) return;
|
||||
warmingUp = true;
|
||||
|
||||
try {
|
||||
const instance = await ensureBrowser();
|
||||
while (pagePool.length < POOL_SIZE && instance.isConnected()) {
|
||||
while (pagePool.length < POOL_SIZE) {
|
||||
const page = await createPage();
|
||||
pagePool.push(page);
|
||||
}
|
||||
|
|
@ -86,17 +150,12 @@ async function warmPool(): Promise<void> {
|
|||
}
|
||||
|
||||
async function acquirePage(): Promise<Page> {
|
||||
// Try to get a page from the pool
|
||||
const page = pagePool.shift();
|
||||
if (page && !page.isClosed()) {
|
||||
// Refill pool in background
|
||||
warmPool().catch(() => {});
|
||||
return page;
|
||||
}
|
||||
|
||||
// No pooled page available, create one
|
||||
const newPage = await createPage();
|
||||
// Start warming pool in background
|
||||
warmPool().catch(() => {});
|
||||
return newPage;
|
||||
}
|
||||
|
|
@ -104,62 +163,88 @@ async function acquirePage(): Promise<Page> {
|
|||
async function releasePage(page: Page): Promise<void> {
|
||||
try {
|
||||
if (page.isClosed()) return;
|
||||
|
||||
// Reset page state for reuse
|
||||
// Navigate to blank to free rendered page resources (DOM, WebGL context)
|
||||
// while keeping the page object alive for V8 code cache reuse
|
||||
await page.goto('about:blank', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
if (!page.isClosed() && pagePool.length < POOL_SIZE) {
|
||||
pagePool.push(page);
|
||||
} else {
|
||||
await page.context().close().catch(() => {});
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
await page.context().close().catch(() => {});
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the browser and populate the network cache by loading the app once.
|
||||
* Subsequent screenshots benefit from cached JS/CSS bundles, map tiles,
|
||||
* and V8 compiled bytecode — eliminating cold-start latency.
|
||||
*/
|
||||
export async function initialize(appUrl: string): Promise<void> {
|
||||
console.log('Pre-warming browser and caches...');
|
||||
const page = await createPage();
|
||||
try {
|
||||
await page.goto(`${appUrl}/?screenshot=1`, {
|
||||
waitUntil: 'load',
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Wait for the app to fully load and cache all resources
|
||||
try {
|
||||
await page.waitForFunction('window.__screenshot_ready === true', {
|
||||
timeout: READY_TIMEOUT,
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal — cache will still have JS/CSS/tiles from the partial load
|
||||
}
|
||||
console.log(`Pre-warm complete. Cache: ${networkCache.stats()}`);
|
||||
} catch (err) {
|
||||
console.warn('Pre-warm failed (non-fatal):', err);
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
await warmPool();
|
||||
}
|
||||
|
||||
export async function takeScreenshot(url: string): Promise<Buffer> {
|
||||
const page = await acquirePage();
|
||||
|
||||
// Log browser console messages for diagnostics
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' || msg.type() === 'warning') {
|
||||
console.log(`[browser ${msg.type()}] ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
console.log(`[browser exception] ${err.message}`);
|
||||
});
|
||||
const t0 = performance.now();
|
||||
|
||||
try {
|
||||
// Use domcontentloaded instead of networkidle - let __screenshot_ready handle readiness
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
});
|
||||
const t1 = performance.now();
|
||||
if (response) {
|
||||
console.log(`Page loaded: ${response.status()} ${response.statusText()}`);
|
||||
console.log(` Navigate: ${(t1 - t0).toFixed(0)}ms (status ${response.status()})`);
|
||||
}
|
||||
|
||||
// Wait for the frontend to signal readiness
|
||||
// Wait for the frontend to signal that data is loaded and layers created
|
||||
try {
|
||||
await page.waitForFunction('window.__screenshot_ready === true', {
|
||||
timeout: NAVIGATION_TIMEOUT,
|
||||
timeout: READY_TIMEOUT,
|
||||
});
|
||||
console.log('Frontend signalled ready');
|
||||
} catch {
|
||||
console.warn('Timed out waiting for __screenshot_ready, proceeding with partial screenshot');
|
||||
console.warn(' Timed out waiting for __screenshot_ready');
|
||||
}
|
||||
const t2 = performance.now();
|
||||
console.log(` Ready: ${(t2 - t1).toFixed(0)}ms`);
|
||||
|
||||
// Extra buffer for map tiles to finish rendering
|
||||
await page.waitForTimeout(TILE_BUFFER_MS);
|
||||
// Brief buffer for SwiftShader to finish rendering the WebGL frame.
|
||||
// Reduced from 500ms → 200ms since tiles now load from the in-memory
|
||||
// cache and don't need network round-trips.
|
||||
await page.waitForTimeout(RENDER_BUFFER_MS);
|
||||
|
||||
// JPEG at quality 85: ~3-5x faster encoding than PNG with negligible
|
||||
// visual difference for screenshot content (map + text overlay)
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 85 });
|
||||
const t3 = performance.now();
|
||||
console.log(` Capture: ${(t3 - t2).toFixed(0)}ms | Total: ${(t3 - t0).toFixed(0)}ms`);
|
||||
console.log(` Cache: ${networkCache.stats()}`);
|
||||
|
||||
const screenshot = await page.screenshot({ type: 'png' });
|
||||
return Buffer.from(screenshot);
|
||||
} finally {
|
||||
// Remove listeners before releasing page back to pool
|
||||
page.removeAllListeners('console');
|
||||
page.removeAllListeners('pageerror');
|
||||
await releasePage(page);
|
||||
}
|
||||
}
|
||||
|
|
@ -182,24 +267,25 @@ export async function checkWebGL(): Promise<Record<string, unknown>> {
|
|||
vendor: debugExt ? g.getParameter(debugExt.UNMASKED_VENDOR_WEBGL) : 'unknown',
|
||||
};
|
||||
});
|
||||
return info;
|
||||
return { ...info, cache: networkCache.stats() };
|
||||
} finally {
|
||||
await releasePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeBrowser(): Promise<void> {
|
||||
// Close all pooled pages
|
||||
for (const page of pagePool) {
|
||||
await page.context().close().catch(() => {});
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
pagePool.length = 0;
|
||||
|
||||
if (sharedContext) {
|
||||
await sharedContext.close().catch(() => {});
|
||||
sharedContext = null;
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
await browser.close().catch(() => {});
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-warm the pool on module load
|
||||
warmPool().catch(() => {});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import express from 'express';
|
||||
import { ScreenshotCache } from './cache.js';
|
||||
import { takeScreenshot, checkWebGL, closeBrowser } from './screenshot.js';
|
||||
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8002', 10);
|
||||
const APP_URL = process.env.APP_URL;
|
||||
|
|
@ -63,7 +63,7 @@ app.get('/screenshot', async (req, res) => {
|
|||
// Check cache first
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
cached.pipe(res);
|
||||
|
|
@ -75,15 +75,15 @@ app.get('/screenshot', async (req, res) => {
|
|||
const url = `${APP_URL}${pagePath}?${qs}`;
|
||||
|
||||
console.log(`Taking screenshot: ${url}`);
|
||||
const png = await takeScreenshot(url);
|
||||
const jpeg = await takeScreenshot(url);
|
||||
|
||||
// Cache it
|
||||
cache.set(cacheKey, png);
|
||||
cache.set(cacheKey, jpeg);
|
||||
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.setHeader('X-Cache', 'MISS');
|
||||
res.send(png);
|
||||
res.send(jpeg);
|
||||
} catch (err) {
|
||||
console.error('Screenshot failed:', err);
|
||||
res.status(500).json({ error: 'Screenshot failed' });
|
||||
|
|
@ -94,6 +94,13 @@ const server = app.listen(PORT, () => {
|
|||
console.log(`Screenshot service listening on port ${PORT}`);
|
||||
console.log(` APP_URL: ${APP_URL}`);
|
||||
console.log(` CACHE_DIR: ${CACHE_DIR}`);
|
||||
|
||||
// Pre-warm browser and populate network cache in background.
|
||||
// The health endpoint is available immediately; screenshot requests
|
||||
// during warm-up will still work (just slower on the first call).
|
||||
initialize(APP_URL).catch((err) => {
|
||||
console.error('Initialization failed:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
use crate::consts::NAN_U16;
|
||||
use crate::data::QuantRef;
|
||||
|
||||
/// Per-cell accumulator for aggregating features (min/max/sum/count).
|
||||
/// Uses Box<[T]> instead of Vec<T> to avoid storing capacity (saves 8 bytes per field per cell).
|
||||
/// Shared by hexagon and postcode aggregation routes.
|
||||
|
|
@ -20,16 +23,23 @@ impl Aggregator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Add a row using row-major feature_data layout.
|
||||
/// Add a row using row-major feature_data layout (quantized u16).
|
||||
/// feature_data[row * num_features + feat_idx] — all features for one row
|
||||
/// are contiguous, so this reads a single cache line per ~8 features.
|
||||
/// are contiguous, so this reads a single cache line per ~16 features.
|
||||
#[inline]
|
||||
pub fn add_row(&mut self, feature_data: &[f32], row: usize, num_features: usize) {
|
||||
pub fn add_row(
|
||||
&mut self,
|
||||
feature_data: &[u16],
|
||||
row: usize,
|
||||
num_features: usize,
|
||||
quant: &QuantRef,
|
||||
) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
let row_slice = &feature_data[base..base + num_features];
|
||||
for (feat_index, &value) in row_slice.iter().enumerate() {
|
||||
if value.is_finite() {
|
||||
for (feat_index, &raw) in row_slice.iter().enumerate() {
|
||||
if raw != NAN_U16 {
|
||||
let value = quant.decode(feat_index, raw);
|
||||
if value < self.mins[feat_index] {
|
||||
self.mins[feat_index] = value;
|
||||
}
|
||||
|
|
@ -46,16 +56,18 @@ impl Aggregator {
|
|||
#[inline]
|
||||
pub fn add_row_selective(
|
||||
&mut self,
|
||||
feature_data: &[f32],
|
||||
feature_data: &[u16],
|
||||
row: usize,
|
||||
num_features: usize,
|
||||
indices: &[usize],
|
||||
quant: &QuantRef,
|
||||
) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
for &feat_index in indices {
|
||||
let value = feature_data[base + feat_index];
|
||||
if value.is_finite() {
|
||||
let raw = feature_data[base + feat_index];
|
||||
if raw != NAN_U16 {
|
||||
let value = quant.decode(feat_index, raw);
|
||||
if value < self.mins[feat_index] {
|
||||
self.mins[feat_index] = value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
pub const NAN_U16: u16 = u16::MAX;
|
||||
pub const QUANT_SCALE: f32 = 65534.0;
|
||||
|
||||
pub const HISTOGRAM_BINS: usize = 100;
|
||||
|
||||
pub const H3_PRECOMPUTE_MAX: u8 = 12;
|
||||
|
|
|
|||
|
|
@ -7,5 +7,7 @@ pub mod travel_time;
|
|||
pub use places::PlaceData;
|
||||
pub use poi::{POICategoryGroup, POIData};
|
||||
pub use postcodes::PostcodeData;
|
||||
pub use property::{precompute_h3, FeatureStats, Histogram, PropertyData, RenovationEvent};
|
||||
pub use property::{
|
||||
precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent,
|
||||
};
|
||||
pub use travel_time::{slugify, TravelTimeStore};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ pub struct POICategoryGroup {
|
|||
}
|
||||
|
||||
pub struct POIData {
|
||||
pub id: Vec<String>,
|
||||
/// Contiguous buffer holding all POI ID strings end-to-end.
|
||||
id_buffer: String,
|
||||
/// Byte offset into `id_buffer` where each row's ID starts.
|
||||
id_offsets: Vec<u32>,
|
||||
/// Length in bytes of each row's ID.
|
||||
id_lengths: Vec<u8>,
|
||||
pub group: InternedColumn,
|
||||
pub category: InternedColumn,
|
||||
pub name: Vec<String>,
|
||||
|
|
@ -31,6 +36,15 @@ pub struct POIData {
|
|||
pub priority: Vec<u32>,
|
||||
}
|
||||
|
||||
impl POIData {
|
||||
/// Get the ID string for a given row.
|
||||
pub fn id(&self, row: usize) -> &str {
|
||||
let offset = self.id_offsets[row] as usize;
|
||||
let length = self.id_lengths[row] as usize;
|
||||
&self.id_buffer[offset..offset + length]
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_str_col(df: &DataFrame, name: &str) -> anyhow::Result<Vec<String>> {
|
||||
let column = df
|
||||
.column(name)
|
||||
|
|
@ -72,7 +86,7 @@ impl POIData {
|
|||
let row_count = df.height();
|
||||
info!("Loaded {} POIs", row_count);
|
||||
|
||||
let id: Vec<String> = extract_str_col(&df, "id")?;
|
||||
let id_raw: Vec<String> = extract_str_col(&df, "id")?;
|
||||
let name = extract_str_col(&df, "name")?;
|
||||
let category_raw = extract_str_col(&df, "category")?;
|
||||
let group_raw = extract_str_col(&df, "group")?;
|
||||
|
|
@ -80,6 +94,19 @@ impl POIData {
|
|||
let lng = extract_f32_col(&df, "lng", 0.0)?;
|
||||
let emoji_raw = extract_str_col(&df, "emoji")?;
|
||||
|
||||
// Pack POI IDs into a contiguous buffer
|
||||
let total_id_bytes: usize = id_raw.iter().map(|s| s.len()).sum();
|
||||
let mut id_buffer = String::with_capacity(total_id_bytes);
|
||||
let mut id_offsets = Vec::with_capacity(row_count);
|
||||
let mut id_lengths = Vec::with_capacity(row_count);
|
||||
for s in &id_raw {
|
||||
let offset = id_buffer.len() as u32;
|
||||
let length = s.len().min(u8::MAX as usize) as u8;
|
||||
id_offsets.push(offset);
|
||||
id_lengths.push(length);
|
||||
id_buffer.push_str(&s[..length as usize]);
|
||||
}
|
||||
|
||||
let category = InternedColumn::build(&category_raw);
|
||||
let group = InternedColumn::build(&group_raw);
|
||||
let emoji = InternedColumn::build(&emoji_raw);
|
||||
|
|
@ -99,7 +126,9 @@ impl POIData {
|
|||
info!("POI data loading complete.");
|
||||
|
||||
Ok(POIData {
|
||||
id,
|
||||
id_buffer,
|
||||
id_offsets,
|
||||
id_lengths,
|
||||
name,
|
||||
category,
|
||||
group,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::path::Path;
|
|||
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS};
|
||||
use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS, NAN_U16, QUANT_SCALE};
|
||||
use crate::features::{self, Bounds};
|
||||
|
||||
fn is_numeric_dtype(dtype: &DataType) -> bool {
|
||||
|
|
@ -47,6 +47,38 @@ pub struct Histogram {
|
|||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
/// Return the bin index for a given value using the outlier-bracket layout.
|
||||
#[cfg(test)]
|
||||
pub fn bin_for_value(&self, value: f32) -> usize {
|
||||
let num_bins = self.counts.len();
|
||||
if value < self.p1 {
|
||||
0
|
||||
} else if value >= self.p99 {
|
||||
num_bins - 1
|
||||
} else {
|
||||
let middle_bins = num_bins.saturating_sub(2);
|
||||
if middle_bins > 0 && self.p99 > self.p1 {
|
||||
let width = (self.p99 - self.p1) / middle_bins as f32;
|
||||
let middle_bin = ((value - self.p1) / width) as usize;
|
||||
(1 + middle_bin).min(num_bins - 2)
|
||||
} else {
|
||||
num_bins / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of a single middle bin (bins 1..n-2).
|
||||
#[cfg(test)]
|
||||
pub fn middle_bin_width(&self) -> f32 {
|
||||
let middle_bins = self.counts.len().saturating_sub(2);
|
||||
if middle_bins > 0 && self.p99 > self.p1 {
|
||||
(self.p99 - self.p1) / middle_bins as f32
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FeatureStats {
|
||||
pub slider_min: f32,
|
||||
|
|
@ -60,14 +92,67 @@ pub struct RenovationEvent {
|
|||
pub event: String,
|
||||
}
|
||||
|
||||
/// Lightweight reference to quantization parameters for decoding u16 feature data.
|
||||
pub struct QuantRef<'a> {
|
||||
pub dequant_a: &'a [f32],
|
||||
pub quant_min: &'a [f32],
|
||||
pub quant_range: &'a [f32],
|
||||
pub num_numeric: usize,
|
||||
}
|
||||
|
||||
impl QuantRef<'_> {
|
||||
/// Decode a raw u16 value back to f32.
|
||||
#[inline]
|
||||
pub fn decode(&self, feat_idx: usize, raw: u16) -> f32 {
|
||||
if raw == NAN_U16 {
|
||||
return f32::NAN;
|
||||
}
|
||||
if feat_idx >= self.num_numeric {
|
||||
raw as f32
|
||||
} else {
|
||||
raw as f32 * self.dequant_a[feat_idx] + self.quant_min[feat_idx]
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a filter minimum bound to u16 (floors to include boundary values).
|
||||
#[inline]
|
||||
pub fn encode_min(&self, feat_idx: usize, value: f32) -> u16 {
|
||||
if !value.is_finite() || self.quant_range[feat_idx] == 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx];
|
||||
(norm * QUANT_SCALE).floor().clamp(0.0, QUANT_SCALE) as u16
|
||||
}
|
||||
|
||||
/// Encode a filter maximum bound to u16 (ceils to include boundary values).
|
||||
#[inline]
|
||||
pub fn encode_max(&self, feat_idx: usize, value: f32) -> u16 {
|
||||
if !value.is_finite() || self.quant_range[feat_idx] == 0.0 {
|
||||
return QUANT_SCALE as u16;
|
||||
}
|
||||
let norm = (value - self.quant_min[feat_idx]) / self.quant_range[feat_idx];
|
||||
(norm * QUANT_SCALE).ceil().clamp(0.0, QUANT_SCALE) as u16
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PropertyData {
|
||||
pub lat: Vec<f32>,
|
||||
pub lon: Vec<f32>,
|
||||
pub feature_names: Vec<String>,
|
||||
pub num_features: usize,
|
||||
/// Number of numeric features (enum features start at this index).
|
||||
pub num_numeric: usize,
|
||||
/// Row-major flat array: feature_data[row * num_features + feat_idx].
|
||||
/// NaN = null. For enum features, stores the index as f32 (0.0, 1.0, etc).
|
||||
pub feature_data: Vec<f32>,
|
||||
/// Quantized to u16. NaN sentinel = u16::MAX (65535).
|
||||
/// Numeric features: encoded via (val - min) / range * 65534.
|
||||
/// Enum features: stored directly as u16 cast of the f32 index.
|
||||
pub feature_data: Vec<u16>,
|
||||
/// Per-feature: range / QUANT_SCALE for fast decode.
|
||||
dequant_a: Vec<f32>,
|
||||
/// Per-feature: minimum value (offset for dequantization).
|
||||
quant_min: Vec<f32>,
|
||||
/// Per-feature: max - min (for encoding filter bounds).
|
||||
quant_range: Vec<f32>,
|
||||
pub feature_stats: Vec<FeatureStats>,
|
||||
/// Contiguous buffer holding all address strings end-to-end.
|
||||
address_buffer: String,
|
||||
|
|
@ -79,7 +164,7 @@ pub struct PropertyData {
|
|||
postcode_interner: lasso::RodeoReader,
|
||||
postcode_keys: Vec<lasso::Spur>,
|
||||
/// For enum features: maps feature index to list of possible string values.
|
||||
/// Index in values list corresponds to the f32 value stored in feature_data.
|
||||
/// Index in values list corresponds to the u16 value stored in feature_data.
|
||||
pub enum_values: rustc_hash::FxHashMap<usize, Vec<String>>,
|
||||
/// Per-row flag: true = construction date is approximate (from EPC band),
|
||||
/// false = exact (from new-build transaction date).
|
||||
|
|
@ -91,10 +176,11 @@ pub struct PropertyData {
|
|||
/// Per-row listing features (key feature bullet points from online listings).
|
||||
/// Only rows with features are present in the map.
|
||||
listing_features: FxHashMap<u32, Vec<String>>,
|
||||
/// Per-row optional string columns from online listings.
|
||||
listing_url: Vec<Option<String>>,
|
||||
property_sub_type: Vec<Option<String>>,
|
||||
price_qualifier: Vec<Option<String>>,
|
||||
/// Sparse per-row optional string columns from online listings.
|
||||
/// Only rows with non-empty values are stored (saves ~1 GB vs Vec<Option<String>>).
|
||||
listing_url: FxHashMap<u32, String>,
|
||||
property_sub_type: FxHashMap<u32, String>,
|
||||
price_qualifier: FxHashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl PropertyData {
|
||||
|
|
@ -139,17 +225,43 @@ impl PropertyData {
|
|||
|
||||
/// Get listing URL for a given row.
|
||||
pub fn listing_url(&self, row: usize) -> Option<&str> {
|
||||
self.listing_url[row].as_deref()
|
||||
self.listing_url.get(&(row as u32)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Get property sub-type for a given row.
|
||||
pub fn property_sub_type(&self, row: usize) -> Option<&str> {
|
||||
self.property_sub_type[row].as_deref()
|
||||
self.property_sub_type
|
||||
.get(&(row as u32))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// Get price qualifier for a given row.
|
||||
pub fn price_qualifier(&self, row: usize) -> Option<&str> {
|
||||
self.price_qualifier[row].as_deref()
|
||||
self.price_qualifier.get(&(row as u32)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Decode a single feature value from quantized u16 storage.
|
||||
#[inline]
|
||||
pub fn get_feature(&self, row: usize, feat_idx: usize) -> f32 {
|
||||
let raw = self.feature_data[row * self.num_features + feat_idx];
|
||||
if raw == NAN_U16 {
|
||||
return f32::NAN;
|
||||
}
|
||||
if feat_idx >= self.num_numeric {
|
||||
raw as f32
|
||||
} else {
|
||||
raw as f32 * self.dequant_a[feat_idx] + self.quant_min[feat_idx]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a QuantRef for passing to aggregation/filter functions.
|
||||
pub fn quant_ref(&self) -> QuantRef<'_> {
|
||||
QuantRef {
|
||||
dequant_a: &self.dequant_a,
|
||||
quant_min: &self.quant_min,
|
||||
quant_range: &self.quant_range,
|
||||
num_numeric: self.num_numeric,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -355,13 +467,12 @@ pub fn precompute_h3(lat: &[f32], lon: &[f32]) -> anyhow::Result<Vec<u64>> {
|
|||
.zip(lon.par_iter())
|
||||
.enumerate()
|
||||
.map(|(i, (&latitude, &longitude))| {
|
||||
let coord = h3o::LatLng::new(latitude as f64, longitude as f64)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"Invalid coordinates at row {}: lat={}, lon={}: {}",
|
||||
i, latitude, longitude, err
|
||||
)
|
||||
});
|
||||
let coord = h3o::LatLng::new(latitude as f64, longitude as f64).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"Invalid coordinates at row {}: lat={}, lon={}: {}",
|
||||
i, latitude, longitude, err
|
||||
)
|
||||
});
|
||||
u64::from(coord.to_cell(h3_res))
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -378,7 +489,10 @@ impl PropertyData {
|
|||
listings_rent_path: &Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Load postcode.parquet
|
||||
tracing::info!("Loading postcode features from {:?}", postcode_features_path);
|
||||
tracing::info!(
|
||||
"Loading postcode features from {:?}",
|
||||
postcode_features_path
|
||||
);
|
||||
let postcode_df = LazyFrame::scan_parquet(postcode_features_path, Default::default())
|
||||
.context("Failed to scan postcode parquet")?
|
||||
.collect()
|
||||
|
|
@ -623,6 +737,16 @@ impl PropertyData {
|
|||
})
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
// Compute quantization parameters from feature stats (numeric features)
|
||||
let mut quant_min = Vec::with_capacity(num_features);
|
||||
let mut quant_range = Vec::with_capacity(num_features);
|
||||
for stats in &numeric_feature_stats {
|
||||
let min = stats.histogram.min;
|
||||
let max = stats.histogram.max;
|
||||
quant_min.push(min);
|
||||
quant_range.push(if max > min { max - min } else { 0.0 });
|
||||
}
|
||||
|
||||
tracing::info!("Extracting string columns");
|
||||
let extract_string_col = |df: &DataFrame, name: &str| -> anyhow::Result<Vec<String>> {
|
||||
let column = df
|
||||
|
|
@ -928,19 +1052,34 @@ impl PropertyData {
|
|||
map
|
||||
};
|
||||
|
||||
// Permute optional string columns
|
||||
let listing_url: Vec<Option<String>> = perm
|
||||
.iter()
|
||||
.map(|&old_row| listing_url_raw[old_row as usize].clone())
|
||||
.collect();
|
||||
let property_sub_type: Vec<Option<String>> = perm
|
||||
.iter()
|
||||
.map(|&old_row| property_sub_type_raw[old_row as usize].clone())
|
||||
.collect();
|
||||
let price_qualifier: Vec<Option<String>> = perm
|
||||
.iter()
|
||||
.map(|&old_row| price_qualifier_raw[old_row as usize].clone())
|
||||
.collect();
|
||||
// Permute optional string columns into sparse HashMaps
|
||||
let listing_url: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
if let Some(ref s) = listing_url_raw[old_row as usize] {
|
||||
map.insert(new_row as u32, s.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
let property_sub_type: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
if let Some(ref s) = property_sub_type_raw[old_row as usize] {
|
||||
map.insert(new_row as u32, s.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
let price_qualifier: FxHashMap<u32, String> = {
|
||||
let mut map = FxHashMap::default();
|
||||
for (new_row, &old_row) in perm.iter().enumerate() {
|
||||
if let Some(ref s) = price_qualifier_raw[old_row as usize] {
|
||||
map.insert(new_row as u32, s.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
};
|
||||
|
||||
// Build enum_values map: feature_index -> list of string values
|
||||
let mut enum_values: rustc_hash::FxHashMap<usize, Vec<String>> =
|
||||
|
|
@ -967,24 +1106,47 @@ impl PropertyData {
|
|||
counts: vec![0; num_values.max(1)],
|
||||
},
|
||||
});
|
||||
// Enum features: not quantized, stored directly as u16
|
||||
quant_min.push(0.0);
|
||||
quant_range.push(0.0);
|
||||
}
|
||||
let dequant_a: Vec<f32> = quant_range
|
||||
.iter()
|
||||
.map(|&r| if r > 0.0 { r / QUANT_SCALE } else { 0.0 })
|
||||
.collect();
|
||||
|
||||
// Transpose to row-major AND apply spatial permutation in one pass.
|
||||
// Combines numeric and enum features into a single feature_data array.
|
||||
tracing::info!("Transposing to row-major layout (spatially sorted)");
|
||||
let mut feature_data = vec![f32::NAN; row_count * num_features];
|
||||
// Combines numeric and enum features into a single feature_data array, quantized to u16.
|
||||
tracing::info!("Transposing to row-major layout (spatially sorted, quantized to u16)");
|
||||
let mut feature_data = vec![NAN_U16; row_count * num_features];
|
||||
feature_data
|
||||
.par_chunks_mut(num_features)
|
||||
.enumerate()
|
||||
.for_each(|(new_row, row_slice)| {
|
||||
let old_index = perm[new_row] as usize;
|
||||
// Numeric features
|
||||
// Numeric features: quantize to u16
|
||||
for (feat_idx, col_vec) in numeric_col_major.iter().enumerate() {
|
||||
row_slice[feat_idx] = col_vec[old_index];
|
||||
let value = col_vec[old_index];
|
||||
row_slice[feat_idx] = if value.is_finite() {
|
||||
let range = quant_range[feat_idx];
|
||||
if range > 0.0 {
|
||||
let normalized = (value - quant_min[feat_idx]) / range;
|
||||
(normalized * QUANT_SCALE).round().clamp(0.0, QUANT_SCALE) as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
NAN_U16
|
||||
};
|
||||
}
|
||||
// Enum features (stored as f32 indices)
|
||||
// Enum features: store as u16 directly
|
||||
for (enum_idx, (_, encoded)) in enum_col_major.iter().enumerate() {
|
||||
row_slice[num_numeric + enum_idx] = encoded[old_index];
|
||||
let value = encoded[old_index];
|
||||
row_slice[num_numeric + enum_idx] = if value.is_finite() {
|
||||
value as u16
|
||||
} else {
|
||||
NAN_U16
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -995,7 +1157,11 @@ impl PropertyData {
|
|||
lon,
|
||||
feature_names,
|
||||
num_features,
|
||||
num_numeric,
|
||||
feature_data,
|
||||
dequant_a,
|
||||
quant_min,
|
||||
quant_range,
|
||||
feature_stats,
|
||||
address_buffer,
|
||||
address_offsets,
|
||||
|
|
|
|||
|
|
@ -124,10 +124,7 @@ impl TravelTimeStore {
|
|||
if file_name.ends_with(".parquet") {
|
||||
let file_stem = file_name.trim_end_matches(".parquet");
|
||||
let slug = strip_numeric_prefix(file_stem).to_string();
|
||||
slug_to_file.insert(
|
||||
(mode.clone(), slug.clone()),
|
||||
file_stem.to_string(),
|
||||
);
|
||||
slug_to_file.insert((mode.clone(), slug.clone()), file_stem.to_string());
|
||||
slugs.insert(slug);
|
||||
}
|
||||
}
|
||||
|
|
@ -207,10 +204,7 @@ impl TravelTimeStore {
|
|||
for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() {
|
||||
if let (Some(pc), Some(min)) = (pc, min) {
|
||||
let best_min = best.as_ref().and_then(|b| b.get(i));
|
||||
let journey = journeys
|
||||
.as_ref()
|
||||
.and_then(|j| j.get(i))
|
||||
.map(Arc::from);
|
||||
let journey = journeys.as_ref().and_then(|j| j.get(i)).map(Arc::from);
|
||||
map.insert(
|
||||
pc.to_string(),
|
||||
TravelDataRow {
|
||||
|
|
@ -274,10 +268,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn strip_numeric_prefix_basic() {
|
||||
assert_eq!(strip_numeric_prefix("000000-bank-tube-station"), "bank-tube-station");
|
||||
assert_eq!(
|
||||
strip_numeric_prefix("000000-bank-tube-station"),
|
||||
"bank-tube-station"
|
||||
);
|
||||
assert_eq!(strip_numeric_prefix("000123-abbey-hey"), "abbey-hey");
|
||||
assert_eq!(strip_numeric_prefix("bank-tube-station"), "bank-tube-station");
|
||||
assert_eq!(
|
||||
strip_numeric_prefix("bank-tube-station"),
|
||||
"bank-tube-station"
|
||||
);
|
||||
assert_eq!(strip_numeric_prefix("london"), "london");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ pub struct EnumFeatureGroup {
|
|||
pub features: &'static [EnumFeatureConfig],
|
||||
}
|
||||
|
||||
|
||||
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
||||
FeatureGroup {
|
||||
name: "Properties in the area",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(clippy::min_ident_chars)]
|
||||
|
||||
mod aggregation;
|
||||
mod auth;
|
||||
mod consts;
|
||||
|
|
@ -17,11 +19,11 @@ use std::sync::Arc;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use consts::SERVICE_CALL_TIMEOUT;
|
||||
use axum::middleware;
|
||||
use axum::routing::{any, get, patch, post};
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
use consts::SERVICE_CALL_TIMEOUT;
|
||||
use tower::limit::ConcurrencyLimitLayer;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
|
||||
|
|
@ -36,7 +38,10 @@ use tracing_subscriber::EnvFilter;
|
|||
use state::AppState;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "perfect-postcode", about = "Perfect Postcode property map server")]
|
||||
#[command(
|
||||
name = "perfect-postcode",
|
||||
about = "Perfect Postcode property map server"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to properties.parquet (one row per historical property)
|
||||
#[arg(long)]
|
||||
|
|
@ -129,7 +134,6 @@ struct Cli {
|
|||
/// Google OAuth client secret for PocketBase SSO
|
||||
#[arg(long, env = "GOOGLE_OAUTH_CLIENT_SECRET")]
|
||||
google_oauth_client_secret: String,
|
||||
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -137,8 +141,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let file_appender = tracing_appender::rolling::daily("logs", "server.log");
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
|
|
@ -332,10 +335,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
info!("Gemini configured (model: {})", cli.gemini_model);
|
||||
let tt_path = &cli.travel_times;
|
||||
if !tt_path.exists() {
|
||||
bail!(
|
||||
"Travel times directory not found: {}",
|
||||
tt_path.display()
|
||||
);
|
||||
bail!("Travel times directory not found: {}", tt_path.display());
|
||||
}
|
||||
info!("Loading travel time data from {}", tt_path.display());
|
||||
let travel_time_store = {
|
||||
|
|
@ -476,7 +476,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/travel-destinations",
|
||||
get(move |query| routes::get_travel_destinations(state_travel_destinations.clone(), query)),
|
||||
get(move |query| {
|
||||
routes::get_travel_destinations(state_travel_destinations.clone(), query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/journey",
|
||||
|
|
@ -490,24 +492,34 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/hexagon-stats",
|
||||
get(move |ext, query| routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)),
|
||||
get(move |ext, query| {
|
||||
routes::get_hexagon_stats(state_hexagon_stats.clone(), ext, query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/postcode-stats",
|
||||
get(move |ext, query| routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)),
|
||||
get(move |ext, query| {
|
||||
routes::get_postcode_stats(state_postcode_stats.clone(), ext, query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/postcode-properties",
|
||||
get(move |ext, query| routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)),
|
||||
get(move |ext, query| {
|
||||
routes::get_postcode_properties(state_postcode_properties.clone(), ext, query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/screenshot",
|
||||
get(move |headers, query| routes::get_screenshot(state_screenshot.clone(), headers, query)),
|
||||
get(move |headers, query| {
|
||||
routes::get_screenshot(state_screenshot.clone(), headers, query)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/export",
|
||||
get(move |headers, ext, query| routes::get_export(state_export.clone(), headers, ext, query))
|
||||
.layer(ConcurrencyLimitLayer::new(3)),
|
||||
get(move |headers, ext, query| {
|
||||
routes::get_export(state_export.clone(), headers, ext, query)
|
||||
})
|
||||
.layer(ConcurrencyLimitLayer::new(3)),
|
||||
)
|
||||
.route("/api/me", get(routes::get_me))
|
||||
.route(
|
||||
|
|
@ -525,9 +537,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/newsletter",
|
||||
patch(move |ext, body| {
|
||||
routes::patch_newsletter(state_newsletter.clone(), ext, body)
|
||||
}),
|
||||
patch(move |ext, body| routes::patch_newsletter(state_newsletter.clone(), ext, body)),
|
||||
)
|
||||
.route(
|
||||
"/api/pricing",
|
||||
|
|
@ -546,8 +556,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/invites",
|
||||
get(move |ext| routes::get_invites(state_invites_list.clone(), ext))
|
||||
.post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
|
||||
get(move |ext| routes::get_invites(state_invites_list.clone(), ext)).post(
|
||||
move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/invite/{code}",
|
||||
|
|
@ -591,35 +602,35 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
let app = if let Some(ref dist) = cli.dist {
|
||||
api.fallback_service(
|
||||
ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))),
|
||||
)
|
||||
api.fallback_service(ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))))
|
||||
} else {
|
||||
api
|
||||
}
|
||||
.layer(middleware::from_fn(metrics::track_metrics))
|
||||
.layer(middleware::from_fn(auth::auth_middleware))
|
||||
.layer(middleware::from_fn(
|
||||
move |req: axum::extract::Request, next: middleware::Next| {
|
||||
let st = state_crawler.clone();
|
||||
async move {
|
||||
// Inject state into request extensions for auth + OG middleware
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.extensions.insert(st);
|
||||
let req = axum::extract::Request::from_parts(parts, body);
|
||||
og_middleware::og_middleware(req, next).await
|
||||
}
|
||||
},
|
||||
))
|
||||
.layer(cors)
|
||||
.layer(CompressionLayer::new().zstd(true).gzip(true))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
.layer(middleware::from_fn(metrics::track_metrics))
|
||||
.layer(middleware::from_fn(auth::auth_middleware))
|
||||
.layer(middleware::from_fn(
|
||||
move |req: axum::extract::Request, next: middleware::Next| {
|
||||
let st = state_crawler.clone();
|
||||
async move {
|
||||
// Inject state into request extensions for auth + OG middleware
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.extensions.insert(st);
|
||||
let req = axum::extract::Request::from_parts(parts, body);
|
||||
og_middleware::og_middleware(req, next).await
|
||||
}
|
||||
},
|
||||
))
|
||||
.layer(cors)
|
||||
.layer(CompressionLayer::new().zstd(true).gzip(true))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
// Lock all current and future memory pages to prevent swapping
|
||||
unsafe {
|
||||
if libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
tracing::warn!("mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): {err}");
|
||||
tracing::warn!(
|
||||
"mlockall failed (need CAP_IPC_LOCK or sufficient RLIMIT_MEMLOCK): {err}"
|
||||
);
|
||||
} else {
|
||||
info!("All memory pages locked (mlockall)");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ use axum::response::Response;
|
|||
|
||||
use crate::state::AppState;
|
||||
|
||||
const OG_PLACEHOLDER: &str = r#"<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_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 {
|
||||
let path = request.uri().path().to_string();
|
||||
|
|
@ -51,10 +52,7 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
let og_image_url = if is_invite {
|
||||
// Include path= so the screenshot service navigates to /invite/CODE
|
||||
if query_string.is_empty() {
|
||||
format!(
|
||||
"{}/api/screenshot?og=1&path={}",
|
||||
state.public_url, path
|
||||
)
|
||||
format!("{}/api/screenshot?og=1&path={}", state.public_url, path)
|
||||
} else {
|
||||
format!(
|
||||
"{}/api/screenshot?og=1&path={}&{}",
|
||||
|
|
|
|||
|
|
@ -24,12 +24,7 @@ pub fn parse_field_indices(
|
|||
}
|
||||
match name_to_index.get(name) {
|
||||
Some(&idx) => indices.push(idx),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Unknown field: {}", name),
|
||||
))
|
||||
}
|
||||
None => return Err((StatusCode::BAD_REQUEST, format!("Unknown field: {}", name))),
|
||||
}
|
||||
}
|
||||
Ok(Some(indices))
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
|
||||
/// Filter for numeric features: value must be in [min, max] range.
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::QuantRef;
|
||||
|
||||
/// Filter for numeric features: value must be in [min_u16, max_u16] range (quantized).
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedFilter {
|
||||
pub feat_idx: usize,
|
||||
pub min: f32,
|
||||
pub max: f32,
|
||||
pub min_u16: u16,
|
||||
pub max_u16: u16,
|
||||
}
|
||||
|
||||
/// Filter for enum features: value must be one of the allowed indices.
|
||||
/// Uses FxHashSet<u32> (f32 bits) for O(1) lookups instead of O(n) Vec::contains.
|
||||
/// Filter for enum features: value must be one of the allowed u16 indices.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedEnumFilter {
|
||||
pub feat_idx: usize,
|
||||
/// Allowed enum indices stored as f32 bits for exact comparison
|
||||
pub allowed: FxHashSet<u32>,
|
||||
/// Allowed enum indices as u16.
|
||||
pub allowed: FxHashSet<u16>,
|
||||
}
|
||||
|
||||
/// Parse `;;`-separated filter string into numeric and enum filters.
|
||||
|
|
@ -26,6 +28,7 @@ pub fn parse_filters(
|
|||
filter_str: Option<&str>,
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
quant: &QuantRef,
|
||||
) -> Result<(Vec<ParsedFilter>, Vec<ParsedEnumFilter>), String> {
|
||||
let mut numeric = Vec::new();
|
||||
let mut enums = Vec::new();
|
||||
|
|
@ -50,20 +53,20 @@ pub fn parse_filters(
|
|||
|
||||
// Check if this is an enum feature
|
||||
if let Some(values) = enum_values.get(&feat_idx) {
|
||||
// Enum filter: convert string values to f32 indices (stored as bits for O(1) lookup)
|
||||
let allowed: FxHashSet<u32> = rest
|
||||
// Enum filter: convert string values to u16 indices
|
||||
let allowed: FxHashSet<u16> = rest
|
||||
.split('|')
|
||||
.filter_map(|value| {
|
||||
let value = value.trim();
|
||||
values
|
||||
.iter()
|
||||
.position(|existing| existing == value)
|
||||
.map(|position| (position as f32).to_bits())
|
||||
.map(|position| position as u16)
|
||||
})
|
||||
.collect();
|
||||
enums.push(ParsedEnumFilter { feat_idx, allowed });
|
||||
} else {
|
||||
// Numeric filter: parse min:max
|
||||
// Numeric filter: parse min:max and encode to u16
|
||||
let num_parts: Vec<&str> = rest.splitn(2, ':').collect();
|
||||
if num_parts.len() != 2 {
|
||||
return Err(format!(
|
||||
|
|
@ -78,7 +81,11 @@ pub fn parse_filters(
|
|||
.trim()
|
||||
.parse::<f32>()
|
||||
.map_err(|err| format!("Invalid max value in filter '{name}': {err}"))?;
|
||||
numeric.push(ParsedFilter { feat_idx, min, max });
|
||||
numeric.push(ParsedFilter {
|
||||
feat_idx,
|
||||
min_u16: quant.encode_min(feat_idx, min),
|
||||
max_u16: quant.encode_max(feat_idx, max),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,23 +93,22 @@ pub fn parse_filters(
|
|||
}
|
||||
|
||||
/// Check if a row passes all filters.
|
||||
/// All features (numeric and enum) are stored in feature_data as f32.
|
||||
/// All features (numeric and enum) are stored in feature_data as quantized u16.
|
||||
pub fn row_passes_filters(
|
||||
row: usize,
|
||||
filters: &[ParsedFilter],
|
||||
enum_filters: &[ParsedEnumFilter],
|
||||
feature_data: &[f32],
|
||||
feature_data: &[u16],
|
||||
num_features: usize,
|
||||
) -> bool {
|
||||
let base = row * num_features;
|
||||
|
||||
filters.iter().all(|filter| {
|
||||
let value = feature_data[base + filter.feat_idx];
|
||||
value.is_finite() && value >= filter.min && value <= filter.max
|
||||
let raw = feature_data[base + filter.feat_idx];
|
||||
raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16
|
||||
}) && enum_filters.iter().all(|filter| {
|
||||
let value = feature_data[base + filter.feat_idx];
|
||||
// O(1) lookup using f32 bits as key
|
||||
value.is_finite() && filter.allowed.contains(&value.to_bits())
|
||||
let raw = feature_data[base + filter.feat_idx];
|
||||
raw != NAN_U16 && filter.allowed.contains(&raw)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -151,52 +157,127 @@ mod tests {
|
|||
map
|
||||
}
|
||||
|
||||
fn allowed_set(values: &[f32]) -> FxHashSet<u32> {
|
||||
values.iter().map(|v| v.to_bits()).collect()
|
||||
/// Build a test QuantRef with known parameters.
|
||||
/// num_numeric indicates how many of the features are numeric (the rest are enum).
|
||||
fn test_quant(num_features: usize, num_numeric: usize) -> TestQuant {
|
||||
// For numeric features: use min=0, range=1000 (simple mapping)
|
||||
let mut quant_min = vec![0.0f32; num_features];
|
||||
let mut quant_range = vec![1000.0f32; num_features];
|
||||
let mut dequant_a = vec![1000.0 / crate::consts::QUANT_SCALE; num_features];
|
||||
// Enum features: no quantization
|
||||
for i in num_numeric..num_features {
|
||||
quant_min[i] = 0.0;
|
||||
quant_range[i] = 0.0;
|
||||
dequant_a[i] = 0.0;
|
||||
}
|
||||
TestQuant {
|
||||
dequant_a,
|
||||
quant_min,
|
||||
quant_range,
|
||||
num_numeric,
|
||||
}
|
||||
}
|
||||
|
||||
struct TestQuant {
|
||||
dequant_a: Vec<f32>,
|
||||
quant_min: Vec<f32>,
|
||||
quant_range: Vec<f32>,
|
||||
num_numeric: usize,
|
||||
}
|
||||
|
||||
impl TestQuant {
|
||||
fn as_ref(&self) -> QuantRef<'_> {
|
||||
QuantRef {
|
||||
dequant_a: &self.dequant_a,
|
||||
quant_min: &self.quant_min,
|
||||
quant_range: &self.quant_range,
|
||||
num_numeric: self.num_numeric,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a f32 value to u16 using the test quantization parameters.
|
||||
fn encode(&self, feat_idx: usize, value: f32) -> u16 {
|
||||
if !value.is_finite() {
|
||||
return NAN_U16;
|
||||
}
|
||||
if feat_idx >= self.num_numeric {
|
||||
// Enum: store directly
|
||||
return value as u16;
|
||||
}
|
||||
let range = self.quant_range[feat_idx];
|
||||
if range > 0.0 {
|
||||
let normalized = (value - self.quant_min[feat_idx]) / range;
|
||||
(normalized * crate::consts::QUANT_SCALE)
|
||||
.round()
|
||||
.clamp(0.0, crate::consts::QUANT_SCALE) as u16
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_numeric() {
|
||||
let tq = test_quant(3, 2);
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("price:100:500"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[0].min, 100.0);
|
||||
assert_eq!(numeric[0].max, 500.0);
|
||||
// min_u16 should be floor(100/1000 * 65534) = floor(6553.4) = 6553
|
||||
assert_eq!(numeric[0].min_u16, 6553);
|
||||
// max_u16 should be ceil(500/1000 * 65534) = ceil(32767.0) = 32767
|
||||
assert_eq!(numeric[0].max_u16, 32767);
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_enum() {
|
||||
let (numeric, enums) =
|
||||
parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values()).unwrap();
|
||||
let tq = test_quant(3, 2);
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("rating:A|C"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(numeric.is_empty());
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert_eq!(enums[0].feat_idx, 2);
|
||||
assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // A = index 0
|
||||
assert!(enums[0].allowed.contains(&(2.0_f32).to_bits())); // C = index 2
|
||||
assert!(enums[0].allowed.contains(&0)); // A = index 0
|
||||
assert!(enums[0].allowed.contains(&2)); // C = index 2
|
||||
assert_eq!(enums[0].allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_empty() {
|
||||
let (n, e) = parse_filters(None, &feature_name_to_index(), &enum_values()).unwrap();
|
||||
let tq = test_quant(3, 2);
|
||||
let (n, e) =
|
||||
parse_filters(None, &feature_name_to_index(), &enum_values(), &tq.as_ref()).unwrap();
|
||||
assert!(n.is_empty() && e.is_empty());
|
||||
|
||||
let (n, e) = parse_filters(Some(""), &feature_name_to_index(), &enum_values()).unwrap();
|
||||
let (n, e) = parse_filters(
|
||||
Some(""),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(n.is_empty() && e.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_unknown_feature_errors() {
|
||||
let tq = test_quant(3, 2);
|
||||
let result = parse_filters(
|
||||
Some("unknown:1:2"),
|
||||
&feature_name_to_index(),
|
||||
&enum_values(),
|
||||
&tq.as_ref(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown feature"));
|
||||
|
|
@ -204,12 +285,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter() {
|
||||
let tq = test_quant(1, 1);
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 10.0,
|
||||
max: 20.0,
|
||||
min_u16: tq.as_ref().encode_min(0, 10.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 20.0),
|
||||
}];
|
||||
let data = vec![15.0, 5.0, f32::NAN];
|
||||
let data = vec![tq.encode(0, 15.0), tq.encode(0, 5.0), NAN_U16];
|
||||
|
||||
assert!(row_passes_filters(0, &filters, &[], &data, 1));
|
||||
assert!(!row_passes_filters(1, &filters, &[], &data, 1));
|
||||
|
|
@ -218,12 +300,18 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_passes_enum_filter() {
|
||||
let allowed: FxHashSet<u32> = [0.0_f32, 2.0].iter().map(|v| v.to_bits()).collect();
|
||||
let tq = test_quant(1, 0); // all enum
|
||||
let allowed: FxHashSet<u16> = [0u16, 2].into_iter().collect();
|
||||
let filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed,
|
||||
}];
|
||||
let data = vec![0.0, 1.0, 2.0, f32::NAN];
|
||||
let data = vec![
|
||||
tq.encode(0, 0.0),
|
||||
tq.encode(0, 1.0),
|
||||
tq.encode(0, 2.0),
|
||||
NAN_U16,
|
||||
];
|
||||
|
||||
assert!(row_passes_filters(0, &[], &filters, &data, 1));
|
||||
assert!(!row_passes_filters(1, &[], &filters, &data, 1));
|
||||
|
|
@ -233,10 +321,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_multiple_numeric_filters() {
|
||||
let tq = test_quant(4, 2);
|
||||
let (numeric, _enums) = parse_filters(
|
||||
Some("Price:100000:500000;;Area:50:200"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -247,10 +337,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_mixed_filters() {
|
||||
let tq = test_quant(4, 2);
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:100000:500000;;Type:Semi|Terraced"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -260,10 +352,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_invalid_numeric_format_errors() {
|
||||
let tq = test_quant(4, 2);
|
||||
let result = parse_filters(
|
||||
Some("Price:not_a_number:500000"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
|
|
@ -272,25 +366,29 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_enum_with_unknown_value() {
|
||||
let tq = test_quant(4, 2);
|
||||
let (_numeric, enums) = parse_filters(
|
||||
Some("Type:Detached|Unknown|Flats/Maisonettes"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // Detached
|
||||
assert!(enums[0].allowed.contains(&(3.0_f32).to_bits())); // Flats/Maisonettes
|
||||
assert!(enums[0].allowed.contains(&0)); // Detached
|
||||
assert!(enums[0].allowed.contains(&3)); // Flats/Maisonettes
|
||||
assert_eq!(enums[0].allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filter_with_whitespace() {
|
||||
let tq = test_quant(4, 2);
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price : 100000 : 500000 ;; Type : Detached | Flats/Maisonettes"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
&tq.as_ref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -300,25 +398,30 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_passes_no_filters() {
|
||||
let feature_data = vec![100.0_f32, 50.0];
|
||||
let tq = test_quant(2, 2);
|
||||
let feature_data = vec![tq.encode(0, 100.0), tq.encode(1, 50.0)];
|
||||
assert!(row_passes_filters(0, &[], &[], &feature_data, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter_at_boundary() {
|
||||
let tq = test_quant(1, 1);
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
min_u16: tq.as_ref().encode_min(0, 100.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 200.0),
|
||||
}];
|
||||
|
||||
assert!(row_passes_filters(0, &filters, &[], &[100.0], 1));
|
||||
assert!(row_passes_filters(0, &filters, &[], &[200.0], 1));
|
||||
let data_100 = vec![tq.encode(0, 100.0)];
|
||||
let data_200 = vec![tq.encode(0, 200.0)];
|
||||
assert!(row_passes_filters(0, &filters, &[], &data_100, 1));
|
||||
assert!(row_passes_filters(0, &filters, &[], &data_200, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_empty_enum_filter() {
|
||||
let feature_data = vec![1.0_f32];
|
||||
let tq = test_quant(1, 0);
|
||||
let feature_data = vec![tq.encode(0, 1.0)];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: FxHashSet::default(),
|
||||
|
|
@ -328,16 +431,17 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn multiple_filters_all_must_pass() {
|
||||
let feature_data = vec![150.0_f32, 1.0];
|
||||
let tq = test_quant(2, 1); // feat 0 = numeric, feat 1 = enum
|
||||
let feature_data = vec![tq.encode(0, 150.0), tq.encode(1, 1.0)];
|
||||
|
||||
let numeric_filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
min_u16: tq.as_ref().encode_min(0, 100.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 200.0),
|
||||
}];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: allowed_set(&[1.0, 2.0]),
|
||||
allowed: [1u16, 2].into_iter().collect(),
|
||||
}];
|
||||
|
||||
assert!(row_passes_filters(
|
||||
|
|
@ -350,7 +454,7 @@ mod tests {
|
|||
|
||||
let enum_filters_fail = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: allowed_set(&[0.0, 2.0]),
|
||||
allowed: [0u16, 2].into_iter().collect(),
|
||||
}];
|
||||
assert!(!row_passes_filters(
|
||||
0,
|
||||
|
|
@ -363,17 +467,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_major_layout_correct_indexing() {
|
||||
let tq = test_quant(2, 1); // feat 0 = numeric, feat 1 = enum
|
||||
let feature_data = vec![
|
||||
100.0_f32, 0.0, // Row 0
|
||||
200.0, 1.0, // Row 1
|
||||
300.0, 2.0, // Row 2
|
||||
tq.encode(0, 100.0),
|
||||
tq.encode(1, 0.0), // Row 0
|
||||
tq.encode(0, 200.0),
|
||||
tq.encode(1, 1.0), // Row 1
|
||||
tq.encode(0, 300.0),
|
||||
tq.encode(1, 2.0), // Row 2
|
||||
];
|
||||
let num_features = 2;
|
||||
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 150.0,
|
||||
max: 250.0,
|
||||
min_u16: tq.as_ref().encode_min(0, 150.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 250.0),
|
||||
}];
|
||||
|
||||
assert!(!row_passes_filters(
|
||||
|
|
@ -401,22 +509,24 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn filter_at_float_precision_boundary() {
|
||||
let value = 100.0_f32;
|
||||
let tq = test_quant(1, 1);
|
||||
let value = tq.encode(0, 100.0);
|
||||
let filter = ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0 - f32::EPSILON,
|
||||
max: 100.0 + f32::EPSILON,
|
||||
min_u16: tq.as_ref().encode_min(0, 100.0 - f32::EPSILON),
|
||||
max_u16: tq.as_ref().encode_max(0, 100.0 + f32::EPSILON),
|
||||
};
|
||||
|
||||
assert!(row_passes_filters(0, &[filter], &[], &[value], 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_filter_with_fractional_index() {
|
||||
let feature_data = vec![1.5_f32]; // Not exactly 1.0 or 2.0
|
||||
fn enum_filter_rejects_non_matching_index() {
|
||||
let tq = test_quant(1, 0); // all enum
|
||||
let feature_data = vec![tq.encode(0, 3.0)]; // index 3
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: allowed_set(&[1.0, 2.0]),
|
||||
allowed: [1u16, 2].into_iter().collect(),
|
||||
}];
|
||||
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ pub fn cell_for_row(
|
|||
return max_cell;
|
||||
}
|
||||
let cell = h3o::CellIndex::try_from(max_cell).expect("precomputed H3 cell must be valid");
|
||||
u64::from(cell.parent(h3_res).expect("parent resolution must be valid for precomputed cell"))
|
||||
u64::from(
|
||||
cell.parent(h3_res)
|
||||
.expect("parent resolution must be valid for precomputed cell"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the given resolution requires computing a parent from precomputed cells.
|
||||
|
|
|
|||
|
|
@ -214,11 +214,7 @@ async fn find_users_collection_id(
|
|||
|
||||
/// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection.
|
||||
/// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields.
|
||||
async fn ensure_user_fields(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> anyhow::Result<()> {
|
||||
let url = format!("{base_url}/api/collections/users");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
|
|
@ -243,7 +239,12 @@ async fn ensure_user_fields(
|
|||
let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used");
|
||||
let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week");
|
||||
|
||||
if has_is_admin && has_subscription && has_newsletter && has_ai_tokens_used && has_ai_tokens_week {
|
||||
if has_is_admin
|
||||
&& has_subscription
|
||||
&& has_newsletter
|
||||
&& has_ai_tokens_used
|
||||
&& has_ai_tokens_week
|
||||
{
|
||||
info!("PocketBase users collection already has all required fields");
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,24 @@ pub(crate) mod hexagons;
|
|||
mod invites;
|
||||
mod journey;
|
||||
mod me;
|
||||
mod newsletter;
|
||||
mod pb_proxy;
|
||||
mod places;
|
||||
mod pois;
|
||||
mod postcode_properties;
|
||||
mod postcode_stats;
|
||||
mod postcodes;
|
||||
pub(crate) mod pricing;
|
||||
pub(crate) mod properties;
|
||||
mod screenshot;
|
||||
mod shorten;
|
||||
mod stats;
|
||||
mod streetview;
|
||||
mod stripe_webhook;
|
||||
mod newsletter;
|
||||
pub(crate) mod pricing;
|
||||
mod tiles;
|
||||
pub(crate) mod travel_time;
|
||||
mod travel_destinations;
|
||||
mod travel_modes;
|
||||
pub(crate) mod travel_time;
|
||||
|
||||
pub use ai_filters::{build_system_prompt, post_ai_filters};
|
||||
pub use checkout::post_checkout;
|
||||
|
|
@ -32,21 +32,21 @@ pub use export::get_export;
|
|||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||
pub use hexagon_stats::get_hexagon_stats;
|
||||
pub use hexagons::get_hexagons;
|
||||
pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite};
|
||||
pub use journey::get_journey;
|
||||
pub use me::get_me;
|
||||
pub use newsletter::patch_newsletter;
|
||||
pub use pb_proxy::proxy_to_pocketbase;
|
||||
pub use places::get_places;
|
||||
pub use pois::{get_poi_categories, get_pois};
|
||||
pub use postcode_properties::get_postcode_properties;
|
||||
pub use postcode_stats::get_postcode_stats;
|
||||
pub use postcodes::{get_postcode_lookup, get_postcodes};
|
||||
pub use pricing::get_pricing;
|
||||
pub use properties::get_hexagon_properties;
|
||||
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||
pub use shorten::{get_short_url, post_shorten};
|
||||
pub use streetview::get_streetview;
|
||||
pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite};
|
||||
pub use journey::get_journey;
|
||||
pub use newsletter::patch_newsletter;
|
||||
pub use pricing::get_pricing;
|
||||
pub use stripe_webhook::post_stripe_webhook;
|
||||
pub use tiles::{get_style, get_tile, init_tile_reader};
|
||||
pub use travel_destinations::get_travel_destinations;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
|
|||
|
||||
let slug_set = match tt_store.destinations.get(mode) {
|
||||
Some(slugs) => slugs,
|
||||
None => return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) }),
|
||||
None => {
|
||||
return json!({ "results": [], "message": format!("No travel data available for mode '{}'", mode) })
|
||||
}
|
||||
};
|
||||
|
||||
// Find places matching the query that have travel time data.
|
||||
|
|
@ -154,7 +156,11 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
|
|||
matches.truncate(10);
|
||||
|
||||
if matches.is_empty() {
|
||||
info!(query = query, mode = mode, "Destination search returned no results");
|
||||
info!(
|
||||
query = query,
|
||||
mode = mode,
|
||||
"Destination search returned no results"
|
||||
);
|
||||
return json!({
|
||||
"results": [],
|
||||
"message": format!("No travel time data available for '{}' by {}. This destination cannot be used as a travel time filter.", query, mode)
|
||||
|
|
@ -597,10 +603,7 @@ pub async fn post_ai_filters(
|
|||
.and_then(|c| c.get("content"))
|
||||
.ok_or_else(|| {
|
||||
warn!("Malformed Gemini response: missing candidates[0].content");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Malformed Gemini response".into(),
|
||||
)
|
||||
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
|
||||
})?;
|
||||
|
||||
let parts = candidate
|
||||
|
|
@ -608,10 +611,7 @@ pub async fn post_ai_filters(
|
|||
.and_then(|p| p.as_array())
|
||||
.ok_or_else(|| {
|
||||
warn!("Malformed Gemini response: missing parts array");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Malformed Gemini response".into(),
|
||||
)
|
||||
(StatusCode::BAD_GATEWAY, "Malformed Gemini response".into())
|
||||
})?;
|
||||
|
||||
// Check if the model made a function call.
|
||||
|
|
@ -621,17 +621,10 @@ pub async fn post_ai_filters(
|
|||
let fn_name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let fn_args = fc.get("args").cloned().unwrap_or(json!({}));
|
||||
|
||||
info!(
|
||||
function = fn_name,
|
||||
round = round,
|
||||
"AI called tool"
|
||||
);
|
||||
info!(function = fn_name, round = round, "AI called tool");
|
||||
|
||||
let fn_result = if fn_name == "search_destinations" {
|
||||
let query = fn_args
|
||||
.get("query")
|
||||
.and_then(|q| q.as_str())
|
||||
.unwrap_or("");
|
||||
let query = fn_args.get("query").and_then(|q| q.as_str()).unwrap_or("");
|
||||
let mode = fn_args
|
||||
.get("mode")
|
||||
.and_then(|m| m.as_str())
|
||||
|
|
@ -710,7 +703,10 @@ pub async fn post_ai_filters(
|
|||
}
|
||||
|
||||
// Exhausted tool rounds without getting a final text response
|
||||
warn!("AI exhausted {} tool-calling rounds without final response", MAX_TOOL_ROUNDS);
|
||||
warn!(
|
||||
"AI exhausted {} tool-calling rounds without final response",
|
||||
MAX_TOOL_ROUNDS
|
||||
);
|
||||
Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"AI could not complete the request".into(),
|
||||
|
|
@ -746,7 +742,11 @@ fn validate_travel_time_filters(raw: &Value, state: &AppState) -> Vec<TravelTime
|
|||
|
||||
// Verify this destination actually exists
|
||||
if !tt_store.has_destination(mode, slug) {
|
||||
warn!(mode = mode, slug = slug, "AI suggested non-existent destination");
|
||||
warn!(
|
||||
mode = mode,
|
||||
slug = slug,
|
||||
"AI suggested non-existent destination"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,10 @@ pub async fn post_checkout(
|
|||
// If a referral code is provided and valid, look it up and apply the coupon
|
||||
if let Some(ref code) = req.referral_code {
|
||||
if validate_referral_invite(&state, code).await {
|
||||
form_params.push(("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()));
|
||||
form_params.push((
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
));
|
||||
info!(code = %code, "Applying referral coupon to checkout");
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +130,13 @@ pub async fn post_checkout(
|
|||
/// Grant a license by updating the user's subscription to "licensed" in PocketBase.
|
||||
async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
|
||||
let token = auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||
let resp = state
|
||||
|
|
@ -151,10 +160,7 @@ async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
|||
/// Check if a referral invite code exists and is unused.
|
||||
async fn validate_referral_invite(state: &AppState, code: &str) -> bool {
|
||||
// Only allow alphanumeric codes to prevent PocketBase filter injection
|
||||
if code.is_empty()
|
||||
|| code.len() > 20
|
||||
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
{
|
||||
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use serde::Deserialize;
|
|||
use tracing::{info, warn};
|
||||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::QuantRef;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{parse_field_indices, parse_filters, require_bounds, row_passes_filters};
|
||||
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
||||
|
|
@ -50,18 +52,20 @@ impl PostcodeExportAgg {
|
|||
#[inline]
|
||||
fn add_row(
|
||||
&mut self,
|
||||
feature_data: &[f32],
|
||||
feature_data: &[u16],
|
||||
row: usize,
|
||||
num_features: usize,
|
||||
enum_indices: &FxHashMap<usize, ()>,
|
||||
quant: &QuantRef,
|
||||
) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
let row_slice = &feature_data[base..base + num_features];
|
||||
for (feat_idx, &value) in row_slice.iter().enumerate() {
|
||||
if !value.is_finite() {
|
||||
for (feat_idx, &raw) in row_slice.iter().enumerate() {
|
||||
if raw == NAN_U16 {
|
||||
continue;
|
||||
}
|
||||
let value = quant.decode(feat_idx, raw);
|
||||
if enum_indices.contains_key(&feat_idx) {
|
||||
*self
|
||||
.enum_freqs
|
||||
|
|
@ -131,10 +135,12 @@ pub async fn get_export(
|
|||
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let filters_str = params.filters;
|
||||
|
|
@ -188,6 +194,7 @@ pub async fn get_export(
|
|||
let t0 = std::time::Instant::now();
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let quant = state.data.quant_ref();
|
||||
let feature_names = &state.data.feature_names;
|
||||
let enum_values = &state.data.enum_values;
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
|
@ -222,7 +229,7 @@ pub async fn get_export(
|
|||
for (pc_idx, rows) in postcode_rows {
|
||||
let mut agg = PostcodeExportAgg::new(num_features);
|
||||
for &row in &rows {
|
||||
agg.add_row(feature_data, row, num_features, &enum_indices);
|
||||
agg.add_row(feature_data, row, num_features, &enum_indices, &quant);
|
||||
}
|
||||
if agg.count > 0 {
|
||||
postcode_aggs.push((pc_idx, agg));
|
||||
|
|
@ -470,11 +477,9 @@ pub async fn get_export(
|
|||
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}")
|
||||
})?;
|
||||
sheet.write_string(row, col, &values[mode_idx]).map_err(
|
||||
|e| format!("Failed to write enum value: {e}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -486,13 +491,11 @@ pub async fn get_export(
|
|||
if let Some(fmt) = feat_num_fmts.get(&feat_idx) {
|
||||
sheet
|
||||
.write_number_with_format(row, col, mean, fmt)
|
||||
.map_err(|e| {
|
||||
format!("Failed to write numeric value: {e}")
|
||||
})?;
|
||||
.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}")
|
||||
})?;
|
||||
sheet
|
||||
.write_number(row, col, mean)
|
||||
.map_err(|e| format!("Failed to write numeric value: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,10 +100,12 @@ pub async fn get_hexagon_stats(
|
|||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -114,10 +116,7 @@ pub async fn get_hexagon_stats(
|
|||
// Load travel time data for central_postcode selection (if requested)
|
||||
let journey_travel_data = match (¶ms.journey_mode, ¶ms.journey_slug) {
|
||||
(Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => {
|
||||
state
|
||||
.travel_time_store
|
||||
.get(mode, slug)
|
||||
.ok()
|
||||
state.travel_time_store.get(mode, slug).ok()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
|
@ -209,18 +208,13 @@ pub async fn get_hexagon_stats(
|
|||
None
|
||||
};
|
||||
|
||||
let price_history = stats::extract_price_history(
|
||||
&matching_rows,
|
||||
feature_data,
|
||||
num_features,
|
||||
&state.feature_name_to_index,
|
||||
);
|
||||
let price_history =
|
||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||
|
||||
let (numeric_features, enum_features_out) = stats::compute_feature_stats(
|
||||
&matching_rows,
|
||||
feature_data,
|
||||
&state.data,
|
||||
&state.data.feature_names,
|
||||
num_features,
|
||||
&state.data.enum_values,
|
||||
&state.data.feature_stats,
|
||||
fields_specified,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ pub struct HexagonParams {
|
|||
travel: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
/// Build feature maps from aggregated cell data, filtering to only cells that intersect the query bounds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_feature_maps(
|
||||
|
|
@ -144,10 +143,12 @@ pub async fn get_hexagons(
|
|||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
}
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -185,6 +186,7 @@ pub async fn get_hexagons(
|
|||
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let quant = state.data.quant_ref();
|
||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
|
|
@ -196,8 +198,9 @@ pub async fn get_hexagons(
|
|||
let need_parent = needs_parent(resolution);
|
||||
|
||||
let mut groups: FxHashMap<u64, Aggregator> = FxHashMap::default();
|
||||
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> =
|
||||
(0..travel_entries.len()).map(|_| FxHashMap::default()).collect();
|
||||
let mut travel_aggs: Vec<FxHashMap<u64, TravelTimeAgg>> = (0..travel_entries.len())
|
||||
.map(|_| FxHashMap::default())
|
||||
.collect();
|
||||
|
||||
// Main aggregation loop
|
||||
let aggregate_row =
|
||||
|
|
@ -246,9 +249,15 @@ pub async fn get_hexagons(
|
|||
.entry(cell_id)
|
||||
.or_insert_with(|| Aggregator::new(num_features));
|
||||
if let Some(sel_indices) = field_indices.as_deref() {
|
||||
aggregation.add_row_selective(feature_data, row, num_features, sel_indices);
|
||||
aggregation.add_row_selective(
|
||||
feature_data,
|
||||
row,
|
||||
num_features,
|
||||
sel_indices,
|
||||
&quant,
|
||||
);
|
||||
} else {
|
||||
aggregation.add_row(feature_data, row, num_features);
|
||||
aggregation.add_row(feature_data, row, num_features, &quant);
|
||||
}
|
||||
|
||||
// Aggregate travel time
|
||||
|
|
|
|||
|
|
@ -107,13 +107,23 @@ pub async fn post_invites(
|
|||
} else if user.subscription == "licensed" {
|
||||
"referral"
|
||||
} else {
|
||||
return (StatusCode::FORBIDDEN, "Only licensed users can create invites").into_response();
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only licensed users can create invites",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let code = generate_invite_code();
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
|
|
@ -190,7 +200,13 @@ pub async fn get_invite(
|
|||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
|
|
@ -205,9 +221,12 @@ pub async fn get_invite(
|
|||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&url)
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send().await
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
|
|
@ -235,8 +254,7 @@ pub async fn get_invite(
|
|||
|
||||
// Look up inviter's name (email local part)
|
||||
let invited_by = if !created_by.is_empty() {
|
||||
let user_url =
|
||||
format!("{pb_url}/api/collections/users/records/{created_by}");
|
||||
let user_url = format!("{pb_url}/api/collections/users/records/{created_by}");
|
||||
match state
|
||||
.http_client
|
||||
.get(&user_url)
|
||||
|
|
@ -245,8 +263,7 @@ pub async fn get_invite(
|
|||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let user_body: serde_json::Value =
|
||||
resp.json().await.unwrap_or_default();
|
||||
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
user_body["email"]
|
||||
.as_str()
|
||||
.and_then(|e| e.split('@').next())
|
||||
|
|
@ -305,7 +322,13 @@ pub async fn post_redeem_invite(
|
|||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
|
|
@ -315,18 +338,18 @@ pub async fn post_redeem_invite(
|
|||
};
|
||||
|
||||
// Look up invite
|
||||
let filter = format!(
|
||||
"code=\"{}\" && used_by_id=\"\"",
|
||||
req.code
|
||||
);
|
||||
let filter = format!("code=\"{}\" && used_by_id=\"\"", req.code);
|
||||
let lookup_url = format!(
|
||||
"{pb_url}/api/collections/invites/records?filter={}&perPage=1",
|
||||
urlencoding::encode(&filter)
|
||||
);
|
||||
|
||||
let res = match state.http_client.get(&lookup_url)
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&lookup_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send().await
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
|
|
@ -428,7 +451,10 @@ pub async fn post_redeem_invite(
|
|||
("cancel_url", cancel_url),
|
||||
("client_reference_id", user.id.clone()),
|
||||
("customer_email", user.email.clone()),
|
||||
("discounts[0][coupon]", state.stripe_referral_coupon_id.clone()),
|
||||
(
|
||||
"discounts[0][coupon]",
|
||||
state.stripe_referral_coupon_id.clone(),
|
||||
),
|
||||
];
|
||||
|
||||
let stripe_res = state
|
||||
|
|
@ -442,10 +468,7 @@ pub async fn post_redeem_invite(
|
|||
match stripe_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let stripe_body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let checkout_url = stripe_body["url"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let checkout_url = stripe_body["url"].as_str().unwrap_or_default().to_string();
|
||||
info!(user_id = %user.id, code = %req.code, "Referral invite redeemed — checkout created");
|
||||
Json(RedeemResponse {
|
||||
result: "checkout".to_string(),
|
||||
|
|
@ -494,9 +517,7 @@ pub async fn get_invites(
|
|||
format!("created_by=\"{}\"", user.id)
|
||||
};
|
||||
|
||||
let mut url = format!(
|
||||
"{pb_url}/api/collections/invites/records?sort=-created&perPage=200"
|
||||
);
|
||||
let mut url = format!("{pb_url}/api/collections/invites/records?sort=-created&perPage=200");
|
||||
if !filter.is_empty() {
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ pub async fn patch_newsletter(
|
|||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ pub async fn get_pois(
|
|||
let pois: Vec<POI> = matching_rows
|
||||
.iter()
|
||||
.map(|&row| POI {
|
||||
id: state.poi_data.id[row].clone(),
|
||||
id: state.poi_data.id(row).to_string(),
|
||||
name: state.poi_data.name[row].clone(),
|
||||
category: state.poi_data.category.get(row).to_string(),
|
||||
group: state.poi_data.group.get(row).to_string(),
|
||||
|
|
|
|||
|
|
@ -46,10 +46,12 @@ pub async fn get_postcode_properties(
|
|||
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -105,8 +107,11 @@ pub async fn get_postcode_properties(
|
|||
.take(limit)
|
||||
.map(|&row| {
|
||||
super::properties::build_property(
|
||||
row, &state, feature_names, feature_name_to_index, feature_data,
|
||||
num_features, enum_values,
|
||||
row,
|
||||
&state,
|
||||
feature_names,
|
||||
feature_name_to_index,
|
||||
enum_values,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -50,10 +50,12 @@ pub async fn get_postcode_stats(
|
|||
// License check using postcode centroid
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -96,18 +98,13 @@ pub async fn get_postcode_stats(
|
|||
|
||||
let total_count = matching_rows.len();
|
||||
|
||||
let price_history = stats::extract_price_history(
|
||||
&matching_rows,
|
||||
feature_data,
|
||||
num_features,
|
||||
&state.feature_name_to_index,
|
||||
);
|
||||
let price_history =
|
||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||
|
||||
let (numeric_features, enum_features_out) = stats::compute_feature_stats(
|
||||
&matching_rows,
|
||||
feature_data,
|
||||
&state.data,
|
||||
&state.data.feature_names,
|
||||
num_features,
|
||||
&state.data.enum_values,
|
||||
&state.data.feature_stats,
|
||||
fields_specified,
|
||||
|
|
|
|||
|
|
@ -76,10 +76,12 @@ pub async fn get_postcodes(
|
|||
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -118,6 +120,7 @@ pub async fn get_postcodes(
|
|||
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let quant = state.data.quant_ref();
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
let avg_keys = &state.avg_keys;
|
||||
|
|
@ -185,18 +188,20 @@ pub async fn get_postcodes(
|
|||
.or_insert_with(|| Aggregator::new(num_features));
|
||||
for &row in rows {
|
||||
if has_selective {
|
||||
agg.add_row_selective(feature_data, row, num_features, sel_indices);
|
||||
agg.add_row_selective(feature_data, row, num_features, sel_indices, &quant);
|
||||
} else {
|
||||
agg.add_row(feature_data, row, num_features);
|
||||
agg.add_row(feature_data, row, num_features, &quant);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate travel times for this postcode
|
||||
if has_travel {
|
||||
let postcode = &postcode_data.postcodes[pc_idx];
|
||||
let tt_aggs = travel_aggs
|
||||
.entry(pc_idx)
|
||||
.or_insert_with(|| (0..travel_entries.len()).map(|_| TravelTimeAgg::new()).collect());
|
||||
let tt_aggs = travel_aggs.entry(pc_idx).or_insert_with(|| {
|
||||
(0..travel_entries.len())
|
||||
.map(|_| TravelTimeAgg::new())
|
||||
.collect()
|
||||
});
|
||||
for (ti, entry) in travel_entries.iter().enumerate() {
|
||||
if let Some(row_data) = travel_data[ti].get(postcode.as_str()) {
|
||||
let minutes = if entry.use_best {
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ use crate::state::AppState;
|
|||
/// Pricing tiers: (cumulative user cap, price in pence).
|
||||
const TIERS: &[(u64, u64)] = &[
|
||||
(1, 0), // First 10 users: free
|
||||
(20, 1000), // Next 10: £10
|
||||
(45, 2500), // Next 25: £25
|
||||
(95, 5000), // Next 50: £50
|
||||
(20, 1000), // Next 10: £10
|
||||
(45, 2500), // Next 25: £25
|
||||
(95, 5000), // Next 50: £50
|
||||
];
|
||||
const FINAL_PRICE_PENCE: u64 = 10000; // £100 after 95
|
||||
|
||||
|
|
@ -45,7 +45,13 @@ pub fn price_for_count(count: u64) -> u64 {
|
|||
/// Count users with subscription="licensed" in PocketBase.
|
||||
pub async fn count_licensed_users(state: &AppState) -> anyhow::Result<u64> {
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await?;
|
||||
let token = auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let filter = "subscription=\"licensed\"";
|
||||
let url = format!(
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::auth::OptionalUser;
|
||||
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, MAX_PROPERTIES_LIMIT};
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::licensing::check_license_bounds;
|
||||
use crate::parsing::{
|
||||
cell_for_row, h3_cell_bounds, needs_parent, parse_filters, row_passes_filters,
|
||||
validate_h3_resolution,
|
||||
};
|
||||
use crate::data::RenovationEvent;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -78,18 +78,17 @@ fn non_empty_string(text: &str) -> Option<String> {
|
|||
}
|
||||
|
||||
/// Look up an enum feature value by column name.
|
||||
/// Uses the unified feature model: enum values stored as f32 indices in feature_data.
|
||||
/// Uses the unified feature model: enum values stored as u16 indices in feature_data.
|
||||
fn lookup_enum_value(
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
feature_data: &[f32],
|
||||
num_features: usize,
|
||||
data: &crate::data::PropertyData,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
row: usize,
|
||||
name: &str,
|
||||
) -> Option<String> {
|
||||
let &feat_idx = feature_name_to_index.get(name)?;
|
||||
let values = enum_values.get(&feat_idx)?;
|
||||
let value = feature_data[row * num_features + feat_idx];
|
||||
let value = data.get_feature(row, feat_idx);
|
||||
if value.is_finite() {
|
||||
let idx = value as usize;
|
||||
values.get(idx).cloned()
|
||||
|
|
@ -103,17 +102,14 @@ pub fn build_property(
|
|||
state: &AppState,
|
||||
feature_names: &[String],
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
feature_data: &[f32],
|
||||
num_features: usize,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
) -> Property {
|
||||
let mut features = FxHashMap::default();
|
||||
let base = row * num_features;
|
||||
for (feat_idx, feat_name) in feature_names.iter().enumerate() {
|
||||
if enum_values.contains_key(&feat_idx) {
|
||||
continue;
|
||||
}
|
||||
let value = feature_data[base + feat_idx];
|
||||
let value = state.data.get_feature(row, feat_idx);
|
||||
if value.is_finite() {
|
||||
features.insert(feat_name.clone(), value);
|
||||
}
|
||||
|
|
@ -124,26 +120,50 @@ pub fn build_property(
|
|||
postcode: non_empty_string(state.data.postcode(row)),
|
||||
is_construction_date_approximate: Some(state.data.is_approx_build_date(row)),
|
||||
property_type: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Property type",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Property type",
|
||||
),
|
||||
built_form: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Property type/built form",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Property type/built form",
|
||||
),
|
||||
duration: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Leasehold/Freehold",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Leasehold/Freehold",
|
||||
),
|
||||
current_energy_rating: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Current energy rating",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Current energy rating",
|
||||
),
|
||||
potential_energy_rating: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Potential energy rating",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Potential energy rating",
|
||||
),
|
||||
lat: state.data.lat[row],
|
||||
lon: state.data.lon[row],
|
||||
renovation_history: state.data.renovation_history(row).to_vec(),
|
||||
listing_features: state.data.listing_features(row).to_vec(),
|
||||
listing_status: lookup_enum_value(
|
||||
feature_name_to_index, feature_data, num_features, enum_values, row, "Listing status",
|
||||
feature_name_to_index,
|
||||
&state.data,
|
||||
enum_values,
|
||||
row,
|
||||
"Listing status",
|
||||
),
|
||||
listing_url: state.data.listing_url(row).map(String::from),
|
||||
property_sub_type: state.data.property_sub_type(row).map(String::from),
|
||||
|
|
@ -175,10 +195,12 @@ pub async fn get_hexagon_properties(
|
|||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3;
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
|
@ -233,8 +255,11 @@ pub async fn get_hexagon_properties(
|
|||
.take(limit)
|
||||
.map(|&row| {
|
||||
build_property(
|
||||
row, &state, feature_names, feature_name_to_index, feature_data,
|
||||
num_features, enum_values,
|
||||
row,
|
||||
&state,
|
||||
feature_names,
|
||||
feature_name_to_index,
|
||||
enum_values,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use tracing::{info, warn};
|
|||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Fetch a PNG screenshot from the screenshot service.
|
||||
/// Fetch a JPEG screenshot from the screenshot service.
|
||||
/// Used by both the `/api/screenshot` proxy and the xlsx export.
|
||||
pub async fn fetch_screenshot_bytes(
|
||||
state: &AppState,
|
||||
|
|
@ -31,9 +31,7 @@ pub async fn fetch_screenshot_bytes(
|
|||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(format!(
|
||||
"Screenshot service returned {status}: {body}"
|
||||
))
|
||||
Err(format!("Screenshot service returned {status}: {body}"))
|
||||
}
|
||||
Err(err) => Err(format!("Failed to reach screenshot service: {err}")),
|
||||
}
|
||||
|
|
@ -51,7 +49,7 @@ pub async fn get_screenshot(
|
|||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/png"),
|
||||
(header::CONTENT_TYPE, "image/jpeg"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400"),
|
||||
],
|
||||
bytes,
|
||||
|
|
|
|||
|
|
@ -65,9 +65,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
|
|||
|
||||
let res = state
|
||||
.http_client
|
||||
.post(format!(
|
||||
"{pb_url}/api/collections/short_urls/records"
|
||||
))
|
||||
.post(format!("{pb_url}/api/collections/short_urls/records"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&record)
|
||||
.send()
|
||||
|
|
@ -95,10 +93,7 @@ pub async fn post_shorten(state: Arc<AppState>, Json(req): Json<ShortenRequest>)
|
|||
}
|
||||
|
||||
pub async fn get_short_url(state: Arc<AppState>, Path(code): Path<String>) -> Response {
|
||||
if code.is_empty()
|
||||
|| code.len() > 20
|
||||
|| !code.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
{
|
||||
if code.is_empty() || code.len() > 20 || !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,14 @@ use rustc_hash::FxHashMap;
|
|||
use tracing::warn;
|
||||
|
||||
use crate::consts::MAX_PRICE_HISTORY_POINTS;
|
||||
use crate::data::FeatureStats;
|
||||
use crate::data::{FeatureStats, PropertyData};
|
||||
|
||||
use super::hexagon_stats::{EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint};
|
||||
|
||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
||||
pub fn extract_price_history(
|
||||
matching_rows: &[usize],
|
||||
feature_data: &[f32],
|
||||
num_features: usize,
|
||||
data: &PropertyData,
|
||||
feature_name_to_index: &FxHashMap<String, usize>,
|
||||
) -> Vec<PricePoint> {
|
||||
let year_idx = feature_name_to_index
|
||||
|
|
@ -24,8 +23,8 @@ pub fn extract_price_history(
|
|||
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];
|
||||
let year = data.get_feature(row, yi);
|
||||
let price = data.get_feature(row, pi);
|
||||
if year.is_finite() && price.is_finite() {
|
||||
Some(PricePoint { year, price })
|
||||
} else {
|
||||
|
|
@ -55,9 +54,8 @@ pub fn extract_price_history(
|
|||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_feature_stats(
|
||||
matching_rows: &[usize],
|
||||
feature_data: &[f32],
|
||||
data: &PropertyData,
|
||||
feature_names: &[String],
|
||||
num_features: usize,
|
||||
enum_values: &FxHashMap<usize, Vec<String>>,
|
||||
feature_stats_data: &[FeatureStats],
|
||||
fields_specified: bool,
|
||||
|
|
@ -74,7 +72,7 @@ pub fn compute_feature_stats(
|
|||
if let Some(ev) = enum_values.get(&feature_index) {
|
||||
let mut value_counts = vec![0u64; ev.len()];
|
||||
for &row in matching_rows {
|
||||
let value = feature_data[row * num_features + feature_index];
|
||||
let value = data.get_feature(row, feature_index);
|
||||
if value.is_finite() {
|
||||
let idx = value as usize;
|
||||
if idx < value_counts.len() {
|
||||
|
|
@ -123,7 +121,7 @@ pub fn compute_feature_stats(
|
|||
};
|
||||
|
||||
for &row in matching_rows {
|
||||
let value = feature_data[row * num_features + feature_index];
|
||||
let value = data.get_feature(row, feature_index);
|
||||
if value.is_finite() {
|
||||
count += 1;
|
||||
if value < min_value {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,10 @@ pub async fn post_stripe_webhook(
|
|||
) -> Response {
|
||||
let webhook_secret = &state.stripe_webhook_secret;
|
||||
|
||||
let sig_header = match headers.get("stripe-signature").and_then(|h| h.to_str().ok()) {
|
||||
let sig_header = match headers
|
||||
.get("stripe-signature")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Missing Stripe-Signature header");
|
||||
|
|
@ -90,8 +93,13 @@ pub async fn post_stripe_webhook(
|
|||
|
||||
// Update user subscription to "licensed" via PocketBase superuser auth
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password)
|
||||
.await
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
|
|
@ -112,12 +120,18 @@ pub async fn post_stripe_webhook(
|
|||
match res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
state.token_cache.invalidate_by_user_id(user_id);
|
||||
info!(user_id, "User subscription updated to licensed via Stripe webhook");
|
||||
info!(
|
||||
user_id,
|
||||
"User subscription updated to licensed via Stripe webhook"
|
||||
);
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(user_id, "Failed to update user subscription ({status}): {text}");
|
||||
warn!(
|
||||
user_id,
|
||||
"Failed to update user subscription ({status}): {text}"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(user_id, "PocketBase request error in webhook: {err}");
|
||||
|
|
|
|||
|
|
@ -71,7 +71,10 @@ pub async fn get_style(
|
|||
.unwrap_or_default();
|
||||
|
||||
// Build absolute tile URL using the configured public URL (not the Host header)
|
||||
let tile_url = format!("{}/api/tiles/{{z}}/{{x}}/{{y}}", public_url.trim_end_matches('/'));
|
||||
let tile_url = format!(
|
||||
"{}/api/tiles/{{z}}/{{x}}/{{y}}",
|
||||
public_url.trim_end_matches('/')
|
||||
);
|
||||
let style = build_style(is_dark, &layers, &tile_url);
|
||||
|
||||
Ok((
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue