-
- Three clicks to clarity
-
-
- {STEPS.map((step, i) => (
-
-
- {i + 1}
-
-
-
- {step.title}
-
-
{step.body}
-
-
- ))}
+ {/* Problem / solution / philosophy */}
+
+ {/* Cereal box — quirky margin note, hidden on narrow screens */}
+
+
+
+
+ Your home is not a box of cereal. Don't let a discount on the wrong
+ property distract you from finding the right one.
+
-
- {/* Numbers */}
-
-
-
- {STATS.map((s) => (
-
-
{s.value}
-
{s.label}
-
- ))}
-
+
+
+ Here's the problem with property search: listings only show you what's on
+ the market{' '}
+ right now {' '}
+ — a thin slice of what an area is actually like. And even if you could look
+ beyond them, there are{' '}
+
+ millions of postcodes
+ {' '}
+ across England. You can't research them all yourself.
+
+
+ We built this for you — years of historical transactions and public records,
+ extended with proprietary algorithms so the map doesn't just show raw data, it{' '}
+
+ surfaces the patterns that matter
+
+ .
+
+
+ Understand areas first. Then find the right property within them, with expectations
+ you've set — not ones the market set for you.
+
{/* Final CTA */}
-
+
- Ready to narrow it down?
+ The biggest financial decision of your life
+
+ deserves proper tools behind it.
- 100% open data. No account required. Just set your filters and go.
+ One payment, lifetime access. Set your filters and go.
-
- Open the map
-
+
+
+ Give your journey a headstart
+
+
+ See pricing
+
+
+
+ {/* Bottom illustration */}
+
);
}
-const FILTERS = [
- { icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
- { icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
- { icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
- { icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
- { icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
- { icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
- { icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
- { icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
-];
+interface Category {
+ icon: string;
+ label: string;
+ group: string;
+ borderClass: string;
+ hoverBgClass: string;
+ iconBgClass: string;
+ artColorClass: string;
+}
-const STEPS = [
+const CATEGORIES: Category[] = [
{
- title: 'Add your deal-breakers',
- body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
- },
- {
- title: 'Spot the clusters',
- body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
- },
- {
- title: 'Dive into a neighbourhood',
- body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
- },
-];
+ icon: '\u{1F3E0}',
+ label: 'Property',
+ group: 'Property',
+ borderClass: 'border-l-teal-400 dark:border-l-teal-500',
+ hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
+ iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
+ artColorClass: 'text-teal-400 dark:text-teal-600',
-const STATS = [
- { value: '26M+', label: 'property records' },
- { value: '12', label: 'open datasets' },
- { value: '1.7M', label: 'postcodes mapped' },
+ },
+ {
+ icon: '\u{1F686}',
+ label: 'Transport',
+ group: 'Transport',
+ borderClass: 'border-l-blue-400 dark:border-l-blue-500',
+ hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
+ iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
+ artColorClass: 'text-blue-400 dark:text-blue-600',
+
+ },
+ {
+ icon: '\u{1F3EB}',
+ label: 'Schools',
+ group: 'Education',
+ borderClass: 'border-l-amber-400 dark:border-l-amber-500',
+ hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
+ iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
+ artColorClass: 'text-amber-400 dark:text-amber-600',
+
+ },
+ {
+ icon: '\u{1F6A8}',
+ label: 'Crime',
+ group: 'Crime',
+ borderClass: 'border-l-rose-400 dark:border-l-rose-500',
+ hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
+ iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
+ artColorClass: 'text-rose-400 dark:text-rose-600',
+
+ },
+ {
+ icon: '\u{1F465}',
+ label: 'Demographics',
+ group: 'Demographics',
+ borderClass: 'border-l-violet-400 dark:border-l-violet-500',
+ hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
+ iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
+ artColorClass: 'text-violet-400 dark:text-violet-600',
+
+ },
+ {
+ icon: '\u{1F3EA}',
+ label: 'Amenities',
+ group: 'Amenities',
+ borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
+ hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
+ iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
+ artColorClass: 'text-emerald-400 dark:text-emerald-600',
+
+ },
+ {
+ icon: '\u{1F30D}',
+ label: 'Environment',
+ group: 'Environment',
+
+ borderClass: 'border-l-orange-400 dark:border-l-orange-500',
+ hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
+ iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
+ artColorClass: 'text-orange-400 dark:text-orange-600',
+
+ },
+ {
+ icon: '\u{1F4E1}',
+ label: 'Broadband',
+ group: 'Environment',
+
+ borderClass: 'border-l-sky-400 dark:border-l-sky-500',
+ hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
+ iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
+ artColorClass: 'text-sky-400 dark:text-sky-600',
+
+ },
+ {
+ icon: '\u{1F4CA}',
+ label: 'Deprivation',
+ group: 'Deprivation',
+ borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
+ hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
+ iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
+ artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
+
+ },
];
diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx
index d440d99..0531b35 100644
--- a/frontend/src/components/map/MapLegend.tsx
+++ b/frontend/src/components/map/MapLegend.tsx
@@ -2,6 +2,7 @@ import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
+import { TickerValue } from '../ui/TickerValue';
export default function MapLegend({
featureLabel,
@@ -50,8 +51,8 @@ export default function MapLegend({
{mode === 'density' ? (
<>
- {formatValue(range[0])}
- {formatValue(range[1])}
+
+
>
) : enumValues && enumValues.length > 0 ? (
<>
@@ -60,8 +61,8 @@ export default function MapLegend({
>
) : (
<>
- {formatValue(range[0])}
- {formatValue(range[1])}
+
+
>
)}
diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx
index 6c64e4f..23d898d 100644
--- a/frontend/src/components/map/PropertiesPane.tsx
+++ b/frontend/src/components/map/PropertiesPane.tsx
@@ -143,6 +143,7 @@ function PropertyLoadingSkeleton() {
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
+ const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
@@ -172,6 +173,14 @@ function PropertyCard({ property }: { property: Property }) {
)}
)}
+ {estimatedPrice !== undefined && (
+
+ Est. value:{' '}
+
+ £{formatNumber(estimatedPrice)}
+
+
+ )}
{property.property_type && (
diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx
new file mode 100644
index 0000000..d944f82
--- /dev/null
+++ b/frontend/src/components/pricing/PricingPage.tsx
@@ -0,0 +1,69 @@
+import { CheckIcon } from '../ui/icons/CheckIcon';
+
+const FEATURES = [
+ '56 data layers across England',
+ 'Every postcode scored and filterable',
+ 'Unlimited map exploration and exports',
+ 'Historical price data back to 1995',
+ 'Crime, schools, transport, broadband & more',
+ 'All future data updates included',
+];
+
+export default function PricingPage({
+ onOpenDashboard,
+}: {
+ onOpenDashboard: () => void;
+}) {
+ return (
+
+
+
+
+ One price. Yours forever.
+
+
+ No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
+
+
+
+
+ {/* Price header */}
+
+
+ Lifetime License
+
+
+ £100
+ /once
+
+
+ One-time payment, no subscription
+
+
+
+ {/* Features list */}
+
+
+ {FEATURES.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+
+ Get started
+
+
+ 30-day money-back guarantee
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx
index 1a6168d..465b18e 100644
--- a/frontend/src/components/ui/Header.tsx
+++ b/frontend/src/components/ui/Header.tsx
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
-import { MapPinIcon } from './icons/MapPinIcon';
+import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon';
@@ -12,7 +12,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
-export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
+export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing';
export default function Header({
activePage,
@@ -97,7 +97,7 @@ export default function Header({
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
-
+
Perfect Postcodes
@@ -124,6 +124,9 @@ export default function Header({
onPageChange('faq')}>
FAQ
+
onPageChange('pricing')}>
+ Pricing
+
)}
diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx
index 742a539..98d4dc5 100644
--- a/frontend/src/components/ui/MobileMenu.tsx
+++ b/frontend/src/components/ui/MobileMenu.tsx
@@ -82,6 +82,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
+ {mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (
diff --git a/frontend/src/components/ui/TickerValue.tsx b/frontend/src/components/ui/TickerValue.tsx
new file mode 100644
index 0000000..e144499
--- /dev/null
+++ b/frontend/src/components/ui/TickerValue.tsx
@@ -0,0 +1,39 @@
+const DIGITS = '0123456789';
+const H = 1.15; // digit slot height in em
+
+function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
+ const idx = DIGITS.indexOf(char);
+ if (idx === -1) return
{char} ;
+
+ const offset = active ? -idx * H : 0;
+
+ return (
+
+
+ {DIGITS.split('').map((d) => (
+
+ {d}
+
+ ))}
+
+
+ );
+}
+
+export function TickerValue({ text, active = true }: { text: string; active?: boolean }) {
+ const chars = text.split('');
+ const len = chars.length;
+ return (
+
+ {chars.map((ch, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c1c10ce..00a6f87 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -53,3 +53,63 @@ h3 {
opacity: 1;
transform: translateY(0);
}
+
+/* Cereal aside — hover to reveal */
+@keyframes cereal-wobble {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 15% {
+ transform: rotate(-8deg);
+ }
+ 30% {
+ transform: rotate(6deg);
+ }
+ 45% {
+ transform: rotate(-4deg);
+ }
+ 60% {
+ transform: rotate(2deg);
+ }
+ 80% {
+ transform: rotate(-1deg);
+ }
+}
+
+.cereal-wobble {
+ transform-origin: bottom center;
+}
+
+.group:hover .cereal-wobble {
+ animation: cereal-wobble 0.8s ease-in-out;
+}
+
+.cereal-reveal {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition:
+ grid-template-rows 0.5s ease-out,
+ color 0.2s ease;
+}
+
+.group:hover .cereal-reveal {
+ grid-template-rows: 1fr;
+}
+
+.cereal-reveal > * {
+ overflow: hidden;
+}
+
+.cereal-text {
+ opacity: 0;
+ transition:
+ opacity 0.4s ease-out,
+ color 0.2s ease;
+}
+
+.group:hover .cereal-text {
+ opacity: 1;
+ transition-delay: 0.2s, 0s;
+}
+
diff --git a/homepage.md b/homepage.md
deleted file mode 100644
index d1e825c..0000000
--- a/homepage.md
+++ /dev/null
@@ -1,21 +0,0 @@
-(above title) Browsing listings is not a strategy. Knowing what you want is.
-
-(title) Find your
perfect postcode before you find your property.
-
-Set the sliders to your expectations and the map highlights the areas that actually match. Instantly.
-
-
-
-That's just two. We've built 43 — spanning transport links, amenities, demographics, environment risk, broadband speeds, crime, and more. (show the filter types with small cards)
-
-Here's the problem with property search: listings only show you what's on the market right now — a thin slice of what an area is actually like. And even if you could look beyond them, there are millions of postcodes across England. You can't research them all yourself.
-
-We built this for you — years of historical transactions and public records, extended with proprietary algorithms so the map doesn't just show raw data, it surfaces the patterns that matter.
-
-Understand areas first. Then find the right property within them, with expectations you've set rather than ones the market set for you.
-
-(Fun cereal graphic on the side with this popup) You might buy a box of cereal because it's 20% off. Your next home is not a box of cereal. Don't let a discount on the wrong property distract you from finding the right one. Know what you're looking for, then go looking.
-
-The biggest financial decision of your life deserves proper tools behind it.
-
-[Explore the map] Button
diff --git a/manual-data/.gitignore b/manual-data/.gitignore
index 40d8742..9361c39 100644
--- a/manual-data/.gitignore
+++ b/manual-data/.gitignore
@@ -1,2 +1,3 @@
certificates.csv
crime
+postcode_boundaries
diff --git a/pipeline/download/tiles.py b/pipeline/download/tiles.py
new file mode 100644
index 0000000..e0302c1
--- /dev/null
+++ b/pipeline/download/tiles.py
@@ -0,0 +1,89 @@
+"""Download UK PMTiles extract from the latest Protomaps daily build."""
+
+import argparse
+import platform
+import stat
+import subprocess
+import sys
+import tarfile
+import urllib.request
+from datetime import datetime, timedelta
+from io import BytesIO
+from pathlib import Path
+
+PROTOMAPS_BASE = "https://build.protomaps.com"
+UK_BBOX = "-10.5,49.5,2.5,61"
+MAX_AGE_DAYS = 14
+
+
+def find_latest_build() -> str:
+ """Find the most recent available Protomaps daily build."""
+ today = datetime.utcnow().date()
+ for i in range(MAX_AGE_DAYS):
+ d = today - timedelta(days=i)
+ url = f"{PROTOMAPS_BASE}/{d:%Y%m%d}.pmtiles"
+ req = urllib.request.Request(url, method="HEAD")
+ try:
+ urllib.request.urlopen(req)
+ print(f"Found build: {d:%Y%m%d}")
+ return url
+ except urllib.error.HTTPError:
+ continue
+ print(
+ f"ERROR: No Protomaps build found in the last {MAX_AGE_DAYS} days",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+
+def ensure_pmtiles_cli(bin_path: Path, version: str) -> None:
+ """Download the pmtiles CLI if not already present."""
+ if bin_path.exists():
+ return
+ machine = platform.machine()
+ if machine == "x86_64":
+ arch = "x86_64"
+ elif machine == "aarch64":
+ arch = "arm64"
+ else:
+ arch = machine
+ url = (
+ f"https://github.com/protomaps/go-pmtiles/releases/download/"
+ f"v{version}/go-pmtiles_{version}_Linux_{arch}.tar.gz"
+ )
+ print(f"Downloading pmtiles CLI v{version}...")
+ data = urllib.request.urlopen(url).read()
+ with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tar:
+ member = tar.getmember("pmtiles")
+ f = tar.extractfile(member)
+ assert f is not None
+ bin_path.parent.mkdir(parents=True, exist_ok=True)
+ bin_path.write_bytes(f.read())
+ bin_path.chmod(bin_path.stat().st_mode | stat.S_IEXEC)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True, help="Output .pmtiles path")
+ parser.add_argument(
+ "--pmtiles-version", default="1.22.3", help="go-pmtiles release version"
+ )
+ args = parser.parse_args()
+
+ bin_path = args.output.parent / "pmtiles"
+ ensure_pmtiles_cli(bin_path, args.pmtiles_version)
+
+ source_url = find_latest_build()
+ print(f"Extracting UK tiles from {source_url}...")
+
+ subprocess.run(
+ [str(bin_path), "extract", source_url, str(args.output), f"--bbox={UK_BBOX}"],
+ check=True,
+ )
+
+ size_mb = args.output.stat().st_size / (1024 * 1024)
+ print(f"Wrote {args.output} ({size_mb:.1f} MB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py
index 3dfb621..bdbea53 100644
--- a/pipeline/transform/merge.py
+++ b/pipeline/transform/merge.py
@@ -55,12 +55,16 @@ def _build_wide(
)
)
- arcgis = pl.scan_parquet(arcgis_path).select(
- pl.col("pcds").alias("postcode"),
- "lat",
- pl.col("long").alias("lon"),
- "lsoa21",
- "oa21",
+ arcgis = (
+ pl.scan_parquet(arcgis_path)
+ .filter(pl.col("ctry") == "E92000001") # England only
+ .select(
+ pl.col("pcds").alias("postcode"),
+ "lat",
+ pl.col("long").alias("lon"),
+ "lsoa21",
+ "oa21",
+ )
)
wide = wide.join(arcgis, on="postcode", how="full", coalesce=True)
diff --git a/scripts/remove_bg.py b/scripts/remove_bg.py
new file mode 100644
index 0000000..4bf87d8
--- /dev/null
+++ b/scripts/remove_bg.py
@@ -0,0 +1,53 @@
+"""Remove white background from an image by flood-filling from edges only."""
+
+import sys
+from collections import deque
+from PIL import Image
+
+def remove_white_bg(path: str, tolerance: int = 20, out: str | None = None):
+ img = Image.open(path).convert("RGBA")
+ pixels = img.load()
+ w, h = img.size
+ threshold = 255 - tolerance
+
+ visited = set()
+ queue = deque()
+
+ # Seed from all edge pixels
+ for x in range(w):
+ queue.append((x, 0))
+ queue.append((x, h - 1))
+ for y in range(h):
+ queue.append((0, y))
+ queue.append((w - 1, y))
+
+ while queue:
+ x, y = queue.popleft()
+ if (x, y) in visited or x < 0 or y < 0 or x >= w or y >= h:
+ continue
+ visited.add((x, y))
+ r, g, b, a = pixels[x, y]
+ if r >= threshold and g >= threshold and b >= threshold:
+ pixels[x, y] = (r, g, b, 0)
+ queue.append((x + 1, y))
+ queue.append((x - 1, y))
+ queue.append((x, y + 1))
+ queue.append((x, y - 1))
+
+ # Crop to bounding box of non-transparent pixels
+ bbox = img.getbbox()
+ if bbox:
+ img = img.crop(bbox)
+
+ dest = out or path
+ img.save(dest)
+ print(f"Saved to {dest} ({img.size[0]}x{img.size[1]})")
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ print("Usage: python remove_bg.py
[tolerance] [output]")
+ sys.exit(1)
+ path = sys.argv[1]
+ tol = int(sys.argv[2]) if len(sys.argv) > 2 else 20
+ out = sys.argv[3] if len(sys.argv) > 3 else None
+ remove_white_bg(path, tol, out)
diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs
index 007f3c8..e9fc688 100644
--- a/server-rs/src/features.rs
+++ b/server-rs/src/features.rs
@@ -86,20 +86,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
suffix: "",
raw: false,
},
- // FeatureConfig {
- // name: "Estimated current price",
- // bounds: Bounds::Fixed {
- // min: 0.0,
- // max: 2_000_000.0,
- // },
- // step: 10000.0,
- // description: "Inflation-adjusted estimate of the current property value",
- // detail: "Estimated by applying a repeat-sales price index to the last known sale price. The index tracks price changes within each postcode sector and property type. Properties sold recently will have estimates close to their sale price; older sales are adjusted more. Coverage depends on having enough repeat sales in the local area to build the index.",
- // source: "price-paid",
- // prefix: "£",
- // suffix: "",
- // raw: false,
- // },
+ FeatureConfig {
+ name: "Estimated current price",
+ bounds: Bounds::Fixed {
+ min: 0.0,
+ max: 2_000_000.0,
+ },
+ step: 10000.0,
+ description: "Inflation-adjusted estimate of the current property value",
+ detail: "Estimated by applying a repeat-sales price index to the last known sale price. The index tracks price changes within each postcode sector and property type. Properties sold recently will have estimates close to their sale price; older sales are adjusted more. Coverage depends on having enough repeat sales in the local area to build the index.",
+ source: "price-paid",
+ prefix: "£",
+ suffix: "",
+ raw: false,
+ },
FeatureConfig {
name: "Price per sqm",
bounds: Bounds::Percentile {