Rewrite in TS

This commit is contained in:
Andras Schmelczer 2026-01-25 21:54:22 +00:00
parent 8c1f6a82e2
commit bfcf26e425
19 changed files with 3229 additions and 632 deletions

31
frontend/.eslintrc.json Normal file
View 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
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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">

View file

@ -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
View 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 />);

View file

@ -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,

View file

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

View 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
View 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;
}

View file

@ -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
View 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"]
}

View file

@ -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$/,