/* * 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( `
${homeAddress.address}