Lots of improvements
This commit is contained in:
parent
205302dbb8
commit
eb02b5832b
39 changed files with 699 additions and 271 deletions
|
|
@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
|
|||
} marchingAnts;`,
|
||||
'fs:DECKGL_FILTER_COLOR': `\
|
||||
float marchSegLen = 4.0;
|
||||
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||
float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||
if (marchPos < marchSegLen) {
|
||||
color = vec4(1.0, 1.0, 1.0, color.a);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
|
|||
return `${name}:${(value as string[]).join('|')}`;
|
||||
}
|
||||
const [min, max] = value as [number, number];
|
||||
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
|
||||
const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
|
||||
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
|
||||
return `${name}:${min}:${maxStr}`;
|
||||
})
|
||||
.join(';;');
|
||||
|
|
|
|||
16
frontend/src/lib/clipboard.ts
Normal file
16
frontend/src/lib/clipboard.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Copy text to clipboard with execCommand fallback for older browsers. */
|
||||
export function copyToClipboard(text: string, onSuccess: () => void): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east:
|
|||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||
zoom: 14,
|
||||
zoom: 15,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
|
|
@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
|
|||
{ maxZoom: 10.5, resolution: 7 },
|
||||
{ maxZoom: 11.5, resolution: 8 },
|
||||
{ maxZoom: 13, resolution: 9 },
|
||||
{ maxZoom: Infinity, resolution: 10 },
|
||||
] as const;
|
||||
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 16;
|
||||
export const POSTCODE_ZOOM_THRESHOLD = 14.5;
|
||||
|
||||
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] },
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export interface HexagonLocation {
|
|||
lat: number;
|
||||
lon: number;
|
||||
resolution: number;
|
||||
postcode?: string;
|
||||
isPostcode?: boolean;
|
||||
}
|
||||
|
||||
const PROPERTY_TYPE_MAP: Record<
|
||||
|
|
@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
|
|||
6: 3,
|
||||
7: 1,
|
||||
8: 0.5,
|
||||
9: 0.25,
|
||||
10: 0.25,
|
||||
11: 0.25,
|
||||
12: 0.25,
|
||||
9: 1,
|
||||
10: 1,
|
||||
11: 1,
|
||||
12: 1,
|
||||
};
|
||||
|
||||
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
|
||||
|
|
@ -46,13 +48,21 @@ 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, resolution } = location;
|
||||
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
|
||||
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
|
||||
interface SearchUrlOptions {
|
||||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
rightmoveLocationId?: string;
|
||||
}
|
||||
|
||||
export function buildPropertySearchUrls({
|
||||
location,
|
||||
filters,
|
||||
rightmoveLocationId,
|
||||
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
|
||||
const { postcode, resolution, isPostcode } = location;
|
||||
if (!postcode) return null;
|
||||
|
||||
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
|
||||
|
||||
const priceFilter = filters['Last known price'];
|
||||
const minPrice =
|
||||
|
|
@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
|
|||
? (propertyTypes as string[])
|
||||
: [];
|
||||
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', 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(','));
|
||||
// Rightmove — requires locationIdentifier from typeahead API
|
||||
let rightmove: string | null = null;
|
||||
if (rightmoveLocationId) {
|
||||
const rmParams = new URLSearchParams();
|
||||
rmParams.set('searchLocation', postcode);
|
||||
rmParams.set('useLocationIdentifier', 'true');
|
||||
rmParams.set('locationIdentifier', rightmoveLocationId);
|
||||
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(','));
|
||||
}
|
||||
rmParams.set('_includeSSTC', 'on');
|
||||
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
|
||||
}
|
||||
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]!;
|
||||
}
|
||||
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
|
||||
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
|
||||
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)));
|
||||
otmParams.set('search-site', 'geo');
|
||||
otmParams.set('geo-lat', String(lat));
|
||||
otmParams.set('geo-lng', String(lon));
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
|
||||
if (selectedTypes.length > 0) {
|
||||
const otmTypes = [
|
||||
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
|
||||
];
|
||||
for (const ot of otmTypes) {
|
||||
otmParams.append('prop-types', ot!);
|
||||
}
|
||||
}
|
||||
otmParams.set('view', 'map-list');
|
||||
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
|
||||
|
||||
// Zoopla
|
||||
const zParams = new URLSearchParams();
|
||||
zParams.set('q', coordStr);
|
||||
zParams.set('q', postcode);
|
||||
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)));
|
||||
|
|
@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
|
|||
zParams.append('property_sub_type', zt!);
|
||||
}
|
||||
}
|
||||
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
|
||||
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
|
||||
|
||||
return { rightmove, onthemarket, zoopla };
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function parseUrlState(): {
|
|||
}
|
||||
|
||||
// Travel time: repeated `tt` params
|
||||
// Format: mode:slug:label or mode:slug:label:min:max
|
||||
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
|
||||
const ttParams = params.getAll('tt');
|
||||
if (ttParams.length > 0) {
|
||||
const entries: TravelTimeEntry[] = [];
|
||||
|
|
@ -82,15 +82,17 @@ export function parseUrlState(): {
|
|||
if (!TRANSPORT_MODES.includes(mode)) continue;
|
||||
const slug = parts[1];
|
||||
const label = decodeURIComponent(parts[2]);
|
||||
const useBest = parts.length >= 4 && parts[3] === 'b';
|
||||
const rangeOffset = useBest ? 1 : 0;
|
||||
let timeRange: [number, number] | null = null;
|
||||
if (parts.length >= 5) {
|
||||
const min = Number(parts[3]);
|
||||
const max = Number(parts[4]);
|
||||
if (parts.length >= 5 + rangeOffset) {
|
||||
const min = Number(parts[3 + rangeOffset]);
|
||||
const max = Number(parts[4 + rangeOffset]);
|
||||
if (!isNaN(min) && !isNaN(max)) {
|
||||
timeRange = [min, max];
|
||||
}
|
||||
}
|
||||
entries.push({ mode, slug, label, timeRange });
|
||||
entries.push({ mode, slug, label, timeRange, useBest });
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
result.travelTime = { entries };
|
||||
|
|
@ -139,6 +141,7 @@ export function stateToParams(
|
|||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) val += ':b';
|
||||
if (entry.timeRange) {
|
||||
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue