Add frontend
This commit is contained in:
parent
ab704c0dc0
commit
77c9a40dbf
17 changed files with 9388 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue