Rewrite in TS
This commit is contained in:
parent
8c1f6a82e2
commit
bfcf26e425
19 changed files with 3229 additions and 632 deletions
31
frontend/.eslintrc.json
Normal file
31
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["react", "react-hooks", "@typescript-eslint"],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/.prettierrc
Normal file
7
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
3492
frontend/package-lock.json
generated
3492
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,12 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack serve --mode development --port 3030",
|
"dev": "webpack serve --mode development --port 3030",
|
||||||
"build": "webpack --mode production"
|
"build": "webpack --mode production",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
@ -29,11 +34,18 @@
|
||||||
"css-loader": "^7.0.0",
|
"css-loader": "^7.0.0",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"postcss-loader": "^8.0.0",
|
"postcss-loader": "^8.0.0",
|
||||||
"babel-loader": "^9.1.0",
|
"ts-loader": "^9.5.0",
|
||||||
"@babel/core": "^7.24.0",
|
"typescript": "^5.4.0",
|
||||||
"@babel/preset-react": "^7.24.0",
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0"
|
"postcss": "^8.4.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"eslint-plugin-react": "^7.34.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"prettier": "^3.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import Map from './components/Map';
|
import Map from './components/Map';
|
||||||
import Filters from './components/Filters';
|
import Filters from './components/Filters';
|
||||||
import { DEFAULT_FILTERS } from './lib/constants';
|
import { DEFAULT_FILTERS } from './lib/constants';
|
||||||
|
import type {
|
||||||
|
Filters as FiltersType,
|
||||||
|
Bounds,
|
||||||
|
HexagonData,
|
||||||
|
ViewChangeParams,
|
||||||
|
ApiResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
const [filters, setFilters] = useState<FiltersType>(DEFAULT_FILTERS);
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState<HexagonData[]>([]);
|
||||||
const [resolution, setResolution] = useState(8);
|
const [resolution, setResolution] = useState<number>(8);
|
||||||
const [bounds, setBounds] = useState(null);
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const debounceRef = useRef(null);
|
const [zoom, setZoom] = useState<number>(6);
|
||||||
const abortControllerRef = useRef(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Debounced fetch when dependencies change
|
// Debounced fetch when dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -44,10 +52,10 @@ export default function App() {
|
||||||
const res = await fetch(`/api/hexagons?${params}`, {
|
const res = await fetch(`/api/hexagons?${params}`, {
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json: ApiResponse = await res.json();
|
||||||
setData(json.features || []);
|
setData(json.features || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
console.error('Failed to fetch data:', err);
|
console.error('Failed to fetch data:', err);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -62,20 +70,22 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, [filters, resolution, bounds]);
|
}, [filters, resolution, bounds]);
|
||||||
|
|
||||||
const handleViewChange = useCallback(({ resolution: newRes, bounds: newBounds }) => {
|
const handleViewChange = useCallback(
|
||||||
|
({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => {
|
||||||
setResolution(newRes);
|
setResolution(newRes);
|
||||||
setBounds(newBounds);
|
setBounds(newBounds);
|
||||||
}, []);
|
setZoom(newZoom);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
<Filters filters={filters} onChange={setFilters} />
|
<Filters filters={filters} onChange={setFilters} zoom={zoom} />
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Map data={data} onViewChange={handleViewChange} />
|
<Map data={data} onViewChange={handleViewChange} />
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
|
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
import React from 'react';
|
|
||||||
import { Slider } from './ui/slider';
|
import { Slider } from './ui/slider';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import {
|
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants';
|
||||||
YEAR_MIN,
|
import type { Filters as FiltersType } from '../types';
|
||||||
YEAR_MAX,
|
|
||||||
YEAR_STEP,
|
|
||||||
PRICE_MIN,
|
|
||||||
PRICE_MAX,
|
|
||||||
PRICE_STEP,
|
|
||||||
} from '../lib/constants';
|
|
||||||
|
|
||||||
export default function Filters({ filters, onChange }) {
|
interface FiltersProps {
|
||||||
const update = (key, value) => onChange({ ...filters, [key]: value });
|
filters: FiltersType;
|
||||||
|
onChange: (filters: FiltersType) => void;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Filters({ filters, onChange, zoom }: FiltersProps) {
|
||||||
|
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-6">
|
<div className="w-72 p-4 bg-white shadow-lg space-y-6">
|
||||||
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
||||||
|
|
||||||
|
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
Year Range: {filters.minYear} - {filters.maxYear}
|
Year Range: {filters.minYear} - {filters.maxYear}
|
||||||
|
|
@ -26,9 +27,7 @@ export default function Filters({ filters, onChange }) {
|
||||||
max={YEAR_MAX}
|
max={YEAR_MAX}
|
||||||
step={YEAR_STEP}
|
step={YEAR_STEP}
|
||||||
value={[filters.minYear, filters.maxYear]}
|
value={[filters.minYear, filters.maxYear]}
|
||||||
onValueChange={([min, max]) =>
|
onValueChange={([min, max]) => onChange({ ...filters, minYear: min, maxYear: max })}
|
||||||
onChange({ ...filters, minYear: min, maxYear: max })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -59,7 +58,8 @@ export default function Filters({ filters, onChange }) {
|
||||||
<div
|
<div
|
||||||
className="h-4 rounded"
|
className="h-4 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
background:
|
||||||
|
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
<div className="flex justify-between mt-1">
|
<div className="flex justify-between mt-1">
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import React, { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||||
import { Map as MapGL } from 'react-map-gl/maplibre';
|
import { Map as MapGL } from 'react-map-gl/maplibre';
|
||||||
import DeckGL from '@deck.gl/react';
|
import DeckGL from '@deck.gl/react';
|
||||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import type { HexagonData, ViewState, ViewChangeParams, Bounds } from '../types';
|
||||||
|
|
||||||
const INITIAL_VIEW = {
|
interface MapProps {
|
||||||
|
data: HexagonData[];
|
||||||
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_VIEW: ViewState = {
|
||||||
longitude: -1.5,
|
longitude: -1.5,
|
||||||
latitude: 53.5,
|
latitude: 53.5,
|
||||||
zoom: 6,
|
zoom: 6,
|
||||||
|
|
@ -13,15 +19,24 @@ const INITIAL_VIEW = {
|
||||||
|
|
||||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
||||||
|
|
||||||
|
interface ColorStop {
|
||||||
|
price: number;
|
||||||
|
color: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
// Continuous color scale from green (low) -> yellow -> red -> purple (high)
|
// Continuous color scale from green (low) -> yellow -> red -> purple (high)
|
||||||
const COLOR_SCALE = [
|
const COLOR_SCALE: ColorStop[] = [
|
||||||
{ price: 0, color: [46, 204, 113] }, // Green
|
{ price: 0, color: [46, 204, 113] }, // Green
|
||||||
{ price: 200000, color: [241, 196, 15] }, // Yellow
|
{ price: 200000, color: [241, 196, 15] }, // Yellow
|
||||||
{ price: 400000, color: [231, 76, 60] }, // Red
|
{ price: 400000, color: [231, 76, 60] }, // Red
|
||||||
{ price: 800000, color: [142, 68, 173] }, // Purple
|
{ price: 800000, color: [142, 68, 173] }, // Purple
|
||||||
];
|
];
|
||||||
|
|
||||||
function interpolateColor(c1, c2, t) {
|
function interpolateColor(
|
||||||
|
c1: [number, number, number],
|
||||||
|
c2: [number, number, number],
|
||||||
|
t: number
|
||||||
|
): [number, number, number] {
|
||||||
return [
|
return [
|
||||||
Math.round(c1[0] + (c2[0] - c1[0]) * t),
|
Math.round(c1[0] + (c2[0] - c1[0]) * t),
|
||||||
Math.round(c1[1] + (c2[1] - c1[1]) * t),
|
Math.round(c1[1] + (c2[1] - c1[1]) * t),
|
||||||
|
|
@ -29,7 +44,7 @@ function interpolateColor(c1, c2, t) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceToColor(price) {
|
function priceToColor(price: number | null | undefined): [number, number, number] {
|
||||||
if (price == null || isNaN(price)) return [128, 128, 128]; // Gray for missing data
|
if (price == null || isNaN(price)) return [128, 128, 128]; // Gray for missing data
|
||||||
|
|
||||||
// Clamp to scale range
|
// Clamp to scale range
|
||||||
|
|
@ -51,16 +66,16 @@ function priceToColor(price) {
|
||||||
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
|
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
|
||||||
}
|
}
|
||||||
|
|
||||||
function zoomToResolution(zoom) {
|
function zoomToResolution(zoom: number): number {
|
||||||
if (zoom < 8) return 6;
|
if (zoom < 7) return 6;
|
||||||
if (zoom < 9) return 7;
|
if (zoom < 8.5) return 7;
|
||||||
if (zoom < 11) return 8;
|
if (zoom < 9.5) return 8;
|
||||||
if (zoom < 14) return 9;
|
if (zoom < 11) return 9;
|
||||||
if (zoom < 16) return 10;
|
if (zoom < 13) return 10;
|
||||||
return 11;
|
return 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBoundsFromViewState(viewState, width, height) {
|
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
|
||||||
const { longitude, latitude, zoom } = viewState;
|
const { longitude, latitude, zoom } = viewState;
|
||||||
|
|
||||||
// Clamp latitude to valid Mercator range to avoid math errors
|
// Clamp latitude to valid Mercator range to avoid math errors
|
||||||
|
|
@ -77,7 +92,7 @@ function getBoundsFromViewState(viewState, width, height) {
|
||||||
|
|
||||||
// Latitude uses Mercator projection (non-linear)
|
// Latitude uses Mercator projection (non-linear)
|
||||||
// Convert center lat to pixel y, offset by half height, convert back to lat
|
// Convert center lat to pixel y, offset by half height, convert back to lat
|
||||||
const latRad = clampedLat * Math.PI / 180;
|
const latRad = (clampedLat * Math.PI) / 180;
|
||||||
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||||
const centerPixelY = mercatorY * worldSize;
|
const centerPixelY = mercatorY * worldSize;
|
||||||
|
|
||||||
|
|
@ -85,10 +100,10 @@ function getBoundsFromViewState(viewState, width, height) {
|
||||||
const bottomPixelY = centerPixelY + height / 2;
|
const bottomPixelY = centerPixelY + height / 2;
|
||||||
|
|
||||||
// Convert pixel Y back to latitude
|
// Convert pixel Y back to latitude
|
||||||
const pixelYToLat = (pixelY) => {
|
const pixelYToLat = (pixelY: number): number => {
|
||||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); // Clamp to avoid edge cases
|
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); // Clamp to avoid edge cases
|
||||||
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
||||||
return latRadians * 180 / Math.PI;
|
return (latRadians * 180) / Math.PI;
|
||||||
};
|
};
|
||||||
|
|
||||||
const north = Math.min(85, pixelYToLat(topPixelY));
|
const north = Math.min(85, pixelYToLat(topPixelY));
|
||||||
|
|
@ -99,10 +114,15 @@ function getBoundsFromViewState(viewState, width, height) {
|
||||||
return { south, west, north, east };
|
return { south, west, north, east };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Map({ data, onViewChange }) {
|
interface Dimensions {
|
||||||
const containerRef = useRef(null);
|
width: number;
|
||||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
height: number;
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
}
|
||||||
|
|
||||||
|
export default function Map({ data, onViewChange }: MapProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||||
|
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||||
|
|
||||||
// Track container dimensions with ResizeObserver
|
// Track container dimensions with ResizeObserver
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -127,15 +147,17 @@ export default function Map({ data, onViewChange }) {
|
||||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||||
const resolution = zoomToResolution(viewState.zoom);
|
const resolution = zoomToResolution(viewState.zoom);
|
||||||
|
|
||||||
onViewChange({ resolution, bounds });
|
onViewChange({ resolution, bounds, zoom: viewState.zoom });
|
||||||
}, [viewState, dimensions, onViewChange]);
|
}, [viewState, dimensions, onViewChange]);
|
||||||
|
|
||||||
const handleViewStateChange = useCallback(({ viewState: newViewState }) => {
|
const handleViewStateChange = useCallback((params: { viewState: unknown }) => {
|
||||||
|
const newViewState = params.viewState as ViewState;
|
||||||
setViewState(newViewState);
|
setViewState(newViewState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const layers = useMemo(() => [
|
const layers = useMemo(
|
||||||
new H3HexagonLayer({
|
() => [
|
||||||
|
new H3HexagonLayer<HexagonData>({
|
||||||
id: 'h3-hexagons',
|
id: 'h3-hexagons',
|
||||||
data,
|
data,
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
|
|
@ -144,7 +166,9 @@ export default function Map({ data, onViewChange }) {
|
||||||
pickable: true,
|
pickable: true,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}),
|
}),
|
||||||
], [data]);
|
],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 h-full" ref={containerRef}>
|
<div className="flex-1 h-full" ref={containerRef}>
|
||||||
|
|
@ -152,7 +176,7 @@ export default function Map({ data, onViewChange }) {
|
||||||
viewState={viewState}
|
viewState={viewState}
|
||||||
controller
|
controller
|
||||||
layers={layers}
|
layers={layers}
|
||||||
onViewStateChange={handleViewStateChange}
|
onViewStateChange={handleViewStateChange as never}
|
||||||
>
|
>
|
||||||
<MapGL mapStyle={MAP_STYLE} />
|
<MapGL mapStyle={MAP_STYLE} />
|
||||||
</DeckGL>
|
</DeckGL>
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function Label({ children, className }) {
|
|
||||||
return (
|
|
||||||
<label className={`text-sm font-medium text-slate-700 ${className || ''}`}>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
12
frontend/src/components/ui/label.tsx
Normal file
12
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface LabelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ children, className }: LabelProps) {
|
||||||
|
return (
|
||||||
|
<label className={`text-sm font-medium text-slate-700 ${className || ''}`}>{children}</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import React from 'react';
|
|
||||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
export function Slider({ className, ...props }) {
|
interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Slider({ className, ...props }: SliderProps) {
|
||||||
return (
|
return (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
className={cn(
|
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||||
'relative flex w-full touch-none select-none items-center',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-200">
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-200">
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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 />);
|
|
||||||
10
frontend/src/index.tsx
Normal file
10
frontend/src/index.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<App />);
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { Filters } from '../types';
|
||||||
|
|
||||||
// Filter configuration constants
|
// Filter configuration constants
|
||||||
// Should match backend pipeline/config.py
|
// Should match backend pipeline/config.py
|
||||||
|
|
||||||
|
|
@ -9,7 +11,7 @@ export const PRICE_MIN = 0;
|
||||||
export const PRICE_MAX = 5000000; // £5M max for slider, but no server-side cap
|
export const PRICE_MAX = 5000000; // £5M max for slider, but no server-side cap
|
||||||
export const PRICE_STEP = 50000;
|
export const PRICE_STEP = 50000;
|
||||||
|
|
||||||
export const DEFAULT_FILTERS = {
|
export const DEFAULT_FILTERS: Filters = {
|
||||||
minYear: 2020,
|
minYear: 2020,
|
||||||
maxYear: YEAR_MAX,
|
maxYear: YEAR_MAX,
|
||||||
minPrice: PRICE_MIN,
|
minPrice: PRICE_MIN,
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export const cn = (...inputs) => twMerge(clsx(inputs));
|
|
||||||
4
frontend/src/lib/utils.ts
Normal file
4
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
|
||||||
41
frontend/src/types.ts
Normal file
41
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface Filters {
|
||||||
|
minYear: number;
|
||||||
|
maxYear: number;
|
||||||
|
minPrice: number;
|
||||||
|
maxPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
south: number;
|
||||||
|
west: number;
|
||||||
|
north: number;
|
||||||
|
east: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HexagonData {
|
||||||
|
h3: string;
|
||||||
|
count: number;
|
||||||
|
avg_price: number;
|
||||||
|
median_price: number;
|
||||||
|
min_price: number;
|
||||||
|
max_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewState {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
zoom: number;
|
||||||
|
pitch: number;
|
||||||
|
bearing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewChangeParams {
|
||||||
|
resolution: number;
|
||||||
|
bounds: Bounds;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse {
|
||||||
|
features: HexagonData[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./src/**/*.{js,jsx,html}'],
|
content: ['./src/**/*.{js,jsx,ts,tsx,html}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,21 @@ const path = require('path');
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.jsx',
|
entry: './src/index.tsx',
|
||||||
output: {
|
output: {
|
||||||
path: path.resolve(__dirname, 'dist'),
|
path: path.resolve(__dirname, 'dist'),
|
||||||
filename: 'bundle.js',
|
filename: 'bundle.js',
|
||||||
clean: true,
|
clean: true,
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.js', '.jsx'],
|
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.jsx?$/,
|
test: /\.tsx?$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
use: 'babel-loader',
|
use: 'ts-loader',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue