changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -1,19 +1,23 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { zoomToResolution } from '../../lib/map-utils';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
const DEMO_FEATURE_NAMES = [
|
||||
'Estimated current price',
|
||||
'Good+ primary schools within 5km',
|
||||
'Number of restaurants within 2km',
|
||||
];
|
||||
const DEMO_BOUNDS = '49,-9.5,57,5';
|
||||
const DEMO_RESOLUTION = 5;
|
||||
const noop = () => {};
|
||||
const noop = () => { };
|
||||
|
||||
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
|
||||
// 0 = feature.min, 1 = feature.max
|
||||
|
|
@ -148,6 +152,9 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const abortRef = useRef<AbortController>();
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const demoFeatures = useMemo(
|
||||
() =>
|
||||
|
|
@ -188,12 +195,82 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, [demoFeatures.length]);
|
||||
|
||||
// Fetch hex data when filters change
|
||||
// Track scroll progress through the section for zoom interpolation
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
let scrollParent: HTMLElement | null = section.parentElement;
|
||||
while (scrollParent) {
|
||||
const { overflow, overflowY } = getComputedStyle(scrollParent);
|
||||
if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
if (!scrollParent) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const totalTravel = rect.height - viewportHeight;
|
||||
if (totalTravel <= 0) return;
|
||||
const scrolled = -rect.top;
|
||||
const progress = Math.max(0, Math.min(1, scrolled / totalTravel));
|
||||
setScrollProgress(progress);
|
||||
});
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const demoView = useMemo(() => {
|
||||
const t = easeOutCubic(scrollProgress);
|
||||
return {
|
||||
longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t,
|
||||
latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t,
|
||||
zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t,
|
||||
pitch: 0,
|
||||
};
|
||||
}, [scrollProgress]);
|
||||
|
||||
// Derive H3 resolution from current zoom (discrete — only changes at thresholds)
|
||||
const resolution = zoomToResolution(demoView.zoom);
|
||||
|
||||
// Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick
|
||||
const demoBounds = useMemo(() => {
|
||||
const { longitude, latitude, zoom } = demoView;
|
||||
const scale = Math.pow(2, zoom);
|
||||
const degreesPerPixelLng = 360 / (512 * scale);
|
||||
const halfW = (1200 / 2) * degreesPerPixelLng * 1.3;
|
||||
const latRad = (latitude * Math.PI) / 180;
|
||||
const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const worldSize = 512 * scale;
|
||||
const halfH = (800 / 2) * 1.3;
|
||||
const topY = mercY * worldSize - halfH;
|
||||
const botY = mercY * worldSize + halfH;
|
||||
const toLat = (py: number) => {
|
||||
const my = Math.max(0.001, Math.min(0.999, py / worldSize));
|
||||
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI;
|
||||
};
|
||||
const snap = (v: number) => Math.round(v * 2) / 2;
|
||||
const south = snap(Math.max(-85, toLat(botY)));
|
||||
const west = snap(Math.max(-180, longitude - halfW));
|
||||
const north = snap(Math.min(85, toLat(topY)));
|
||||
const east = snap(Math.min(180, longitude + halfW));
|
||||
return `${south},${west},${north},${east}`;
|
||||
}, [demoView]);
|
||||
|
||||
// Fetch hex data when resolution, filters, or bounds change
|
||||
useEffect(() => {
|
||||
if (features.length === 0) return;
|
||||
const params = new URLSearchParams({
|
||||
resolution: String(DEMO_RESOLUTION),
|
||||
bounds: DEMO_BOUNDS,
|
||||
resolution: String(resolution),
|
||||
bounds: demoBounds,
|
||||
});
|
||||
const filterParts: string[] = [];
|
||||
for (const [name, [min, max]] of Object.entries(stageFilters)) {
|
||||
|
|
@ -219,7 +296,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
|
||||
}, 300);
|
||||
return () => clearTimeout(fetchTimeoutRef.current);
|
||||
}, [features, stageFilters, stage]);
|
||||
}, [features, stageFilters, stage, resolution, demoBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -234,13 +311,16 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const colorRange: [number, number] | null =
|
||||
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
|
||||
|
||||
// Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background
|
||||
const deferredHexData = useDeferredValue(hexData);
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
<section ref={sectionRef} className="relative">
|
||||
{/* Sticky map background */}
|
||||
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
|
||||
<div className="absolute inset-0">
|
||||
<MapComponent
|
||||
data={hexData}
|
||||
data={deferredHexData}
|
||||
postcodeData={[]}
|
||||
usePostcodeView={false}
|
||||
pois={[]}
|
||||
|
|
@ -255,7 +335,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
hoveredHexagonId={null}
|
||||
onHexagonClick={noop}
|
||||
onHexagonHover={noop}
|
||||
initialViewState={DEMO_VIEW}
|
||||
initialViewState={demoView}
|
||||
theme={theme}
|
||||
screenshotMode={true}
|
||||
hideLegend={true}
|
||||
|
|
@ -272,9 +352,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter indicators */}
|
||||
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
|
||||
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
|
||||
{/* Filter indicators — left sidebar */}
|
||||
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center">
|
||||
<div className="bg-white/85 dark:bg-warm-800/85 rounded-r-xl p-5 md:p-6 backdrop-blur-sm shadow-lg space-y-5 w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1">
|
||||
Filters
|
||||
</div>
|
||||
{demoFeatures.map((feature) => {
|
||||
const filterVal = stageFilters[feature.name];
|
||||
const isActive = !!filterVal;
|
||||
|
|
@ -288,20 +371,20 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
key={feature.name}
|
||||
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
|
||||
>
|
||||
<div className="flex justify-between items-baseline text-[11px] mb-1 gap-2">
|
||||
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2">
|
||||
<span
|
||||
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
{isActive && filterVal && (
|
||||
<span className="text-teal-600 dark:text-teal-400 font-medium whitespace-nowrap">
|
||||
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
|
||||
{formatValue(filterVal[0], feature)}–
|
||||
{formatValue(filterVal[1], feature)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-1.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
||||
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue