mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
possibility to display distance (#262)
This commit is contained in:
committed by
GitHub
parent
28a3a7f372
commit
9dde377fe6
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
99
ui/src/views/listings/mapUtils.js
Normal file
99
ui/src/views/listings/mapUtils.js
Normal 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],
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user