These work

This commit is contained in:
Andras Schmelczer 2026-02-11 21:32:33 +00:00
parent 3599803589
commit 1588c01b19
19 changed files with 260 additions and 201 deletions

View file

@ -3,6 +3,7 @@ import MapPage, { type ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import LearnPage from './components/learn/LearnPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
@ -27,6 +28,8 @@ function pageToPath(page: Page): string {
return '/dashboard';
case 'saved-searches':
return '/saved';
case 'learn':
return '/learn';
case 'pricing':
return '/pricing';
default:
@ -37,6 +40,7 @@ case 'saved-searches':
function pathToPage(pathname: string): Page | null {
if (pathname === '/dashboard') return 'dashboard';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/learn') return 'learn';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/') return 'home';
return null;
@ -75,14 +79,6 @@ export default function App() {
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
// Backward compat: dashboard params on unknown path
const params = new URLSearchParams(window.location.search);
if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f') || params.has('dest')) {
// Rewrite URL to /dashboard keeping query params
window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`);
return 'dashboard';
}
return 'home';
});
@ -235,6 +231,8 @@ export default function App() {
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}

View file

@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, authHeaders } from '../../lib/api';
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { TickerValue } from '../ui/TickerValue';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
@ -23,6 +24,8 @@ interface HomeDemoProps {
export default function HomeDemo({ features, theme }: HomeDemoProps) {
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
@ -83,10 +86,18 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
}
abortRef.current?.abort();
abortRef.current = new AbortController();
setFetching(true);
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setHexData(data.features))
.catch(() => {});
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
setFetching(false);
})
.catch((err) => {
logNonAbortError('Failed to fetch demo hexagons', err);
setFetching(false);
});
}, [features, sliderValues]);
useEffect(() => {
@ -133,7 +144,7 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch(() => {});
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
},
[features, sliderValues]
);
@ -182,6 +193,21 @@ export default function HomeDemo({ features, theme }: HomeDemoProps) {
hideLegend={true}
/>
</div>
{loading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
Connecting to server...
</p>
</div>
</div>
)}
{!loading && fetching && (
<div className="absolute top-3 left-3 z-50 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
Loading...
</div>
)}
{/* Colour spectrum legend */}
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">

View file

@ -6,7 +6,8 @@ import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { formatFilterValue } from '../../lib/format';
import { formatFilterValue, buildPercentileScale } from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import InfoPopup from '../ui/InfoPopup';
@ -21,27 +22,30 @@ function SliderLabels({
min,
max,
value,
displayValues,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
const rightPct = ((value[1] - min) / range) * 100;
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}%` }}
>
{formatFilterValue(value[0])}
{formatFilterValue(labels[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(value[1])}
{formatFilterValue(labels[1])}
</span>
</div>
);
@ -119,6 +123,16 @@ export default memo(function Filters({
[enabledFeatureList]
);
const percentileScales = useMemo(() => {
const scales = new Map<string, PercentileScale>();
for (const f of features) {
if (f.type === 'numeric' && f.histogram) {
scales.set(f.name, buildPercentileScale(f.histogram));
}
}
return scales;
}, [features]);
return (
<div className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
@ -230,7 +244,10 @@ export default memo(function Filters({
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, feature.max!];
const step = feature.step ?? (feature.max! - feature.min!) / 100;
const scale = percentileScales.get(feature.name);
const sliderValue: [number, number] = scale
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
: displayValue;
return (
<div
@ -248,15 +265,24 @@ export default memo(function Filters({
</div>
<div>
<Slider
min={feature.min!}
max={feature.max!}
step={step}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
: ([min, max]) => onDragChange([min, max])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels min={feature.min!} max={feature.max!} value={displayValue} />
<SliderLabels
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
/>
</div>
</div>
);

View file

@ -142,18 +142,14 @@ function PropertyLoadingSkeleton() {
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
property,
'Rooms (including bedrooms & bathrooms)',
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
const transactionDate = getNum(property, 'Date of last transaction', 'date_of_transfer');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
const age = getNum(property, 'Approximate construction age');
const transactionDate = getNum(property, 'Date of last transaction');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
export default function Header({
activePage,
@ -133,6 +133,9 @@ export default function Header({
Saved
</button>
)}
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
Learn
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>

View file

@ -81,6 +81,7 @@ export default function MobileMenu({
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}

View file

@ -95,7 +95,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.then((json: ApiResponse) => setDragData(json.features))
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
},
[filters, features]

View file

@ -9,7 +9,7 @@ import type {
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
@ -101,7 +101,7 @@ export function useMapData({
})
);
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features || []);
setPostcodeData(json.features);
setRawData([]);
} else {
const params = new URLSearchParams({
@ -121,7 +121,7 @@ export function useMapData({
})
);
const json: ApiResponse = await res.json();
setRawData(json.features || []);
setRawData(json.features);
setPostcodeData([]);
}
} catch (err) {

View file

@ -51,25 +51,19 @@ export function useSavedSearches(userId: string | null) {
try {
const params = window.location.search.replace(/^\?/, '');
// Try to capture a screenshot via the screenshot endpoint
let screenshotBlob: Blob | null = null;
try {
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const res = await fetch(screenshotUrl);
if (res.ok) {
screenshotBlob = await res.blob();
}
} catch {
// Screenshot is optional — save without it
// Capture a screenshot via the screenshot endpoint
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const screenshotRes = await fetch(screenshotUrl);
if (!screenshotRes.ok) {
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
}
const screenshotBlob = await screenshotRes.blob();
const formData = new FormData();
formData.append('user', userId);
formData.append('name', name);
formData.append('params', params);
if (screenshotBlob) {
formData.append('screenshot', screenshotBlob, 'screenshot.png');
}
formData.append('screenshot', screenshotBlob, 'screenshot.png');
await pb.collection('saved_searches').create(formData);
await fetchSearches();

View file

@ -65,6 +65,81 @@ export function formatRelativeTime(isoDate: string): string {
return new Date(isoDate).toLocaleDateString();
}
// Percentile-based scale: maps between percentile space (0100) and absolute values
// using the histogram's CDF. Each percentile step = 1% of data.
export interface PercentileScale {
toValue: (percentile: number) => number;
toPercentile: (value: number) => number;
}
export function buildPercentileScale(hist: {
min: number;
max: number;
p1: number;
p99: number;
counts: number[];
}): PercentileScale {
const n = hist.counts.length;
const total = hist.counts.reduce((a, b) => a + b, 0);
if (n === 0 || total === 0) {
const range = hist.max - hist.min || 1;
return {
toValue: (p) => hist.min + (p / 100) * range,
toPercentile: (v) => ((v - hist.min) / range) * 100,
};
}
// Bin boundaries: [min, p1, ..middle edges.., p99, max]
const boundaries: number[] = [];
if (n === 1) {
boundaries.push(hist.min, hist.max);
} else {
boundaries.push(hist.min, hist.p1);
if (n > 2) {
const middleWidth = (hist.p99 - hist.p1) / (n - 2);
for (let i = 1; i < n - 1; i++) {
boundaries.push(hist.p1 + i * middleWidth);
}
}
boundaries.push(hist.max);
}
// Cumulative fraction: cumFrac[0]=0, cumFrac[n]=1
const cumFrac: number[] = [0];
for (let i = 0; i < n; i++) {
cumFrac.push(cumFrac[i] + hist.counts[i] / total);
}
cumFrac[n] = 1; // ensure exact 1.0
return {
toValue(percentile: number): number {
const target = Math.max(0, Math.min(1, percentile / 100));
if (target <= 0) return boundaries[0];
if (target >= 1) return boundaries[n];
let i = 0;
for (; i < n - 1; i++) {
if (cumFrac[i + 1] > target) break;
}
const binFrac = cumFrac[i + 1] - cumFrac[i];
const t = binFrac > 0 ? (target - cumFrac[i]) / binFrac : 0;
return boundaries[i] + t * (boundaries[i + 1] - boundaries[i]);
},
toPercentile(value: number): number {
if (value <= boundaries[0]) return 0;
if (value >= boundaries[n]) return 100;
let i = 0;
for (; i < n - 1; i++) {
if (boundaries[i + 1] > value) break;
}
const binWidth = boundaries[i + 1] - boundaries[i];
const t = binWidth > 0 ? (value - boundaries[i]) / binWidth : 0;
return (cumFrac[i] + t * (cumFrac[i + 1] - cumFrac[i])) * 100;
},
};
}
// Calculate weighted mean from histogram with outlier bins.
// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max].
export function calculateHistogramMean(histogram: {

View file

@ -1,10 +1,7 @@
import type { Property } from '../types';
// Generic getter for any field names (for dynamic lookups)
export function getNum(property: Property, ...keys: string[]): number | undefined {
for (const key of keys) {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') return v;
}
export function getNum(property: Property, key: string): number | undefined {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') return v;
return undefined;
}

View file

@ -27,30 +27,6 @@ function parseFilters(params: URLSearchParams): FeatureFilters | undefined {
return Object.keys(filters).length > 0 ? filters : undefined;
}
/** Backward compat: parse old comma-packed `f` param */
function parseLegacyFilters(f: string): FeatureFilters | undefined {
const filters: FeatureFilters = {};
for (const segment of f.split(',')) {
const colonIdx = segment.indexOf(':');
if (colonIdx === -1) continue;
const name = segment.substring(0, colonIdx);
const rest = segment.substring(colonIdx + 1);
if (rest.includes(':')) {
const [minStr, maxStr] = rest.split(':');
const min = Number(minStr);
const max = Number(maxStr);
if (!isNaN(min) && !isNaN(max)) {
filters[name] = [min, max];
}
} else if (rest.includes('|')) {
filters[name] = rest.split('|');
} else {
filters[name] = [rest];
}
}
return Object.keys(filters).length > 0 ? filters : undefined;
}
export function parseUrlState(): {
viewState?: ViewState;
filters?: FeatureFilters;
@ -72,45 +48,21 @@ export function parseUrlState(): {
if (!isNaN(latN) && !isNaN(lonN) && !isNaN(zoomN)) {
result.viewState = { latitude: latN, longitude: lonN, zoom: zoomN, pitch: 0 };
}
} else {
// Backward compat: old packed `v=lat,lon,zoom`
const v = params.get('v');
if (v) {
const parts = v.split(',').map(Number);
if (parts.length === 3 && parts.every((n) => !isNaN(n))) {
result.viewState = { latitude: parts[0], longitude: parts[1], zoom: parts[2], pitch: 0 };
}
}
}
// Filters: repeated `filter` params
result.filters = parseFilters(params);
if (!result.filters) {
// Backward compat: old packed `f` param
const f = params.get('f');
if (f) result.filters = parseLegacyFilters(f);
}
// POI categories: repeated `poi` params
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
// Handle both new (repeated params) and old (comma-separated) formats
const categories = poiParams.flatMap((p) => p.split(',')).filter(Boolean);
if (categories.length > 0) {
result.poiCategories = new Set(categories);
}
result.poiCategories = new Set(poiParams.filter(Boolean));
}
// Tab: full name
const tab = params.get('tab');
if (tab === 'properties' || tab === 'pois' || tab === 'area') {
result.tab = tab;
} else if (tab === 'p') {
result.tab = 'properties'; // backward compat
} else if (tab === 'o') {
result.tab = 'pois';
} else if (tab === 'a') {
result.tab = 'area';
}
// Travel time
@ -121,7 +73,7 @@ export function parseUrlState(): {
const tt: TravelTimeInitial = {
destination: [parts[0], parts[1]],
destinationLabel: params.get('destLabel') || '',
mode: (params.get('tmode') as TransportMode) || 'transit',
mode: (params.get('tmode') as TransportMode) || 'car',
};
const ttRange = params.get('tt');
if (ttRange) {
@ -178,7 +130,7 @@ export function stateToParams(
if (travelTime.destinationLabel) {
params.set('destLabel', travelTime.destinationLabel);
}
if (travelTime.mode !== 'transit') {
if (travelTime.mode !== 'car') {
params.set('tmode', travelTime.mode);
}
if (travelTime.timeRange) {
@ -193,7 +145,6 @@ export function summarizeParams(queryString: string): string {
const params = new URLSearchParams(queryString);
const parts: string[] = [];
// New format: repeated `filter` params
const filterParams = params.getAll('filter');
if (filterParams.length > 0) {
const filterNames = filterParams
@ -207,28 +158,11 @@ export function summarizeParams(queryString: string): string {
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
);
}
} else {
// Backward compat: old packed `f` param
const f = params.get('f');
if (f) {
const filterNames = f
.split(',')
.map((seg) => {
const colonIdx = seg.indexOf(':');
return colonIdx > 0 ? seg.substring(0, colonIdx) : seg;
})
.filter(Boolean);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
);
}
}
}
const poiParams = params.getAll('poi');
if (poiParams.length > 0) {
const count = poiParams.flatMap((p) => p.split(',')).filter(Boolean).length;
const count = poiParams.filter(Boolean).length;
if (count > 0) {
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
}