mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Map View in Fredy :D (#253)
* init map view * switching off 3d buildings when sattelite view is on * rename menu items * upgrading dependencies, adding provider to popups * adding screenshot for map view * fixing readme * next release version
This commit is contained in:
committed by
GitHub
parent
7fd8be07a2
commit
d43c5b3f97
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||
import { Banner, Divider } from '@douyinfe/semi-ui';
|
||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||
import Listings from './views/listings/Listings.jsx';
|
||||
import MapView from './views/listings/Map.jsx';
|
||||
import Navigation from './components/navigation/Navigation.jsx';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||
@@ -94,6 +95,7 @@ export default function FredyApp() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/listings" element={<Listings />} />
|
||||
<Route path="/map" element={<MapView />} />
|
||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,15 @@ export default function Navigation({ isAdmin }) {
|
||||
const items = [
|
||||
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
|
||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
|
||||
{
|
||||
itemKey: 'listings',
|
||||
text: 'Listings',
|
||||
icon: <IconStar />,
|
||||
items: [
|
||||
{ itemKey: '/listings', text: 'Overview' },
|
||||
{ itemKey: '/map', text: 'Map View' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@@ -205,6 +205,28 @@ export const useFredyState = create(
|
||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||
}
|
||||
},
|
||||
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
|
||||
try {
|
||||
const qryString = queryString.stringify(
|
||||
{
|
||||
jobId,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
},
|
||||
{ skipNull: true, skipEmptyString: true },
|
||||
);
|
||||
const response = await xhrGet(`/api/listings/map?${qryString}`);
|
||||
set((state) => ({
|
||||
listingsData: {
|
||||
...state.listingsData,
|
||||
mapListings: response.json?.listings || [],
|
||||
maxPrice: response.json?.maxPrice || 0,
|
||||
},
|
||||
}));
|
||||
} catch (Exception) {
|
||||
console.error('Error while trying to get resource for api/listings/map. Error:', Exception);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -216,6 +238,8 @@ export const useFredyState = create(
|
||||
totalNumber: 0,
|
||||
page: 1,
|
||||
result: [],
|
||||
mapListings: [],
|
||||
maxPrice: 0,
|
||||
},
|
||||
features: {},
|
||||
generalSettings: { settings: {} },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
331
ui/src/views/listings/Map.jsx
Normal file
331
ui/src/views/listings/Map.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
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 { Select, Slider, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconFilter } from '@douyinfe/semi-icons';
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import './Map.less';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const GERMANY_BOUNDS = [
|
||||
[5.866, 47.27], // Southwest coordinates
|
||||
[15.042, 55.059], // Northeast coordinates
|
||||
];
|
||||
|
||||
const STYLES = {
|
||||
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||
SATELLITE: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'satellite-tiles': {
|
||||
type: 'raster',
|
||||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||
},
|
||||
'satellite-labels': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© Esri',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'satellite-tiles',
|
||||
type: 'raster',
|
||||
source: 'satellite-tiles',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'satellite-labels',
|
||||
type: 'raster',
|
||||
source: 'satellite-labels',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function MapView() {
|
||||
const mapContainer = useRef(null);
|
||||
const map = useRef(null);
|
||||
const markers = useRef([]);
|
||||
const actions = useActions();
|
||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||
const maxPriceFromStore = useSelector((state) => state.listingsData.maxPrice);
|
||||
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, 100000]);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
|
||||
const lastJobIdRef = useRef('__INITIAL__');
|
||||
|
||||
useEffect(() => {
|
||||
if (maxPriceFromStore > 0 && lastJobIdRef.current !== jobId) {
|
||||
setPriceRange([0, maxPriceFromStore]);
|
||||
lastJobIdRef.current = jobId;
|
||||
}
|
||||
}, [maxPriceFromStore, jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: STYLES[style],
|
||||
center: [10.4515, 51.1657], // Center of Germany
|
||||
zoom: 4,
|
||||
maxBounds: GERMANY_BOUNDS,
|
||||
antialias: true,
|
||||
});
|
||||
|
||||
map.current.addControl(
|
||||
new maplibregl.NavigationControl({
|
||||
showCompass: false,
|
||||
visualizePitch: true,
|
||||
visualizeRoll: true,
|
||||
}),
|
||||
'top-right',
|
||||
);
|
||||
|
||||
return () => {
|
||||
map.current.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
map.current.setStyle(STYLES[style]);
|
||||
}
|
||||
}, [style]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show3dBuildings && style !== 'STANDARD') {
|
||||
setStyle('STANDARD');
|
||||
}
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
map.current.setPitch(show3dBuildings ? 45 : 0);
|
||||
}, [show3dBuildings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
const add3dLayer = () => {
|
||||
if (show3dBuildings) {
|
||||
if (!map.current.getSource('openfreemap')) {
|
||||
map.current.addSource('openfreemap', {
|
||||
type: 'vector',
|
||||
url: 'https://tiles.openfreemap.org/planet',
|
||||
});
|
||||
}
|
||||
if (!map.current.getLayer('3d-buildings')) {
|
||||
const layers = map.current.getStyle().layers;
|
||||
let labelLayerId;
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
|
||||
labelLayerId = layers[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
map.current.addLayer(
|
||||
{
|
||||
id: '3d-buildings',
|
||||
source: 'openfreemap',
|
||||
'source-layer': 'building',
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 15,
|
||||
filter: ['!=', ['get', 'hide_3d'], true],
|
||||
paint: {
|
||||
'fill-extrusion-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'render_height'],
|
||||
0,
|
||||
'lightgray',
|
||||
200,
|
||||
'royalblue',
|
||||
400,
|
||||
'lightblue',
|
||||
],
|
||||
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
|
||||
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
|
||||
'fill-extrusion-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
labelLayerId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (map.current.getLayer('3d-buildings')) {
|
||||
map.current.removeLayer('3d-buildings');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (map.current.isStyleLoaded()) {
|
||||
add3dLayer();
|
||||
} else {
|
||||
map.current.once('styledata', add3dLayer);
|
||||
}
|
||||
}, [show3dBuildings, style]);
|
||||
|
||||
const setMapStyle = (value) => {
|
||||
setStyle(value);
|
||||
if (value === 'SATELLITE') {
|
||||
setShow3dBuildings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchListings = async () => {
|
||||
actions.listingsData.getListingsForMap({
|
||||
jobId,
|
||||
minPrice: priceRange[0] > 0 ? priceRange[0] : null,
|
||||
maxPrice: maxPriceFromStore > 0 && priceRange[1] < maxPriceFromStore ? priceRange[1] : null,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchListings();
|
||||
}, [jobId, priceRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
|
||||
markers.current.forEach((marker) => marker.remove());
|
||||
markers.current = [];
|
||||
|
||||
listings.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 popup = new maplibregl.Popup({ offset: 25 }).setHTML(
|
||||
`<div class="map-popup-content">
|
||||
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
||||
<h4>${listing.title}</h4>
|
||||
<div class="info">
|
||||
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
||||
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
|
||||
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
|
||||
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
|
||||
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a>
|
||||
</div>
|
||||
</div>`,
|
||||
);
|
||||
|
||||
const marker = new maplibregl.Marker()
|
||||
.setLngLat([listing.longitude, listing.latitude])
|
||||
.setPopup(popup)
|
||||
.addTo(map.current);
|
||||
|
||||
markers.current.push(marker);
|
||||
}
|
||||
});
|
||||
}, [listings]);
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<div className="listingsGrid__searchbar map-filter-bar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexGrow: 1 }}>
|
||||
<Text strong>Map View</Text>
|
||||
<Select placeholder="Style" style={{ width: 120 }} value={style} onChange={(val) => setMapStyle(val)}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
|
||||
<Text strong>3D Buildings</Text>
|
||||
<Switch size="small" checked={show3dBuildings} onChange={(v) => setShow3dBuildings(v)} />
|
||||
</div>
|
||||
</div>
|
||||
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="listingsGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="Job"
|
||||
showClear
|
||||
style={{ width: 150 }}
|
||||
onChange={(val) => setJobId(val)}
|
||||
value={jobId}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Price Range (€):</Text>
|
||||
</div>
|
||||
<div style={{ width: 250, padding: '0 10px' }}>
|
||||
<Slider
|
||||
range
|
||||
min={0}
|
||||
max={maxPriceFromStore || 100000}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onChange={(val) => setPriceRange(val)}
|
||||
tipFormatter={(val) => `${val} €`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="Keep in mind, only listings with proper adresses are being shown on this map."
|
||||
/>
|
||||
|
||||
<div ref={mapContainer} className="map-container" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
ui/src/views/listings/Map.less
Normal file
73
ui/src/views/listings/Map.less
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
.map-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.map-filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
}
|
||||
|
||||
.map-popup-content {
|
||||
max-width: 250px;
|
||||
color: var(--semi-color-text-0);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override MapLibre default popup styles to match application theme */
|
||||
.maplibregl-popup-content {
|
||||
background-color: var(--semi-color-bg-1) !important;
|
||||
color: var(--semi-color-text-0) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--semi-shadow-elevated) !important;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: var(--semi-color-bg-1) !important;
|
||||
border-bottom-color: var(--semi-color-bg-1) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--semi-color-bg-1) !important;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background-color: var(--semi-color-bg-1) !important;
|
||||
border-color: var(--semi-color-border) !important;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user