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

@@ -40,12 +40,12 @@ export default function FredyApp() {
async function init() {
await actions.user.getCurrentUser();
if (!needsLogin()) {
await actions.features.getFeatures();
await actions.provider.getProvider();
await actions.jobsData.getJobs();
await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.userSettings.getUserSettings();
await actions.versionUpdate.getVersionUpdate();
}
setLoading(false);

View File

@@ -63,16 +63,6 @@ export const useFredyState = create(
}
},
},
features: {
async getFeatures() {
try {
const response = await xhrGet('/api/features');
set((state) => ({ ...state.features, ...response.json }));
} catch (Exception) {
console.error('Error while trying to get resource for api/features. Error:', Exception);
}
},
},
provider: {
async getProvider() {
try {
@@ -228,6 +218,16 @@ export const useFredyState = create(
}
},
},
userSettings: {
async getUserSettings() {
try {
const response = await xhrGet('/api/user/settings');
set((state) => ({ userSettings: { ...state.userSettings, settings: response.json } }));
} catch (Exception) {
console.error('Error while trying to get resource for api/user/settings. Error:', Exception);
}
},
},
};
// Initial state
@@ -241,8 +241,8 @@ export const useFredyState = create(
mapListings: [],
maxPrice: 0,
},
features: {},
generalSettings: { settings: {} },
userSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
@@ -265,9 +265,9 @@ export const useFredyState = create(
versionUpdate: { ...effects.versionUpdate },
listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
features: { ...effects.features },
jobsData: { ...effects.jobsData },
user: { ...effects.user },
userSettings: { ...effects.userSettings },
};
return {

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],
];
};

View File

@@ -6,6 +6,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Divider, Button, AutoComplete, Toast, Typography, Banner } from '@douyinfe/semi-ui-19';
import { IconSave, IconHome } from '@douyinfe/semi-icons';
import { useSelector, useActions } from '../../services/state/store';
import { xhrGet, xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import debounce from 'lodash/debounce';
@@ -13,30 +14,17 @@ import debounce from 'lodash/debounce';
const { Title } = Typography;
const UserSettings = () => {
const [address, setAddress] = useState('');
const [coords, setCoords] = useState(null);
const [loading, setLoading] = useState(true);
const actions = useActions();
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [address, setAddress] = useState(homeAddress?.address || '');
const [coords, setCoords] = useState(homeAddress?.coords || null);
const [saving, setSaving] = useState(false);
const [dataSource, setDataSource] = useState([]);
useEffect(() => {
fetchUserSettings();
}, []);
const fetchUserSettings = async () => {
try {
const response = await xhrGet('/api/user/settings');
if (response.status === 200) {
const homeAddress = response.json.home_address;
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}
} catch {
Toast.error('Failed to fetch user settings');
} finally {
setLoading(false);
}
};
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const handleSave = async () => {
setSaving(true);
@@ -44,6 +32,7 @@ const UserSettings = () => {
const response = await xhrPost('/api/user/settings', { home_address: address });
if (response.status === 200) {
setCoords(response.json.coords);
await actions.userSettings.getUserSettings();
Toast.success('Settings saved successfully');
} else {
Toast.error(response.json.error || 'Failed to save settings');
@@ -79,10 +68,6 @@ const UserSettings = () => {
debouncedSearch(value);
};
if (loading) {
return null;
}
return (
<div className="user-settings">
<Title heading={2}>User Specific Settings</Title>