Spice up website

This commit is contained in:
Andras Schmelczer 2026-01-31 22:04:28 +00:00
parent f7d586a1e9
commit 7627818e98
9 changed files with 831 additions and 164 deletions

View file

@ -0,0 +1,362 @@
import { useRef, useState, useEffect, useCallback } from 'react';
// --- Floating hex particle canvas that reacts to scroll ---
const HEX_COUNT = 60;
const TAU = Math.PI * 2;
interface Hex {
x: number;
y: number;
baseY: number;
size: number;
opacity: number;
speed: number; // horizontal drift px/s
phase: number; // for gentle bob
}
function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
hexes.push({
x: Math.random() * w,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.06 + Math.random() * 0.12,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
});
}
return hexes;
}
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (TAU / 6) * i - Math.PI / 6;
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
function HexCanvas({ scrollProgress }: { scrollProgress: number }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const scrollRef = useRef(scrollProgress);
scrollRef.current = scrollProgress;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = 0;
let h = 0;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas!.parentElement!.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = `${w}px`;
canvas!.style.height = `${h}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
hexesRef.current = initHexes(w, h);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas.parentElement!);
let prev = performance.now();
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
const scroll = scrollRef.current;
ctx!.clearRect(0, 0, w, h);
// Teal accent color, fade to 0 as user scrolls down
const globalAlpha = Math.max(0, 1 - scroll * 2);
for (const hex of hexesRef.current) {
// drift right, wrap
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
// gentle vertical bob + parallax push from scroll
const bob = Math.sin(now / 1000 + hex.phase) * 8;
const parallax = scroll * h * 0.3 * (hex.speed / 20);
hex.y = hex.baseY + bob - parallax;
// wrap vertically
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
ctx!.globalAlpha = hex.opacity * globalAlpha;
ctx!.fillStyle = '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha;
ctx!.strokeStyle = '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.stroke();
}
animRef.current = requestAnimationFrame(frame);
}
animRef.current = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(animRef.current);
ro.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 pointer-events-none"
style={{ zIndex: 0 }}
/>
);
}
// --- Fade-in hook ---
function useFadeInRef() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('fade-in-visible');
observer.unobserve(el);
}
},
{ threshold: 0.15 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ref;
}
// --- Page ---
export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => void }) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
setScrollProgress(el.scrollTop / max);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const heroRef = useFadeInRef();
const problemRef = useFadeInRef();
const filtersRef = useFadeInRef();
const howRef = useFadeInRef();
const numbersRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto bg-warm-50 relative"
>
<HexCanvas scrollProgress={scrollProgress} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div ref={heroRef} className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2">
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
</p>
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood<br />
in England &amp; Wales.<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1>
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime
threshold you&apos;ll accept. Narrowit shows you every area that
qualifies &mdash; instantly.
</p>
<div className="flex items-center gap-4">
<button
onClick={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"
>
Explore the map
</button>
<span className="text-warm-400 text-sm">
No signup &middot; Free &middot; Open data
</span>
</div>
</div>
</div>
{/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">The old way</h3>
<p className="text-warm-700 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on
another site. Look up commute times. Realise it&apos;s too
expensive. Start over. Repeat 40 times.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">With Narrowit</h3>
<p className="text-warm-700 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is
a place worth looking at. Drill into any one to see
individual properties, prices, and energy ratings.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Filter showcase */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={filtersRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 mb-2 text-center">
12 datasets. One slider&nbsp;each.
</h2>
<p className="text-warm-500 text-center mb-10 max-w-lg mx-auto">
Every filter narrows the map in real time. Combine as many as you like.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{FILTERS.map((f) => (
<div
key={f.label}
className="rounded-xl bg-white border border-warm-200 p-4 shadow-sm hover:shadow-md hover:border-teal-300 transition-all"
>
<div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 text-sm">{f.label}</div>
<div className="text-xs text-warm-500 mt-0.5">{f.example}</div>
</div>
))}
</div>
</div>
</div>
{/* How it works */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 mb-10 text-center">
Three clicks to clarity
</h2>
<div className="space-y-6">
{STEPS.map((step, i) => (
<div key={i} className="flex gap-5 items-start">
<span className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center text-lg font-bold">
{i + 1}
</span>
<div>
<h3 className="font-semibold text-navy-950 text-lg">{step.title}</h3>
<p className="text-warm-600 mt-0.5">{step.body}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Numbers */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={numbersRef} className="fade-in-section">
<div className="grid grid-cols-3 gap-6 text-center">
{STATS.map((s) => (
<div key={s.label}>
<div className="text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-sm text-warm-500 mt-1">{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 mb-3">
Ready to narrow it down?
</h2>
<p className="text-warm-500 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
</p>
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Open the map
</button>
</div>
</div>
</div>
</div>
);
}
// --- Data ---
const FILTERS = [
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
];
const STEPS = [
{
title: 'Add your deal-breakers',
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
},
{
title: 'Spot the clusters',
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
},
{
title: 'Dive into a neighbourhood',
body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
},
];
const STATS = [
{ value: '26M+', label: 'property records' },
{ value: '12', label: 'open datasets' },
{ value: '1.7M', label: 'postcodes mapped' },
];