diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7eeb9bd5..b4a04118 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,13 +31,17 @@ "proj4leaflet": "^1.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "three": "^0.166.1", + "uuid": "^10.0.0" }, "devDependencies": { "@types/leaflet": "^1.9.12", "@types/node": "^20.12.12", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/three": "^0.166.0", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", @@ -1746,6 +1750,12 @@ "win32" ] }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.2", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.2.tgz", + "integrity": "sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==", + "dev": true + }, "node_modules/@types/autosuggest-highlight": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@types/autosuggest-highlight/-/autosuggest-highlight-3.2.3.tgz", @@ -1887,6 +1897,37 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "dev": true + }, + "node_modules/@types/three": { + "version": "0.166.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.166.0.tgz", + "integrity": "sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==", + "dev": true, + "dependencies": { + "@tweenjs/tween.js": "~23.1.2", + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, + "node_modules/@types/webxr": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.19.tgz", + "integrity": "sha512-4hxA+NwohSgImdTSlPXEqDqqFktNgmTXQ05ff1uWam05tNGroCMp4G+4XVl6qWm1p7GQ/9oD41kAYsSssF6Mzw==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz", @@ -2852,6 +2893,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3395,6 +3442,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true + }, "node_modules/mgrs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", @@ -4047,6 +4100,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/three": { + "version": "0.166.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.166.1.tgz", + "integrity": "sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -4161,6 +4219,18 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "5.2.12", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 023b86b8..98b4f8be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,13 +33,17 @@ "proj4leaflet": "^1.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "three": "^0.166.1", + "uuid": "^10.0.0" }, "devDependencies": { "@types/leaflet": "^1.9.12", "@types/node": "^20.12.12", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/three": "^0.166.0", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/src/App.css b/frontend/src/App.css index c3c7000e..61662613 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,7 +2,7 @@ position: fixed; top: 0; left: 0; - width: 100%; + width: 100%; height: 100%; overflow: hidden; } @@ -12,30 +12,35 @@ overflow: hidden; height: 100%; position: relative; + padding: 0.6rem; } .multi-map { position: relative; width: 50%; - transition: width 0.5s; - padding-top: 10px; - padding-left: 0px; - padding-bottom: 10px; + transition: width 0.5s; +} + +.multimap-container { + height: 100%; + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + padding-right: 10px; + position: relative; } .data-view { width: 50%; position: relative; - transition: width 0.5s; + transition: width 0.5s; overflow: hidden; box-sizing: border-box; - padding-top: 10px; - padding-bottom: 10px; - padding-right:10px; } .hidden { - width: 0; + width: 0; overflow: hidden; padding: 0px; } @@ -44,21 +49,14 @@ width: 100%; } -.multimap-container { - display: flex; - flex-direction: column; - position: relative; - height: 100%; -} - -.toggle-button { +.toggle-data-view-button { position: absolute; z-index: 5; - top: 13px; - right: 0px; - align-self: end; + top: 7px; + right: 0px; + align-self: end; padding: 0; - padding-top:3px; + padding-top: 3px; align-items: center; justify-content: center; min-width: 2rem; @@ -82,4 +80,4 @@ .toggle-button:focus { color: black; border-color: black; -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f6a19255..5140d977 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,13 +11,12 @@ import { TabsContext } from "./contexts/TabsContext"; import MainMenu from "./components/MainMenu/MainMenu"; import ErrorAlert from "./components/Alerts/ErrorAlert"; -import Tooltip from '@mui/material/Tooltip'; +import Tooltip from "@mui/material/Tooltip"; import { CaretDoubleLeft, CaretDoubleRight } from "@phosphor-icons/react"; import { MapContext } from "./contexts/MapContext"; function App() { - const { currentMapCache } = useContext(MapContext); const { currentTabsCache } = useContext(TabsContext); @@ -27,7 +26,9 @@ function App() { const toggleDataView = () => { setDataViewVisible((prevVisible) => !prevVisible); const { mapInstance } = currentMapCache; - setTimeout(function(){ mapInstance?.invalidateSize()}, 400); + setTimeout(function () { + mapInstance?.invalidateSize(); + }, 400); }; return ( @@ -37,24 +38,30 @@ function App() { ) : (
-
-
- - - - - -
+ +
- +
diff --git a/frontend/src/components/DataView/DataPanel.css b/frontend/src/components/DataView/DataPanel.css deleted file mode 100644 index 15cc6023..00000000 --- a/frontend/src/components/DataView/DataPanel.css +++ /dev/null @@ -1,58 +0,0 @@ -.datapanels-container { - width: 100%; - height: 100%; - max-height: 100%; - display: flex; - flex-direction: column; - overflow-y: scroll; - margin-bottom: 1rem; -} - -.data-panel-container { - display: flex; - flex-direction: column; -} - -.data-panel-title { - display: flex; - padding: 0.5rem; - gap: 0.5rem; - align-items: center; - cursor: pointer; - font-weight: bold; -} - -.data-panel-grid { - margin: 0 !important; - padding: 0 !important; - max-height: 3000px; - transition: all 0.2s ease-in-out; - overflow: visible; -} - -.data-panel-grid-hidden { - max-height: 0px; - overflow: hidden; - transition: all 0.2s ease-in-out; -} - -.MuiGrid-root { - padding: 0 !important; -} - -.search-box-label { - display: flex; - justify-content: center; - align-items: center; - gap: 0.3rem; -} - -.data-panel-toggle-icon { - rotate: 0deg; - transition: all 0.2s ease-in-out; -} - -.data-panel-toggle-icon-hidden { - transition: all 0.2s ease-in-out; - rotate: -90deg; -} diff --git a/frontend/src/components/DataView/DataPanel.tsx b/frontend/src/components/DataView/DataPanel.tsx deleted file mode 100644 index 560037fc..00000000 --- a/frontend/src/components/DataView/DataPanel.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; -import Grid from "@mui/material/Grid"; -import IconButton from "@mui/material/IconButton"; -import { CaretDown, MapTrifold } from "@phosphor-icons/react"; -import "./DataPanel.css"; -import { Tooltip } from "@mui/material"; -import { GridToolbar, GridToolbarProps } from "@mui/x-data-grid"; -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 ; -} - -interface DataPanelProps { - listTitle: string; - filterValue: string; - mapRows: DatasetItem[]; - genericRows: DatasetItem[]; -} - -/* - This component displays a mui DataGrid. - Depending on the value of the "button" column, a map icon with the hover "open as map" is shown -*/ -const DataPanel: React.FC = ({ - listTitle, - filterValue, - mapRows, - genericRows, -}) => { - // Keep track of if tabs are hidden - const [ifMapDataTabHidden, toggleMapDataHidden] = useState(false); - const [ifGeneralDataTabHidden, toggleGeneralDataHidden] = - useState(false); - 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 ( -
-
-
{ - toggleMapDataHidden(!ifMapDataTabHidden); - }} - > - - {listTitle} -
- - { - return row.key + row.value; - }} - hideFooter={true} - disableColumnMenu - columnHeaderHeight={0} - rows={mapRows} - columns={columns} - slots={{ - toolbar: MyCustomToolbar, - }} - slotProps={{ - toolbar: { - printOptions: { disableToolbarButton: true }, - csvOptions: { disableToolbarButton: true }, - }, - }} - disableDensitySelector - disableColumnFilter - disableColumnSelector - disableColumnSorting - initialState={{ - filter: { - filterModel: { - items: [], - quickFilterValues: [filterValue], - quickFilterExcludeHiddenColumns: true, - }, - }, - }} - filterModel={{ - items: [ - { field: "key", operator: "contains", value: filterValue }, - ], - }} - density="compact" - disableRowSelectionOnClick - autoHeight - /> - -
-
-
{ - toggleGeneralDataHidden(!ifGeneralDataTabHidden); - }} - > - - Individual Data -
- - { - return row.key + row.value; - }} - hideFooter={true} - disableColumnMenu - columnHeaderHeight={0} - rows={genericRows} - columns={columns} - slots={{ - toolbar: MyCustomToolbar, - }} - slotProps={{ - toolbar: { - printOptions: { disableToolbarButton: true }, - csvOptions: { disableToolbarButton: true }, - }, - }} - disableDensitySelector - disableColumnFilter - disableColumnSelector - disableColumnSorting - initialState={{ - filter: { - filterModel: { - items: [], - quickFilterValues: [filterValue], - quickFilterExcludeHiddenColumns: true, - }, - }, - }} - filterModel={{ - items: [ - { field: "key", operator: "contains", value: filterValue }, - ], - }} - density="compact" - disableRowSelectionOnClick - autoHeight - /> - -
-
- ); -}; - -export default DataPanel; diff --git a/frontend/src/components/DataView/DataRow.css b/frontend/src/components/DataView/DataRow.css new file mode 100644 index 00000000..e6138b19 --- /dev/null +++ b/frontend/src/components/DataView/DataRow.css @@ -0,0 +1,20 @@ +.toggle-column { + width: 2rem; +} + +.data-row { + height: 40px; + background-color: rgb(251, 251, 251); +} + +.data-row-title-container { + padding-left: 0 !important; +} + +.data-row-value { + text-align: end !important; +} + +.subdata-rows-container > *:last-child > * { + border-bottom: none; +} diff --git a/frontend/src/components/DataView/DataRow.tsx b/frontend/src/components/DataView/DataRow.tsx new file mode 100644 index 00000000..6be0649e --- /dev/null +++ b/frontend/src/components/DataView/DataRow.tsx @@ -0,0 +1,172 @@ +import { Fragment, useState, useEffect } from "react"; +import { DatasetItem } from "../../types/LocationDataTypes"; +import { useContext } from "react"; +import { + Collapse, + IconButton, + Table, + TableBody, + TableCell, + TableRow, + Tooltip, +} from "@mui/material"; +import { CaretDown, CaretUp, MapPin } from "@phosphor-icons/react"; +import "./DataRow.css"; +import { Dataset, DatasetBasicData } from "../../types/DatasetTypes"; +import CustomSvgIcon from "../DatasetsList/CustomSvgIcon"; +import { svgIconDefault } from "../DatasetsList/DatasetsList"; +import { AlertContext } from "../../contexts/AlertContext"; +import L, { LatLng } from "leaflet"; +import { TabsContext } from "../../contexts/TabsContext"; +import { MapContext } from "../../contexts/MapContext"; + +interface RowProps { + row: DatasetItem; + currentDatasets: DatasetBasicData[]; +} + +const DataRow: React.FC = ({ row, currentDatasets }) => { + const [open, setOpen] = useState(false); + const [shouldFlyTo, setShouldFlyTo] = useState(null); + const { currentAlertCache, setCurrentAlertCache } = useContext(AlertContext); + const { changeToOrOpenNewTab } = useContext(TabsContext); + const { currentMapCache } = useContext(MapContext); + + /** + * Triggers fly to on the next map change + */ + useEffect(() => { + if (shouldFlyTo && currentMapCache.mapInstance) { + currentMapCache.mapInstance.flyTo(shouldFlyTo, currentMapCache.zoom); + setShouldFlyTo(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentMapCache.mapInstance]); + + const openDatasetFromMapIcon = async ( + mapId: string | null, + coordinates: number[] | null + ) => { + if (currentDatasets) { + const datasetToOpen = currentDatasets.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)), + }; + // Open the map + const ifSwitched = changeToOrOpenNewTab(datasetToOpenTransformed); + // If provided fly to the coordinates + if (coordinates && coordinates.length === 2) { + const latLng = new LatLng(coordinates[0], coordinates[1]); + if (ifSwitched) { + setShouldFlyTo(latLng); + } else { + if (currentMapCache.mapInstance) { + currentMapCache.mapInstance.flyTo(latLng, currentMapCache.zoom); + } + } + } + } else { + // Display alert + setCurrentAlertCache({ + ...currentAlertCache, + isAlertOpened: true, + text: "Dataset with provided ID does not exist.", + }); + console.error("Dataset with provided ID does not exist."); + } + } + }; + + return ( + + + {row.subdata.length > 0 ? ( + + setOpen(!open)} + > + {open ? : } + + + ) : ( + + )} + + {row.displayName} + + {row.value && row.value !== "" ? ( + + {row.value} + + ) : ( + + )} + {row.datasetID && row.datasetID !== "" ? ( + + + { + openDatasetFromMapIcon(row.datasetID, row.coordinate); + }} + > + + + + + ) : ( + + )} + + + + + + + {row.subdata.map((subItem) => ( + + + {subItem.key} + + {subItem.value} + + ))} + +
+
+
+
+
+ ); +}; + +export default DataRow; diff --git a/frontend/src/components/DataView/DataView.css b/frontend/src/components/DataView/DataView.css index e75e3dd8..98b80c9e 100644 --- a/frontend/src/components/DataView/DataView.css +++ b/frontend/src/components/DataView/DataView.css @@ -103,3 +103,27 @@ left: 0; z-index: 10; /* Ensure the spinner is on top */ } + +.data-panel-title { + display: flex; + padding: 1rem 0.5rem 1rem 0.5rem; + gap: 0.5rem; + align-items: center; + cursor: pointer; + font-weight: bold; + margin: 0; +} + +.datapanels-container { + width: 100%; + height: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; + margin-bottom: 1rem; +} + +.data-collapse-table { + min-height: auto !important; +} diff --git a/frontend/src/components/DataView/DataView.tsx b/frontend/src/components/DataView/DataView.tsx index f1f8029f..2805fffe 100644 --- a/frontend/src/components/DataView/DataView.tsx +++ b/frontend/src/components/DataView/DataView.tsx @@ -1,19 +1,38 @@ -import DataPanel from "./DataPanel"; import "./DataView.css"; -import { Fragment, useContext, useEffect, useState } from "react"; +import React, { Fragment, useContext, useEffect, useState } from "react"; +import { + Box, + TextField, + Tooltip, + CircularProgress, + Table, + TableBody, + TableContainer, + Collapse, +} from "@mui/material"; +import { + CaretDown, + CaretUp, + Funnel, + MapPin, + MapPinLine, +} from "@phosphor-icons/react"; import { TabsContext } from "../../contexts/TabsContext"; -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 { + LocationDataResponse, + DatasetItem, +} from "../../types/LocationDataTypes"; import { fetchLocationData } from "../../services/locationDataService"; import { MarkerSelection, PolygonSelection, } from "../../types/MapSelectionTypes"; import { MultiPolygon, Position } from "geojson"; -import { CircularProgress } from "@mui/material"; +import DataRow from "./DataRow"; +import { fetchDatasets } from "../../services/datasetsService"; +import { DatasetBasicData } from "../../types/DatasetTypes"; // Function to filter and return an array of outer polygons function getOuterPolygons(multiPolygon: MultiPolygon): Position[][] { @@ -21,13 +40,31 @@ function getOuterPolygons(multiPolygon: MultiPolygon): Position[][] { return multiPolygon.coordinates.map((polygon) => polygon[0]); } -function DataView() { +const DataView = () => { const [isLoading, setIsLoading] = useState(false); const { currentTabsCache, getCurrentTab } = useContext(TabsContext); const { currentMapCache, setCurrentMapCache } = useContext(MapContext); const [filterValue, setFilterValue] = useState(""); const [ifNeedsReloading, setIfNeedsReloading] = useState(false); const [data, setData] = useState(); + const [showSelectionData, setShowSelectionData] = useState(true); + const [showIndividualData, setShowIndividualData] = useState(true); + const [currentDatasets, setCurrentDatasets] = useState( + [] + ); + + /** + * Fetch the current datasets + */ + useEffect(() => { + const fetchCurrentDatasets = async () => { + const datasets = await fetchDatasets(); + if (datasets) { + setCurrentDatasets(datasets); + } + }; + fetchCurrentDatasets(); + }); const handleFilterChange = (event: React.ChangeEvent) => { setFilterValue(event.target.value); @@ -100,14 +137,19 @@ function DataView() { } else { console.log("Currently selected coordinates are null."); } - - setIsLoading(false); // Set loading to false after the fetch request completes + // Set loading to false after the fetch request completes + setIsLoading(false); }; + const filterData = (items: DatasetItem[]) => + items.filter((item) => + item.displayName.toLowerCase().includes(filterValue.toLowerCase()) + ); + return (
{currentMapCache.loadedCoordinates ? ( - +
@@ -155,14 +197,59 @@ function DataView() {
) : ( - + +

setShowSelectionData(!showSelectionData)} + className="data-panel-title" + > + {showSelectionData ? : } + Selection Data +

+ + + + + {filterData(data?.selectionData ?? []).map((row) => ( + + ))} + +
+
+
+

setShowIndividualData(!showIndividualData)} + className="data-panel-title" + > + {showIndividualData ? : } + Individual Data +

+ + + + + {filterData(data?.individualData ?? []).map((row) => ( + + ))} + +
+
+
+
)} - {ifNeedsReloading ? ( + {ifNeedsReloading && (
- ) : ( - )} - +
) : (

No coordinates selected

- Click on the map, to select a new location + Click on the map to select a new location
); -} +}; export default DataView; diff --git a/frontend/src/components/MapView/LeafletMap.css b/frontend/src/components/MapView/LeafletMap.css index e337a3fe..6165ccdb 100644 --- a/frontend/src/components/MapView/LeafletMap.css +++ b/frontend/src/components/MapView/LeafletMap.css @@ -16,3 +16,8 @@ background-color: #ff0000 !important; border-radius: 1rem; } + +.leaflet-interactive { + stroke-opacity: 1 !important; + stroke-width: 1.5 !important; +} diff --git a/frontend/src/components/MapView/LeafletMap.tsx b/frontend/src/components/MapView/LeafletMap.tsx index a41dd0a2..dd03b6d5 100644 --- a/frontend/src/components/MapView/LeafletMap.tsx +++ b/frontend/src/components/MapView/LeafletMap.tsx @@ -34,10 +34,10 @@ import { interface LeafletMapProps { datasetId: string; - mapType: string; + if3D: boolean; } -const LeafletMap: React.FC = ({ datasetId, mapType }) => { +const LeafletMap: React.FC = ({ datasetId, if3D }) => { const { currentTabsCache, getCurrentTab, getOrFetchMetadata } = useContext(TabsContext); const [map, setMap] = useState(null); @@ -102,7 +102,6 @@ const LeafletMap: React.FC = ({ datasetId, mapType }) => { const initialBounds = map.getBounds(); const initialCenter = map.getCenter(); const initialZoom = map.getZoom(); - const drawnItems = new L.FeatureGroup(); setCurrentMapCache((prevCache) => ({ ...prevCache, @@ -110,10 +109,9 @@ const LeafletMap: React.FC = ({ datasetId, mapType }) => { mapCenter: initialCenter, mapBounds: initialBounds, zoom: initialZoom, - drawnItems: drawnItems, })); // Allow for drawing polygons - map.addLayer(drawnItems); + map.addLayer(currentMapCache.drawnItems); // Define the options for the polygon drawer const polygonOptions = { shapeOptions: { @@ -127,39 +125,36 @@ const LeafletMap: React.FC = ({ datasetId, mapType }) => { 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, - }); + currentMapCache.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, + }); } }); } @@ -209,7 +204,7 @@ const LeafletMap: React.FC = ({ datasetId, mapType }) => { maxBounds={L.latLngBounds([47.1512, 5.6259], [54.967, 15.4446])} minZoom={6} > - + {!if3D && } {isGrayscale ? ( ) : ( @@ -223,10 +218,10 @@ const LeafletMap: React.FC = ({ datasetId, mapType }) => {
)} - {mapType === "satellite" && } - {mapType === "aerial" && } - {mapType === "normal" && } - {mapType === "parcel" && } + {currentMapCache.mapType === "satellite" && } + {currentMapCache.mapType === "aerial" && } + {currentMapCache.mapType === "normal" && } + {currentMapCache.mapType === "parcel" && }
diff --git a/frontend/src/components/MapView/MapOptions.css b/frontend/src/components/MapView/MapOptions.css index 620e3629..ee1bd4b5 100644 --- a/frontend/src/components/MapView/MapOptions.css +++ b/frontend/src/components/MapView/MapOptions.css @@ -68,7 +68,7 @@ border-color: blue; } -.draw-polygon-icon-container { +.threed-map-icon-container { width: 2.1rem; height: 2.1rem; position: absolute; @@ -84,7 +84,77 @@ border: 2px solid rgba(0, 0, 0, 0.27); box-shadow: none; } +.threed-map-icon-container:hover { + background-color: #f4f4f4; + color: #535bf2; +} + +.draw-polygon-icon-container { + width: 2.1rem; + height: 2.1rem; + position: absolute; + top: 10rem; + 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; } + +.draw-polygon-icon-disabled { + background-color: rgb(215, 215, 215) !important; +} + +.draw-polygon-icon-disabled:hover { + background-color: rgb(215, 215, 215) !important; + color: black !important; +} + +.zoom-in-icon-container { + width: 2.1rem; + height: 2rem; + position: absolute; + top: 0.6rem; + right: 0.6rem; + cursor: pointer; + z-index: 2; + display: flex; + flex-direction: column; + background-color: rgb(215, 215, 215); + justify-content: center; + align-items: center; + border: 2px solid rgba(0, 0, 0, 0.27); + box-shadow: none; + text-align: center; + font-size: 1.65rem; + border-bottom-width: 1px; +} + +.zoom-out-icon-container { + width: 2.1rem; + height: 2.1rem; + position: absolute; + top: 2.5rem; + right: 0.6rem; + cursor: pointer; + z-index: 2; + display: flex; + flex-direction: column; + background-color: rgb(215, 215, 215); + justify-content: center; + align-items: center; + border: 2px solid rgba(0, 0, 0, 0.27); + box-shadow: none; + text-align: center; + font-size: 2rem; + border-top-width: 1px; +} diff --git a/frontend/src/components/MapView/MapOptions.tsx b/frontend/src/components/MapView/MapOptions.tsx index d0c645c2..18fc2188 100644 --- a/frontend/src/components/MapView/MapOptions.tsx +++ b/frontend/src/components/MapView/MapOptions.tsx @@ -1,15 +1,22 @@ -import React, { useContext, useState } from "react"; +import React, { Fragment, useContext, useState } from "react"; import { Paper, Popover, Grid, Typography, Box, Tooltip } from "@mui/material"; import "./MapOptions.css"; -import { Polygon, StackSimple } from "@phosphor-icons/react"; +import { Polygon, StackSimple, ThreeD } from "@phosphor-icons/react"; import SearchBar from "../SearchBar/SearchBar"; import { MapContext } from "../../contexts/MapContext"; +import { MapTypes } from "../../types/MapTypes"; interface MapOptionsProps { - onMapTypeChange: (type: "normal" | "satellite" | "parcel" | "aerial") => void; + onMapTypeChange: (type: MapTypes) => void; + if3D: boolean; + toggle3D: () => void; } -const MapOptions: React.FC = ({ onMapTypeChange }) => { +const MapOptions: React.FC = ({ + onMapTypeChange, + if3D, + toggle3D, +}) => { const [anchorEl, setAnchorEl] = useState(null); const { currentMapCache, setCurrentMapCache } = useContext(MapContext); @@ -21,9 +28,7 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => { setAnchorEl(null); }; - const handleMapTypeChange = ( - type: "normal" | "satellite" | "parcel" | "aerial" - ) => { + const handleMapTypeChange = (type: MapTypes) => { onMapTypeChange(type); handleClose(); }; @@ -44,19 +49,43 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => {
+ +
+ +
+
{ - setCurrentMapCache({ - ...currentMapCache, - isDrawing: !currentMapCache.isDrawing, - }); + if (!if3D) { + setCurrentMapCache({ + ...currentMapCache, + isDrawing: !currentMapCache.isDrawing, + }); + } }} - className="draw-polygon-icon-container leaflet-touch leaflet-bar leaflet-control leaflet-control-custom" + className={`draw-polygon-icon-container ${ + if3D ? "draw-polygon-icon-disabled" : "" + } leaflet-touch leaflet-bar leaflet-control leaflet-control-custom`} >
+ {if3D ? ( + +
+ + +
+
+ - +
+
+ ) : ( + + )} = ({ onMapTypeChange }) => { width="50" height="50" onClick={() => { - handleMapTypeChange("normal"); + handleMapTypeChange(MapTypes.Normal); handleClose(); }} /> @@ -114,7 +143,7 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => { width="50" height="50" onClick={() => { - handleMapTypeChange("satellite"); + handleMapTypeChange(MapTypes.Satellite); handleClose(); }} /> @@ -132,7 +161,7 @@ const MapOptions: React.FC = ({ onMapTypeChange }) => { width="50" height="50" onClick={() => { - handleMapTypeChange("aerial"); + handleMapTypeChange(MapTypes.Aerial); handleClose(); }} /> diff --git a/frontend/src/components/MapView/MapView.tsx b/frontend/src/components/MapView/MapView.tsx index 5f0e1981..c2414603 100644 --- a/frontend/src/components/MapView/MapView.tsx +++ b/frontend/src/components/MapView/MapView.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useContext, useState } from "react"; import "leaflet/dist/leaflet.css"; import "leaflet.markercluster/dist/MarkerCluster.css"; import "leaflet.markercluster/dist/MarkerCluster.Default.css"; @@ -9,6 +9,9 @@ import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; import MapOptions from "./MapOptions"; import LeafletMap from "./LeafletMap"; +import ThreeDView from "../ThreeDView/ThreeDView"; +import { MapTypes } from "../../types/MapTypes"; +import { MapContext } from "../../contexts/MapContext"; const DefaultIcon = L.icon({ iconUrl: icon, @@ -23,24 +26,36 @@ interface MapViewProps { } const MapView: React.FC = ({ datasetId }) => { - const [mapType, setMapType] = useState< - "normal" | "satellite" | "parcel" | "aerial" - >("normal"); + const [if3D, setIf3D] = useState(false); + const { currentMapCache, setCurrentMapCache } = useContext(MapContext); /** * Changes the layer type * @param type type of the layer */ - const handleMapTypeChange = ( - type: "normal" | "satellite" | "parcel" | "aerial" - ) => { - setMapType(type); + const handleMapTypeChange = (type: MapTypes) => { + setCurrentMapCache({ ...currentMapCache, mapType: type }); + }; + + /** + * Toggles the 3D View + */ + const toggle3D = () => { + setIf3D(!if3D); }; return (
- - + {if3D ? ( + + ) : ( + + )} +
); }; diff --git a/frontend/src/components/MultiMap/MultiMap.css b/frontend/src/components/MultiMap/MultiMap.css index 08af6286..ed93d12b 100644 --- a/frontend/src/components/MultiMap/MultiMap.css +++ b/frontend/src/components/MultiMap/MultiMap.css @@ -1,12 +1,9 @@ -.multimap-container { +.multimap-inner-container { height: 100%; width: 100%; max-width: 100%; display: flex; flex-direction: column; - padding-left: 20px; - padding-right: 10px; - padding-top: 3px; } .tab-list-container { diff --git a/frontend/src/components/MultiMap/MultiMap.tsx b/frontend/src/components/MultiMap/MultiMap.tsx index 70882142..84e9e791 100644 --- a/frontend/src/components/MultiMap/MultiMap.tsx +++ b/frontend/src/components/MultiMap/MultiMap.tsx @@ -41,7 +41,7 @@ const MultiMap = () => { }; return ( -
+
{ 0 ? 400 : 150 }} getOptionLabel={(option) => typeof option === "string" ? option : option.displayName } diff --git a/frontend/src/components/ThreeDView/ThreeDView.tsx b/frontend/src/components/ThreeDView/ThreeDView.tsx new file mode 100644 index 00000000..0ea27f90 --- /dev/null +++ b/frontend/src/components/ThreeDView/ThreeDView.tsx @@ -0,0 +1,152 @@ +import React, { useRef, useEffect } from "react"; +import * as THREE from "three"; +import { + CSS3DObject, + CSS3DRenderer, +} from "three/examples/jsm/renderers/CSS3DRenderer.js"; +import { MapControls } from "three/examples/jsm/controls/MapControls.js"; +import "leaflet/dist/leaflet.css"; +import LeafletMap from "../MapView/LeafletMap"; + +interface ThreeDViewProps { + datasetId: string; +} + +const ThreeDView: React.FC = ({ datasetId }) => { + const mountRef = useRef(null); + const threedMapRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current!; + const mapElement = threedMapRef.current!; + + // Scene setup + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x87ceeb); // Light blue sky + const camera = new THREE.PerspectiveCamera( + 75, + mount.clientWidth / mount.clientHeight, + 0.1, + 1000 + ); + camera.position.set(0, 400, 10); + camera.rotateX(-1); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(mount.clientWidth, mount.clientHeight); + renderer.setPixelRatio(window.devicePixelRatio); + mount.appendChild(renderer.domElement); + + const cssRenderer = new CSS3DRenderer(); + cssRenderer.setSize(mount.clientWidth, mount.clientHeight); + cssRenderer.domElement.style.position = "absolute"; + cssRenderer.domElement.style.top = "0"; + cssRenderer.domElement.style.pointerEvents = "none"; // Allow mouse events to pass through + mount.appendChild(cssRenderer.domElement); + + // Ensure the map container is displayed and has dimensions before initializing Leaflet + mapElement.style.display = "block"; // Ensure the element is visible + mapElement.style.pointerEvents = "auto"; // Ensure the element can consume events + + const cssObject = new CSS3DObject(mapElement); + cssObject.rotation.x = -Math.PI / 2; + cssObject.position.set(0, 0, 0); + scene.add(cssObject); + + // Light + const light = new THREE.DirectionalLight(0xffffff, 1); + light.position.set(10, 10, 10).normalize(); + scene.add(light); + + // Set up MapControls + const controls = new MapControls(camera, cssRenderer.domElement); + + controls.enableDamping = true; // Enable damping (inertia) + controls.dampingFactor = 0.05; // Damping factor + controls.screenSpacePanning = false; // Prevent camera from moving vertically in screen space + controls.minDistance = 10; // Minimum zoom distance + controls.maxDistance = 500; // Maximum zoom distance + controls.maxPolarAngle = Math.PI / 2; // Limit angle from the top + + // Prevent camera from going below a certain height + const minCameraY = 50; + + controls.addEventListener("change", () => { + if (camera.position.y < minCameraY) { + camera.position.y = minCameraY; + } + }); + + // Configure controls to respond only to right-click + controls.mouseButtons = { + LEFT: null, + MIDDLE: null, + RIGHT: THREE.MOUSE.ROTATE, + }; + controls.enableZoom = false; + + // Disable right-click context menu + const disableContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + + mount.addEventListener("contextmenu", disableContextMenu); + + // Resize handler + const handleResize = () => { + camera.aspect = mount.clientWidth / mount.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(mount.clientWidth, mount.clientHeight); + cssRenderer.setSize(mount.clientWidth, mount.clientHeight); + }; + + // Use ResizeObserver to handle parent element resize + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(mount); + + // Animate + const animate = () => { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + cssRenderer.render(scene, camera); + }; + animate(); + + // Cleanup on component unmount + return () => { + resizeObserver.unobserve(mount); + controls.dispose(); + scene.clear(); + renderer.dispose(); + renderer.forceContextLoss(); + mount.removeChild(renderer.domElement); + mount.removeChild(cssRenderer.domElement); + cssObject.position.set(0, 0, 0); + + // Additional cleanup for CSS3DObject + if (cssObject.element.parentNode) { + cssObject.element.parentNode.removeChild(cssObject.element); + } + + // Remove the context menu event listener + mount.removeEventListener("contextmenu", disableContextMenu); + }; + }, []); + + return ( +
+
+ +
+
+ ); +}; + +export default ThreeDView; diff --git a/frontend/src/contexts/MapContext.tsx b/frontend/src/contexts/MapContext.tsx index cf6effb6..6668cc3e 100644 --- a/frontend/src/contexts/MapContext.tsx +++ b/frontend/src/contexts/MapContext.tsx @@ -1,6 +1,7 @@ import L, { LatLng, LatLngBounds } from "leaflet"; import React, { createContext, useState, ReactNode } from "react"; import { MarkerSelection, PolygonSelection } from "../types/MapSelectionTypes"; +import { MapTypes } from "../types/MapTypes"; //// TYPES //// @@ -12,9 +13,10 @@ export type MapCacheProps = { currentTabID: null | string; mapCenter: LatLng; mapBounds: LatLngBounds; + mapType: MapTypes; zoom: number; isDrawing: boolean; - drawnItems: L.FeatureGroup | null; + drawnItems: L.FeatureGroup; }; // Map Context Type @@ -38,9 +40,10 @@ const defaultMapCache: MapCacheProps = { currentTabID: null, // The currently loaded tab ID mapCenter: L.latLng([49.5732, 11.0288]), mapBounds: L.latLngBounds([49.5732, 11.0288], [49.5732, 11.0288]), + mapType: MapTypes.Normal, zoom: 13, isDrawing: false, - drawnItems: null, + drawnItems: new L.FeatureGroup(), }; // Actual value of the context diff --git a/frontend/src/contexts/TabsContext.tsx b/frontend/src/contexts/TabsContext.tsx index 84cc9469..6bdc320c 100644 --- a/frontend/src/contexts/TabsContext.tsx +++ b/frontend/src/contexts/TabsContext.tsx @@ -27,6 +27,7 @@ type TabsContextValue = { datasetID: string ) => Promise; openNewTab: (datasetID: Dataset) => boolean; + changeToOrOpenNewTab: (datasetID: Dataset) => boolean; }; // Provider component props type @@ -49,6 +50,7 @@ export const TabsContext = createContext({ getCurrentTab: () => undefined, getOrFetchMetadata: async () => undefined, openNewTab: () => false, + changeToOrOpenNewTab: () => false, }); // Provider component @@ -148,12 +150,44 @@ export const TabsContextProvider: React.FC = ({ return true; }; + /** + * Opens a new tab if necessary and/or switched to already existing one. + * @param dataset dataset to change to or open + * @return if switched to a different tab + */ + const changeToOrOpenNewTab = (dataset: Dataset) => { + // Check if the provided tab is the current one + const currentTab = getCurrentTab(); + if (currentTab?.dataset.id === dataset.id) { + return false; + } + // Open the tab if it does not exist + if ( + !currentTabsCache.openedTabs.some((tab) => tab.dataset.id === dataset.id) + ) { + openNewTab(dataset); + } else { + // Switch to that tab + const tabID = currentTabsCache.openedTabs.find((tab) => { + return tab.dataset.id === dataset.id; + }); + if (tabID) { + setCurrentTabsCache({ + ...currentTabsCache, + currentTabID: tabID.id, + }); + } + } + return true; + }; + const value = { currentTabsCache, setCurrentTabsCache, getCurrentTab, getOrFetchMetadata, openNewTab, + changeToOrOpenNewTab, }; return {children}; diff --git a/frontend/src/types/LocationDataTypes.tsx b/frontend/src/types/LocationDataTypes.tsx index 5163856e..66233b38 100644 --- a/frontend/src/types/LocationDataTypes.tsx +++ b/frontend/src/types/LocationDataTypes.tsx @@ -1,10 +1,17 @@ export interface LocationDataResponse { individualData: DatasetItem[]; - generalData: DatasetItem[]; + selectionData: DatasetItem[]; } -export interface DatasetItem { +export interface SubdataItem { key: string; value: string; - mapId: string; +} + +export interface DatasetItem { + displayName: string; + value: string | null; + datasetID: string | null; + coordinate: number[] | null; + subdata: SubdataItem[]; } diff --git a/frontend/src/types/MapTypes.tsx b/frontend/src/types/MapTypes.tsx new file mode 100644 index 00000000..64098087 --- /dev/null +++ b/frontend/src/types/MapTypes.tsx @@ -0,0 +1,7 @@ +// Enum for types of maps +export enum MapTypes { + Normal = "normal", + Satellite = "satellite", + Parcel = "parcel", + Aerial = "aerial", +}