Fun changes
Some checks failed
CI / Python (lint + test) (push) Failing after 3m38s
CI / Rust (lint + test) (push) Failing after 3m32s
CI / Frontend (lint + typecheck) (push) Failing after 4m12s
Build and publish Docker image / build-and-push (push) Failing after 4m48s

This commit is contained in:
Andras Schmelczer 2026-04-04 22:59:44 +01:00
parent cd778dd088
commit 349a6c1d53
60 changed files with 1260 additions and 2600 deletions

View file

@ -4,30 +4,43 @@ import { ENUM_PALETTE } from './consts';
/**
* LayerExtension that turns polygon fills into pie charts.
* Injects a fragment shader that computes angle from each fragment's position
* to the polygon centroid, then picks a slice color from the enum palette.
*
* Works with H3HexagonLayer (hex fills) and GeoJsonLayer (postcode fills).
* Only activates on SolidPolygonLayer sublayers (fill), not PathLayer (stroke).
* Follows the canonical deck.gl v9 extension pattern:
* - defaultProps with type:'accessor' enables getSubLayerProps() to wrap
* accessors via getSubLayerAccessor(), which unwraps __source.object to
* access the original data item through CompositeLayer sublayer chains.
* - stepMode:'dynamic' handles per-instance counting automatically.
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
*
* Required layer props when this extension is active:
* getCenter: (d) => [lon, lat] polygon centroid in world coordinates
* getRatios0: (d) => number[4] pie ratios for slices 0-3
* getRatios1: (d) => number[4] pie ratios for slices 4-7
* getRatios2: (d) => number[2] pie ratios for slices 8-9
* Accepts an optional custom palette in the constructor for per-feature color overrides.
*/
// Build palette as GLSL vec3 constants (normalized 0-1)
const PALETTE_GLSL = ENUM_PALETTE.map(
(c) =>
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
).join(',\n ');
function paletteToGlsl(palette: [number, number, number][]): string {
return palette
.map(
(c) =>
`vec3(${(c[0] / 255).toFixed(4)}, ${(c[1] / 255).toFixed(4)}, ${(c[2] / 255).toFixed(4)})`
)
.join(',\n ');
}
export class PieHexExtension extends LayerExtension {
static extensionName = 'PieHexExtension';
static defaultProps = {
getCenter: { type: 'accessor', value: [0, 0] },
getRatios0: { type: 'accessor', value: [1, 0, 0, 0] },
getRatios1: { type: 'accessor', value: [0, 0, 0, 0] },
getRatios2: { type: 'accessor', value: [0, 0] },
};
private paletteGlsl: string;
constructor(palette?: [number, number, number][]) {
super();
this.paletteGlsl = paletteToGlsl(palette ?? ENUM_PALETTE);
}
isEnabled(layer: any): boolean {
// Only apply to fill sublayers (SolidPolygonLayer), not stroke (PathLayer)
return layer.id.endsWith('-fill');
}
@ -61,7 +74,7 @@ in vec4 vRatios0;
in vec4 vRatios1;
in vec2 vRatios2;
const vec3 pieColors[10] = vec3[10](
${PALETTE_GLSL}
${this.paletteGlsl}
);`,
'fs:DECKGL_FILTER_COLOR': `\
{
@ -98,25 +111,25 @@ const vec3 pieColors[10] = vec3[10](
if (!extension.isEnabled(this)) return;
const am = this.getAttributeManager();
if (!am) return;
am.addInstanced({
am.add({
instancePieCenter: {
size: 2,
type: 'float32',
stepMode: 'dynamic',
accessor: 'getCenter',
},
instanceRatios0: {
size: 4,
type: 'float32',
stepMode: 'dynamic',
accessor: 'getRatios0',
},
instanceRatios1: {
size: 4,
type: 'float32',
stepMode: 'dynamic',
accessor: 'getRatios1',
},
instanceRatios2: {
size: 2,
type: 'float32',
stepMode: 'dynamic',
accessor: 'getRatios2',
},
});

View file

@ -109,6 +109,8 @@ export const STACKED_GROUPS: Record<
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
feature?: string;
/** If set, display this feature's mean as the primary value (e.g. per-1k rate) instead of the absolute total. */
rateFeature?: string;
/** Suffix shown after the total value (e.g. "avg/yr") */
unit?: string;
/** Feature names that make up the segments */
@ -119,7 +121,8 @@ export const STACKED_GROUPS: Record<
{
label: 'Serious crime',
feature: 'Serious crime (avg/yr)',
unit: 'avg/yr',
rateFeature: 'Serious crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
components: [
'Violence and sexual offences (avg/yr)',
'Robbery (avg/yr)',
@ -130,7 +133,8 @@ export const STACKED_GROUPS: Record<
{
label: 'Minor crime',
feature: 'Minor crime (avg/yr)',
unit: 'avg/yr',
rateFeature: 'Minor crime per 1k residents (avg/yr)',
unit: 'per 1k/yr',
components: [
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
@ -179,7 +183,7 @@ export const STACKED_ENUM_GROUPS: Record<
feature: 'Property type',
components: ['Property type'],
valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flats/Maisonettes', 'Other'],
valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b', '#6b7280'],
valueColors: ['#f97316', '#3b82f6', '#22c55e', '#ec4899', '#6b7280'],
},
{
label: 'Leasehold/Freehold',
@ -208,6 +212,60 @@ export const ENUM_PALETTE: [number, number, number][] = [
[107, 114, 128], // gray-500
];
/**
* Per-feature color overrides for enum values on the map and dashboard.
* Keys are feature names (as returned by the server), values map enum value RGB.
* Any value not listed falls back to ENUM_PALETTE by index.
*/
export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number, number]>> = {
'Winning party': {
Labour: [220, 36, 31], // Labour red
Conservative: [0, 135, 220], // Conservative blue
'Liberal Democrat': [253, 187, 48], // Lib Dem gold
'Reform UK': [18, 178, 196], // Reform teal
Green: [106, 176, 35], // Green party green
'Other parties': [148, 130, 160], // muted purple
},
'Property type': {
Detached: [249, 115, 22], // orange
'Semi-Detached': [59, 130, 246], // blue
Terraced: [34, 197, 94], // green
'Flats/Maisonettes': [236, 72, 153], // pink
Other: [107, 114, 128], // gray
},
};
/**
* Build a 10-color palette for a given feature, using overrides where defined.
* Returns the default ENUM_PALETTE when no overrides exist.
*/
export function getEnumPaletteForFeature(
featureName: string | null,
values?: string[]
): [number, number, number][] {
if (!featureName || !values) return ENUM_PALETTE;
const overrides = ENUM_COLOR_OVERRIDES[featureName];
if (!overrides) return ENUM_PALETTE;
const palette: [number, number, number][] = [];
for (let i = 0; i < 10; i++) {
if (i < values.length && overrides[values[i]]) {
palette.push(overrides[values[i]]);
} else {
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
}
}
return palette;
}
/** Look up override color for a specific enum value, or null if none. */
export function getEnumValueColor(
featureName: string,
valueName: string
): [number, number, number] | null {
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
}
/** Colors for stacked bar segments */
export const SEGMENT_COLORS = [
'#ef4444', // red-500

View file

@ -49,12 +49,6 @@ const RIGHTMOVE_PRICES = [
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
];
// Rightmove allowed monthly rent values (pcm)
const RIGHTMOVE_RENTS = [
250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500,
4000, 5000, 7500, 10000, 15000, 25000,
];
// OnTheMarket allowed buy prices
const OTM_PRICES = [
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
@ -64,12 +58,6 @@ const OTM_PRICES = [
10000000, 15000000,
];
// OnTheMarket allowed monthly rent values (pcm)
const OTM_RENTS = [
100, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000, 1100,
1200, 1250, 1300, 1400, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 5000, 7500, 10000, 25000,
];
// Zoopla allowed buy prices
const ZOOPLA_PRICES = [
10000, 25000, 50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000, 250000, 275000,
@ -78,12 +66,6 @@ const ZOOPLA_PRICES = [
5000000, 7500000, 10000000, 15000000,
];
// Zoopla allowed monthly rent values (pcm)
const ZOOPLA_RENTS = [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000,
5000, 7500, 10000, 25000,
];
function snapToAllowed(value: number, allowed: number[], direction: 'floor' | 'ceil'): number {
if (direction === 'floor') {
for (let i = allowed.length - 1; i >= 0; i--) {
@ -115,26 +97,14 @@ export function buildPropertySearchUrls({
rightmove: string | null;
onthemarket: string;
zoopla: string;
openrent: string | null;
} | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const listingStatus = filters['Listing status'];
const isRent =
Array.isArray(listingStatus) &&
typeof listingStatus[0] === 'string' &&
(listingStatus as string[]).includes('For rent');
// Check price filters in priority order: asking price (current listings) > estimated > last known
// For rent mode, check asking rent first
const priceFilter = isRent
? filters['Asking rent (monthly)']
: (filters['Asking price'] ??
filters['Estimated current price'] ??
filters['Last known price']);
const priceFilter =
filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
Array.isArray(priceFilter) && typeof priceFilter[0] === 'number' ? priceFilter[0] : undefined;
const maxPrice =
@ -146,26 +116,6 @@ export function buildPropertySearchUrls({
? (propertyTypes as string[])
: [];
const bedroomFilter = filters['Bedrooms'];
const minBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number'
? bedroomFilter[0]
: undefined;
const maxBedrooms =
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;
const maxBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number'
? bathroomFilter[1]
: undefined;
const tenureFilter = filters['Leasehold/Freehold'];
const selectedTenures =
Array.isArray(tenureFilter) && typeof tenureFilter[0] === 'string'
@ -175,20 +125,15 @@ export function buildPropertySearchUrls({
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
const rmPrices = isRent ? RIGHTMOVE_RENTS : RIGHTMOVE_PRICES;
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(snapToAllowed(minPrice, rmPrices, 'floor')));
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, rmPrices, '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)));
if (maxBathrooms !== undefined) rmParams.set('maxBathrooms', String(Math.ceil(maxBathrooms)));
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -200,24 +145,22 @@ export function buildPropertySearchUrls({
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
if (!isRent && selectedTenures.length > 0) {
if (selectedTenures.length > 0) {
const rmTenures = selectedTenures.map((t) => (t === 'Freehold' ? 'FREEHOLD' : 'LEASEHOLD'));
rmParams.set('tenureTypes', rmTenures.join(','));
}
if (!isRent) rmParams.set('_includeSSTC', 'on');
const rmPath = isRent ? 'property-to-rent' : 'property-for-sale';
rightmove = `https://www.rightmove.co.uk/${rmPath}/find.html?${rmParams.toString()}`;
rmParams.set('_includeSSTC', 'on');
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmPrices = isRent ? OTM_RENTS : OTM_PRICES;
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined)
otmParams.set('min-price', String(snapToAllowed(minPrice, otmPrices, 'floor')));
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
if (maxPrice !== undefined)
otmParams.set('max-price', String(snapToAllowed(maxPrice, otmPrices, 'ceil')));
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@ -227,20 +170,17 @@ export function buildPropertySearchUrls({
}
}
otmParams.set('view', 'map-list');
const otmPath = isRent ? 'to-rent' : 'for-sale';
const onthemarket = `https://www.onthemarket.com/${otmPath}/property/${otmSlug}/?${otmParams.toString()}`;
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
// Zoopla
const zPrices = isRent ? ZOOPLA_RENTS : ZOOPLA_PRICES;
const zParams = new URLSearchParams();
zParams.set('q', postcode);
const zSearchSource = isRent ? 'to-rent' : 'for-sale';
zParams.set('search_source', zSearchSource);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined)
zParams.set('price_min', String(snapToAllowed(minPrice, zPrices, 'floor')));
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
if (maxPrice !== undefined)
zParams.set('price_max', String(snapToAllowed(maxPrice, zPrices, 'ceil')));
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),
@ -249,28 +189,7 @@ export function buildPropertySearchUrls({
zParams.append('property_sub_type', zt!);
}
}
const zoopla = `https://www.zoopla.co.uk/${zSearchSource}/property/?${zParams.toString()}`;
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
// OpenRent — rent mode only
let openrent: string | null = null;
if (isRent) {
const postcodeNoSpaces = postcode.replace(/\s+/g, '');
const orSlug = postcodeNoSpaces.toLowerCase();
const orParams = new URLSearchParams();
orParams.set('term', postcodeNoSpaces.toUpperCase());
const radiusKm = Math.round((isPostcode ? 0.25 : radiusMiles) * 1.609);
orParams.set('area', String(Math.max(1, radiusKm)));
const rentFilter = filters['Asking rent (monthly)'];
const minRent =
Array.isArray(rentFilter) && typeof rentFilter[0] === 'number' ? rentFilter[0] : undefined;
const maxRent =
Array.isArray(rentFilter) && typeof rentFilter[1] === 'number' ? rentFilter[1] : undefined;
if (minRent !== undefined) orParams.set('prices_min', String(Math.round(minRent)));
if (maxRent !== undefined) orParams.set('prices_max', String(Math.round(maxRent)));
if (minBedrooms !== undefined) orParams.set('bedrooms_min', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) orParams.set('bedrooms_max', String(Math.ceil(maxBedrooms)));
openrent = `https://www.openrent.co.uk/properties-to-rent/${orSlug}?${orParams.toString()}`;
}
return { rightmove, onthemarket, zoopla, openrent };
return { rightmove, onthemarket, zoopla };
}

View file

@ -74,49 +74,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</>
),
'Asking price': (
<>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</>
),
'Asking rent (monthly)': (
<>
<circle cx="9" cy="9" r="7" />
<path d="M15.58 8.42A7 7 0 0122 15a7 7 0 01-7 7 7 7 0 01-6.58-4.58" />
</>
),
Bedrooms: (
<>
<path d="M2 4v16" />
<path d="M2 8h18a2 2 0 012 2v10" />
<path d="M2 17h20" />
<path d="M6 4v4" />
</>
),
Bathrooms: (
<>
<path d="M4 12h16a1 1 0 011 1v3a4 4 0 01-4 4H7a4 4 0 01-4-4v-3a1 1 0 011-1z" />
<path d="M6 12V5a2 2 0 012-2h3" />
<line x1="14" y1="4" x2="17" y2="4" />
</>
),
'Listing date': (
<>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</>
),
'Listing status': (
<>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</>
),
'Leasehold/Freehold': (
<>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
@ -424,7 +381,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6" />
</>
),
'Number of parks within 2km': (
'Number of parks within 1km': (
<>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />

View file

@ -195,9 +195,12 @@ export function emojiToTwemojiUrl(emoji: string): string {
}
/** Look up a discrete color from the enum palette by index (wraps if > palette size). */
export function enumIndexToColor(index: number): [number, number, number] {
const i = Math.round(Math.max(0, index)) % ENUM_PALETTE.length;
return ENUM_PALETTE[i];
export function enumIndexToColor(
index: number,
palette: [number, number, number][] = ENUM_PALETTE
): [number, number, number] {
const i = Math.round(Math.max(0, index)) % palette.length;
return palette[i];
}
/**
@ -216,7 +219,8 @@ export function getFeatureFillColor(
densityGradient: GradientStop[],
isDark: boolean,
alpha: number,
enumCount: number = 0
enumCount: number = 0,
enumPalette?: [number, number, number][]
): [number, number, number, number] {
if (colorRange) {
if (value == null)
@ -234,7 +238,7 @@ export function getFeatureFillColor(
// Discrete coloring for enum features (used as base; PieHexExtension overrides when active)
if (enumCount > 0) {
const rgb = enumIndexToColor(Math.round(value as number));
const rgb = enumIndexToColor(Math.round(value as number), enumPalette);
return [...rgb, alpha] as [number, number, number, number];
}

View file

@ -176,7 +176,7 @@ export function summarizeParams(queryString: string): string {
const colonIdx = entry.indexOf(':');
return colonIdx > 0 ? entry.substring(0, colonIdx) : entry;
})
.filter((n) => n && n !== 'Listing status');
.filter((n) => n);
if (filterNames.length > 0) {
parts.push(
filterNames.length <= 2