This commit is contained in:
Andras Schmelczer 2026-05-06 23:13:58 +01:00
parent 94f9c0d594
commit 5c3b87f2d5
69 changed files with 1334 additions and 213 deletions

View file

@ -68,7 +68,9 @@ export default function HexCanvas({
height: (hex.size * 2) / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.45 : 0.6),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: animated ? `hex-bob ${hex.bobDuration}s ease-in-out infinite` : undefined,
animation: animated
? `hex-bob ${hex.bobDuration}s ease-in-out infinite`
: undefined,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties
}

View file

@ -281,8 +281,8 @@ const INSPECT_JOURNEYS: JourneyInstructionPreset[] = [
bestMinutes: 29,
legs: [
{ mode: 'Victoria', from: 'Oxford Circus Underground Station', to: 'Victoria', minutes: 4 },
{ mode: 'District', from: 'Victoria', to: 'Earl\'s Court', minutes: 10 },
{ mode: 'walk', from: 'Earl\'s Court', to: 'SW5 9AA', minutes: 7 },
{ mode: 'District', from: 'Victoria', to: "Earl's Court", minutes: 10 },
{ mode: 'walk', from: "Earl's Court", to: 'SW5 9AA', minutes: 7 },
],
},
];
@ -478,8 +478,7 @@ function FilterPreviewRow({
<span
className={`w-fit shrink-0 rounded-md px-2.5 py-1 text-xs font-bold leading-none ${style.chip}`}
>
+
<span className="font-mono tabular-nums">{withoutCount.toLocaleString()}</span>
+<span className="font-mono tabular-nums">{withoutCount.toLocaleString()}</span>
{' without this filter'}
</span>
</div>
@ -946,75 +945,73 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
<div
className={`overflow-hidden transition-all duration-500 ease-out ${
isTableRevealed
? 'mt-3 max-h-64 opacity-100 sm:mt-4'
: 'mt-0 max-h-0 opacity-0'
isTableRevealed ? 'mt-3 max-h-64 opacity-100 sm:mt-4' : 'mt-0 max-h-0 opacity-0'
}`}
aria-hidden={!isTableRevealed}
>
<div className="relative z-10 shrink-0 overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl shadow-navy-950/10 ring-1 ring-white/80 dark:border-navy-700 dark:bg-navy-900/70 dark:ring-white/5 dark:backdrop-blur-sm">
<div className="flex items-center justify-between gap-3 border-b border-warm-200 bg-gradient-to-r from-white via-emerald-50/80 to-white px-3 py-1.5 text-navy-950 dark:border-navy-700 dark:from-navy-900/90 dark:via-emerald-900/20 dark:to-navy-900/90 dark:text-warm-100 sm:px-4 sm:py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-emerald-600 text-white shadow-sm shadow-emerald-900/20 dark:bg-emerald-400 dark:text-navy-950 sm:h-7 sm:w-7">
<DownloadIcon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 truncate text-xs font-black sm:text-sm">
{t('home.showcaseStep4FileName')}
</span>
</div>
<span className="shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200 sm:text-xs">
Top 3
</span>
</div>
<div className="grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] border-b border-warm-200 bg-warm-50 text-[10px] font-black uppercase text-warm-500 dark:border-navy-700 dark:bg-navy-950/45 dark:text-warm-400 sm:text-xs">
{[
t('home.showcaseStep4ColPostcode'),
t('home.showcaseStep4ColScore'),
t('home.showcaseStep4ColCommute'),
t('home.showcaseStep4ColPrice'),
].map((heading) => (
<div
key={heading}
className="truncate border-r border-warm-200 px-2 py-1.5 last:border-r-0 dark:border-navy-700 sm:px-3 sm:py-2"
>
{heading}
</div>
))}
</div>
{scoutRows.map((row, index) => (
<div
key={row.postcode}
className={`grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] text-[11px] font-semibold text-navy-950 dark:text-warm-100 sm:text-sm ${
index % 2 === 0
? 'bg-white dark:bg-navy-900/55'
: 'bg-warm-50/70 dark:bg-navy-950/35'
}`}
>
<div className="flex min-w-0 items-center gap-1.5 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-[10px] font-black text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
{index + 1}
</span>
<span className="truncate font-black">{row.postcode}</span>
</div>
<div className="min-w-0 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<div className="flex items-center gap-2">
<span className="shrink-0 font-black text-emerald-700 dark:text-emerald-300">
{row.score}
<div className="flex items-center justify-between gap-3 border-b border-warm-200 bg-gradient-to-r from-white via-emerald-50/80 to-white px-3 py-1.5 text-navy-950 dark:border-navy-700 dark:from-navy-900/90 dark:via-emerald-900/20 dark:to-navy-900/90 dark:text-warm-100 sm:px-4 sm:py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-emerald-600 text-white shadow-sm shadow-emerald-900/20 dark:bg-emerald-400 dark:text-navy-950 sm:h-7 sm:w-7">
<DownloadIcon className="h-3.5 w-3.5" />
</span>
<span className="hidden h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700 sm:block">
<span
className="block h-full rounded-full bg-emerald-500 dark:bg-emerald-300"
style={{ width: row.score }}
/>
<span className="min-w-0 truncate text-xs font-black sm:text-sm">
{t('home.showcaseStep4FileName')}
</span>
</div>
<span className="shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200 sm:text-xs">
Top 3
</span>
</div>
<div className="truncate border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
{row.commute}
<div className="grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] border-b border-warm-200 bg-warm-50 text-[10px] font-black uppercase text-warm-500 dark:border-navy-700 dark:bg-navy-950/45 dark:text-warm-400 sm:text-xs">
{[
t('home.showcaseStep4ColPostcode'),
t('home.showcaseStep4ColScore'),
t('home.showcaseStep4ColCommute'),
t('home.showcaseStep4ColPrice'),
].map((heading) => (
<div
key={heading}
className="truncate border-r border-warm-200 px-2 py-1.5 last:border-r-0 dark:border-navy-700 sm:px-3 sm:py-2"
>
{heading}
</div>
))}
</div>
<div className="truncate px-2 py-1.5 font-black sm:px-3 sm:py-2.5">{row.price}</div>
</div>
))}
{scoutRows.map((row, index) => (
<div
key={row.postcode}
className={`grid grid-cols-[1.25fr_0.85fr_0.9fr_1fr] text-[11px] font-semibold text-navy-950 dark:text-warm-100 sm:text-sm ${
index % 2 === 0
? 'bg-white dark:bg-navy-900/55'
: 'bg-warm-50/70 dark:bg-navy-950/35'
}`}
>
<div className="flex min-w-0 items-center gap-1.5 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-emerald-50 text-[10px] font-black text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
{index + 1}
</span>
<span className="truncate font-black">{row.postcode}</span>
</div>
<div className="min-w-0 border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
<div className="flex items-center gap-2">
<span className="shrink-0 font-black text-emerald-700 dark:text-emerald-300">
{row.score}
</span>
<span className="hidden h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-warm-200 dark:bg-navy-700 sm:block">
<span
className="block h-full rounded-full bg-emerald-500 dark:bg-emerald-300"
style={{ width: row.score }}
/>
</span>
</div>
</div>
<div className="truncate border-r border-warm-200 px-2 py-1.5 dark:border-navy-700 sm:px-3 sm:py-2.5">
{row.commute}
</div>
<div className="truncate px-2 py-1.5 font-black sm:px-3 sm:py-2.5">{row.price}</div>
</div>
))}
</div>
</div>
@ -1262,10 +1259,7 @@ export default function HomePage({
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
24;
start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top + 24;
const distance = end - start;
const duration = 1200;
let startTime: number;
@ -1364,9 +1358,7 @@ export default function HomePage({
{/* Our philosophy */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-6`}>
{t('home.ourPhilosophy')}
</h2>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-6`}>{t('home.ourPhilosophy')}</h2>
<div className="space-y-4 text-base md:text-lg leading-relaxed text-warm-700 dark:text-warm-300">
<p>{t('home.philosophyP1')}</p>
<p>{highlightBrandText(t('home.philosophyP2'))}</p>

View file

@ -84,7 +84,11 @@ interface Dimensions {
height: number;
}
function resolveInset(pixelValue: number | undefined, ratioValue: number | undefined, size: number) {
function resolveInset(
pixelValue: number | undefined,
ratioValue: number | undefined,
size: number
) {
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
}
@ -122,8 +126,7 @@ function getViewportRelativeVisibleAreaCenter(
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
const viewportRight =
viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
const viewportRight = viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
const viewportBottom =
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
@ -532,7 +535,12 @@ export default memo(function Map({
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<img
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
src={getPoiIconUrl(
popupInfo.category,
popupInfo.emoji,
popupInfo.icon_category,
popupInfo.name
)}
alt=""
aria-hidden="true"
loading="lazy"

View file

@ -415,10 +415,13 @@ export default function MapPage({
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
);
const handleMobileDrawerPanelRectChange = useCallback((rect: DOMRectReadOnly) => {
mobileDrawerPanelRectRef.current = rect;
consumePendingCurrentLocationFlyTo(rect);
}, [consumePendingCurrentLocationFlyTo]);
const handleMobileDrawerPanelRectChange = useCallback(
(rect: DOMRectReadOnly) => {
mobileDrawerPanelRectRef.current = rect;
consumePendingCurrentLocationFlyTo(rect);
},
[consumePendingCurrentLocationFlyTo]
);
const handleMobileDrawerClose = useCallback(() => {
pendingCurrentLocationFlyToRef.current = null;

View file

@ -54,10 +54,7 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function MobileBottomSheet({
children,
legend,
}: MobileBottomSheetProps) {
export default function MobileBottomSheet({ children, legend }: MobileBottomSheetProps) {
const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);

View file

@ -64,9 +64,7 @@ describe('useMapData', () => {
);
await act(async () => {
result.current.handleViewChange(
viewChange({ south: 1, west: 1, north: 2, east: 2 })
);
result.current.handleViewChange(viewChange({ south: 1, west: 1, north: 2, east: 2 }));
});
await act(async () => {
vi.advanceTimersByTime(150);
@ -74,9 +72,7 @@ describe('useMapData', () => {
expect(requests).toHaveLength(1);
await act(async () => {
result.current.handleViewChange(
viewChange({ south: 3, west: 3, north: 4, east: 4 })
);
result.current.handleViewChange(viewChange({ south: 3, west: 3, north: 4, east: 4 }));
});
await act(async () => {

View file

@ -34,6 +34,17 @@ const busStop: POI = {
emoji: '🚌',
};
const foodWarehouse: POI = {
id: 'poi-4',
name: 'Iceland Avonmead Food Warehouse',
category: 'Iceland',
icon_category: 'The Food Warehouse',
group: 'Groceries',
lat: 51.49,
lng: -0.18,
emoji: '🛒',
};
function layerById(layers: readonly unknown[], id: string) {
const layer = layers.find((item) => (item as { id?: string }).id === id);
if (!layer) throw new Error(`Layer ${id} not found`);
@ -62,8 +73,18 @@ describe('usePoiLayers', () => {
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
expect(getIcon(waitrose).url).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
expect(getIcon(waitrose).url).toBe('/assets/poi-icons/brands/waitrose_24px.svg');
});
it('prefers POI fascia icon categories for map marker icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [foodWarehouse], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => { url: string };
expect(getIcon(foodWarehouse).url).toBe(
'/assets/poi-icons/brands/iceland_food_warehouse_24px.svg'
);
});
@ -99,6 +120,7 @@ describe('usePoiLayers', () => {
y: 88,
name: supermarket.name,
category: supermarket.category,
icon_category: undefined,
group: supermarket.group,
emoji: supermarket.emoji,
id: supermarket.id,

View file

@ -19,6 +19,7 @@ export interface PopupInfo {
y: number;
name: string;
category: string;
icon_category?: string;
group: string;
emoji: string;
id: string;
@ -49,6 +50,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
y: info.y,
name: info.object.name,
category: info.object.category,
icon_category: info.object.icon_category,
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
@ -176,7 +178,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
data: visiblePois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: getPoiIconUrl(d.category, d.emoji),
url: getPoiIconUrl(d.category, d.emoji, d.icon_category, d.name),
width: 72,
height: 72,
}),

View file

@ -54,7 +54,8 @@ const descriptions: Record<string, Record<string, string>> = {
'Health Deprivation and Disability Score':
'Score de santé et handicap (plus élevé = meilleurs résultats)',
'Housing Conditions Score': 'Qualité et état du logement (plus élevé = meilleur)',
'Air Quality and Road Safety Score': 'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Air Quality and Road Safety Score':
'Qualité de lair et sécurité routière (plus élevé = meilleur)',
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an',
'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an',
'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
@ -137,8 +138,7 @@ const descriptions: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km':
'Von Ofsted mit Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
'Income Score':
'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Income Score': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Employment Score':
'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
'Health Deprivation and Disability Score':

View file

@ -433,8 +433,7 @@ const de: Translations = {
'Lebenslanger Zugang zu der Karte, die zeigt, wo Sie suchen sollten, bevor Sie Besichtigungen buchen.',
costContext:
'Käufer verbringen oft Abende damit, Inserate, Pendelzeiten, Schulberichte, Kriminalitätskarten, Street View und Verkaufspreise zusammenzuführen. In London ist das besonders mühsam, aber dasselbe Rechercheproblem gibt es in ganz England. Perfect Postcode bringt die Gebietsrecherche auf eine Karte, bevor Sie Wochenenden, Gebühren und Aufmerksamkeit investieren.',
lessThanSurvey:
'Weniger als ein Gutachten. Deutlich wirksamer, um Ihre Auswahl zu steuern.',
lessThanSurvey: 'Weniger als ein Gutachten. Deutlich wirksamer, um Ihre Auswahl zu steuern.',
currentTier: 'Aktuelle Stufe',
firstNUsers: 'Erste {{count}} Nutzer',
everyoneAfter: 'Alle danach',
@ -723,8 +722,7 @@ const de: Translations = {
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
propertyInfo:
'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
inviteAlreadyUsedDesc: 'Dieser Einladungslink wurde bereits eingelöst.',

View file

@ -725,8 +725,7 @@ const fr: Translations = {
genericFreeInvite: 'Vous avez été invité à obtenir un accès à vie gratuit.',
genericDiscount: 'Un ami vous fait bénéficier dune réduction de 30% sur laccès à vie.',
exploreEvery: 'Trouvez les codes postaux adaptés à votre vie',
propertyInfo:
'Prix, trajet, écoles, criminalité, bruit, débit internet, DPE et plus encore',
propertyInfo: 'Prix, trajet, écoles, criminalité, bruit, débit internet, DPE et plus encore',
invalidInvite: 'Invitation invalide',
inviteAlreadyUsed: 'Invitation déjà utilisée',
inviteAlreadyUsedDesc: 'Ce lien dinvitation a déjà été utilisé.',

View file

@ -589,15 +589,15 @@ const hu: Translations = {
faqSafety2A:
'Foglalás előtt ellenőrizd a bűnözést, közúti zajt, internetet, parkokat, élelmiszerboltokat, iskolákat és ingázást. A hirdetési fotók hasznosak lehetnek, de ne azokból derüljön ki először, milyen az utca.',
// FAQ items — Families and Schools
faqFamilies1Q:
'Mely területeken jó az iskolák, tér, biztonság és ingázás keveréke?',
faqFamilies1Q: 'Mely területeken jó az iskolák, tér, biztonság és ingázás keveréke?',
faqFamilies1A:
'Tedd egy térképre az iskolaminősítéseket, bűnözést, parkokat, ingázást, teret, otthontípust és költségvetést. Az eredmény gyakorlati családi lista, nem sok külön keresés halmaza.',
faqFamilies2Q: 'Ez bizonyítja, hogy iskola-felvételi körzeten belül vagyok?',
faqFamilies2A:
'Nem. Közeli iskolaminőséget és helyi oktatási információkat mutatunk, de a felvételi határok és elsőbbségi szabályok változhatnak. A Perfect Postcode-dal válogass helyeket, majd ellenőrizd a körzeteket és felvételit az iskolánál vagy a helyi önkormányzatnál.',
// FAQ items — Environment and Quality of Life
faqEnv1Q: 'Hogyan kerülhetek el zajos utat az ingázás vagy internet minőségének elvesztése nélkül?',
faqEnv1Q:
'Hogyan kerülhetek el zajos utat az ingázás vagy internet minőségének elvesztése nélkül?',
faqEnv1A:
'Szűrj közúti zajra, miközben az ingázás, internet, ár és otthonszűrők aktívak maradnak. Egy jellemző szerint színezheted a térképet, a többi pedig reálisan tartja a listát.',
faqEnv2Q: 'Mutat árvíz-, süllyedés- vagy felmérési kockázatot?',
@ -720,8 +720,7 @@ const hu: Translations = {
genericDiscount:
'Egy barát megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
exploreEvery: 'Találd meg az életedhez illő irányítószámokat',
propertyInfo:
'Árak, ingázás, iskolák, bűnözés, zaj, szélessáv, EPC és még sok más',
propertyInfo: 'Árak, ingázás, iskolák, bűnözés, zaj, szélessáv, EPC és még sok más',
invalidInvite: 'Érvénytelen meghívó',
inviteAlreadyUsed: 'A meghívó már felhasználva',
inviteAlreadyUsedDesc: 'Ez a meghívó link már be lett váltva.',

View file

@ -346,8 +346,7 @@ const zh: Translations = {
showcaseFeatureTravelShort: '出行',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '把模糊需求变成精准搜索',
showcaseStep1Body:
'设置真正重要的条件,并清楚看到每项要求为您排除了多少不合适的邮编。',
showcaseStep1Body: '设置真正重要的条件,并清楚看到每项要求为您排除了多少不合适的邮编。',
showcaseStep1Chip1: '安静街道',
showcaseStep1Chip2: '顶级小学',
showcaseStep1Chip3: '£500k 以内',

View file

@ -133,56 +133,59 @@ 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',
Aldi: 'https://geolytix.github.io/MapIcons/brands/aldi_24px.svg',
Amazon: 'https://geolytix.github.io/MapIcons/brands/amazon_fresh_alt_24px.svg',
Asda: 'https://geolytix.github.io/MapIcons/asda/asda_primary.svg',
'Asda Express': 'https://geolytix.github.io/MapIcons/asda/asda_express_24px.svg',
'Asda Living': 'https://geolytix.github.io/MapIcons/asda/asda_living_24px.svg',
'Asda PFS': 'https://geolytix.github.io/MapIcons/asda/asda_pfs_24px.svg',
Aldi: '/assets/poi-icons/logos/aldi.svg',
Amazon: '/assets/poi-icons/brands_2024/amazon_fresh.svg',
Asda: '/assets/poi-icons/logos/asda.svg',
'Asda Express': '/assets/poi-icons/logos/asda.svg',
'Asda Living': '/assets/poi-icons/logos/asda.svg',
'Asda PFS': '/assets/poi-icons/logos/asda.svg',
'Asda Supercentre': '/assets/poi-icons/logos/asda.svg',
'Asda Supermarket': '/assets/poi-icons/logos/asda.svg',
'Asda Superstore': '/assets/poi-icons/logos/asda.svg',
Bakery: '/assets/twemoji/1f950.png',
Booths: 'https://geolytix.github.io/MapIcons/brands/booths_24px.svg',
Budgens: 'https://geolytix.github.io/MapIcons/brands/budgens_24px.svg',
Booths: '/assets/poi-icons/brands_2024/booths.svg',
Budgens: '/assets/poi-icons/brands_2024/budgens.svg',
'Bus station': '/assets/twemoji/1f68c.png',
'Bus stop': '/assets/twemoji/1f68f.png',
'Butcher & Fishmonger': '/assets/twemoji/1f969.png',
Centra: 'https://geolytix.github.io/MapIcons/brands/centra_24px.svg',
'Co-op': 'https://geolytix.github.io/MapIcons/brands/coop_24px.svg',
COOK: 'https://geolytix.github.io/MapIcons/brands/cook.svg',
Centra: '/assets/poi-icons/logos/centra.svg',
'Co-op': '/assets/poi-icons/logos/coop.svg',
COOK: '/assets/poi-icons/brands_2024/cook.svg',
'Convenience Store': '/assets/twemoji/1f3ea.png',
Costco: 'https://geolytix.github.io/MapIcons/brands/costco_24px.svg',
Costco: '/assets/poi-icons/brands/costco.svg',
'Deli & Specialty': '/assets/twemoji/1f9c6.png',
'Dunnes Stores': 'https://geolytix.github.io/MapIcons/brands/dunnes_stores_24px.svg',
Farmfoods: 'https://geolytix.github.io/MapIcons/brands/farmfoods_updated_24px.svg',
'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': 'https://geolytix.github.io/MapIcons/brands/heron_24px.svg',
Iceland: 'https://geolytix.github.io/MapIcons/brands/iceland_24px.svg',
Lidl: 'https://geolytix.github.io/MapIcons/brands/lidl_24px.svg',
Makro: 'https://geolytix.github.io/MapIcons/brands/makro_24px.svg',
'M&S': 'https://geolytix.github.io/MapIcons/brands/mns_24px.svg',
'M&S Clothing': 'https://geolytix.github.io/MapIcons/brands/mns_high_street_24px.svg',
'M&S Food': 'https://geolytix.github.io/MapIcons/brands/mns_food_24px.svg',
'M&S Hospital': 'https://geolytix.github.io/MapIcons/brands/mns_hospital_24px.svg',
'M&S MSA': 'https://geolytix.github.io/MapIcons/brands/mns_moto_24px.svg',
'M&S Outlet': 'https://geolytix.github.io/MapIcons/brands/mns_outlet_24px.svg',
Morrisons: 'https://geolytix.github.io/MapIcons/brands/morrisons_24px.svg',
'Morrisons Daily': 'https://geolytix.github.io/MapIcons/brands/morrisons_daily_24px.svg',
'Heron Foods': '/assets/poi-icons/brands_2023/supermarkets/heron_foods.svg',
Iceland: '/assets/poi-icons/logos/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',
Morrisons: '/assets/poi-icons/logos/morrisons.svg',
'Morrisons Daily': '/assets/poi-icons/brands_2024/morrisons_daily.svg',
'Off-Licence': '/assets/twemoji/1f377.png',
'Planet Organic': 'https://geolytix.github.io/MapIcons/logos/planet_organic_24px.svg',
'Planet Organic': '/assets/poi-icons/logos/planet_organic.svg',
'Rail station': '/assets/twemoji/1f686.png',
"Sainsbury's": 'https://geolytix.github.io/MapIcons/brands/sainsburys_24px.svg',
"Sainsbury's Local": 'https://geolytix.github.io/MapIcons/brands/sainsburys_local_24px.svg',
Spar: 'https://geolytix.github.io/MapIcons/brands/spar_24px.svg',
"Sainsbury's": '/assets/poi-icons/logos/sainsburys.svg',
"Sainsbury's Local": '/assets/poi-icons/brands_2024/sainsburys_local.svg',
Spar: '/assets/poi-icons/logos/spar.svg',
Supermarket: '/assets/twemoji/1f6d2.png',
Tesco: 'https://geolytix.github.io/MapIcons/brands/tesco_24px.svg',
'Tesco Express': 'https://geolytix.github.io/MapIcons/brands/tesco_express_24px.svg',
'Tesco Extra': 'https://geolytix.github.io/MapIcons/brands/tesco_extra_24px.svg',
Tesco: '/assets/poi-icons/logos/tesco.svg',
'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': 'https://geolytix.github.io/MapIcons/brands/iceland_food_warehouse_24px.svg',
'Tube station': 'https://geolytix.github.io/MapIcons/public_transport/london_tube.svg',
Waitrose: 'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg',
'Little Waitrose': 'https://geolytix.github.io/MapIcons/brands/little_waitrose_24px.svg',
'Whole Foods Market': 'https://geolytix.github.io/MapIcons/brands/wholefoods_24px.svg',
'The Food Warehouse': '/assets/poi-icons/logos/iceland.svg',
'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',
'Whole Foods Market': '/assets/poi-icons/brands_2024/wholefoods.svg',
};
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */

View file

@ -62,8 +62,7 @@ function pointInPolygon(point: Point, polygon: Point[]): boolean {
if (current[1] > point[1] !== previous[1] > point[1]) {
const x =
((previous[0] - current[0]) * (point[1] - current[1])) /
(previous[1] - current[1]) +
((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) +
current[0];
if (point[0] < x) inside = !inside;
}
@ -92,9 +91,7 @@ export function hasMatchingHexagonAtResolution(
hexagons: HexagonData[],
resolution: number
): boolean {
return hexagons.some(
(hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution
);
return hexagons.some((hexagon) => hexagon.count > 0 && getResolution(hexagon.h3) === resolution);
}
export function findOverlappingMatchingHexagon(

View file

@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest';
import { existsSync } from 'fs';
import { join } from 'path';
import {
DENSITY_GRADIENT,
ENUM_PALETTE,
FEATURE_GRADIENT,
POI_CATEGORY_LOGOS,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
} from './consts';
import {
@ -52,12 +55,25 @@ describe('map utilities', () => {
});
it('prefers POI category logos before falling back to emoji icons', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe(
'https://geolytix.github.io/MapIcons/brands/waitrose_24px.svg'
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/brands/waitrose_24px.svg');
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
'/assets/poi-icons/brands/iceland_food_warehouse_24px.svg'
);
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
'/assets/poi-icons/brands/sainsburys_local_24px.svg'
);
expect(getPoiIconUrl('Unknown category', '🛒')).toBe('/assets/twemoji/1f6d2.png');
});
it('keeps POI icon URLs bundled locally', () => {
expect(Object.values(POI_CATEGORY_LOGOS).filter((url) => /^https?:\/\//.test(url))).toEqual([]);
expect(
Object.values(POI_CATEGORY_LOGOS)
.filter((url) => url.startsWith('/assets/poi-icons/'))
.filter((url) => !existsSync(join(process.cwd(), 'public', url.slice(1))))
).toEqual([]);
});
it('returns fallback, filtered, enum, feature, and density colors', () => {
expect(
getFeatureFillColor(

View file

@ -241,7 +241,60 @@ export function emojiToTwemojiUrl(emoji: string): string {
return `${TWEMOJI_BASE}${hex}.png`;
}
export function getPoiIconUrl(category: string, emoji: string): string {
function inferPoiIconCategory(category: string, name?: string): string | undefined {
if (!name) return undefined;
const text = `${category} ${name}`.toLowerCase();
switch (category) {
case 'Asda':
if (text.includes('asda express') || text.includes(' express')) return 'Asda Express';
if (text.includes('asda living')) return 'Asda Living';
if (text.includes('asda pfs') || /\bpfs\b/.test(text)) return 'Asda PFS';
return undefined;
case 'Iceland':
return text.includes('food warehouse') ? 'The Food Warehouse' : undefined;
case 'M&S':
if (text.includes('hospital')) return 'M&S Hospital';
if (text.includes('moto')) return 'M&S MSA';
if (text.includes('outlet')) return 'M&S Outlet';
if (
text.includes('foodhall') ||
text.includes('simply food') ||
text.includes('food to go') ||
text.includes(' bp') ||
/\bsf\b/.test(text)
) {
return 'M&S Food';
}
if (text.includes('clothing')) return 'M&S Clothing';
return undefined;
case 'Morrisons':
return text.includes('morrisons daily') || text.includes('morrisons dailly')
? 'Morrisons Daily'
: undefined;
case "Sainsbury's":
return text.includes('local') ? "Sainsbury's Local" : undefined;
case 'Tesco':
if (text.includes('tesco extra')) return 'Tesco Extra';
if (text.includes('tesco express') || text.includes(' express')) return 'Tesco Express';
return undefined;
case 'Waitrose':
return text.includes('little waitrose') ? 'Little Waitrose' : undefined;
default:
return undefined;
}
}
export function getPoiIconUrl(
category: string,
emoji: string,
iconCategory?: string,
name?: string
): string {
const resolvedIconCategory = iconCategory || inferPoiIconCategory(category, name);
if (resolvedIconCategory && POI_CATEGORY_LOGOS[resolvedIconCategory]) {
return POI_CATEGORY_LOGOS[resolvedIconCategory];
}
return POI_CATEGORY_LOGOS[category] ?? emojiToTwemojiUrl(emoji);
}

View file

@ -8,7 +8,6 @@ import {
import {
SCHOOL_FILTER_NAME,
createSchoolFilterKey,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
isSchoolFilterName,
type SchoolDistance,