Codex changes
This commit is contained in:
parent
0bae902e08
commit
d4dde21ad2
46 changed files with 4953 additions and 966 deletions
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)': (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue