Add frontend

This commit is contained in:
Andras Schmelczer 2026-01-25 21:07:48 +00:00
parent ab704c0dc0
commit 77c9a40dbf
17 changed files with 9388 additions and 0 deletions

54
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,54 @@
import React, { useState, useEffect, useCallback } from 'react';
import Map from './components/Map';
import Filters from './components/Filters';
import { DEFAULT_FILTERS } from './lib/constants';
export default function App() {
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [data, setData] = useState([]);
const [resolution, setResolution] = useState(8);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
resolution: resolution.toString(),
min_year: filters.minYear.toString(),
max_year: filters.maxYear.toString(),
min_price: filters.minPrice.toString(),
max_price: filters.maxPrice.toString(),
});
const res = await fetch(`/api/hexagons?${params}`);
const json = await res.json();
setData(
json.features.map((f) => ({
h3: f.properties.h3,
...f.properties,
}))
);
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
}, [filters, resolution]);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div className="h-screen flex">
<Filters filters={filters} onChange={setFilters} />
<div className="flex-1 relative">
<Map data={data} onZoom={setResolution} />
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
Loading...
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import React from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import {
YEAR_MIN,
YEAR_MAX,
YEAR_STEP,
PRICE_MIN,
PRICE_MAX,
PRICE_STEP,
} from '../lib/constants';
export default function Filters({ filters, onChange }) {
const update = (key, value) => onChange({ ...filters, [key]: value });
return (
<div className="w-72 p-4 bg-white shadow-lg space-y-6">
<h1 className="text-xl font-bold">UK Property Prices</h1>
<div className="space-y-2">
<Label>
Year Range: {filters.minYear} - {filters.maxYear}
</Label>
<Slider
min={YEAR_MIN}
max={YEAR_MAX}
step={YEAR_STEP}
value={[filters.minYear, filters.maxYear]}
onValueChange={([min, max]) =>
onChange({ ...filters, minYear: min, maxYear: max })
}
/>
</div>
<div className="space-y-2">
<Label>Min Price: £{filters.minPrice.toLocaleString()}</Label>
<Slider
min={PRICE_MIN}
max={PRICE_MAX}
step={PRICE_STEP}
value={[filters.minPrice]}
onValueChange={([v]) => update('minPrice', v)}
/>
</div>
<div className="space-y-2">
<Label>Max Price: £{filters.maxPrice.toLocaleString()}</Label>
<Slider
min={PRICE_MIN}
max={PRICE_MAX}
step={PRICE_STEP}
value={[filters.maxPrice]}
onValueChange={([v]) => update('maxPrice', v)}
/>
</div>
<div className="mt-6 p-3 bg-slate-100 rounded text-xs space-y-1">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: 'rgb(46, 204, 113)' }}
></span>
<span>{'< £150k'}</span>
</div>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: 'rgb(241, 196, 15)' }}
></span>
<span>£150k - £300k</span>
</div>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: 'rgb(231, 76, 60)' }}
></span>
<span>£300k - £500k</span>
</div>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{ backgroundColor: 'rgb(142, 68, 173)' }}
></span>
<span>{'> £500k'}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import { Map as MapGL } from 'react-map-gl/maplibre';
import DeckGL from '@deck.gl/react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import 'maplibre-gl/dist/maplibre-gl.css';
const INITIAL_VIEW = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
function priceToColor(price) {
if (price < 150000) return [46, 204, 113];
if (price < 300000) return [241, 196, 15];
if (price < 500000) return [231, 76, 60];
return [142, 68, 173];
}
function zoomToResolution(zoom) {
if (zoom < 7) return 6;
if (zoom < 9) return 7;
if (zoom < 11) return 8;
if (zoom < 13) return 9;
if (zoom < 15) return 10;
if (zoom < 17) return 11;
return 12;
}
export default function Map({ data, onZoom }) {
const onViewStateChange = useCallback(
({ viewState }) => {
onZoom(zoomToResolution(viewState.zoom));
},
[onZoom]
);
const layers = [
new H3HexagonLayer({
id: 'h3-hexagons',
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => priceToColor(d.avg_price),
extruded: false,
pickable: true,
opacity: 0.7,
}),
];
return (
<div className="flex-1">
<DeckGL
initialViewState={INITIAL_VIEW}
controller
layers={layers}
onViewStateChange={onViewStateChange}
>
<MapGL mapStyle={MAP_STYLE} />
</DeckGL>
</div>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
export function Label({ children, className }) {
return (
<label className={`text-sm font-medium text-slate-700 ${className || ''}`}>
{children}
</label>
);
}

View file

@ -0,0 +1,25 @@
import React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '../../lib/utils';
export function Slider({ className, ...props }) {
return (
<SliderPrimitive.Root
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-200">
<SliderPrimitive.Range className="absolute h-full bg-slate-900" />
</SliderPrimitive.Track>
{props.value?.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className="block h-5 w-5 rounded-full border-2 border-slate-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}

11
frontend/src/index.css Normal file
View file

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}

11
frontend/src/index.html Normal file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UK Property Prices Map</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

7
frontend/src/index.jsx Normal file
View file

@ -0,0 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

View file

@ -0,0 +1,16 @@
// Filter configuration constants
export const YEAR_MIN = 1995;
export const YEAR_MAX = 2024;
export const YEAR_STEP = 1;
export const PRICE_MIN = 0;
export const PRICE_MAX = 2000000;
export const PRICE_STEP = 50000;
export const DEFAULT_FILTERS = {
minYear: 2020,
maxYear: YEAR_MAX,
minPrice: PRICE_MIN,
maxPrice: PRICE_MAX,
};

View file

@ -0,0 +1,4 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs) => twMerge(clsx(inputs));