diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a4136a56..7eeb9bd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,12 +18,14 @@ "@phosphor-icons/react": "^2.1.5", "@types/autosuggest-highlight": "^3.2.3", "@types/geojson": "^7946.0.14", + "@types/leaflet-draw": "^1.0.11", "@types/leaflet.markercluster": "^1.5.4", "@types/proj4leaflet": "^1.0.10", "autosuggest-highlight": "^3.3.4", "axios": "^1.7.2", "geojson": "^0.5.0", "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "leaflet.markercluster": "^1.5.3", "proj4leaflet": "^1.0.2", @@ -1809,6 +1811,14 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet-draw": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.11.tgz", + "integrity": "sha512-dyedtNm3aSmnpi6FM6VSl28cQuvP+MD7pgpXyO3Q1ZOCvrJKmzaDq0P3YZTnnBs61fQCKSnNYmbvCkDgFT9FHQ==", + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/leaflet.markercluster": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.4.tgz", @@ -3295,6 +3305,11 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, + "node_modules/leaflet-draw": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz", + "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==" + }, "node_modules/leaflet-geosearch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/leaflet-geosearch/-/leaflet-geosearch-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ada8ff3d..023b86b8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,12 +20,14 @@ "@phosphor-icons/react": "^2.1.5", "@types/autosuggest-highlight": "^3.2.3", "@types/geojson": "^7946.0.14", + "@types/leaflet-draw": "^1.0.11", "@types/leaflet.markercluster": "^1.5.4", "@types/proj4leaflet": "^1.0.10", "autosuggest-highlight": "^3.3.4", "axios": "^1.7.2", "geojson": "^0.5.0", "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "leaflet.markercluster": "^1.5.3", "proj4leaflet": "^1.0.2", diff --git a/frontend/src/components/DataView/DataPanel.tsx b/frontend/src/components/DataView/DataPanel.tsx index a6fd10ae..7d653725 100644 --- a/frontend/src/components/DataView/DataPanel.tsx +++ b/frontend/src/components/DataView/DataPanel.tsx @@ -5,43 +5,15 @@ import { CaretDown, MapTrifold } from "@phosphor-icons/react"; import "./DataPanel.css"; import { Tooltip } from "@mui/material"; import { GridToolbar, GridToolbarProps } from "@mui/x-data-grid"; -import { useState } from "react"; - -// Returns a button if the "button" value is set to 1 -const renderDetailsButton = (params: GridRenderCellParams) => { - const value = params.value; - if (value === 1) { - return ( - - - - - - - - ); - } else { - return null; - } -}; - -// Defines the columns of the Datagrid -const columns: GridColDef[] = [ - { - field: "button", - headerName: "button", - width: 60, - renderCell: renderDetailsButton, - }, - { field: "key", headerName: "key", width: 250 }, - { - field: "value", - headerName: "value", - type: "number", - width: 250, - getApplyQuickFilterFn: undefined, - }, -]; +import { useContext, useState } from "react"; +import { DatasetItem } from "../../types/LocationDataTypes"; +import { TabsContext } from "../../contexts/TabsContext"; +import { fetchDatasets } from "../../services/datasetsService"; +import { AlertContext } from "../../contexts/AlertContext"; +import { Dataset } from "../../types/DatasetTypes"; +import L from "leaflet"; +import CustomSvgIcon from "../DatasetsList/CustomSvgIcon"; +import { svgIconDefault } from "../DatasetsList/DatasetsList"; function MyCustomToolbar(props: GridToolbarProps) { return ; @@ -50,9 +22,9 @@ function MyCustomToolbar(props: GridToolbarProps) { interface DataPanelProps { listTitle: string; filterValue: string; - mapRows: object[]; - genericRows: object[]; - extraRows: object[]; + mapRows: DatasetItem[]; + genericRows: DatasetItem[]; + extraRows: DatasetItem[]; } /* @@ -72,6 +44,88 @@ const DataPanel: React.FC = ({ useState(false); const [ifExtraDataTabHidden, toggleExtraDataHidden] = useState(false); + const { currentAlertCache, setCurrentAlertCache } = useContext(AlertContext); + + const { openNewTab } = useContext(TabsContext); + + const openDatasetFromMapIcon = async (mapId: string) => { + const datasetsData = await fetchDatasets(); + if (datasetsData) { + const datasetToOpen = datasetsData.find( + (dataset) => dataset.datasetId === mapId + ); + if (datasetToOpen) { + const datasetToOpenTransformed: Dataset = { + id: datasetToOpen.datasetId, + displayName: datasetToOpen.name, + shortDescription: datasetToOpen.shortDescription, + datasetIcon: datasetToOpen.icon ? ( + + ) : ( + + ), + metaData: undefined, + data: { + type: "FeatureCollection", + features: [], + }, + lastDataRequestBounds: L.latLngBounds(L.latLng(0, 0), L.latLng(0, 0)), + }; + + openNewTab(datasetToOpenTransformed); + } else { + // Display alert + setCurrentAlertCache({ + ...currentAlertCache, + isAlertOpened: true, + text: "Dataset with provided ID does not exist.", + }); + console.error("Dataset with provided ID does not exist."); + } + } + }; + + // Returns a button if the "button" value is set to 1 + const renderDetailsButton = (params: GridRenderCellParams) => { + const dataObject = params.row as DatasetItem; + if (dataObject.mapId !== "") { + return ( + + + { + openDatasetFromMapIcon(dataObject.mapId); + }} + > + + + + + ); + } else { + return null; + } + }; + + // Defines the columns of the Datagrid + const columns: GridColDef[] = [ + { + field: "button", + headerName: "button", + width: 60, + renderCell: renderDetailsButton, + }, + { field: "key", headerName: "key", width: 250 }, + { + field: "value", + headerName: "value", + type: "number", + width: 250, + getApplyQuickFilterFn: undefined, + }, + ]; return (
@@ -99,6 +153,9 @@ const DataPanel: React.FC = ({ }`} > { + return row.key + row.value; + }} hideFooter={true} disableColumnMenu columnHeaderHeight={0} @@ -161,6 +218,9 @@ const DataPanel: React.FC = ({ }`} > { + return row.key + row.value; + }} hideFooter={true} disableColumnMenu columnHeaderHeight={0} @@ -223,6 +283,9 @@ const DataPanel: React.FC = ({ }`} > { + return row.key + row.value; + }} hideFooter={true} disableColumnMenu columnHeaderHeight={0} diff --git a/frontend/src/components/DataView/DataView.css b/frontend/src/components/DataView/DataView.css index f735d6ef..de24cdd0 100644 --- a/frontend/src/components/DataView/DataView.css +++ b/frontend/src/components/DataView/DataView.css @@ -86,3 +86,8 @@ width: 1rem; height: 1rem; } + +.sub-text { + color: gray; + font-size: 0.6rem; +} diff --git a/frontend/src/components/DataView/DataView.tsx b/frontend/src/components/DataView/DataView.tsx index e65f4471..188cd5d2 100644 --- a/frontend/src/components/DataView/DataView.tsx +++ b/frontend/src/components/DataView/DataView.tsx @@ -2,15 +2,26 @@ import DataPanel from "./DataPanel"; import "./DataView.css"; import { Fragment, useContext, useEffect, useState } from "react"; import { TabsContext } from "../../contexts/TabsContext"; -import { Box, TextField } from "@mui/material"; +import { Box, TextField, Tooltip } from "@mui/material"; import { Funnel, MapPin, MapPinLine } from "@phosphor-icons/react"; import { MapContext } from "../../contexts/MapContext"; import LoadDataButton from "./LoadDataButton"; import { LocationDataResponse } from "../../types/LocationDataTypes"; import { fetchLocationData } from "../../services/locationDataService"; +import { + MarkerSelection, + PolygonSelection, +} from "../../types/MapSelectionTypes"; +import { MultiPolygon, Position } from "geojson"; + +// Function to filter and return an array of outer polygons +function getOuterPolygons(multiPolygon: MultiPolygon): Position[][] { + // Filter out the inner polygons (holes) and keep only the outer ones + return multiPolygon.coordinates.map((polygon) => polygon[0]); +} function DataView() { - const { currentTabsCache } = useContext(TabsContext); + const { currentTabsCache, getCurrentTab } = useContext(TabsContext); const { currentMapCache, setCurrentMapCache } = useContext(MapContext); const [filterValue, setFilterValue] = useState(""); const [ifNeedsReloading, setIfNeedsReloading] = useState(false); @@ -20,6 +31,10 @@ function DataView() { setFilterValue(event.target.value); }; + /** + * Returns the title of the currently selected tab + * @returns current tab title + */ const getCurrentTabTitle = (): string => { const currentTabID = currentTabsCache.currentTabID.toString(); const currentTab = currentTabsCache.openedTabs.find( @@ -29,28 +44,72 @@ function DataView() { return currentTab ? currentTab.dataset.displayName : "No map loaded"; }; + /** + * Check if the "Reload data" button should be visible. + * Should be run on selecting different coordinates or changing the current map ID. + */ useEffect(() => { + // Check if different coordinates were selected if ( !ifNeedsReloading && currentMapCache.selectedCoordinates !== null && currentMapCache.loadedCoordinates !== currentMapCache.selectedCoordinates + ) { + setIfNeedsReloading(true); + // Check if tab was switched + } else if ( + !ifNeedsReloading && + currentMapCache.selectedCoordinates !== null && + currentMapCache.currentTabID !== currentTabsCache.currentTabID ) { setIfNeedsReloading(true); } - }, [currentMapCache, ifNeedsReloading]); + }, [currentMapCache, ifNeedsReloading, currentTabsCache.currentTabID]); /** * Reloads the location data. */ const reloadData = async () => { + // Get all parameters setIfNeedsReloading(false); + const currentID = currentTabsCache.currentTabID; + const currentCoords = currentMapCache.selectedCoordinates; + // Set the current map cache setCurrentMapCache({ ...currentMapCache, - loadedCoordinates: currentMapCache.selectedCoordinates, + loadedCoordinates: currentCoords, + currentTabID: currentID, }); - const responseData = await fetchLocationData(); - if (responseData) { - setData(responseData); + // Prepare the location data + if (currentCoords) { + let coords: Position[][] = []; + + if (currentCoords instanceof MarkerSelection) { + const singlePosition: Position = [ + currentCoords.marker.lng, + currentCoords.marker.lat, + ]; + coords = [[singlePosition]]; + } else if (currentCoords instanceof PolygonSelection) { + // we have multipolygons which can have quite complicated inner structures. + // we simplfiy fot the current api in a way that we ignore all inner "holes" or other parts and only take + // the outer parts. so the independent general polygons. + coords = getOuterPolygons(currentCoords.polygon); + } + + // Send the location request + const currentTab = getCurrentTab(); + if (currentTab) { + const responseData = await fetchLocationData( + currentTab.dataset.id, + coords + ); + if (responseData) { + setData(responseData); + } + } + } else { + console.log("Currently selected coordinates are null."); } }; @@ -61,8 +120,29 @@ function DataView() {
- {currentMapCache.loadedCoordinates.lat.toFixed(6)},{" "} - {currentMapCache.loadedCoordinates.lng.toFixed(6)} +
+ + + {currentMapCache.loadedCoordinates.displayName.substring( + 0, + 40 + ) + + (currentMapCache.loadedCoordinates.displayName.length > 40 + ? "... " + : "")} + + + {currentMapCache.loadedCoordinates instanceof + MarkerSelection && ( +
+ ({currentMapCache.loadedCoordinates.marker.lat.toFixed(6)},{" "} + {currentMapCache.loadedCoordinates.marker.lng.toFixed(6)}) +
+ )} +
= ({ closeDialog }) => { const [datasets, setDatasets] = useState([]); - const { currentTabsCache, setCurrentTabsCache } = useContext(TabsContext); - const { currentAlertCache, setCurrentAlertCache } = useContext(AlertContext); + const { openNewTab } = useContext(TabsContext); useEffect(() => { const fetchDatasetsData = async () => { @@ -50,7 +41,10 @@ const DatasetsList: React.FC = ({ closeDialog }) => { ), metaData: undefined, - data: emptyFeatureCollection, + data: { + type: "FeatureCollection", + features: [], + }, lastDataRequestBounds: L.latLngBounds( L.latLng(0, 0), L.latLng(0, 0) @@ -67,29 +61,12 @@ const DatasetsList: React.FC = ({ closeDialog }) => { }, []); // Opens a new tab - const openNewTab = (dataset: Dataset) => { - if ( - currentTabsCache.openedTabs.some((tab) => tab.dataset.id === dataset.id) - ) { - setCurrentAlertCache({ - ...currentAlertCache, - isAlertOpened: true, - text: "This dataset was already added.", - }); - return; - } - const newTabID = currentTabsCache.openedTabs.length + 1; - const newTab: TabProps = { - id: newTabID.toString(), - dataset: dataset, - ifPinned: false, - }; - setCurrentTabsCache({ - ...currentTabsCache, - openedTabs: [...currentTabsCache.openedTabs, newTab], - }); + const openNewDataset = (dataset: Dataset) => { + const ifOpened = openNewTab(dataset); // Close the dialog if necessary - closeDialog(); + if (ifOpened) { + closeDialog(); + } }; return ( @@ -102,7 +79,7 @@ const DatasetsList: React.FC = ({ closeDialog }) => { { - openNewTab(dataset); + openNewDataset(dataset); }} > {dataset.datasetIcon} diff --git a/frontend/src/components/MapView/BackgroundMaps/AerialMap.tsx b/frontend/src/components/MapView/BackgroundMaps/AerialMap.tsx new file mode 100644 index 00000000..82b381dd --- /dev/null +++ b/frontend/src/components/MapView/BackgroundMaps/AerialMap.tsx @@ -0,0 +1,23 @@ +import L from "leaflet"; +import { TileLayer, WMSTileLayer } from "react-leaflet"; + +const AerialMap = () => { + return ( +
+ + +
+ ); +}; + +export default AerialMap; diff --git a/frontend/src/components/MapView/BackgroundMaps/NormalMap.tsx b/frontend/src/components/MapView/BackgroundMaps/NormalMap.tsx new file mode 100644 index 00000000..8a2ae738 --- /dev/null +++ b/frontend/src/components/MapView/BackgroundMaps/NormalMap.tsx @@ -0,0 +1,14 @@ +import { TileLayer } from "react-leaflet"; + +const NormalMap = () => { + return ( +
+ +
+ ); +}; + +export default NormalMap; diff --git a/frontend/src/components/MapView/BackgroundMaps/ParcelMap.tsx b/frontend/src/components/MapView/BackgroundMaps/ParcelMap.tsx new file mode 100644 index 00000000..9ab6bd39 --- /dev/null +++ b/frontend/src/components/MapView/BackgroundMaps/ParcelMap.tsx @@ -0,0 +1,22 @@ +import { TileLayer, WMSTileLayer } from "react-leaflet"; + +const ParcelMap = () => { + return ( +
+ + + +
+ ); +}; + +export default ParcelMap; diff --git a/frontend/src/components/MapView/BackgroundMaps/SatelliteMap.tsx b/frontend/src/components/MapView/BackgroundMaps/SatelliteMap.tsx new file mode 100644 index 00000000..118cc600 --- /dev/null +++ b/frontend/src/components/MapView/BackgroundMaps/SatelliteMap.tsx @@ -0,0 +1,23 @@ +import L from "leaflet"; +import { TileLayer, WMSTileLayer } from "react-leaflet"; + +const SatelliteMap = () => { + return ( +
+ + +
+ ); +}; + +export default SatelliteMap; diff --git a/frontend/src/components/MapView/GeoDataFetcher.tsx b/frontend/src/components/MapView/GeoDataFetcher.tsx index fd6c6825..9fc37136 100644 --- a/frontend/src/components/MapView/GeoDataFetcher.tsx +++ b/frontend/src/components/MapView/GeoDataFetcher.tsx @@ -67,6 +67,7 @@ const GeoDataFetcher = ( isAlertOpened: true, text: "Fetching data failed.", }); + console.error("Error fetching data."); } }; @@ -78,6 +79,13 @@ const GeoDataFetcher = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [bounds, zoom, id]); + /** + * Fetches the data at first load. + */ + useEffect(() => { + fetchMetadataAndData(); + }); + return data; }; diff --git a/frontend/src/components/MapView/LeafletMap.css b/frontend/src/components/MapView/LeafletMap.css new file mode 100644 index 00000000..e337a3fe --- /dev/null +++ b/frontend/src/components/MapView/LeafletMap.css @@ -0,0 +1,18 @@ +.leaflet-draw-tooltip { + display: none; +} + +.leaflet-editing-icon { + background-color: #ff0000; + border-color: black; + margin-top: -3px !important; + margin-left: -3px !important; + border-radius: 1rem; + width: 8px !important; + height: 8px !important; +} + +.leaflet-draw-guide-dash { + background-color: #ff0000 !important; + border-radius: 1rem; +} diff --git a/frontend/src/components/MapView/LeafletMap.tsx b/frontend/src/components/MapView/LeafletMap.tsx new file mode 100644 index 00000000..a41dd0a2 --- /dev/null +++ b/frontend/src/components/MapView/LeafletMap.tsx @@ -0,0 +1,236 @@ +import React, { + Fragment, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { MapContainer, ZoomControl } from "react-leaflet"; +import { TabProps, TabsContext } from "../../contexts/TabsContext"; +import { MapContext } from "../../contexts/MapContext"; +import MapDatasetVisualizer from "./MapDatasetVisualizer"; +import { Dataset } from "../../types/DatasetTypes"; +import MapEventsHandler from "./MapEventsHandler"; +import ZoomWarningLabel from "../ZoomWarningLabel/ZoomWarningLabel"; +import L, { LeafletEvent } from "leaflet"; +import "leaflet-draw/dist/leaflet.draw.css"; +import "leaflet-draw"; +import SatelliteMap from "./BackgroundMaps/SatelliteMap"; +import AerialMap from "./BackgroundMaps/AerialMap"; +import NormalMap from "./BackgroundMaps/NormalMap"; +import ParcelMap from "./BackgroundMaps/ParcelMap"; +import "./LeafletMap.css"; +import { + MarkerSelection, + PolygonSelection, +} from "../../types/MapSelectionTypes"; +import { + Feature, + GeoJsonProperties, + Geometry, + MultiPolygon, + Position, +} from "geojson"; + +interface LeafletMapProps { + datasetId: string; + mapType: string; +} + +const LeafletMap: React.FC = ({ datasetId, mapType }) => { + const { currentTabsCache, getCurrentTab, getOrFetchMetadata } = + useContext(TabsContext); + const [map, setMap] = useState(null); + const { currentMapCache, setCurrentMapCache } = useContext(MapContext); + const currentMapCacheRef = useRef(currentMapCache); + const [isGrayscale, setIsGrayscale] = useState(false); + const [polygonDrawer, setPolygonDrawer] = useState( + null + ); + const currentTab = getCurrentTab(); + + // Update ref value whenever currentMapCache changes + useEffect(() => { + currentMapCacheRef.current = currentMapCache; + }, [currentMapCache]); + + /** + * Toggle polygon drawer + */ + useEffect(() => { + if (polygonDrawer) { + if (currentMapCache.isDrawing) { + if (currentMapCache.drawnItems) { + currentMapCache.drawnItems.clearLayers(); + } + setCurrentMapCache({ ...currentMapCache, selectedCoordinates: null }); + polygonDrawer.enable(); + } else { + polygonDrawer.disable(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentMapCache.isDrawing]); + + /** + * Delete the selection if other coordinate was selected + */ + useEffect(() => { + if (currentMapCache.selectedCoordinates instanceof MarkerSelection) { + polygonDrawer?.disable(); + if (currentMapCache.drawnItems) { + currentMapCache.drawnItems.clearLayers(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentMapCache.selectedCoordinates]); + + /** + * Fetches the metadata of the current tab + */ + useEffect(() => { + if (currentTab) { + getOrFetchMetadata(currentTab.dataset.id); + } + }, [currentTab, getOrFetchMetadata]); + + /** + * Refresh the map bounds on map change + */ + useEffect(() => { + if (map) { + const initialBounds = map.getBounds(); + const initialCenter = map.getCenter(); + const initialZoom = map.getZoom(); + const drawnItems = new L.FeatureGroup(); + + setCurrentMapCache((prevCache) => ({ + ...prevCache, + mapInstance: map, + mapCenter: initialCenter, + mapBounds: initialBounds, + zoom: initialZoom, + drawnItems: drawnItems, + })); + // Allow for drawing polygons + map.addLayer(drawnItems); + // Define the options for the polygon drawer + const polygonOptions = { + shapeOptions: { + color: "#ff0000", + weight: 3, + fillOpacity: 0.06, + }, + }; + setPolygonDrawer(new L.Draw.Polygon(map as L.DrawMap, polygonOptions)); + // Bind for polygon created + map.on(L.Draw.Event.CREATED, (event: LeafletEvent) => { + const drawnObject = (event as L.DrawEvents.Created).layer; + if (drawnObject instanceof L.Polygon) { + if (drawnItems) { + drawnItems.addLayer(drawnObject); + const geoJsonObject = drawnObject.toGeoJSON() as Feature< + Geometry, + GeoJsonProperties + >; + let multiPolygon: MultiPolygon; + + // we will probably always encounter only polygons but in a istant future it may be interesting to have multi polygon selection + if (geoJsonObject.geometry.type === "Polygon") { + const polygon = geoJsonObject.geometry + .coordinates as Position[][]; + multiPolygon = { + type: "MultiPolygon", + coordinates: [polygon], + }; + } else if (geoJsonObject.geometry.type === "MultiPolygon") { + multiPolygon = geoJsonObject.geometry as MultiPolygon; + } else { + throw new Error("Unsupported geometry type"); + } + const polygonSelection = new PolygonSelection( + multiPolygon, + "Custom Polygon", + true + ); + + setCurrentMapCache({ + ...currentMapCacheRef.current, + selectedCoordinates: polygonSelection, + isDrawing: false, + }); + } + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + /** + * Check for the zoom level threshold to apply the zoom warning and its effects + */ + useEffect(() => { + if (currentTab && currentTab.dataset.metaData) { + setIsGrayscale( + currentMapCache.zoom <= currentTab.dataset.metaData.minZoomLevel + ); + } + }, [currentMapCache.zoom, currentTab]); + + /** + * Adds or removes the grayscale css value + */ + useEffect(() => { + if (map) { + if (isGrayscale) { + map.getContainer().classList.add("grayscale-overlay"); + } else { + map.getContainer().classList.remove("grayscale-overlay"); + } + } + }, [isGrayscale, map]); + + const pinnedFeatureCollections = currentTabsCache.openedTabs + .filter((tab: TabProps) => tab.ifPinned) + .map((tab: TabProps) => tab.dataset); + + const isCurrentDataPinned = pinnedFeatureCollections.some( + (dataset: Dataset) => dataset.id === datasetId + ); + + return ( +
+ + + {isGrayscale ? ( + + ) : ( +
+ {pinnedFeatureCollections.map((dataset: Dataset, index: number) => ( + + ))} + {!isCurrentDataPinned && currentTab && ( + + )} +
+ )} + + {mapType === "satellite" && } + {mapType === "aerial" && } + {mapType === "normal" && } + {mapType === "parcel" && } + +
+
+ ); +}; + +export default LeafletMap; diff --git a/frontend/src/components/MapView/MapEventsHandler.tsx b/frontend/src/components/MapView/MapEventsHandler.tsx index 8048fd17..e59cc526 100644 --- a/frontend/src/components/MapView/MapEventsHandler.tsx +++ b/frontend/src/components/MapView/MapEventsHandler.tsx @@ -1,12 +1,12 @@ -import { Fragment, useContext } from "react"; -import { Marker } from "react-leaflet/Marker"; -import { Popup } from "react-leaflet/Popup"; -import { useMap, useMapEvents } from "react-leaflet/hooks"; +import React, { Fragment, useContext } from "react"; +import { Marker } from "react-leaflet"; +import { useMapEvents } from "react-leaflet/hooks"; import { MapContext } from "../../contexts/MapContext"; import L, { DivIcon } from "leaflet"; import { MapPin } from "@phosphor-icons/react"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; +import { MarkerSelection } from "../../types/MapSelectionTypes"; // Utility function to render a React component to HTML string const renderToHtml = (Component: React.FC) => { @@ -25,23 +25,23 @@ const divIconMarker: DivIcon = L.divIcon({ iconAnchor: [18, 36], // Adjust the anchor point as needed }); -const MapEventsHandler = () => { +const MapEventsHandler: React.FC = () => { const { currentMapCache, setCurrentMapCache } = useContext(MapContext); - const setPosition = (latlng: L.LatLng) => { - setCurrentMapCache({ ...currentMapCache, selectedCoordinates: latlng }); - }; - - const map = useMap(); // Add events useMapEvents({ click: (event) => { - currentMapCache.polygon?.remove(); - setCurrentMapCache({ - ...currentMapCache, - selectedCoordinates: event.latlng, - polygon: null, - }); + if (!currentMapCache.isDrawing) { + const markerSelection = new MarkerSelection( + event.latlng, + "Custom Marker", + true + ); + setCurrentMapCache({ + ...currentMapCache, + selectedCoordinates: markerSelection, + }); + } }, moveend: (event) => { setCurrentMapCache({ @@ -53,34 +53,11 @@ const MapEventsHandler = () => { }, }); - return currentMapCache.selectedCoordinates !== null ? ( - - - { - map - .locate({ setView: true }) - .on("locationfound", function (event) { - //currentMapCache.polygon?.remove(); - setPosition(event.latlng); - map.flyTo(event.latlng, map.getZoom(), { - animate: true, - duration: 50, - }); - }) - // If access to the location was denied - .on("locationerror", function (event) { - console.log(event); - alert("Location access denied."); - }); - }} - > - {currentMapCache.selectedCoordinates.lat.toFixed(4)},{" "} - {currentMapCache.selectedCoordinates.lng.toFixed(4)} - - - + return currentMapCache.selectedCoordinates instanceof MarkerSelection ? ( + ) : ( ); diff --git a/frontend/src/components/MapView/MapOptions.css b/frontend/src/components/MapView/MapOptions.css index d2b02e6e..620e3629 100644 --- a/frontend/src/components/MapView/MapOptions.css +++ b/frontend/src/components/MapView/MapOptions.css @@ -47,7 +47,7 @@ color: #535bf2; } -.layers-map-icon { +.options-icons { width: 1.5rem; height: 1.5rem; } @@ -65,5 +65,26 @@ } .image-hover-effect:hover { - border-color: blue; -} \ No newline at end of file + border-color: blue; +} + +.draw-polygon-icon-container { + width: 2.1rem; + height: 2.1rem; + position: absolute; + top: 7.5rem; + right: 0.6rem; + cursor: pointer; + z-index: 2; + display: flex; + flex-direction: column; + background-color: white; + justify-content: center; + align-items: center; + border: 2px solid rgba(0, 0, 0, 0.27); + box-shadow: none; +} +.draw-polygon-icon-container:hover { + background-color: #f4f4f4; + color: #535bf2; +} diff --git a/frontend/src/components/MapView/MapOptions.tsx b/frontend/src/components/MapView/MapOptions.tsx index b84d5313..8db0839a 100644 --- a/frontend/src/components/MapView/MapOptions.tsx +++ b/frontend/src/components/MapView/MapOptions.tsx @@ -1,17 +1,17 @@ -import React, { useState } from "react"; -import { Paper, Popover, Grid, Typography, Box } from "@mui/material"; +import React, { useContext, useState } from "react"; +import { Paper, Popover, Grid, Typography, Box, Tooltip } from "@mui/material"; import "./MapOptions.css"; -import { StackSimple } from "@phosphor-icons/react"; +import { Polygon, StackSimple } from "@phosphor-icons/react"; import SearchBar from "../SearchBar/SearchBar"; +import { MapContext } from "../../contexts/MapContext"; interface MapOptionsProps { - onMapTypeChange: ( - type: "normal" | "satellite" | "parzellar" | "aerial" - ) => void; + onMapTypeChange: (type: "normal" | "satellite" | "parcel" | "aerial") => void; } const MapOptions: React.FC = ({ onMapTypeChange }) => { const [anchorEl, setAnchorEl] = useState(null); + const { currentMapCache, setCurrentMapCache } = useContext(MapContext); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -22,7 +22,7 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => { }; const handleMapTypeChange = ( - type: "normal" | "satellite" | "parzellar" | "aerial" + type: "normal" | "satellite" | "parcel" | "aerial" ) => { onMapTypeChange(type); handleClose(); @@ -36,12 +36,27 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => {
-
- -
+ +
+ +
+
+ +
{ + setCurrentMapCache({ + ...currentMapCache, + isDrawing: !currentMapCache.isDrawing, + }); + }} + className="draw-polygon-icon-container leaflet-touch leaflet-bar leaflet-control leaflet-control-custom" + > + +
+
= ({ onMapTypeChange }) => { handleClose(); }} /> - Aerial
- {/* - - Satellite { - handleMapTypeChange("parzellar"); - handleClose(); - }} - /> - - Parzellar - - - */} diff --git a/frontend/src/components/MapView/MapView.css b/frontend/src/components/MapView/MapView.css index 32b25569..731cd6c1 100644 --- a/frontend/src/components/MapView/MapView.css +++ b/frontend/src/components/MapView/MapView.css @@ -10,5 +10,5 @@ } .grayscale-overlay { - filter: grayscale(80%); + filter: grayscale(40%); } diff --git a/frontend/src/components/MapView/MapView.tsx b/frontend/src/components/MapView/MapView.tsx index ba0c7569..5f0e1981 100644 --- a/frontend/src/components/MapView/MapView.tsx +++ b/frontend/src/components/MapView/MapView.tsx @@ -1,10 +1,4 @@ -import { Fragment, useContext, useEffect, useState } from "react"; -import { - MapContainer, - TileLayer, - WMSTileLayer, - ZoomControl, -} from "react-leaflet"; +import { useState } from "react"; import "leaflet/dist/leaflet.css"; import "leaflet.markercluster/dist/MarkerCluster.css"; import "leaflet.markercluster/dist/MarkerCluster.Default.css"; @@ -14,12 +8,7 @@ import L from "leaflet"; import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; import MapOptions from "./MapOptions"; -import { MapContext } from "../../contexts/MapContext"; -import { TabProps, TabsContext } from "../../contexts/TabsContext"; -import MapDatasetVisualizer from "./MapDatasetVisualizer"; -import MapEventsHandler from "./MapEventsHandler"; -import { Dataset } from "../../types/DatasetTypes"; -import ZoomWarningLabel from "../ZoomWarningLabel/ZoomWarningLabel"; +import LeafletMap from "./LeafletMap"; const DefaultIcon = L.icon({ iconUrl: icon, @@ -34,156 +23,24 @@ interface MapViewProps { } const MapView: React.FC = ({ datasetId }) => { - const { currentTabsCache, getCurrentTab, getOrFetchMetadata } = - useContext(TabsContext); - const [map, setMap] = useState(null); - const { currentMapCache, setCurrentMapCache } = useContext(MapContext); - const [isGrayscale, setIsGrayscale] = useState(false); const [mapType, setMapType] = useState< - "normal" | "satellite" | "parzellar" | "aerial" + "normal" | "satellite" | "parcel" | "aerial" >("normal"); + /** + * Changes the layer type + * @param type type of the layer + */ const handleMapTypeChange = ( - type: "normal" | "satellite" | "parzellar" | "aerial" + type: "normal" | "satellite" | "parcel" | "aerial" ) => { setMapType(type); }; - const currentTab = getCurrentTab(); - - useEffect(() => { - if (currentTab) { - getOrFetchMetadata(currentTab.dataset.id); - } - }, [currentTab, getOrFetchMetadata]); - - useEffect(() => { - if (map) { - const initialBounds = map.getBounds(); - const initialCenter = map.getCenter(); - const initialZoom = map.getZoom(); - - setCurrentMapCache((prevCache) => ({ - ...prevCache, - mapInstance: map, - mapCenter: initialCenter, - mapBounds: initialBounds, - zoom: initialZoom, - })); - } - }, [map, setCurrentMapCache]); - - useEffect(() => { - if (currentTab && currentTab.dataset.metaData) { - setIsGrayscale( - currentMapCache.zoom <= currentTab.dataset.metaData.minZoomLevel - ); - } - }, [currentMapCache.zoom, currentTab]); - - useEffect(() => { - if (map) { - if (isGrayscale) { - map.getContainer().classList.add("grayscale-overlay"); - } else { - map.getContainer().classList.remove("grayscale-overlay"); - } - } - }, [isGrayscale, map]); - - const pinnedFeatureCollections = currentTabsCache.openedTabs - .filter((tab: TabProps) => tab.ifPinned) - .map((tab: TabProps) => tab.dataset); - - const isCurrentDataPinned = pinnedFeatureCollections.some( - (dataset: Dataset) => dataset.id === datasetId - ); - return (
+ - - - {isGrayscale ? ( - - ) : ( -
- {pinnedFeatureCollections.map((dataset: Dataset, index: number) => ( - - ))} - {!isCurrentDataPinned && currentTab && ( - - )} -
- )} - - - {mapType === "satellite" && ( -
- - -
- )} - {mapType === "aerial" && ( -
- - -
- )} - {mapType === "normal" && ( -
- -
- )} - {mapType === "parzellar" && ( -
- - - -
- )} - -
); }; diff --git a/frontend/src/components/MultiMap/MultiMap.css b/frontend/src/components/MultiMap/MultiMap.css index 98a11f97..08af6286 100644 --- a/frontend/src/components/MultiMap/MultiMap.css +++ b/frontend/src/components/MultiMap/MultiMap.css @@ -4,8 +4,8 @@ max-width: 100%; display: flex; flex-direction: column; - padding-left:20px; - padding-right:10px; + padding-left: 20px; + padding-right: 10px; padding-top: 3px; } @@ -75,33 +75,6 @@ right: 0.2rem; } -.add-tab-button { - background-color: white; - border-color: gray; - color: gray; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - margin: 0.4rem; - min-width: 2rem; - min-height: 2rem; - width: 2rem; - height: 2rem; - margin-right: 2rem; -} - -.add-tab-button:hover { - background-color: rgb(245, 245, 245); - border-color: black; - color: black; -} - -.add-tab-button:focus { - color: black; - border-color: black; -} - .MuiTabs-scrollButtons.Mui-disabled { opacity: 0.3 !important; } diff --git a/frontend/src/components/MultiMap/NewTabButton.css b/frontend/src/components/MultiMap/NewTabButton.css new file mode 100644 index 00000000..a7b70ade --- /dev/null +++ b/frontend/src/components/MultiMap/NewTabButton.css @@ -0,0 +1,27 @@ +.add-tab-button { + background-color: white; + border-color: gray; + color: gray; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + margin: 0.4rem; + min-width: 2rem; + min-height: 2rem; + width: 2rem; + height: 2rem; + margin-right: 2rem; +} + +.add-tab-button:hover { + background-color: rgb(245, 245, 245); + border-color: black; + color: black; +} + +.add-tab-button:focus { + color: none; + border-color: none; + outline: none; +} diff --git a/frontend/src/components/MultiMap/NewTabButton.tsx b/frontend/src/components/MultiMap/NewTabButton.tsx index 7c4315c7..8e2d2c4e 100644 --- a/frontend/src/components/MultiMap/NewTabButton.tsx +++ b/frontend/src/components/MultiMap/NewTabButton.tsx @@ -2,6 +2,7 @@ import { Tooltip } from "@mui/material"; import { Plus } from "@phosphor-icons/react"; import { useState } from "react"; import DatasetsPopUp from "../PopUp/DatasetsPopUp"; +import "./NewTabButton.css"; const NewTabButton = () => { // Stores the state of if the datasets popup is open diff --git a/frontend/src/components/SearchBar/SearchBar.tsx b/frontend/src/components/SearchBar/SearchBar.tsx index 6dd978a2..40ca0c6a 100644 --- a/frontend/src/components/SearchBar/SearchBar.tsx +++ b/frontend/src/components/SearchBar/SearchBar.tsx @@ -8,9 +8,13 @@ import { MapSelection, SearchContext } from "../../contexts/SearchContext"; import { OpenStreetMapProvider } from "leaflet-geosearch"; import { LatLng } from "leaflet"; import { MapContext } from "../../contexts/MapContext"; -import L from "leaflet"; import "./SearchBar.css"; -import { GeoJSON } from "geojson"; +import { GeoJSON, MultiPolygon } from "geojson"; +import { + MarkerSelection, + PolygonSelection, +} from "../../types/MapSelectionTypes"; +import L from "leaflet"; declare module "leaflet-geosearch/dist/providers/openStreetMapProvider.js" { interface RawResult { @@ -111,24 +115,42 @@ const SearchBar: React.FC = () => { if (mapInstance) { if (item.area && item.bounds) { mapInstance.flyToBounds(item.bounds, { animate: true, duration: 5 }); - const drawPolygon = L.geoJSON(item.polygon, { - style: { - color: "#ff0000", - weight: 2, - fillOpacity: 0, - }, - }); - drawPolygon.addTo(mapInstance); - setCurrentMapCache({ - ...currentMapCache, - polygon: drawPolygon, - selectedCoordinates: null, - }); + if (currentMapCache.drawnItems) { + currentMapCache.drawnItems.clearLayers(); + } + if (item.polygon) { + const drawPolygon = L.geoJSON(item.polygon, { + style: { + color: "#ff0000", + weight: 2, + fillOpacity: 0.06, + }, + }); + drawPolygon.addTo(currentMapCache.drawnItems!); + const polygonSelection = new PolygonSelection( + item.polygon as MultiPolygon, + item.displayName, + false + ); + setCurrentMapCache({ + ...currentMapCache, + selectedCoordinates: polygonSelection, + }); + } } else { - currentMapCache.selectedCoordinates = targetPosition; - mapInstance.flyTo(targetPosition, 13, { animate: true, duration: 5 }); + // Select a marker on the map + const markerSelection = new MarkerSelection( + targetPosition, + item.displayName, + false + ); + currentMapCache.selectedCoordinates = markerSelection; + mapInstance.flyTo(targetPosition, currentMapCache.zoom, { + animate: true, + duration: 5, + }); } - } else console.log("no map instance"); + } else console.log("No map instance"); }; const getUniqueOptions = (options: MapSelection[]) => { @@ -156,7 +178,7 @@ const SearchBar: React.FC = () => { getOptionLabel={(option) => typeof option === "string" ? option : option.displayName } - freeSolo={inputValue?.length ? false : true} + freeSolo={true} loading={loading} forcePopupIcon={false} filterOptions={(x) => x} @@ -183,19 +205,12 @@ const SearchBar: React.FC = () => { } }} onInputChange={(_event, newInputValue) => { - if (newInputValue === "") { - currentMapCache.polygon?.remove(); - setCurrentMapCache({ - ...currentMapCache, - polygon: null, - }); - } setInputValue(newInputValue); }} renderInput={(params) => ( Search…
} + placeholder="Search..." size="small" sx={{ width: inputValue.length > 0 ? "100%" : 150, @@ -237,7 +252,7 @@ const SearchBar: React.FC = () => { ); return ( -
  • +
  • Promise; + openNewTab: (datasetID: Dataset) => boolean; }; // Provider component props type @@ -46,6 +48,7 @@ export const TabsContext = createContext({ setCurrentTabsCache: () => null, getCurrentTab: () => undefined, getOrFetchMetadata: async () => undefined, + openNewTab: () => false, }); // Provider component @@ -54,6 +57,7 @@ export const TabsContextProvider: React.FC = ({ }) => { const [currentTabsCache, setCurrentTabsCache] = useState(defaultTabsCache); + const { currentAlertCache, setCurrentAlertCache } = useContext(AlertContext); /** * Returns the currently opened tab @@ -113,11 +117,43 @@ export const TabsContextProvider: React.FC = ({ } }; + /** + * Opens a new tab + * @param dataset a dataset id to open + */ + const openNewTab = (dataset: Dataset) => { + if ( + currentTabsCache.openedTabs.some((tab) => tab.dataset.id === dataset.id) + ) { + setCurrentAlertCache({ + ...currentAlertCache, + isAlertOpened: true, + text: "This dataset was already added.", + }); + return false; + } + + const newTabID = currentTabsCache.openedTabs.length + 1; + const newTab: TabProps = { + id: newTabID.toString(), + dataset: dataset, + ifPinned: false, + }; + + setCurrentTabsCache({ + ...currentTabsCache, + currentTabID: newTab.id, + openedTabs: [...currentTabsCache.openedTabs, newTab], + }); + return true; + }; + const value = { currentTabsCache, setCurrentTabsCache, getCurrentTab, getOrFetchMetadata, + openNewTab, }; return {children}; diff --git a/frontend/src/services/locationDataService.ts b/frontend/src/services/locationDataService.ts index 86d14449..0a9a9028 100644 --- a/frontend/src/services/locationDataService.ts +++ b/frontend/src/services/locationDataService.ts @@ -1,17 +1,23 @@ import axios from "axios"; import { LocationDataResponse } from "../types/LocationDataTypes"; import { getAPIGatewayURL } from "../utils/apiGatewayURL"; +import { Position } from "geojson"; -export const fetchLocationData = async (): Promise< - LocationDataResponse | undefined -> => { +/** + * Fetches the data from a specific location + * @param datasetId the dataset ID of the current map + * @param location an array of coordinates + * @returns + */ +export const fetchLocationData = async ( + datasetId: string, + location: Position[][][] | Position[][] +): Promise => { + // Build the request body const requestBody = { - datasetId: "example_dataset", - location: [ - { latitude: 51.509865, longitude: -0.118092 }, // Example coordinate - ], + datasetId: datasetId, + location: location, }; - try { const response = await axios.put( getAPIGatewayURL() + "/api/loadLocationData", diff --git a/frontend/src/types/MapSelectionTypes.tsx b/frontend/src/types/MapSelectionTypes.tsx new file mode 100644 index 00000000..f1c0f8c3 --- /dev/null +++ b/frontend/src/types/MapSelectionTypes.tsx @@ -0,0 +1,32 @@ +import { LatLng } from "leaflet"; +import { MultiPolygon } from "geojson"; + +// Define PolygonSelection class +export class PolygonSelection { + polygon: MultiPolygon; + displayName: string; + ifHandSelected: boolean; + + constructor( + polygon: MultiPolygon, + displayName: string, + ifHandSelected: boolean + ) { + this.polygon = polygon; + this.displayName = displayName; + this.ifHandSelected = ifHandSelected; + } +} + +// An interface for a single marker selection +export class MarkerSelection { + marker: LatLng; + displayName: string; + ifHandSelected: boolean; + + constructor(marker: LatLng, displayName: string, ifHandSelected: boolean) { + this.marker = marker; + this.displayName = displayName; + this.ifHandSelected = ifHandSelected; + } +}