/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { useEffect, useRef, useState } from 'react'; import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js'; import { renderToString } from 'react-dom/server'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useActions, useSelector } from '../../services/state/store.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js'; import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19'; import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons'; import no_image from '../../assets/no_image.jpg'; import _RangeSlider from 'react-range-slider-input'; import 'react-range-slider-input/dist/style.css'; import './Map.less'; import { xhrDelete } from '../../services/xhr.js'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import Map from '../../components/map/Map.jsx'; const RangeSlider = _RangeSlider?.default ?? _RangeSlider; const { Text } = Typography; export default function MapView() { const mapContainer = useRef(null); const map = useRef(null); const markers = useRef([]); const homeMarker = useRef(null); const actions = useActions(); const navigate = useNavigate(); const sp = useSearchParams(); const [searchParams, setSearchParams] = sp; const listings = useSelector((state) => state.listingsData.mapListings); const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const jobs = useSelector((state) => state.jobsData.jobs); const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString); const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber); const [style] = useSearchParamState(sp, 'style', 'STANDARD', parseString); const [show3dBuildings, setShow3dBuildings] = useSearchParamState(sp, 'buildings', false, parseBoolean); // Price range: stored as priceMin/priceMax URL params; default max derived from loaded listings const urlPriceMin = searchParams.has('priceMin') ? Number(searchParams.get('priceMin')) : null; const urlPriceMax = searchParams.has('priceMax') ? Number(searchParams.get('priceMax')) : null; const [priceRange, setPriceRange] = useState([urlPriceMin ?? 0, urlPriceMax ?? 0]); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [listingToDelete, setListingToDelete] = useState(null); const confirmListingDeletion = async (hardDelete) => { try { await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete }); Toast.success('Listing successfully removed'); fetchListings(); } catch (error) { Toast.error(error.message || 'Error deleting listing'); } finally { setDeleteModalVisible(false); setListingToDelete(null); } }; useEffect(() => { // Only reset to full range when no URL override is set if (urlPriceMax === null) { setPriceRange([0, getMaxPrice()]); } }, [listings]); const getMaxPrice = () => { return listings.reduce((acc, item) => { const price = Number(item.price); return Number.isFinite(price) && price > acc ? price : acc; }, 0); }; const filterListings = () => { const min = priceRange[0]; const max = priceRange[1] && priceRange[1] > 0 ? priceRange[1] : getMaxPrice(); return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max); }; useEffect(() => { window.deleteListing = (id) => { setListingToDelete(id); setDeleteModalVisible(true); }; window.viewDetails = (id) => { navigate(`/listings/listing/${id}`); }; return () => { delete window.deleteListing; delete window.viewDetails; }; }, [navigate]); useEffect(() => { if (mapContainer.current && !map.current) { const checkMapReady = () => { if (mapContainer.current?.map) { map.current = mapContainer.current.map; } else { setTimeout(checkMapReady, 100); } }; checkMapReady(); } }, []); const handleMapReady = (mapInstance) => { map.current = mapInstance; }; const handleMapStyle = (value) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (value === 'STANDARD') { next.delete('style'); } else { next.set('style', value); } if (value === 'SATELLITE') { next.delete('buildings'); } return next; }, { replace: true }, ); }; const handlePriceRange = (val) => { const maxPrice = getMaxPrice(); if (maxPrice <= 0) return; // skip until listings are loaded setPriceRange(val); setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (val[0] === 0) { next.delete('priceMin'); } else { next.set('priceMin', String(val[0])); } if (val[1] === 0 || val[1] >= maxPrice) { next.delete('priceMax'); } else { next.set('priceMax', String(val[1])); } return next; }, { replace: true }, ); }; const fetchListings = async () => { actions.listingsData.getListingsForMap({ jobId, }); }; useEffect(() => { fetchListings(); }, [jobId]); useEffect(() => { if (!map.current) return; if (homeAddress?.coords) { 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, }); } } else { const filtered = filterListings(); const coords = filtered .filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1) .map((l) => [l.longitude, l.latitude]); if (coords.length > 0) { const bounds = getBoundsFromCoords(coords); map.current.fitBounds(bounds, { padding: 50, maxZoom: 15, duration: 1000, }); } } }, [homeAddress?.address, distanceFilter, listings]); 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( `

Home Address

${homeAddress.address}

`, ), ) .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, }, }); } }; const updateLayers = () => { addCircleLayer(); }; if (map.current.isStyleLoaded()) { updateLayers(); } else { map.current.on('load', updateLayers); } filterListings().forEach((listing) => { if ( listing.latitude != null && listing.longitude != null && listing.latitude !== -1 && listing.longitude !== -1 ) { const capitalizedProvider = listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'N/A'; const popupContent = `

${listing.title}

Price: ${listing.price ? listing.price + ' €' : 'N/A'} Address: ${listing.address || 'N/A'} Job: ${listing.job_name || 'N/A'} Provider: ${capitalizedProvider} Size: ${listing.size != null ? `${listing.size} m²` : 'N/A'}
`; const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent); let color = '#3FB1CE'; 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); markers.current.push(marker); } }); }, [listings, priceRange, homeAddress, distanceFilter]); return (
{!homeAddress && ( No home address set. Configure it in user settings to use the distance filter. } /> )}
{/* Floating filter panel */}
Job
Distance
Price (€)
{priceRange[0]} {priceRange[1]}
Style
3D Buildings setShow3dBuildings(v)} disabled={style === 'SATELLITE'} />
{ setDeleteModalVisible(false); setListingToDelete(null); }} />
); }