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

View file

@ -0,0 +1,185 @@
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';
interface MapProps {
data: HexagonData[];
onViewChange: (params: ViewChangeParams) => void;
}
const INITIAL_VIEW: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
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: 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: [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),
Math.round(c1[2] + (c2[2] - c1[2]) * t),
];
}
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
if (price <= COLOR_SCALE[0].price) return COLOR_SCALE[0].color;
if (price >= COLOR_SCALE[COLOR_SCALE.length - 1].price) {
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
}
// Find the two colors to interpolate between
for (let i = 0; i < COLOR_SCALE.length - 1; i++) {
const lower = COLOR_SCALE[i];
const upper = COLOR_SCALE[i + 1];
if (price >= lower.price && price <= upper.price) {
const t = (price - lower.price) / (upper.price - lower.price);
return interpolateColor(lower.color, upper.color, t);
}
}
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
}
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: ViewState, width: number, height: number): Bounds {
const { longitude, latitude, zoom } = viewState;
// Clamp latitude to valid Mercator range to avoid math errors
const clampedLat = Math.max(-85, Math.min(85, latitude));
// Web Mercator projection math
const TILE_SIZE = 256;
const scale = Math.pow(2, zoom);
const worldSize = TILE_SIZE * scale;
// Longitude is linear
const degreesPerPixelLng = 360 / worldSize;
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
// 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 mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
const centerPixelY = mercatorY * worldSize;
const topPixelY = centerPixelY - height / 2;
const bottomPixelY = centerPixelY + height / 2;
// Convert pixel Y back to latitude
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;
};
const north = Math.min(85, pixelYToLat(topPixelY));
const south = Math.max(-85, pixelYToLat(bottomPixelY));
const west = Math.max(-180, longitude - halfWidthDeg);
const east = Math.min(180, longitude + halfWidthDeg);
return { south, west, north, east };
}
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(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0) {
setDimensions({ width, height });
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Notify parent when view or dimensions change
useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return;
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
const resolution = zoomToResolution(viewState.zoom);
onViewChange({ resolution, bounds, zoom: viewState.zoom });
}, [viewState, dimensions, onViewChange]);
const handleViewStateChange = useCallback((params: { viewState: unknown }) => {
const newViewState = params.viewState as ViewState;
setViewState(newViewState);
}, []);
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}>
<DeckGL
viewState={viewState}
controller
layers={layers}
onViewStateChange={handleViewStateChange as never}
>
<MapGL mapStyle={MAP_STYLE} />
</DeckGL>
</div>
);
}