Codex changes

This commit is contained in:
Andras Schmelczer 2026-05-04 16:19:09 +01:00
parent 0bae902e08
commit d4dde21ad2
46 changed files with 4953 additions and 966 deletions

View file

@ -44,6 +44,46 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
{ t: 1, color: [142, 68, 173] },
];
export type GradientStop = { t: number; color: [number, number, number] };
function partyGradient(color: [number, number, number]): GradientStop[] {
return [
{ t: 0, color: [255, 255, 255] },
{
t: 0.5,
color: [
Math.round(255 + (color[0] - 255) * 0.45),
Math.round(255 + (color[1] - 255) * 0.45),
Math.round(255 + (color[2] - 255) * 0.45),
],
},
{ t: 1, color },
];
}
/** UK party colours for the 2024 General Election vote-share map layers. */
export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
'% Labour': partyGradient([228, 0, 59]), // Labour red
'% Conservative': partyGradient([0, 135, 220]), // Conservative blue
'% Liberal Democrat': partyGradient([255, 100, 0]), // Liberal Democrat orange
'% Reform UK': partyGradient([18, 182, 207]), // Reform UK cyan
'% Green': partyGradient([106, 176, 35]), // Green Party green
'% Other parties': partyGradient([107, 114, 128]), // neutral fallback for grouped parties
};
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
Object.entries(PARTY_FEATURE_GRADIENTS).map(([featureName, gradient]) => {
const color = gradient[gradient.length - 1].color;
return [featureName, `rgb(${color[0]}, ${color[1]}, ${color[2]})`];
})
);
export function getFeatureGradient(featureName: string | null | undefined): GradientStop[] {
return featureName
? (PARTY_FEATURE_GRADIENTS[featureName] ?? FEATURE_GRADIENT)
: FEATURE_GRADIENT;
}
/** Number of properties gradient — light mode (cream → orange) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [255, 255, 255] },

View file

@ -122,6 +122,16 @@ export function buildPropertySearchUrls({
? (tenureFilter as string[])
: [];
const habitableRoomsFilter = filters['Number of bedrooms & living rooms'];
const minBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[0] === 'number'
? Math.max(0, habitableRoomsFilter[0] - 1)
: undefined;
const maxBedrooms =
Array.isArray(habitableRoomsFilter) && typeof habitableRoomsFilter[1] === 'number'
? Math.max(0, habitableRoomsFilter[1] - 1)
: undefined;
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
@ -134,6 +144,8 @@ export function buildPropertySearchUrls({
rmParams.set('minPrice', String(snapToAllowed(minPrice, RIGHTMOVE_PRICES, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapToAllowed(maxPrice, RIGHTMOVE_PRICES, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
@ -161,6 +173,8 @@ export function buildPropertySearchUrls({
otmParams.set('min-price', String(snapToAllowed(minPrice, OTM_PRICES, 'floor')));
if (maxPrice !== undefined)
otmParams.set('max-price', String(snapToAllowed(maxPrice, OTM_PRICES, 'ceil')));
if (minBedrooms !== undefined) otmParams.set('min-bedrooms', String(minBedrooms));
if (maxBedrooms !== undefined) otmParams.set('max-bedrooms', String(maxBedrooms));
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
@ -181,6 +195,8 @@ export function buildPropertySearchUrls({
zParams.set('price_min', String(snapToAllowed(minPrice, ZOOPLA_PRICES, 'floor')));
if (maxPrice !== undefined)
zParams.set('price_max', String(snapToAllowed(maxPrice, ZOOPLA_PRICES, 'ceil')));
if (minBedrooms !== undefined) zParams.set('beds_min', String(minBedrooms));
if (maxBedrooms !== undefined) zParams.set('beds_max', String(maxBedrooms));
if (selectedTypes.length > 0) {
const zTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.zoopla).filter(Boolean)),

View file

@ -129,6 +129,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 5km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 5km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Good+ primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
@ -142,6 +155,19 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
'Outstanding primary schools within 2km': (
<>
<path d="M4 19V9l8-6 8 6v10" />
<path d="M9 19v-6h6v6" />
<line x1="4" y1="19" x2="20" y2="19" />
</>
),
'Outstanding secondary schools within 2km': (
<>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2.5 3 4 6 4s6-1.5 6-4v-5" />
</>
),
// ── Deprivation ──────────────────────────────
'Income Score (rate)': (

View file

@ -1,3 +1,5 @@
import i18n from 'i18next';
interface ValueFormat {
prefix?: string;
suffix?: string;
@ -5,10 +7,31 @@ interface ValueFormat {
raw?: boolean;
}
function usesChineseNumberUnits(): boolean {
return i18n.language?.toLowerCase().startsWith('zh') ?? false;
}
function formatChineseCompactNumber(value: number): string | null {
const abs = Math.abs(value);
if (abs >= 100_000_000) return `${trimFixed(value / 100_000_000)}亿`;
if (abs >= 10_000) return `${trimFixed(value / 10_000)}`;
return null;
}
function trimFixed(value: number): string {
return value.toFixed(1).replace(/\.0$/, '');
}
export function formatValue(value: number, fmt?: ValueFormat): string {
const p = fmt?.prefix ?? '';
const s = fmt?.suffix ?? '';
if (fmt?.raw) return `${p}${Math.round(value)}${s}`;
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
return `${p}${value.toFixed(1)}${s}`;
}
if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`;
if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`;
if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`;
@ -17,6 +40,12 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
export function formatFilterValue(value: number, raw?: boolean): string {
if (raw) return Math.round(value).toString();
if (usesChineseNumberUnits()) {
const chineseCompactValue = formatChineseCompactNumber(value);
if (chineseCompactValue) return chineseCompactValue;
if (Number.isInteger(value)) return value.toString();
return value.toFixed(2);
}
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();
@ -31,14 +60,17 @@ export function parseInputValue(
let s = text.trim();
if (opts?.prefix) s = s.replace(new RegExp(`^\\${opts.prefix}`), '');
if (opts?.suffix) s = s.replace(new RegExp(`${opts.suffix.trim()}$`), '');
s = s.trim().replace(/,/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM]?)$/);
s = s.trim().replace(/[,]/g, '');
const m = s.match(/^(-?\d+\.?\d*)\s*([kKmM万亿億]?)$/);
if (!m) return null;
let val = parseFloat(m[1]);
if (isNaN(val)) return null;
const unit = m[2].toLowerCase();
const unit = m[2];
if (unit === 'k') val *= 1_000;
else if (unit === 'm') val *= 1_000_000;
else if (unit === 'K') val *= 1_000;
else if (unit === 'm' || unit === 'M') val *= 1_000_000;
else if (unit === '万') val *= 10_000;
else if (unit === '亿' || unit === '億') val *= 100_000_000;
if (opts?.step) val = Math.round(val / opts.step) * opts.step;
return val;
}
@ -102,9 +134,7 @@ export function roundedPercentages(values: number[], total: number, decimals = 0
const floors = raw.map((r) => Math.floor(r));
const result = floors.slice();
let diff = targetSum - floors.reduce((a, b) => a + b, 0);
const order = raw
.map((r, i) => ({ i, frac: r - floors[i] }))
.sort((a, b) => b.frac - a.frac);
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac);
for (let k = 0; k < order.length && diff > 0; k++) {
result[order[k].i] += 1;
diff -= 1;

View file

@ -9,6 +9,7 @@ import {
TWEMOJI_BASE,
BUFFER_MULTIPLIER,
ENUM_PALETTE,
type GradientStop,
} from './consts';
const ROAD_OPACITY = 0.4;
@ -64,8 +65,6 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
} as StyleSpecification;
}
type GradientStop = { t: number; color: [number, number, number] };
// Oklab color space for perceptually uniform interpolation
function srgbToLinear(c: number): number {
const v = c / 255;
@ -131,8 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
return gradient[gradient.length - 1].color;
}
function normalizedToColor(t: number): [number, number, number] {
return interpolateGradient(t, FEATURE_GRADIENT);
function normalizedToColor(
t: number,
gradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number] {
return interpolateGradient(t, gradient);
}
function countToColor(
@ -220,7 +222,8 @@ export function getFeatureFillColor(
isDark: boolean,
alpha: number,
enumCount: number = 0,
enumPalette?: [number, number, number][]
enumPalette?: [number, number, number][],
featureGradient: GradientStop[] = FEATURE_GRADIENT
): [number, number, number, number] {
if (colorRange) {
if (value == null)
@ -244,9 +247,9 @@ export function getFeatureFillColor(
const range = colorRange[1] - colorRange[0];
if (range === 0)
return [...FEATURE_GRADIENT[0].color, alpha] as [number, number, number, number];
return [...featureGradient[0].color, alpha] as [number, number, number, number];
const t = ((value as number) - colorRange[0]) / range;
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)), featureGradient);
return [...rgb, alpha] as [number, number, number, number];
}
return [...countToColor(Math.max(0, Math.min(1, countNormalized)), densityGradient), alpha] as [