Add frontend
This commit is contained in:
parent
ab704c0dc0
commit
77c9a40dbf
17 changed files with 9388 additions and 0 deletions
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files.exclude": {
|
||||||
|
"*.venv": true,
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/node_modules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/.babelrc
Normal file
3
frontend/.babelrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"presets": ["@babel/preset-react"]
|
||||||
|
}
|
||||||
8994
frontend/package-lock.json
generated
Normal file
8994
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "property-map-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "webpack serve --mode development --port 3030",
|
||||||
|
"build": "webpack --mode production"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"@deck.gl/core": "^9.0.0",
|
||||||
|
"@deck.gl/layers": "^9.0.0",
|
||||||
|
"@deck.gl/geo-layers": "^9.0.0",
|
||||||
|
"@deck.gl/react": "^9.0.0",
|
||||||
|
"maplibre-gl": "^4.0.0",
|
||||||
|
"react-map-gl": "^7.1.0",
|
||||||
|
"@radix-ui/react-slider": "^1.1.0",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"webpack": "^5.90.0",
|
||||||
|
"webpack-cli": "^5.1.0",
|
||||||
|
"webpack-dev-server": "^5.0.0",
|
||||||
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"css-loader": "^7.0.0",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"postcss-loader": "^8.0.0",
|
||||||
|
"babel-loader": "^9.1.0",
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@babel/preset-react": "^7.24.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
54
frontend/src/App.jsx
Normal file
54
frontend/src/App.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/components/Filters.jsx
Normal file
89
frontend/src/components/Filters.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/components/Map.jsx
Normal file
65
frontend/src/components/Map.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/ui/label.jsx
Normal file
9
frontend/src/components/ui/label.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/ui/slider.jsx
Normal file
25
frontend/src/components/ui/slider.jsx
Normal 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
11
frontend/src/index.css
Normal 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
11
frontend/src/index.html
Normal 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
7
frontend/src/index.jsx
Normal 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 />);
|
||||||
16
frontend/src/lib/constants.js
Normal file
16
frontend/src/lib/constants.js
Normal 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,
|
||||||
|
};
|
||||||
4
frontend/src/lib/utils.js
Normal file
4
frontend/src/lib/utils.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs) => twMerge(clsx(inputs));
|
||||||
7
frontend/tailwind.config.js
Normal file
7
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
content: ['./src/**/*.{js,jsx,html}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
41
frontend/webpack.config.js
Normal file
41
frontend/webpack.config.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const path = require('path');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.jsx',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'bundle.js',
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.js', '.jsx'],
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.jsx?$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: 'babel-loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ['style-loader', 'css-loader', 'postcss-loader'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: './src/index.html',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
devServer: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: [
|
||||||
|
{
|
||||||
|
context: ['/api'],
|
||||||
|
target: 'http://localhost:8001',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue