LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
|
|
@ -1,6 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { LayerExtension } from '@deck.gl/core';
|
||||
import { ENUM_PALETTE } from './consts';
|
||||
|
||||
/**
|
||||
* LayerExtension that turns polygon fills into pie charts.
|
||||
|
|
@ -12,7 +11,7 @@ import { ENUM_PALETTE } from './consts';
|
|||
* - stepMode:'dynamic' handles per-instance counting automatically.
|
||||
* - isEnabled() restricts to SolidPolygonLayer (fill) sublayers only.
|
||||
*
|
||||
* Accepts an optional custom palette in the constructor for per-feature color overrides.
|
||||
* Accepts the configured enum palette in the constructor.
|
||||
*/
|
||||
|
||||
function paletteToGlsl(palette: [number, number, number][]): string {
|
||||
|
|
@ -35,9 +34,9 @@ export class PieHexExtension extends LayerExtension {
|
|||
|
||||
private paletteGlsl: string;
|
||||
|
||||
constructor(palette?: [number, number, number][]) {
|
||||
constructor(palette: [number, number, number][]) {
|
||||
super();
|
||||
this.paletteGlsl = paletteToGlsl(palette ?? ENUM_PALETTE);
|
||||
this.paletteGlsl = paletteToGlsl(palette);
|
||||
}
|
||||
|
||||
isEnabled(layer: any): boolean {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ import type { FeatureMeta } from '../types';
|
|||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
createPoiDistanceFilterKey,
|
||||
createPoiFilterKey,
|
||||
} from './poi-distance-filter';
|
||||
|
||||
describe('api utilities', () => {
|
||||
it('builds API URLs from endpoint names, paths, and params', () => {
|
||||
|
|
@ -99,4 +105,52 @@ describe('api utilities', () => {
|
|||
)
|
||||
).toBe('Burglary (avg/yr):0:5;;Vehicle crime (avg/yr):1:10');
|
||||
});
|
||||
|
||||
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
|
||||
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createEthnicityFilterKey('% White', 1)]: [10, 90],
|
||||
[createEthnicityFilterKey('% White', 2)]: [20, 80],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('% White:20:80');
|
||||
});
|
||||
|
||||
it('serializes POI distance filters using their selected backend feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Distance to nearest park (km)', type: 'numeric', min: 0, max: 2 },
|
||||
{ name: 'Distance to nearest Tesco (km)', type: 'numeric', min: 0, max: 5 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createPoiDistanceFilterKey('Distance to nearest park (km)', 1)]: [0, 0.5],
|
||||
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 2)]: [0, 1],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Distance to nearest park (km):0:0.5;;Distance to nearest Tesco (km):0:1');
|
||||
});
|
||||
|
||||
it('serializes POI count filters using their selected backend feature', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Number of Cafe POIs within 2km', type: 'numeric', min: 0, max: 20 },
|
||||
];
|
||||
|
||||
expect(
|
||||
buildFilterString(
|
||||
{
|
||||
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 1)]: [
|
||||
2, 10,
|
||||
],
|
||||
},
|
||||
features
|
||||
)
|
||||
).toBe('Number of Cafe POIs within 2km:2:10');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
|
|||
import pb from './pocketbase';
|
||||
import { getSchoolBackendFeatureName } from './school-filter';
|
||||
import { getSpecificCrimeFeatureName } from './crime-filter';
|
||||
import { getEthnicityFeatureName } from './ethnicity-filter';
|
||||
import { getPoiDistanceFeatureName } from './poi-distance-filter';
|
||||
|
||||
export function logNonAbortError(label: string, error: unknown): void {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
|
|
@ -89,7 +91,11 @@ export function buildFilterString(
|
|||
for (const [name, value] of entries) {
|
||||
if (name === exclude) continue;
|
||||
const backendName =
|
||||
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name;
|
||||
const prev = merged.get(backendName);
|
||||
if (
|
||||
prev &&
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export const PARTY_FEATURE_GRADIENTS: Record<string, GradientStop[]> = {
|
|||
'% 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
|
||||
'% Other parties': partyGradient([107, 114, 128]), // neutral color for grouped parties
|
||||
};
|
||||
|
||||
export const PARTY_FEATURE_COLORS: Record<string, string> = Object.fromEntries(
|
||||
|
|
@ -127,9 +127,6 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
|
|||
Shops: [99, 102, 241],
|
||||
};
|
||||
|
||||
/** Default color for unknown POI groups */
|
||||
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
|
||||
|
||||
/** POI category → icon/logo URL for branded and transport categories */
|
||||
export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
||||
Airport: '/assets/twemoji/2708.png',
|
||||
|
|
@ -152,22 +149,22 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
|||
'Co-op': '/assets/poi-icons/logos/coop.svg',
|
||||
COOK: '/assets/poi-icons/brands_2024/cook.svg',
|
||||
'Convenience Store': '/assets/twemoji/1f3ea.png',
|
||||
Costco: '/assets/poi-icons/brands/costco.svg',
|
||||
Costco: '/assets/poi-icons/logos/costco.svg',
|
||||
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
|
||||
'Dunnes Stores': '/assets/poi-icons/brands_2024/dunnes_stores.svg',
|
||||
Farmfoods: '/assets/poi-icons/brands_2023/supermarkets/farmfoods.svg',
|
||||
Ferry: '/assets/twemoji/26f4.png',
|
||||
Greengrocer: '/assets/twemoji/1f96c.png',
|
||||
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
|
||||
Iceland: '/assets/poi-icons/logos/iceland.svg',
|
||||
Iceland: '/assets/poi-icons/brands_2024/iceland.svg',
|
||||
Lidl: '/assets/poi-icons/logos/lidl.svg',
|
||||
Makro: '/assets/poi-icons/brands_2024/makro.svg',
|
||||
'M&S': '/assets/poi-icons/brands/mns.svg',
|
||||
'M&S Clothing': '/assets/poi-icons/brands/mns_high_street.svg',
|
||||
'M&S Food': '/assets/poi-icons/brands/mns_food.svg',
|
||||
'M&S Hospital': '/assets/poi-icons/brands/mns_hospital.svg',
|
||||
'M&S MSA': '/assets/poi-icons/brands/mns_moto.svg',
|
||||
'M&S Outlet': '/assets/poi-icons/brands/mns_outlet.svg',
|
||||
'M&S': '/assets/poi-icons/brands_2024/mns.svg',
|
||||
'M&S Clothing': '/assets/poi-icons/brands_2024/mns.svg',
|
||||
'M&S Food': '/assets/poi-icons/visuals/mns.svg',
|
||||
'M&S Hospital': '/assets/poi-icons/brands_2024/mns.svg',
|
||||
'M&S MSA': '/assets/poi-icons/brands_2024/mns.svg',
|
||||
'M&S Outlet': '/assets/poi-icons/brands_2024/mns.svg',
|
||||
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
|
||||
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
|
||||
'Off-Licence': '/assets/twemoji/1f377.png',
|
||||
|
|
@ -181,10 +178,10 @@ export const POI_CATEGORY_LOGOS: Record<string, string> = {
|
|||
'Tesco Express': '/assets/poi-icons/logos/tesco_express.svg',
|
||||
'Tesco Extra': '/assets/poi-icons/logos/tesco_extra.svg',
|
||||
'Taxi rank': '/assets/twemoji/1f695.png',
|
||||
'The Food Warehouse': '/assets/poi-icons/logos/iceland.svg',
|
||||
'The Food Warehouse': '/assets/poi-icons/logos/the_food_warehouse.png',
|
||||
'Tube station': '/assets/poi-icons/public_transport/london_tube.svg',
|
||||
Waitrose: '/assets/poi-icons/logos/waitrose.svg',
|
||||
'Little Waitrose': '/assets/poi-icons/brands/little_waitrose.svg',
|
||||
'Little Waitrose': '/assets/poi-icons/brands_2023/supermarkets/little_waitrose.svg',
|
||||
'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg',
|
||||
};
|
||||
|
||||
|
|
@ -329,9 +326,8 @@ export const ENUM_PALETTE: [number, number, number][] = [
|
|||
];
|
||||
|
||||
/**
|
||||
* Per-feature color overrides for enum values on the map and dashboard.
|
||||
* Per-feature color definitions 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]>> = {
|
||||
'Property type': {
|
||||
|
|
@ -341,49 +337,105 @@ export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number
|
|||
'Flats/Maisonettes': [236, 72, 153], // pink
|
||||
Other: [107, 114, 128], // gray
|
||||
},
|
||||
'Leasehold/Freehold': {
|
||||
Freehold: [59, 130, 246],
|
||||
Leasehold: [245, 158, 11],
|
||||
},
|
||||
'Former council house': {
|
||||
Yes: [239, 68, 68],
|
||||
No: [34, 197, 94],
|
||||
},
|
||||
'Current energy rating': {
|
||||
A: [22, 163, 74],
|
||||
B: [132, 204, 22],
|
||||
C: [234, 179, 8],
|
||||
D: [245, 158, 11],
|
||||
E: [249, 115, 22],
|
||||
F: [239, 68, 68],
|
||||
G: [126, 34, 206],
|
||||
},
|
||||
'Potential energy rating': {
|
||||
A: [22, 163, 74],
|
||||
B: [132, 204, 22],
|
||||
C: [234, 179, 8],
|
||||
D: [245, 158, 11],
|
||||
E: [249, 115, 22],
|
||||
F: [239, 68, 68],
|
||||
G: [126, 34, 206],
|
||||
},
|
||||
'Max available download speed (Mbps)': {
|
||||
'10': [107, 114, 128],
|
||||
'30': [245, 158, 11],
|
||||
'100': [59, 130, 246],
|
||||
'300': [20, 184, 166],
|
||||
'1000': [34, 197, 94],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a 10-color palette for a given feature, using overrides where defined.
|
||||
* Returns the default ENUM_PALETTE when no overrides exist.
|
||||
* Build the 10-color shader palette for a given enum feature.
|
||||
* The trailing slots are invisible for features with fewer than 10 enum values.
|
||||
*/
|
||||
export function getEnumPaletteForFeature(
|
||||
featureName: string | null,
|
||||
values?: string[]
|
||||
featureName: string,
|
||||
values: string[]
|
||||
): [number, number, number][] {
|
||||
if (!featureName || !values) return ENUM_PALETTE;
|
||||
const overrides = ENUM_COLOR_OVERRIDES[featureName];
|
||||
if (!overrides) return ENUM_PALETTE;
|
||||
if (!overrides) {
|
||||
throw new Error(`Missing enum color definitions for '${featureName}'`);
|
||||
}
|
||||
|
||||
const palette: [number, number, number][] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i < values.length && overrides[values[i]]) {
|
||||
palette.push(overrides[values[i]]);
|
||||
if (i < values.length) {
|
||||
const color = overrides[values[i]];
|
||||
if (!color) {
|
||||
throw new Error(`Missing enum color for '${featureName}' value '${values[i]}'`);
|
||||
}
|
||||
palette.push(color);
|
||||
} else {
|
||||
palette.push(ENUM_PALETTE[i % ENUM_PALETTE.length]);
|
||||
palette.push([0, 0, 0]);
|
||||
}
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
/** Look up override color for a specific enum value, or null if none. */
|
||||
/** Look up the configured color for a specific enum value. */
|
||||
export function getEnumValueColor(
|
||||
featureName: string,
|
||||
valueName: string
|
||||
): [number, number, number] | null {
|
||||
return ENUM_COLOR_OVERRIDES[featureName]?.[valueName] ?? null;
|
||||
): [number, number, number] {
|
||||
const color = ENUM_COLOR_OVERRIDES[featureName]?.[valueName];
|
||||
if (!color) {
|
||||
throw new Error(`Missing enum color for '${featureName}' value '${valueName}'`);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/** Colors for stacked bar segments */
|
||||
export const SEGMENT_COLORS = [
|
||||
'#ef4444', // red-500
|
||||
'#f97316', // orange-500
|
||||
'#eab308', // yellow-500
|
||||
'#22c55e', // green-500
|
||||
'#14b8a6', // teal-500
|
||||
'#06b6d4', // cyan-500
|
||||
'#3b82f6', // blue-500
|
||||
'#8b5cf6', // violet-500
|
||||
'#d946ef', // fuchsia-500
|
||||
'#ec4899', // pink-500
|
||||
];
|
||||
/** Explicit colors for stacked bar segments. */
|
||||
export const STACKED_SEGMENT_COLORS: Record<string, string> = {
|
||||
'Violence and sexual offences (avg/yr)': '#ef4444',
|
||||
'Robbery (avg/yr)': '#f97316',
|
||||
'Burglary (avg/yr)': '#eab308',
|
||||
'Possession of weapons (avg/yr)': '#8b5cf6',
|
||||
'Anti-social behaviour (avg/yr)': '#14b8a6',
|
||||
'Criminal damage and arson (avg/yr)': '#f97316',
|
||||
'Shoplifting (avg/yr)': '#ec4899',
|
||||
'Bicycle theft (avg/yr)': '#22c55e',
|
||||
'Theft from the person (avg/yr)': '#d946ef',
|
||||
'Other theft (avg/yr)': '#06b6d4',
|
||||
'Vehicle crime (avg/yr)': '#3b82f6',
|
||||
'Public order (avg/yr)': '#8b5cf6',
|
||||
'Drugs (avg/yr)': '#22c55e',
|
||||
'Other crime (avg/yr)': '#6b7280',
|
||||
'% White': '#3b82f6',
|
||||
'% South Asian': '#f97316',
|
||||
'% East Asian': '#eab308',
|
||||
'% Black': '#8b5cf6',
|
||||
'% Mixed': '#14b8a6',
|
||||
'% Other': '#6b7280',
|
||||
'Anti-social': '#14b8a6',
|
||||
Vehicle: '#3b82f6',
|
||||
Burglary: '#eab308',
|
||||
Other: '#6b7280',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[]
|
|||
const seen = new Map<string, FeatureMeta[]>();
|
||||
|
||||
for (const feature of features) {
|
||||
const groupName = feature.group || 'Other';
|
||||
if (!feature.group) {
|
||||
throw new Error(`Feature '${feature.name}' is missing its group`);
|
||||
}
|
||||
const groupName = feature.group;
|
||||
let arr = seen.get(groupName);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
|
|
|
|||
|
|
@ -38,18 +38,21 @@ export function formatValue(value: number, fmt?: ValueFormat): string {
|
|||
return `${p}${value.toFixed(1)}${s}`;
|
||||
}
|
||||
|
||||
export function formatFilterValue(value: number, raw?: boolean): string {
|
||||
if (raw) return Math.round(value).toString();
|
||||
export function formatFilterValue(value: number, rawOrFmt?: boolean | ValueFormat): string {
|
||||
const fmt = typeof rawOrFmt === 'object' ? rawOrFmt : { raw: rawOrFmt };
|
||||
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 chineseCompactValue;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
if (chineseCompactValue) return `${p}${chineseCompactValue}${s}`;
|
||||
if (Number.isInteger(value)) return `${p}${value}${s}`;
|
||||
return `${p}${value.toFixed(2)}${s}`;
|
||||
}
|
||||
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);
|
||||
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}${s}`;
|
||||
return `${p}${value.toFixed(2)}${s}`;
|
||||
}
|
||||
|
||||
/** Parse a user-typed value like "250k", "1.2M", "£300000", "50 sqm" back to a number. */
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ShieldIcon,
|
||||
UsersIcon,
|
||||
ShoppingBagIcon,
|
||||
MapPinIcon,
|
||||
TreeIcon,
|
||||
TagIcon,
|
||||
} from '../components/ui/icons';
|
||||
|
|
@ -18,6 +19,7 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
|||
Deprivation: ChartBarIcon,
|
||||
Crime: ShieldIcon,
|
||||
Demographics: UsersIcon,
|
||||
'Nearby POIs': MapPinIcon,
|
||||
Amenities: ShoppingBagIcon,
|
||||
Environment: TreeIcon,
|
||||
Property: TagIcon,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
DENSITY_GRADIENT,
|
||||
ENUM_PALETTE,
|
||||
FEATURE_GRADIENT,
|
||||
MAP_BOUNDS,
|
||||
POI_CATEGORY_LOGOS,
|
||||
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
||||
} from './consts';
|
||||
|
|
@ -13,6 +14,8 @@ import {
|
|||
emojiToTwemojiUrl,
|
||||
enumIndexToColor,
|
||||
getBoundsFromViewState,
|
||||
getBoundsWithBottomScreenInset,
|
||||
getLatitudeAtVerticalPixelOffset,
|
||||
getFeatureFillColor,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
getPoiIconUrl,
|
||||
|
|
@ -48,21 +51,41 @@ describe('map utilities', () => {
|
|||
expect(centered.latitude).toBeLessThan(51.5);
|
||||
});
|
||||
|
||||
it('builds twemoji URLs and wraps enum colors', () => {
|
||||
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');
|
||||
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
|
||||
it('expands the southern map bound by a covered screen area', () => {
|
||||
const shiftedSouth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, 320);
|
||||
const shiftedNorth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, -320);
|
||||
const expandedBounds = getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 320);
|
||||
|
||||
expect(shiftedSouth).toBeLessThan(MAP_BOUNDS[1]);
|
||||
expect(shiftedNorth).toBeGreaterThan(MAP_BOUNDS[1]);
|
||||
expect(expandedBounds[0]).toBe(MAP_BOUNDS[0]);
|
||||
expect(expandedBounds[1]).toBeCloseTo(shiftedSouth, 6);
|
||||
expect(expandedBounds[2]).toBe(MAP_BOUNDS[2]);
|
||||
expect(expandedBounds[3]).toBe(MAP_BOUNDS[3]);
|
||||
expect(getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 0)).toEqual(MAP_BOUNDS);
|
||||
});
|
||||
|
||||
it('prefers POI category logos before falling back to emoji icons', () => {
|
||||
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/brands/waitrose_24px.svg');
|
||||
it('builds twemoji URLs and wraps enum colors', () => {
|
||||
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||
expect(() => emojiToTwemojiUrl('')).toThrow('Cannot build a Twemoji URL without an emoji');
|
||||
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
|
||||
});
|
||||
|
||||
it('resolves POI category logos and rejects unknown icon categories', () => {
|
||||
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
|
||||
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
|
||||
'/assets/poi-icons/brands/iceland_food_warehouse_24px.svg'
|
||||
'/assets/poi-icons/logos/the_food_warehouse.png'
|
||||
);
|
||||
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
|
||||
'/assets/poi-icons/brands/sainsburys_local_24px.svg'
|
||||
'/assets/poi-icons/brands_2024/sainsburys_local.svg'
|
||||
);
|
||||
expect(getPoiIconUrl('Costco', '🛒')).toBe('/assets/poi-icons/logos/costco.svg');
|
||||
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
|
||||
'/assets/poi-icons/visuals/mns.svg'
|
||||
);
|
||||
expect(() => getPoiIconUrl('Unknown category', '🛒')).toThrow(
|
||||
"Missing POI icon for category 'Unknown category'"
|
||||
);
|
||||
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||
});
|
||||
|
||||
it('keeps POI icon URLs bundled locally', () => {
|
||||
|
|
@ -74,6 +97,43 @@ describe('map utilities', () => {
|
|||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not use pin-shaped SVGs for branded POI logos', () => {
|
||||
const pinSignatures = ['viewBox="0 0 400 520"', 'C 18.914 185.931'];
|
||||
const svgUrls = [
|
||||
...new Set(
|
||||
Object.values(POI_CATEGORY_LOGOS)
|
||||
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
||||
.filter((url) => url.endsWith('.svg'))
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
svgUrls.filter((url) => {
|
||||
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
||||
return pinSignatures.some((signature) => content.includes(signature));
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps bundled SVG logos large enough for the map icon atlas', () => {
|
||||
const svgUrls = [
|
||||
...new Set(
|
||||
Object.values(POI_CATEGORY_LOGOS)
|
||||
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
||||
.filter((url) => url.endsWith('.svg'))
|
||||
),
|
||||
];
|
||||
|
||||
expect(
|
||||
svgUrls.filter((url) => {
|
||||
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
||||
const width = Number(content.match(/width="([0-9.]+)/)?.[1]);
|
||||
const height = Number(content.match(/height="([0-9.]+)/)?.[1]);
|
||||
return Math.max(width, height) < 256;
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
||||
expect(
|
||||
getFeatureFillColor(
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
},
|
||||
{
|
||||
title: 'Separate cheap from good value',
|
||||
body: 'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode is not automatically treated as the best option.',
|
||||
body: 'A lower price can reflect smaller homes, weaker transport, more noise, or fewer local services. The map keeps those trade-offs visible so the cheapest postcode isn’t automatically treated as the best option.',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
|
|
@ -110,7 +110,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
methodology: [
|
||||
{
|
||||
title: 'What the price data is for',
|
||||
body: 'Use the map to compare areas and spot search candidates. It is not a valuation, mortgage decision, survey, legal search, or live listing feed.',
|
||||
body: 'Use the map to compare areas and spot search candidates. It isn’t a valuation, mortgage decision, survey, legal search, or live listing feed.',
|
||||
},
|
||||
{
|
||||
title: 'How to validate a promising area',
|
||||
|
|
@ -121,7 +121,7 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
{
|
||||
question: 'Is this a replacement for Rightmove or Zoopla?',
|
||||
answer:
|
||||
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what is currently for sale.',
|
||||
'No. Use it before and alongside listing portals. Perfect Postcode helps decide where to look; listing portals show what’s currently for sale.',
|
||||
},
|
||||
{
|
||||
question: 'Can I compare price with schools or commute time?',
|
||||
|
|
@ -232,11 +232,11 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
workflows: [
|
||||
{
|
||||
title: 'Start with the destination that matters',
|
||||
body: 'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey does not work.',
|
||||
body: 'Choose a commute destination, transport mode, and time range, then add the property filters. This prevents a cheap-looking area from reaching the shortlist if the daily journey doesn’t work.',
|
||||
},
|
||||
{
|
||||
title: 'Compare the commute against the rest of daily life',
|
||||
body: 'A fast commute is not enough if the property size, school context, safety threshold, broadband, or road-noise exposure do not fit. The map keeps those signals side by side.',
|
||||
body: 'A fast commute isn’t enough if the property size, school context, safety threshold, broadband, or road-noise exposure don’t fit. The map keeps those signals side by side.',
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
|
|
@ -397,15 +397,15 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
body: 'A postcode check can surface price context, environmental signals, nearby amenities, and other local indicators that are easy to miss in a listing.',
|
||||
},
|
||||
{
|
||||
title: 'What a postcode check cannot prove',
|
||||
body: 'It cannot confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.',
|
||||
title: 'What a postcode check can’t prove',
|
||||
body: 'It can’t confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.',
|
||||
},
|
||||
],
|
||||
faq: [
|
||||
{
|
||||
question: 'Can I use the checker before a viewing?',
|
||||
answer:
|
||||
'Yes. That is one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.',
|
||||
'Yes. That’s one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.',
|
||||
},
|
||||
{
|
||||
question: 'Does the checker include exact property condition?',
|
||||
|
|
@ -654,7 +654,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
metaDescription:
|
||||
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
|
||||
intro:
|
||||
'Perfect Postcode is designed to make area shortlisting more evidence-led. It does not replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.',
|
||||
'Perfect Postcode is designed to make area shortlisting more evidence-led. It doesn’t replace estate agents, surveyors, conveyancers, lenders, school admissions teams, or local authority checks.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Start with hard constraints',
|
||||
|
|
@ -665,7 +665,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
body: 'After filtering, colour the remaining map by one signal at a time: price per square metre, road noise, school context, commute time, broadband, or crime. This makes trade-offs easier to discuss.',
|
||||
},
|
||||
{
|
||||
title: 'Measure what is working',
|
||||
title: 'Measure what’s working',
|
||||
body: 'Use Search Console and analytics to track which public pages are indexed, which queries produce impressions, and which pages convert visitors into dashboard exploration. Review Core Web Vitals after every substantial frontend change.',
|
||||
},
|
||||
],
|
||||
|
|
@ -715,11 +715,11 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
},
|
||||
{
|
||||
title: 'Saved search data is account-scoped',
|
||||
body: 'Saved searches and properties are intended for signed-in use. They are not included in the public sitemap and should not be crawlable as public content.',
|
||||
body: 'Saved searches and properties are intended for signed-in use. They aren’t included in the public sitemap and shouldn’t be crawlable as public content.',
|
||||
},
|
||||
{
|
||||
title: 'Search measurement without exposing private data',
|
||||
body: 'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views should not become indexable landing pages.',
|
||||
body: 'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldn’t become indexable landing pages.',
|
||||
},
|
||||
],
|
||||
faq: [
|
||||
|
|
@ -731,7 +731,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
{
|
||||
question: 'Can private dashboard URLs appear in search?',
|
||||
answer:
|
||||
'They should not be indexed. The server marks private routes noindex and the sitemap only lists public pages.',
|
||||
'They shouldn’t be indexed. The server marks private routes noindex and the sitemap only lists public pages.',
|
||||
},
|
||||
],
|
||||
relatedLinks: [
|
||||
|
|
|
|||
|
|
@ -1,52 +1,57 @@
|
|||
import type { Styles } from 'react-joyride';
|
||||
import type { Options, Styles } from 'react-joyride';
|
||||
|
||||
export function getTutorialStyles(theme: 'light' | 'dark'): Partial<Styles> {
|
||||
export function getTutorialStyles(theme: 'light' | 'dark'): {
|
||||
options: Partial<Options>;
|
||||
styles: Partial<Styles>;
|
||||
} {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return {
|
||||
options: {
|
||||
arrowColor: isDark ? '#292524' : '#ffffff',
|
||||
backgroundColor: isDark ? '#292524' : '#ffffff',
|
||||
disableFocusTrap: true,
|
||||
hideOverlay: true,
|
||||
overlayColor: isDark ? 'rgba(10,14,26,0.75)' : 'rgba(0,0,0,0.5)',
|
||||
primaryColor: '#00a28c',
|
||||
spotlightRadius: 8,
|
||||
textColor: isDark ? '#d6d3d1' : '#44403c',
|
||||
zIndex: 1000,
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: isDark ? '#f5f5f4' : '#0a0e1a',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
},
|
||||
tooltipContent: {
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
padding: '8px 0 0',
|
||||
},
|
||||
buttonNext: {
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
padding: '6px 14px',
|
||||
},
|
||||
buttonBack: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonSkip: {
|
||||
color: isDark ? '#78716c' : '#a8a29e',
|
||||
fontSize: 12,
|
||||
},
|
||||
buttonClose: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
},
|
||||
spotlight: {
|
||||
borderRadius: 8,
|
||||
styles: {
|
||||
tooltip: {
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: isDark ? '#f5f5f4' : '#0a0e1a',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
},
|
||||
tooltipContent: {
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
padding: '8px 0 0',
|
||||
},
|
||||
buttonPrimary: {
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
padding: '6px 14px',
|
||||
},
|
||||
buttonBack: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonSkip: {
|
||||
color: isDark ? '#78716c' : '#a8a29e',
|
||||
fontSize: 12,
|
||||
},
|
||||
buttonClose: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ import { beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
import { createSpecificCrimeFilterKey } from './crime-filter';
|
||||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
createPoiDistanceFilterKey,
|
||||
createPoiFilterKey,
|
||||
} from './poi-distance-filter';
|
||||
|
||||
describe('url-state', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -43,6 +50,15 @@ describe('url-state', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('leaves POIs unselected when URL params are omitted', () => {
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.viewState).toEqual(INITIAL_VIEW_STATE);
|
||||
expect(state.filters).toEqual({});
|
||||
expect(state.poiCategories).toEqual(new Set());
|
||||
expect(state.tab).toBe('area');
|
||||
});
|
||||
|
||||
it('serializes map state and active filters into stable URL params', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Last known price', type: 'numeric' },
|
||||
|
|
@ -81,6 +97,17 @@ describe('url-state', () => {
|
|||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
||||
});
|
||||
|
||||
it('round-trips an explicitly empty POI selection', () => {
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||
|
||||
expect(params.getAll('poi')).toEqual(['__none']);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.poiCategories).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('round-trips repeated school filters with dedicated URL params', () => {
|
||||
const schoolOne = createSchoolFilterKey('primary', 'good', 2, 1);
|
||||
const schoolTwo = createSchoolFilterKey('secondary', 'outstanding', 5, 2);
|
||||
|
|
@ -141,6 +168,91 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated ethnicity filters with dedicated URL params', () => {
|
||||
const white = createEthnicityFilterKey('% White', 3);
|
||||
const southAsian = createEthnicityFilterKey('% South Asian', 4);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[white]: [10, 80],
|
||||
[southAsian]: [5, 35],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('ethnicity')).toEqual(['% White:10:80', '% South Asian:5:35']);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createEthnicityFilterKey('% White', 0)]: [10, 80],
|
||||
[createEthnicityFilterKey('% South Asian', 1)]: [5, 35],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated POI distance filters with dedicated URL params', () => {
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest park (km)', 3);
|
||||
const tesco = createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 4);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[park]: [0, 0.4],
|
||||
[tesco]: [0, 1.5],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('poiDistance')).toEqual([
|
||||
'Distance%20to%20nearest%20park%20(km):0:0.4',
|
||||
'Distance%20to%20nearest%20Tesco%20(km):0:1.5',
|
||||
]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createPoiDistanceFilterKey('Distance to nearest park (km)', 0)]: [0, 0.4],
|
||||
[createPoiDistanceFilterKey('Distance to nearest Tesco (km)', 1)]: [0, 1.5],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips POI count filters with dedicated URL params', () => {
|
||||
const cafes = createPoiFilterKey(
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
'Number of Cafe POIs within 2km',
|
||||
3
|
||||
);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[cafes]: [2, 8],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('poiCount2km')).toEqual(['Number%20of%20Cafe%20POIs%20within%202km:2:8']);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createPoiFilterKey(POI_COUNT_2KM_FILTER_NAME, 'Number of Cafe POIs within 2km', 0)]: [2, 8],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the default area tab', () => {
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue