More
This commit is contained in:
parent
cd34ee693f
commit
05a1f316e1
58 changed files with 3113 additions and 1277 deletions
|
|
@ -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]">
|
||||
✓
|
||||
</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">✓</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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal file
189
frontend/src/components/map/MobileBottomSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -316,7 +316,6 @@ function PropertyCard({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal file
20
frontend/src/components/ui/icons/ExpandIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue