|
| 1 | +import { Map } from "react-map-gl/maplibre"; |
| 2 | +import { useEffect, useMemo, useState, type ReactNode } from "react"; |
| 3 | +import DeckGL, { ScatterplotLayer, type MapViewState } from "deck.gl"; |
| 4 | +import { |
| 5 | + type House, |
| 6 | +} from "@/api"; |
| 7 | + |
| 8 | + |
| 9 | +export function HousesMap({ |
| 10 | + houses, |
| 11 | + mapStyle, |
| 12 | + children, |
| 13 | +}: { |
| 14 | + houses: House[], |
| 15 | + mapStyle: string, |
| 16 | + children?: ReactNode, |
| 17 | +}) { |
| 18 | + |
| 19 | +const gradientStops = useMemo<{ t: number; color: [number, number, number] }[]>(() => [ |
| 20 | + { t: 0.00, color: [255, 255, 255] }, // white |
| 21 | + { t: 0.15, color: [200, 255, 200] }, // mint green |
| 22 | + { t: 0.20, color: [120, 220, 120] }, // light green |
| 23 | + { t: 0.35, color: [0, 180, 0] }, // green |
| 24 | + { t: 0.5, color: [255, 255, 100] }, // yellow |
| 25 | + { t: 0.65, color: [255, 180, 0] }, // orange |
| 26 | + { t: 0.75, color: [255, 90, 0] }, // red |
| 27 | + { t: 0.88, color: [255, 0, 0] }, // dark red |
| 28 | + { t: 1.00, color: [140, 0, 0] }, // maroon |
| 29 | + ], []); |
| 30 | + |
| 31 | + const { min, max, data, latitude, longitude } = useMemo(() => { |
| 32 | + const data = houses.filter(x => typeof x.unitPrice === 'number' && x.location !== null && typeof x.location.lat === 'number' && typeof x.location.lng === 'number') ?? []; |
| 33 | + const sortedPrices = data.map(x => x.unitPrice!).sort((a, b) => a - b); |
| 34 | + const center: NonNullable<House['location']> = { |
| 35 | + lat: data.reduce((p, c)=>p + c.location!.lat, 0) / data.length, |
| 36 | + lng: data.reduce((p, c)=>p + c.location!.lng, 0) / data.length, |
| 37 | + }; |
| 38 | + return { |
| 39 | + data, |
| 40 | + min: sortedPrices[Math.floor((sortedPrices.length - 1) * gradientStops[1].t)], |
| 41 | + max: sortedPrices[Math.floor((sortedPrices.length - 1) * gradientStops[gradientStops.length - 2].t)], |
| 42 | + latitude: isNaN(center.lat) ? 0 : center.lat, |
| 43 | + longitude: isNaN(center.lng) ? 0 : center.lng, |
| 44 | + }; |
| 45 | + }, [houses, gradientStops]); |
| 46 | + |
| 47 | + |
| 48 | + const [viewState, setViewState] = useState<MapViewState>({ |
| 49 | + latitude: 0, |
| 50 | + longitude: 0, |
| 51 | + zoom: 13, |
| 52 | + }); |
| 53 | + |
| 54 | + useEffect(() => { |
| 55 | + setViewState(p => ({ ...p, latitude, longitude })) |
| 56 | + }, [latitude, longitude]); |
| 57 | + |
| 58 | + |
| 59 | + const heatLayer = new ScatterplotLayer({ |
| 60 | + id: "house-layer", |
| 61 | + data, |
| 62 | + getPosition: (d: House) => [d.location?.lng ?? 0, d.location?.lat ?? 0], |
| 63 | + getRadius: () => { |
| 64 | + return 50; |
| 65 | + }, |
| 66 | + radiusUnits: "meters", |
| 67 | + getFillColor: (d: House) => { |
| 68 | + const price = d.unitPrice ?? 0; |
| 69 | + const t = (Math.max(min, Math.min(price, max)) - min) / (max - min); |
| 70 | + for (let i = 0; i < gradientStops.length - 1; i++) { |
| 71 | + const curr = gradientStops[i]; |
| 72 | + const next = gradientStops[i + 1]; |
| 73 | + |
| 74 | + if (t >= curr.t && t <= next.t) { |
| 75 | + const localT = (t - curr.t) / (next.t - curr.t); |
| 76 | + const r = Math.round(curr.color[0] + (next.color[0] - curr.color[0]) * localT); |
| 77 | + const g = Math.round(curr.color[1] + (next.color[1] - curr.color[1]) * localT); |
| 78 | + const b = Math.round(curr.color[2] + (next.color[2] - curr.color[2]) * localT); |
| 79 | + return [r, g, b]; |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + return gradientStops[gradientStops.length - 1].color; |
| 84 | + }, |
| 85 | + onClick: ({ object }) => { |
| 86 | + alert(JSON.stringify(object, null, 4)) |
| 87 | + }, |
| 88 | + opacity: 0.9, |
| 89 | + stroked: false, |
| 90 | + pickable: true, |
| 91 | + updateTriggers: { |
| 92 | + getRadius: [viewState.zoom], |
| 93 | + }, |
| 94 | + }); |
| 95 | + |
| 96 | + return ( |
| 97 | + <DeckGL |
| 98 | + viewState={viewState} |
| 99 | + controller={true} |
| 100 | + onViewStateChange={({ viewState: newViewState }) => { |
| 101 | + if (data.length) { |
| 102 | + setViewState(newViewState as MapViewState) |
| 103 | + } |
| 104 | + }} |
| 105 | + layers={[heatLayer]} |
| 106 | + > |
| 107 | + <Map |
| 108 | + mapStyle={mapStyle} |
| 109 | + attributionControl={false} |
| 110 | + /> |
| 111 | + {children} |
| 112 | + </DeckGL> |
| 113 | + ); |
| 114 | +} |
0 commit comments