/* * 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 { renderToString } from 'react-dom/server'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useSelector, useActions } from '../../services/state/store.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js'; import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19'; import { IconFilter, IconLink } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened } 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 } from 'react-router-dom'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import Map from '../../components/map/Map.jsx'; 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 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); const jobs = useSelector((state) => state.jobsData.jobs); const [jobId, setJobId] = useState(null); const [priceRange, setPriceRange] = useState([0, 0]); const [showFilterBar, setShowFilterBar] = useState(false); const [distanceFilter, setDistanceFilter] = useState(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(() => { setPriceRange([0, getMaxPrice()]); }, [listings]); const getMaxPrice = () => { return listings.reduce((max, item) => { const price = Number(item.price); return Number.isFinite(price) && price > max ? price : max; }, -Infinity); }; 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]); // Get map instance reference after MapComponent renders useEffect(() => { if (mapContainer.current && !map.current) { // Wait for MapComponent to initialize the map const checkMapReady = () => { if (mapContainer.current?.map) { map.current = mapContainer.current.map; } else { setTimeout(checkMapReady, 100); } }; checkMapReady(); } }, []); const handleMapReady = (mapInstance) => { map.current = mapInstance; }; const setMapStyle = (value) => { setStyle(value); if (value === 'SATELLITE') { setShow3dBuildings(false); } }; const fetchListings = async () => { actions.listingsData.getListingsForMap({ jobId, }); }; useEffect(() => { fetchListings(); }, [jobId]); useEffect(() => { if (!map.current) return; if (homeAddress?.coords) { // 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, }); } } 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'; // 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); markers.current.push(marker); } }); }, [listings, priceRange, homeAddress, distanceFilter]); return (
Map View
3D Buildings setShow3dBuildings(v)} />
{showFilterBar && (
Filter by:
Distance:
Price Range (€):
{priceRange[0]} € {priceRange[1]} €
{ setPriceRange(val); }} tipFormatter={(val) => `${val} €`} />
)} {!homeAddress && ( You have not set your home address yet. Please do so in the user settings{' '} to use the distance filter. } /> )} { setDeleteModalVisible(false); setListingToDelete(null); }} />
); }