possibility to display distance (#262)

This commit is contained in:
Christian Kellner
2026-01-25 13:52:56 +01:00
committed by GitHub
parent 28a3a7f372
commit 9dde377fe6
8 changed files with 600 additions and 200 deletions

View File

@@ -7,6 +7,7 @@ import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
import { IconFilter } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg';
@@ -65,8 +66,10 @@ export default function MapView() {
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
const homeMarker = useRef(null);
const actions = useActions();
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
const [show3dBuildings, setShow3dBuildings] = useState(false);
@@ -74,6 +77,7 @@ export default function MapView() {
const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0);
useEffect(() => {
setPriceRange([0, getMaxPrice()]);
@@ -150,6 +154,7 @@ export default function MapView() {
if (!map.current) return;
const add3dLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (show3dBuildings) {
if (!map.current.getSource('openfreemap')) {
map.current.addSource('openfreemap', {
@@ -201,11 +206,7 @@ export default function MapView() {
}
};
if (map.current.isStyleLoaded()) {
add3dLayer();
} else {
map.current.once('styledata', add3dLayer);
}
add3dLayer();
}, [show3dBuildings, style]);
const setMapStyle = (value) => {
@@ -225,12 +226,94 @@ export default function MapView() {
fetchListings();
}, [jobId]);
useEffect(() => {
if (!map.current || !homeAddress?.coords) return;
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
// not on every render. useEffect dependency array handles this.
if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.fitBounds(bounds, {
padding: 20,
maxZoom: 15,
duration: 1000,
});
} else {
map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12,
duration: 1000,
});
}
}, [homeAddress?.address, distanceFilter]);
useEffect(() => {
if (!map.current) return;
markers.current.forEach((marker) => marker.remove());
markers.current = [];
if (homeMarker.current) {
homeMarker.current.remove();
homeMarker.current = null;
}
if (homeAddress?.coords) {
homeMarker.current = new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
`<div class="map-popup-content"><h4>Home Address</h4><p>${homeAddress.address}</p></div>`,
),
)
.addTo(map.current);
}
const addCircleLayer = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
if (map.current.getLayer('distance-circle')) map.current.removeLayer('distance-circle');
if (map.current.getLayer('distance-circle-outline')) map.current.removeLayer('distance-circle-outline');
if (map.current.getSource('distance-circle-source')) map.current.removeSource('distance-circle-source');
if (distanceFilter > 0 && homeAddress?.coords) {
const ret = generateCircleCoords([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
map.current.addSource('distance-circle-source', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
},
});
map.current.addLayer({
id: 'distance-circle',
type: 'fill',
source: 'distance-circle-source',
paint: {
'fill-color': '#90EE90',
'fill-opacity': 0.3,
},
});
map.current.addLayer({
id: 'distance-circle-outline',
type: 'line',
source: 'distance-circle-source',
paint: {
'line-color': '#006400',
'line-width': 1,
},
});
}
};
addCircleLayer();
filterListings().forEach((listing) => {
if (
listing.latitude != null &&
@@ -256,7 +339,20 @@ export default function MapView() {
</div>`,
);
const marker = new maplibregl.Marker()
let color = '#3FB1CE'; // Default blue-ish
if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters(
homeAddress.coords.lat,
homeAddress.coords.lng,
listing.latitude,
listing.longitude,
);
if (dist <= distanceFilter * 1000) {
color = 'orange';
}
}
const marker = new maplibregl.Marker({ color })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(popup)
.addTo(map.current);
@@ -264,7 +360,7 @@ export default function MapView() {
markers.current.push(marker);
}
});
}, [listings, priceRange]);
}, [listings, priceRange, homeAddress, distanceFilter]);
return (
<div className="map-view-container">
@@ -318,6 +414,29 @@ export default function MapView() {
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Distance:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
<Select
placeholder="Distance"
style={{ width: 100 }}
onChange={(val) => {
setDistanceFilter(val);
}}
value={distanceFilter}
>
<Select.Option value={0}>---</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Price Range ():</Text>
@@ -343,6 +462,16 @@ export default function MapView() {
</div>
)}
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
description="You have not set your home address yet. Please do so in the settings to use the distance filter."
/>
)}
<Banner
fullMode={true}
type="info"

View File

@@ -0,0 +1,99 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Calculates the great-circle distance between two points on a sphere using the Haversine formula.
*
* I'm using the Haversine formula here because it accounts for the Earth's curvature.
* By calculating the central angle (c) between two points and multiplying it by the Earth's radius (R ≈ 6371km),
* we get a pretty accurate straight-line distance. It's basically some trigonometry involving
* sines and cosines of the latitudes and longitudes to find the chord length (a) first.
*
* @param {number} lat1 - Latitude of the first point
* @param {number} lon1 - Longitude of the first point
* @param {number} lat2 - Latitude of the second point
* @param {number} lon2 - Longitude of the second point
* @returns {number} Distance in meters, rounded to one decimal place
*/
export const distanceMeters = (lat1, lon1, lat2, lon2) => {
const R = 6371000;
const toRad = (deg) => (deg * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dPhi = toRad(lat2 - lat1);
const dLambda = toRad(lon2 - lon1);
const a =
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c * 10) / 10;
};
/**
* Generates an array of coordinates representing a circle on a map.
*
* To get this circle right, I'm approximating it with a polygon of 64 points.
* Since the Earth isn't flat, I have to adjust the longitude distance based on the latitude
* using the cosine of the latitude. The formula for the points is basically:
* x = center_lon + radius_lon * cos(theta)
* y = center_lat + radius_lat * sin(theta)
* where theta ranges from 0 to 2π. This handles the slight "squishing" of distances as you move away from the equator.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius of the circle in kilometers
* @param {number} [points=64] - Number of points to generate for the polygon
* @returns {number[][]} Array of [longitude, latitude] coordinates
*/
export const generateCircleCoords = (center, radiusInKm, points = 64) => {
const [longitude, latitude] = center;
const coords = [];
// 1 degree of latitude is roughly 110.574 km
// 1 degree of longitude is roughly 111.32 km * cos(latitude)
const distanceX = radiusInKm / (111.32 * Math.cos((latitude * Math.PI) / 180));
const distanceY = radiusInKm / 110.574;
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI);
const x = distanceX * Math.cos(theta);
const y = distanceY * Math.sin(theta);
coords.push([longitude + x, latitude + y]);
}
// Close the polygon
coords.push(coords[0]);
return coords;
};
/**
* Calculates the bounding box for a given center and radius.
*
* I'm calculating the bounds by offsetting the center coordinates by the radius.
* Again, using the 110.574 km per degree latitude and the cosine-adjusted longitude
* to make sure the bounds actually contain the circle, even at our latitudes.
* I've added a bit of padding (15% by default) to make sure everything fits nicely on the screen.
*
* @param {number[]} center - [longitude, latitude] of the center
* @param {number} radiusInKm - Radius in kilometers
* @param {number} [padding=0.15] - Percentage of padding to add
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
*/
export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
const [lng, lat] = center;
const kmInDegLat = 1 / 110.574;
const kmInDegLng = 1 / (111.32 * Math.cos((lat * Math.PI) / 180));
const offsetLng = radiusInKm * kmInDegLng * (1 + padding);
const offsetLat = radiusInKm * kmInDegLat * (1 + padding);
return [
[lng - offsetLng, lat - offsetLat],
[lng + offsetLng, lat + offsetLat],
];
};