This commit is contained in:
Andras Schmelczer 2026-02-02 21:56:35 +00:00
parent 2c613dc0d1
commit a677b9331f
28 changed files with 1647 additions and 1498 deletions

61
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,61 @@
import type { FeatureMeta, FeatureFilters } from '../types';
const INITIAL_RETRY_MS = 1000;
const MAX_RETRY_MS = 10000;
export async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
signal: AbortSignal
): Promise<void> {
let delay = INITIAL_RETRY_MS;
while (!signal.aborted) {
try {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
onSuccess(json);
return;
} catch (err) {
if (signal.aborted) return;
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 2, MAX_RETRY_MS);
}
}
}
export function getApiBaseUrl(): string {
if (process.env.NODE_ENV === 'production') {
return '';
}
const { pathname, href } = window.location;
const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/);
if (pathMatch) {
return `${pathMatch[1]}8001`;
}
const hrefMatch = href.match(/(\/proxy\/)\d+/);
if (hrefMatch) {
return `${hrefMatch[1]}8001`;
}
return '';
}
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
return entries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
}

View file

@ -0,0 +1,136 @@
import type { FeatureFilters } from '../types';
export interface HexagonLocation {
lat: number;
lon: number;
postcode: string | null;
resolution: number;
}
const PROPERTY_TYPE_MAP: Record<
string,
{ rightmove: string; onthemarket: string; zoopla: string }
> = {
House: { rightmove: 'detached,semi-detached,terraced', onthemarket: 'property', zoopla: '' },
Detached: { rightmove: 'detached', onthemarket: 'detached', zoopla: 'detached' },
'Semi-Detached': {
rightmove: 'semi-detached',
onthemarket: 'semi-detached',
zoopla: 'semi_detached',
},
'Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'Enclosed Mid-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
'Enclosed End-Terrace': { rightmove: 'terraced', onthemarket: 'terraced', zoopla: 'terraced' },
Flat: { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
Maisonette: { rightmove: 'flat', onthemarket: 'flats', zoopla: 'flat' },
Bungalow: { rightmove: 'bungalow', onthemarket: 'bungalow', zoopla: 'bungalow' },
'Park home': { rightmove: 'park-home', onthemarket: 'property', zoopla: '' },
};
export const H3_RADIUS_MILES: Record<number, number> = {
4: 15,
5: 6,
6: 3,
7: 1,
8: 0.5,
9: 0.25,
10: 0.25,
11: 0.25,
12: 0.25,
};
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
export function buildPropertySearchUrls(
location: HexagonLocation,
filters: FeatureFilters
): { rightmove: string; onthemarket: string; zoopla: string } {
const { lat, lon, postcode, resolution } = location;
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
const priceFilter = filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =
Array.isArray(priceFilter) && typeof priceFilter[1] === 'number' ? priceFilter[1] : undefined;
const propertyTypes = filters['Property type'];
const selectedTypes =
Array.isArray(propertyTypes) && typeof propertyTypes[0] === 'string'
? (propertyTypes as string[])
: [];
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', postcode || coordStr);
rmParams.set('channel', 'BUY');
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
selectedTypes.flatMap((t) => {
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
return mapped ? mapped.split(',') : [];
})
),
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
let otmType = 'property';
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
];
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
}
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
let onthemarket: string;
if (postcode) {
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/${slug}/?${otmParams.toString()}`;
} else {
otmParams.set('search-site', 'geo');
otmParams.set('geo-lat', String(lat));
otmParams.set('geo-lng', String(lon));
onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
}
const zParams = new URLSearchParams();
zParams.set('q', postcode || coordStr);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
if (maxPrice !== undefined) zParams.set('price_max', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
];
for (const zt of zTypes) {
zParams.append('property_sub_type', zt!);
}
}
let zoopla: string;
if (postcode) {
const slug = postcode.replace(/\s+/g, '-').toLowerCase();
zoopla = `https://www.zoopla.co.uk/for-sale/property/${slug}/?${zParams.toString()}`;
} else {
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
}
return { rightmove, onthemarket, zoopla };
}

View file

@ -0,0 +1,24 @@
export function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toLocaleString();
return value.toFixed(1);
}
export function formatFilterValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
if (Number.isInteger(value)) return value.toString();
return value.toFixed(2);
}
export function formatDuration(d: string): string {
if (d === 'F') return 'Freehold';
if (d === 'L') return 'Leasehold';
return d;
}
export function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}

View file

@ -0,0 +1,109 @@
import type { ViewState, Bounds } from '../types';
export const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
export const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] },
{ t: 0.66, color: [231, 76, 60] },
{ t: 1, color: [142, 68, 173] },
];
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] },
{ t: 0.5, color: [20, 140, 180] },
{ t: 1, color: [88, 28, 140] },
];
export function normalizedToColor(t: number): [number, number, number] {
if (t <= 0) return GRADIENT[0].color;
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
for (let i = 0; i < GRADIENT.length - 1; i++) {
const lo = GRADIENT[i];
const hi = GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return GRADIENT[GRADIENT.length - 1].color;
}
export function countToColor(t: number): [number, number, number] {
if (t <= 0) return DENSITY_GRADIENT[0].color;
if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) {
const lo = DENSITY_GRADIENT[i];
const hi = DENSITY_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
];
}
}
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
export function zoomToResolution(zoom: number): number {
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
if (zoom < 15) return 11;
return 12;
}
export function getBoundsFromViewState(
viewState: ViewState,
width: number,
height: number
): Bounds {
const { longitude, latitude, zoom } = viewState;
const clampedLat = Math.max(-85, Math.min(85, latitude));
const TILE_SIZE = 256;
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
const latRad = (clampedLat * Math.PI) / 180;
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
const pixelYToLat = (pixelY: number): number => {
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
return (latRadians * 180) / Math.PI;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);
return { south, west, north, east };
}
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
export function emojiToTwemojiUrl(emoji: string): string {
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}

View file

@ -0,0 +1,113 @@
import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
export const DEFAULT_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
export function parseUrlState(): {
viewState?: ViewState;
filters?: FeatureFilters;
poiCategories?: Set<string>;
tab?: 'pois' | 'properties' | 'area';
} {
const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {};
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,
};
}
}
const f = params.get('f');
if (f) {
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];
}
}
if (Object.keys(filters).length > 0) {
result.filters = filters;
}
}
const poi = params.get('poi');
if (poi) {
result.poiCategories = new Set(poi.split(',').filter(Boolean));
}
const tab = params.get('tab');
if (tab === 'p') result.tab = 'properties';
else if (tab === 'o') result.tab = 'pois';
else if (tab === 'a') result.tab = 'area';
return result;
}
export function stateToParams(
viewState: { latitude: number; longitude: number; zoom: number } | null,
filters: FeatureFilters,
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'pois' | 'properties' | 'area'
): URLSearchParams {
const params = new URLSearchParams();
if (viewState) {
params.set(
'v',
`${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}`
);
}
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filtersStr = filterEntries
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
return `${name}:${min}:${max}`;
})
.join(',');
params.set('f', filtersStr);
}
if (selectedPOICategories.size > 0) {
params.set('poi', Array.from(selectedPOICategories).join(','));
}
if (rightPaneTab === 'properties') {
params.set('tab', 'p');
} else if (rightPaneTab === 'area') {
params.set('tab', 'a');
}
return params;
}