More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s

This commit is contained in:
Andras Schmelczer 2026-05-04 17:21:26 +01:00
parent cd34ee693f
commit 05a1f316e1
58 changed files with 3113 additions and 1277 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
@ -7,6 +7,386 @@ import { TickerValue } from '../ui/TickerValue';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
const SHOWCASE_STEP_COUNT = 4;
const SHOWCASE_INTERVAL_MS = 4200;
function ProductMapFrame({ children }: { children: ReactNode }) {
return (
<div className="relative h-full min-h-0 rounded-lg overflow-hidden bg-warm-900 border border-white/10">
<div className="absolute inset-0 opacity-70">
<div className="absolute left-[12%] top-[18%] w-[76%] h-[68%] rounded-[45%] border border-warm-500/25" />
<div className="absolute left-[22%] top-[24%] w-[52%] h-[55%] rounded-[45%] border border-warm-500/20" />
<div className="absolute left-[36%] top-[8%] h-[84%] w-px bg-warm-500/20 rotate-12" />
<div className="absolute left-[18%] top-[49%] h-px w-[68%] bg-warm-500/20 -rotate-6" />
<div className="absolute left-[48%] top-[16%] h-[72%] w-px bg-warm-500/20 -rotate-24" />
<div className="absolute left-[4%] top-[34%] h-px w-[92%] bg-teal-400/10 rotate-12" />
</div>
{children}
</div>
);
}
function DemoMapPin({
name,
detail,
x,
y,
active = false,
}: {
name: string;
detail: string;
x: string;
y: string;
active?: boolean;
}) {
return (
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 ${active ? 'z-20' : 'z-10'}`}
style={{ left: x, top: y }}
>
<div className="relative mx-auto w-4 h-4">
<div
className={`absolute inset-0 rounded-full ${
active ? 'bg-coral-400 ring-8 ring-coral-400/20' : 'bg-teal-400 ring-4 ring-teal-400/15'
}`}
/>
</div>
<div
className={`mt-2 w-32 rounded-md border px-2 py-1 shadow-lg ${
active
? 'border-coral-400/40 bg-navy-950/95 text-white'
: 'border-white/10 bg-navy-950/85 text-warm-100'
}`}
>
<div className="text-xs font-semibold leading-tight">{name}</div>
<div className="text-[10px] leading-tight text-warm-400">{detail}</div>
</div>
</div>
);
}
function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (isPaused || prefersReducedMotion) return;
const timer = window.setInterval(() => {
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
}, SHOWCASE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [isPaused]);
const steps = [
{
tab: t('home.showcaseStep1Tab'),
title: t('home.showcaseStep1Title'),
body: t('home.showcaseStep1Body'),
},
{
tab: t('home.showcaseStep2Tab'),
title: t('home.showcaseStep2Title'),
body: t('home.showcaseStep2Body'),
},
{
tab: t('home.showcaseStep3Tab'),
title: t('home.showcaseStep3Title'),
body: t('home.showcaseStep3Body'),
},
{
tab: t('home.showcaseStep4Tab'),
title: t('home.showcaseStep4Title'),
body: t('home.showcaseStep4Body'),
},
];
const criteriaChips = [
t('home.showcaseStep1Chip1'),
t('home.showcaseStep1Chip2'),
t('home.showcaseStep1Chip3'),
t('home.showcaseStep1Chip4'),
];
const knownAreas = ['Clapham', 'St Albans', 'Brighton'];
const mapPins = [
{ name: 'Penge', detail: t('home.showcaseMatchPenge'), x: '63%', y: '54%' },
{ name: 'Abbey Wood', detail: t('home.showcaseMatchAbbeyWood'), x: '73%', y: '63%' },
{ name: 'Totterdown', detail: t('home.showcaseMatchTotterdown'), x: '42%', y: '71%' },
{ name: 'Walkley', detail: t('home.showcaseMatchWalkley'), x: '50%', y: '35%' },
];
const evidenceRows = [
t('home.showcaseEvidence1'),
t('home.showcaseEvidence2'),
t('home.showcaseEvidence3'),
t('home.showcaseEvidence4'),
];
const compareRows = [
t('home.showcaseCompare1'),
t('home.showcaseCompare2'),
t('home.showcaseCompare3'),
];
const active = steps[activeStep];
const renderSidePanel = () => {
if (activeStep === 0) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="text-[11px] font-semibold uppercase text-warm-400">
{t('aiFilter.describeIdealArea')}
</div>
<div className="mt-2 text-sm leading-snug text-white">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-teal-400/10 px-2 py-1 text-[11px] font-medium text-teal-200"
>
{chip}
</span>
))}
</div>
</div>
);
}
if (activeStep === 1) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-coral-400/30 bg-coral-400/10 p-3">
<div className="text-2xl font-bold text-white">{t('home.showcaseStep2Metric')}</div>
<div className="text-xs text-coral-100">{t('home.showcaseStep2Note')}</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-2">
<div className="font-semibold text-warm-300">{t('home.showcaseKnownAreas')}</div>
{knownAreas.map((area) => (
<div key={area} className="mt-1 flex justify-between gap-2 text-warm-500">
<span>{area}</span>
<span>{t('home.showcaseKnownAreaStatus')}</span>
</div>
))}
</div>
<div className="rounded-lg border border-teal-400/30 bg-teal-400/10 p-2">
<div className="font-semibold text-teal-100">{t('home.showcaseNewMatches')}</div>
{mapPins.slice(0, 3).map((pin) => (
<div key={pin.name} className="mt-1 text-teal-200">
{pin.name}
</div>
))}
</div>
</div>
</div>
);
}
if (activeStep === 2) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-lg font-bold text-white">{t('home.showcaseStep3Area')}</div>
</div>
<div className="rounded-full bg-teal-400/15 px-2.5 py-1 text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="mt-3 space-y-1.5">
{evidenceRows.map((row) => (
<div key={row} className="flex items-center gap-2 text-xs text-warm-300">
<span className="w-4 h-4 rounded-full bg-teal-400/15 text-teal-200 flex items-center justify-center text-[10px]">
&#x2713;
</span>
<span>{row}</span>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-2">
{compareRows.map((row, index) => (
<div key={row} className="rounded-lg border border-white/10 bg-white/[0.04] p-2.5">
<div className="flex items-start gap-2">
<span className="mt-0.5 w-5 h-5 rounded-full bg-coral-400/15 text-coral-100 flex items-center justify-center text-xs font-bold">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-xs leading-snug text-warm-200">{row}</span>
</div>
</div>
))}
<div className="text-xs font-semibold text-teal-200">{t('home.showcaseSaveLabel')}</div>
</div>
);
};
const renderVisual = () => {
if (activeStep === 0) {
return (
<ProductMapFrame>
<div className="absolute inset-x-4 top-4 rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-xl">
<div className="text-[10px] font-semibold uppercase text-warm-400">
{t('home.showcaseStep1Tab')}
</div>
<div className="mt-2 rounded-md bg-white text-warm-800 px-3 py-2 text-xs leading-snug">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="absolute inset-x-4 bottom-4 flex flex-wrap gap-2">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-navy-950/85 px-2.5 py-1 text-[11px] font-semibold text-teal-100"
>
{chip}
</span>
))}
</div>
</ProductMapFrame>
);
}
if (activeStep === 1) {
return (
<ProductMapFrame>
<div className="absolute left-3 top-3 rounded-md bg-navy-950/85 px-2.5 py-1.5 text-xs text-warm-300 border border-white/10">
{t('home.showcaseMapLabel')}
</div>
{mapPins.map((pin, index) => (
<DemoMapPin key={pin.name} {...pin} active={index === 0} />
))}
</ProductMapFrame>
);
}
if (activeStep === 2) {
return (
<ProductMapFrame>
<DemoMapPin {...mapPins[0]} active />
<div className="absolute right-3 top-3 w-40 rounded-lg border border-white/10 bg-navy-950/95 p-3 shadow-xl">
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-xl font-bold text-white">{t('home.showcaseStep3Code')}</div>
<div className="mt-2 rounded-full bg-teal-400/15 px-2 py-1 text-center text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="absolute inset-x-3 bottom-3 rounded-lg border border-white/10 bg-navy-950/90 p-3">
<div className="grid grid-cols-2 gap-2">
{evidenceRows.map((row) => (
<div key={row} className="text-[11px] text-warm-300">
<span className="text-teal-300">&#x2713;</span> {row}
</div>
))}
</div>
</div>
</ProductMapFrame>
);
}
return (
<ProductMapFrame>
<div className="absolute inset-3 grid grid-rows-3 gap-2">
{compareRows.map((row, index) => (
<div
key={row}
className="rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-lg"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 text-xs font-semibold leading-snug text-white">
{row}
</div>
<div className="h-2 w-16 shrink-0 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-coral-400"
style={{ width: `${88 - index * 14}%` }}
/>
</div>
</div>
</div>
))}
</div>
</ProductMapFrame>
);
};
return (
<div
className="relative w-full max-w-xl mt-10 lg:mt-0"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onFocus={() => setIsPaused(true)}
onBlur={() => setIsPaused(false)}
aria-label={t('home.showcaseHeader')}
>
<div className="h-[44rem] sm:h-[40rem] md:h-[34rem] lg:h-[35rem] xl:h-[33rem] rounded-xl border border-white/10 bg-navy-950/85 shadow-2xl overflow-hidden flex flex-col">
<div className="shrink-0 px-4 py-3 border-b border-white/10 flex items-center gap-2">
<LogoIcon className="w-5 h-5 text-teal-400" />
<div className="text-sm font-semibold text-white">{t('home.showcaseHeader')}</div>
<div className="ml-auto text-xs text-warm-400">{t('home.showcaseContext')}</div>
</div>
<div className="shrink-0 grid grid-cols-2 sm:grid-cols-4 gap-1 p-2 bg-white/[0.03] border-b border-white/10">
{steps.map((step, index) => (
<button
key={step.tab}
type="button"
onClick={() => setActiveStep(index)}
aria-pressed={activeStep === index}
className={`rounded-md px-2 py-2 text-[11px] sm:text-xs font-semibold leading-tight text-left transition-colors ${
activeStep === index
? 'bg-white/10 text-white'
: 'text-warm-400 hover:bg-white/[0.06] hover:text-warm-200'
}`}
>
<span>{step.tab}</span>
<span className="mt-2 block h-0.5 overflow-hidden rounded-full bg-white/10">
{activeStep === index && (
<span
key={activeStep}
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isPaused ? 'paused' : 'running',
}}
/>
)}
</span>
</button>
))}
</div>
<div className="flex-1 min-h-0 p-4 md:p-5">
<div className="grid h-full min-h-0 grid-rows-[minmax(0,0.92fr)_minmax(0,1.18fr)] md:grid-rows-1 md:grid-cols-[0.88fr_1.12fr] gap-4">
<div className="min-h-0 overflow-hidden flex flex-col justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.035] p-4">
<div className="shrink-0" aria-live={isPaused ? 'polite' : 'off'}>
<div className="text-xs font-semibold text-teal-300">{steps[activeStep].tab}</div>
<h2 className="mt-2 text-xl font-bold leading-tight text-white">{active.title}</h2>
<p className="mt-3 text-sm leading-relaxed text-warm-400">{active.body}</p>
</div>
<div className="min-h-0 overflow-hidden">{renderSidePanel()}</div>
</div>
{renderVisual()}
</div>
</div>
</div>
</div>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
@ -63,82 +443,84 @@ export default function HomePage({
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} />
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
<div className="relative z-10 max-w-7xl mx-auto w-full px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div className="max-w-4xl">
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
<div className="grid lg:grid-cols-[1fr_0.9fr] gap-8 lg:gap-12 items-center">
<div className="max-w-4xl">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '}
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('how-it-works');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
</div>
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
<HeroProductShowcase />
</div>
<div className="flex-1" />
</div>
@ -155,6 +537,32 @@ export default function HomePage({
</div>
</div>
{/* Street by street */}
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div className="grid md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
{t('home.streetTitle')}
</h2>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{t('home.streetIntro')}
</p>
</div>
{[
{ label: t('home.streetCard1Title'), body: t('home.streetCard1Body') },
{ label: t('home.streetCard2Title'), body: t('home.streetCard2Body') },
].map((item) => (
<div
key={item.label}
className="rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-5"
>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{item.label}</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">{item.body}</p>
</div>
))}
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section">

View file

@ -47,6 +47,11 @@ const DATA_SOURCE_DEFS: DataSourceDef[] = [
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
license: 'Open Data Commons Open Database License (ODbL)',
},
{
id: 'geolytix-retail-points',
url: 'https://geolytix.com/blog/supermarket-retail-points/',
license: 'GEOLYTIX Open Data License',
},
{
id: 'os-open-greenspace',
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
@ -106,6 +111,11 @@ const DS_KEYS: Record<string, [string, string, string]> = {
],
crime: ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
'geolytix-retail-points': [
'learnPage.dsGeolytixRetailName',
'learnPage.dsGeolytixRetailOrigin',
'learnPage.dsGeolytixRetailUse',
],
'os-open-greenspace': [
'learnPage.dsGreenspaceName',
'learnPage.dsGreenspaceOrigin',

View file

@ -13,9 +13,7 @@ export default function EnumBarChart({
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
const globalTotal = globalCounts
? Object.values(globalCounts).reduce((sum, c) => sum + c, 0)
: 0;
const globalTotal = globalCounts ? Object.values(globalCounts).reduce((sum, c) => sum + c, 0) : 0;
const hasGlobal = globalCounts && globalTotal > 0;
@ -61,9 +59,14 @@ export default function EnumBarChart({
)}
<div
className={
barStyle ? 'h-full rounded relative' : 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
barStyle
? 'h-full rounded relative'
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
}
style={{ width: `${localWidth}%`, ...(barStyle ? { backgroundColor: barStyle } : {}) }}
style={{
width: `${localWidth}%`,
...(barStyle ? { backgroundColor: barStyle } : {}),
}}
/>
</div>
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">

View file

@ -29,6 +29,19 @@ import {
type TravelTimeEntry,
travelFieldKey,
} from '../../hooks/useTravelTime';
import {
SCHOOL_FILTER_NAME,
clampSchoolRange,
getDefaultSchoolFeatureName,
getSchoolBackendFeatureName,
getSchoolFilterConfig,
getSchoolFilterMeta,
isSchoolFilterName,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../lib/school-filter';
function EditableLabel({
value,
@ -169,6 +182,223 @@ function SliderLabels({
);
}
function SchoolFilterCard({
features,
schoolFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
schoolFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const config = getSchoolFilterConfig(schoolFeature.name);
const schoolMeta = getSchoolFilterMeta(features);
const backendFeature = config
? features.find((feature) => feature.name === config.featureName)
: undefined;
const isActive = activeFeature === schoolFeature.name;
const isPinned = pinnedFeature === schoolFeature.name;
const hist = backendFeature?.histogram;
const dataMin = hist?.min ?? backendFeature?.min ?? 0;
const dataMax = hist?.max ?? backendFeature?.max ?? 10;
const displayValue =
isActive && dragValue
? dragValue
: (filters[schoolFeature.name] as [number, number]) || [dataMin, dataMax];
const sliderValue: [number, number] = [
displayValue[0] <= dataMin ? (backendFeature?.min ?? dataMin) : displayValue[0],
displayValue[1] >= dataMax ? (backendFeature?.max ?? dataMax) : displayValue[1],
];
if (!config) return null;
const replaceSchoolFeature = (
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
if (nextName === schoolFeature.name) return;
const nextBackendName = getSchoolBackendFeatureName(nextName);
const nextFeature = nextBackendName
? features.find((feature) => feature.name === nextBackendName)
: undefined;
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampSchoolRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const segmentedClass =
'grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800';
const optionClass = (active: boolean) =>
`px-2 py-1 text-xs font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
active
? 'bg-teal-600 text-white dark:bg-teal-500'
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
}`;
return (
<div
data-filter-name={SCHOOL_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={schoolMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={schoolMeta}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={() => onTogglePin(schoolFeature.name)}
onShowInfo={() => onShowInfo(schoolMeta)}
onRemove={onRemove}
/>
</div>
<div className="space-y-1.5">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
School type
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School type">
<button
type="button"
role="radio"
aria-checked={config.phase === 'primary'}
onClick={() => replaceSchoolFeature({ phase: 'primary' })}
className={optionClass(config.phase === 'primary')}
>
Primary
</button>
<button
type="button"
role="radio"
aria-checked={config.phase === 'secondary'}
onClick={() => replaceSchoolFeature({ phase: 'secondary' })}
className={optionClass(config.phase === 'secondary')}
>
Secondary
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Rating
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School rating">
<button
type="button"
role="radio"
aria-checked={config.rating === 'good'}
onClick={() => replaceSchoolFeature({ rating: 'good' })}
className={optionClass(config.rating === 'good')}
>
Good+
</button>
<button
type="button"
role="radio"
aria-checked={config.rating === 'outstanding'}
onClick={() => replaceSchoolFeature({ rating: 'outstanding' })}
className={optionClass(config.rating === 'outstanding')}
>
Outstanding
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Distance
</div>
<div className={segmentedClass} role="radiogroup" aria-label="School distance">
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
step={backendFeature?.step ?? 1}
value={sliderValue}
onValueChange={([min, max]) =>
onDragChange([
min <= (backendFeature?.min ?? dataMin) ? dataMin : min,
max >= (backendFeature?.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(schoolFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={backendFeature?.min ?? dataMin}
max={backendFeature?.max ?? dataMax}
value={sliderValue}
displayValues={displayValue}
isAtMin={displayValue[0] === dataMin}
isAtMax={displayValue[1] === dataMax}
raw={backendFeature?.raw}
feature={backendFeature}
onValueChange={(v) => onFilterChange(schoolFeature.name, v)}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
);
}
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
@ -214,6 +444,7 @@ interface FiltersProps {
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
destinationDropdownPortal?: boolean;
}
export default memo(function Filters({
@ -255,17 +486,57 @@ export default memo(function Filters({
onClearAll,
onSaveSearch,
savingSearch,
destinationDropdownPortal = true,
}: FiltersProps) {
const { t } = useTranslation();
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name)),
[features, enabledFeatures]
);
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
const schoolFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isSchoolFilterName)
.map((name) => {
const backendName = getSchoolBackendFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
});
}, [filters, features, schoolMeta]);
const availableFeatures = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
if (defaultSchoolFeatureName && !insertedSchoolFilter) {
result.push(schoolMeta);
insertedSchoolFilter = true;
}
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
const enabledFeatureList = useMemo(() => {
const result: FeatureMeta[] = [];
let insertedSchoolFilter = false;
for (const feature of features) {
if (isSchoolFilterName(feature.name)) {
if (!insertedSchoolFilter) {
result.push(...schoolFilterItems);
insertedSchoolFilter = true;
}
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature);
}
return result;
}, [features, enabledFeatures, schoolFilterItems]);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@ -279,10 +550,24 @@ export default memo(function Filters({
const handleAddAndScroll = useCallback(
(name: string) => {
if (name === SCHOOL_FILTER_NAME) {
if (!defaultSchoolFeatureName) return;
pendingScrollRef.current = SCHOOL_FILTER_NAME;
onAddFilter(SCHOOL_FILTER_NAME);
return;
}
pendingScrollRef.current = name;
onAddFilter(name);
},
[onAddFilter]
[defaultSchoolFeatureName, onAddFilter]
);
const handleRemoveSchoolFilter = useCallback(
(name: string) => {
onRemoveFilter(name);
},
[onRemoveFilter]
);
const handleAddTravelTimeAndScroll = useCallback(
@ -455,6 +740,59 @@ export default memo(function Filters({
<div className="px-2 py-1 space-y-1">
{enabledFeatureList.map((feature, featureIdx) => {
if (isSchoolFilterName(feature.name)) {
const schoolBackendName = getSchoolBackendFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<SchoolFilterCard
features={features}
schoolFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
schoolBackendName ? filterImpacts?.[schoolBackendName] : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => handleRemoveSchoolFilter(feature.name)}
/>
</Fragment>
);
}
if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || [];
@ -483,6 +821,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -587,6 +926,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -689,6 +1029,7 @@ export default memo(function Filters({
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
@ -722,10 +1063,20 @@ export default memo(function Filters({
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
pinnedFeature={pinnedFeature}
allFeatures={[...features, schoolMeta]}
pinnedFeature={
pinnedFeature && isSchoolFilterName(pinnedFeature)
? SCHOOL_FILTER_NAME
: pinnedFeature
}
onAddFilter={handleAddAndScroll}
onTogglePin={onTogglePin}
onTogglePin={(name) => {
if (name === SCHOOL_FILTER_NAME) {
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
return;
}
onTogglePin(name);
}}
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
interface HoverCardData {
count: number;
@ -41,14 +42,18 @@ export default memo(function HoverCard({
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
const backendName = getSchoolBackendFeatureName(name) ?? name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
const meta = featureMap.get(backendName);
if (meta?.type === 'enum' && meta.values) {
const label = meta.values[Math.round(val)];
if (label) results.push({ name, value: ts(label) });
if (label) results.push({ name: backendName, value: ts(label) });
} else {
results.push({ name, value: formatValue(val, meta) });
results.push({
name: backendName === name ? name : SCHOOL_FILTER_NAME,
value: formatValue(val, meta),
});
}
}

View file

@ -14,7 +14,12 @@ import type {
Bounds,
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import {
zoomToResolution,
getBoundsFromViewState,
getMapStyle,
getPoiIconUrl,
} from '../../lib/map-utils';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
@ -395,7 +400,14 @@ export default memo(function Map({
) : (
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-lg leading-none">{popupInfo.emoji}</span>
<img
src={getPoiIconUrl(popupInfo.category, popupInfo.emoji)}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
/>
<div>
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">

View file

@ -17,6 +17,7 @@ import POIPane from './POIPane';
import { PropertiesPane } from './PropertiesPane';
import AreaPane from './AreaPane';
import MobileDrawer from './MobileDrawer';
import MobileBottomSheet from './MobileBottomSheet';
import MapLegend from './MapLegend';
import { MapPageSelectionPane } from './MapPageSelectionPane';
import { useMapData } from '../../hooks/useMapData';
@ -41,6 +42,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -116,12 +118,6 @@ export default function MapPage({
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [, mobileResizeHandlers, mobileMapRef] = usePaneResize(
Math.round(window.innerHeight * 0.4),
120,
0.8,
'top'
);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
@ -510,9 +506,15 @@ export default function MapPage({
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
const mobileLegendMeta = useMemo(() => {
const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
: null;
return featureName ? features.find((f) => f.name === featureName) || null : null;
}, [viewFeature, features]);
const mapViewFeature = useMemo(
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
[viewFeature]
);
const mobileDensityRange = useMemo((): [number, number] => {
const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data;
@ -595,7 +597,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={[]}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -663,7 +665,7 @@ export default function MapPage({
/>
);
const renderFilters = () => (
const renderFilters = (options?: { destinationDropdownPortal?: boolean }) => (
<Filters
features={features}
filters={filters}
@ -702,12 +704,71 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
);
const renderMobileLegend = () => {
if (mapViewFeature && mapData.colorRange) {
if (mapViewFeature.startsWith('tt_')) {
return (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
mapViewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
);
}
if (mobileLegendMeta) {
return (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
);
}
return null;
}
return (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
);
};
if (isMobile) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative touch-pan-y">
<div className="flex-1 overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
@ -719,14 +780,14 @@ export default function MapPage({
</div>
)}
<div ref={mobileMapRef} className="relative overflow-hidden">
<div className="absolute inset-0">
<Map
data={mapData.data}
postcodeData={mapData.postcodeData}
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}
@ -748,90 +809,39 @@ export default function MapPage({
hideLegend
travelTimeEntries={entries}
/>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
</div>
<div
className="relative z-10 py-2 -my-2 cursor-row-resize touch-none group"
{...mobileResizeHandlers}
>
<div className="h-3 flex items-center justify-center bg-warm-100 dark:bg-navy-800 group-hover:bg-warm-200 dark:group-hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700">
<div className="flex flex-row gap-1.5">
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
{mapData.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
</div>
)}
<div className="flex-1 min-h-0 bg-white dark:bg-warm-900 overflow-hidden flex flex-col">
{viewFeature && mapData.colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
theme={theme}
inline
suffix=" min"
/>
) : mobileLegendMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(mobileLegendMeta.name) })
: ts(mobileLegendMeta.name)
}
range={mapData.colorRange}
showCancel={viewSource === 'eye'}
onCancel={handleCancelPin}
mode="feature"
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
featureName={mobileLegendMeta.name}
theme={theme}
inline
raw={mobileLegendMeta.raw}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={mobileDensityRange}
showCancel={false}
onCancel={handleCancelPin}
mode="density"
theme={theme}
inline
/>
)}
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
<button
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
aria-label={t('poiPane.pointsOfInterest')}
>
<MapPinIcon className="w-5 h-5" />
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
<MobileBottomSheet
activeCount={Object.keys(filters).length + entries.length}
legend={renderMobileLegend()}
>
{renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet>
{mobileDrawerOpen && selectedHexagon && (
<MobileDrawer
@ -914,7 +924,7 @@ export default function MapPage({
usePostcodeView={mapData.usePostcodeView}
pois={pois}
onViewChange={mapData.handleViewChange}
viewFeature={viewFeature}
viewFeature={mapViewFeature}
colorRange={mapData.colorRange}
filterRange={filterRange}
viewSource={viewSource}

View file

@ -0,0 +1,189 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface VisualViewportState {
height: number;
bottomInset: number;
}
interface MobileBottomSheetProps {
activeCount: number;
children: ReactNode;
legend?: ReactNode;
}
function getVisualViewportState(): VisualViewportState {
const vv = window.visualViewport;
if (!vv) {
return {
height: window.innerHeight,
bottomInset: 0,
};
}
const bottomInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
return {
height: vv.height,
bottomInset,
};
}
function useVisualViewportState(): VisualViewportState {
const [state, setState] = useState(getVisualViewportState);
useEffect(() => {
const vv = window.visualViewport;
const update = () => setState(getVisualViewportState());
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
vv?.addEventListener('resize', update);
vv?.addEventListener('scroll', update);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
vv?.removeEventListener('resize', update);
vv?.removeEventListener('scroll', update);
};
}, []);
return state;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export default function MobileBottomSheet({
activeCount,
children,
legend,
}: MobileBottomSheetProps) {
const { t } = useTranslation();
const viewport = useVisualViewportState();
const sheetRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const dragStartYRef = useRef(0);
const dragStartHeightRef = useRef(0);
const [height, setHeight] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false);
const heightBounds = useMemo(() => {
const available = viewport.height;
return {
min: Math.min(132, Math.max(104, available * 0.22)),
initial: Math.min(available * 0.56, Math.max(330, available * 0.44)),
max: Math.max(300, available - 12),
};
}, [viewport.height]);
const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max);
useEffect(() => {
setHeight((value) =>
value == null ? value : clamp(value, heightBounds.min, heightBounds.max)
);
}, [heightBounds]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragStartYRef.current = e.clientY;
dragStartHeightRef.current = currentHeight;
setIsDragging(true);
},
[currentHeight]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (dragStartHeightRef.current === 0) return;
const nextHeight = dragStartHeightRef.current + dragStartYRef.current - e.clientY;
setHeight(clamp(nextHeight, heightBounds.min, heightBounds.max));
},
[heightBounds]
);
const handlePointerUp = useCallback(() => {
if (dragStartHeightRef.current === 0) return;
dragStartHeightRef.current = 0;
setIsDragging(false);
}, []);
useEffect(() => {
const sheet = sheetRef.current;
if (!sheet) return;
const handleFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (!target.matches('input, textarea, select, [contenteditable="true"]')) return;
const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55));
setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight));
window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 120);
};
sheet.addEventListener('focusin', handleFocusIn);
return () => sheet.removeEventListener('focusin', handleFocusIn);
}, [heightBounds.initial, heightBounds.max, viewport.height]);
const sheetTitle = activeCount === 0 ? t('filters.chooseFilters') : t('filters.activeFilters');
return (
<section
ref={sheetRef}
className="fixed inset-x-0 z-30 flex flex-col rounded-t-2xl bg-white dark:bg-navy-950 shadow-2xl border-t border-warm-200 dark:border-navy-700 overflow-hidden"
style={{
bottom: viewport.bottomInset,
height: currentHeight,
paddingBottom: 'env(safe-area-inset-bottom)',
transition:
isDragging || viewport.bottomInset > 0
? undefined
: 'height 140ms ease, bottom 180ms ease',
}}
aria-label={sheetTitle}
>
<div
className="shrink-0 touch-none px-4 pt-2 pb-1"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div className="w-full flex flex-col items-center gap-2" role="presentation">
<span className="h-1.5 w-12 rounded-full bg-warm-300 dark:bg-navy-600" />
<span className="w-full flex items-center justify-between">
<span className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100 truncate">
{sheetTitle}
</span>
{activeCount > 0 && (
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
{activeCount}
</span>
)}
</span>
</span>
</div>
</div>
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y"
>
{children}
</div>
</section>
);
}

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -186,15 +187,30 @@ export default function POIPane({
{!isCollapsed && (
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => (
<PillToggle
key={category}
label={ts(category)}
active={selectedCategories.has(category)}
onClick={() => toggleCategory(category)}
size="xs"
/>
))}
{group.categories.map((category) => {
const logo = POI_CATEGORY_LOGOS[category];
return (
<PillToggle
key={category}
label={ts(category)}
icon={
logo ? (
<img
src={logo}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-4 w-4 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
/>
) : undefined
}
active={selectedCategories.has(category)}
onClick={() => toggleCategory(category)}
size="xs"
/>
);
})}
</PillGroup>
</div>
)}

View file

@ -316,7 +316,6 @@ function PropertyCard({
</div>
</div>
)}
</div>
);
}

View file

@ -31,7 +31,12 @@ function shortenLabel(name: string): string {
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
() => roundedPercentages(sortedSegments.map((s) => s.value), total, 1),
() =>
roundedPercentages(
sortedSegments.map((s) => s.value),
total,
1
),
[sortedSegments, total]
);

View file

@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { HexagonLocation } from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { CloseIcon, ExpandIcon } from '../ui/icons';
interface StreetViewEmbedProps {
location: HexagonLocation;
@ -13,6 +15,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>('loading');
const [panoId, setPanoId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setStatus('loading');
@ -47,31 +50,107 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
return () => controller.abort();
}, [location.lat, location.lon]);
useEffect(() => {
if (!expanded) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setExpanded(false);
}
};
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [expanded]);
if (status === 'none' || status === 'error') return null;
const panoUrl = panoId
? `https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`
: null;
return (
<div>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
{t('streetView.title')}
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 flex items-center justify-between">
<span>{t('streetView.title')}</span>
{status === 'ok' && panoUrl && (
<button
type="button"
onClick={() => setExpanded(true)}
title={t('streetView.openLarge')}
aria-label={t('streetView.openLarge')}
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
>
<ExpandIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="px-3 py-2">
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
{status === 'loading' ? (
{status === 'loading' || !panoUrl ? (
<div
className="w-full animate-pulse bg-warm-200 dark:bg-warm-700"
style={{ height: 240 }}
/>
) : (
<iframe
title={t('streetView.title')}
className="w-full"
style={{ height: 240, border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://maps.google.com/maps?layer=c&panoid=${panoId}&cbp=11,0,0,0,0&output=svembed`}
src={panoUrl}
/>
)}
</div>
</div>
{expanded &&
panoUrl &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
role="dialog"
aria-modal="true"
aria-label={t('streetView.title')}
>
<div
className="absolute inset-0 bg-black/60 dark:bg-black/75"
aria-hidden="true"
onMouseDown={() => setExpanded(false)}
/>
<div className="relative flex h-[86vh] w-full max-w-7xl flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-2xl dark:border-warm-700 dark:bg-warm-900">
<div className="flex items-center justify-between border-b border-warm-200 px-4 py-3 dark:border-warm-700">
<h2 className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{t('streetView.title')}
</h2>
<button
type="button"
onClick={() => setExpanded(false)}
title={t('common.close')}
aria-label={t('common.close')}
className="rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:text-warm-500 dark:hover:bg-warm-800 dark:hover:text-warm-200"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
<iframe
title={t('streetView.expandedTitle')}
className="min-h-0 flex-1"
style={{ border: 0 }}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={panoUrl}
/>
</div>
</div>,
document.body
)}
</div>
);
}

View file

@ -31,6 +31,7 @@ interface TravelTimeCardProps {
onToggleBest: () => void;
onRemove: () => void;
filterImpact?: number;
destinationDropdownPortal?: boolean;
}
export function TravelTimeCard({
@ -51,6 +52,7 @@ export function TravelTimeCard({
onToggleBest,
onRemove,
filterImpact,
destinationDropdownPortal = true,
}: TravelTimeCardProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
@ -110,6 +112,7 @@ export function TravelTimeCard({
value={label || undefined}
onClear={() => onSetDestination('', '', 0, 0)}
placeholder={t('travel.selectDestination')}
portal={destinationDropdownPortal}
/>
{/* Best-case toggle — transit only, shown when destination is set */}

View file

@ -14,6 +14,7 @@ interface DestinationDropdownProps {
onClear?: () => void;
value?: string;
placeholder?: string;
portal?: boolean;
}
export function DestinationDropdown({
@ -23,6 +24,7 @@ export function DestinationDropdown({
onClear,
value,
placeholder,
portal = true,
}: DestinationDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@ -32,7 +34,7 @@ export function DestinationDropdown({
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pos = useDropdownPosition(containerRef, open);
const pos = useDropdownPosition(containerRef, open && portal);
const filtered = useMemo(() => {
if (!filter) return destinations;
@ -212,7 +214,12 @@ export function DestinationDropdown({
)}
</div>
{open && createPortal(dropdown, document.body)}
{open &&
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
))}
</div>
);
}

View file

@ -72,9 +72,9 @@ export default function MobileMenu({
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-50 flex flex-col shadow-xl">
<div className="fixed top-0 right-0 bottom-0 w-64 bg-navy-900 z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-4 h-12 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button

View file

@ -1,7 +1,10 @@
import type { ReactNode } from 'react';
interface PillToggleProps {
label: string;
active: boolean;
onClick: () => void;
icon?: ReactNode;
/** Visual hint for partial selection (e.g. some children selected) */
indeterminate?: boolean;
size?: 'sm' | 'xs';
@ -11,6 +14,7 @@ export function PillToggle({
label,
active,
onClick,
icon,
indeterminate,
size = 'sm',
}: PillToggleProps) {
@ -26,8 +30,9 @@ export function PillToggle({
<button
type="button"
onClick={onClick}
className={`${sizeClasses} ${colorClasses} rounded-full font-medium whitespace-nowrap cursor-pointer`}
className={`${sizeClasses} ${colorClasses} inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
>
{icon}
{label}
</button>
);

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function ExpandIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h6v6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 3l-7 7" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 21H3v-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21l7-7" />
</svg>
);
}

View file

@ -7,6 +7,7 @@ export { ChevronIcon } from './ChevronIcon';
export { ClipboardIcon } from './ClipboardIcon';
export { CloseIcon } from './CloseIcon';
export { DownloadIcon } from './DownloadIcon';
export { ExpandIcon } from './ExpandIcon';
export { EyeIcon } from './EyeIcon';
export { FilterIcon } from './FilterIcon';
export { GoogleIcon } from './GoogleIcon';