Rewrite in TS
This commit is contained in:
parent
8c1f6a82e2
commit
bfcf26e425
19 changed files with 3229 additions and 632 deletions
|
|
@ -1,22 +1,23 @@
|
|||
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';
|
||||
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants';
|
||||
import type { Filters as FiltersType } from '../types';
|
||||
|
||||
export default function Filters({ filters, onChange }) {
|
||||
const update = (key, value) => onChange({ ...filters, [key]: value });
|
||||
interface FiltersProps {
|
||||
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 (
|
||||
<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="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Year Range: {filters.minYear} - {filters.maxYear}
|
||||
|
|
@ -26,9 +27,7 @@ export default function Filters({ filters, onChange }) {
|
|||
max={YEAR_MAX}
|
||||
step={YEAR_STEP}
|
||||
value={[filters.minYear, filters.maxYear]}
|
||||
onValueChange={([min, max]) =>
|
||||
onChange({ ...filters, minYear: min, maxYear: max })
|
||||
}
|
||||
onValueChange={([min, max]) => onChange({ ...filters, minYear: min, maxYear: max })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -59,7 +58,8 @@ export default function Filters({ filters, onChange }) {
|
|||
<div
|
||||
className="h-4 rounded"
|
||||
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 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 DeckGL from '@deck.gl/react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
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,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
|
|
@ -13,15 +19,24 @@ const INITIAL_VIEW = {
|
|||
|
||||
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)
|
||||
const COLOR_SCALE = [
|
||||
{ price: 0, color: [46, 204, 113] }, // Green
|
||||
{ price: 200000, color: [241, 196, 15] }, // Yellow
|
||||
{ price: 400000, color: [231, 76, 60] }, // Red
|
||||
{ price: 800000, color: [142, 68, 173] }, // Purple
|
||||
const COLOR_SCALE: ColorStop[] = [
|
||||
{ price: 0, color: [46, 204, 113] }, // Green
|
||||
{ price: 200000, color: [241, 196, 15] }, // Yellow
|
||||
{ price: 400000, color: [231, 76, 60] }, // Red
|
||||
{ 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 [
|
||||
Math.round(c1[0] + (c2[0] - c1[0]) * 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
|
||||
|
||||
// Clamp to scale range
|
||||
|
|
@ -51,16 +66,16 @@ function priceToColor(price) {
|
|||
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
|
||||
}
|
||||
|
||||
function zoomToResolution(zoom) {
|
||||
if (zoom < 8) return 6;
|
||||
if (zoom < 9) return 7;
|
||||
if (zoom < 11) return 8;
|
||||
if (zoom < 14) return 9;
|
||||
if (zoom < 16) return 10;
|
||||
function zoomToResolution(zoom: number): number {
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 8.5) return 7;
|
||||
if (zoom < 9.5) return 8;
|
||||
if (zoom < 11) return 9;
|
||||
if (zoom < 13) return 10;
|
||||
return 11;
|
||||
}
|
||||
|
||||
function getBoundsFromViewState(viewState, width, height) {
|
||||
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
|
||||
// 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)
|
||||
// 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 centerPixelY = mercatorY * worldSize;
|
||||
|
||||
|
|
@ -85,10 +100,10 @@ function getBoundsFromViewState(viewState, width, height) {
|
|||
const bottomPixelY = centerPixelY + height / 2;
|
||||
|
||||
// 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 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));
|
||||
|
|
@ -99,10 +114,15 @@ function getBoundsFromViewState(viewState, width, height) {
|
|||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
export default function Map({ data, onViewChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -127,24 +147,28 @@ export default function Map({ data, onViewChange }) {
|
|||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
onViewChange({ resolution, bounds });
|
||||
onViewChange({ resolution, bounds, zoom: viewState.zoom });
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleViewStateChange = useCallback(({ viewState: newViewState }) => {
|
||||
const handleViewStateChange = useCallback((params: { viewState: unknown }) => {
|
||||
const newViewState = params.viewState as ViewState;
|
||||
setViewState(newViewState);
|
||||
}, []);
|
||||
|
||||
const layers = useMemo(() => [
|
||||
new H3HexagonLayer({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => priceToColor(d.avg_price),
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
], [data]);
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => priceToColor(d.avg_price),
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
],
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full" ref={containerRef}>
|
||||
|
|
@ -152,7 +176,7 @@ export default function Map({ data, onViewChange }) {
|
|||
viewState={viewState}
|
||||
controller
|
||||
layers={layers}
|
||||
onViewStateChange={handleViewStateChange}
|
||||
onViewStateChange={handleViewStateChange as never}
|
||||
>
|
||||
<MapGL mapStyle={MAP_STYLE} />
|
||||
</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 { 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 (
|
||||
<SliderPrimitive.Root
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
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">
|
||||
Loading…
Add table
Add a link
Reference in a new issue