diff --git a/map/src/App.js b/map/src/App.js index ef400c964..f7a43af9e 100644 --- a/map/src/App.js +++ b/map/src/App.js @@ -23,6 +23,7 @@ import { OLD_LOGIN_URL, TRAVEL_URL, SHARE_FILE_URL, + TRACK_ANALYZER_URL, } from './manager/GlobalManager'; import ExploreMenu from './menu/search/explore/ExploreMenu'; import SearchMenu from './menu/search/SearchMenu'; @@ -36,6 +37,7 @@ import ConfigureMap from './menu/configuremap/ConfigureMap'; import LoginMenu from './menu/login/LoginMenu'; import TravelMenu from './menu/travel/TravelMenu'; import ShareFile from './menu/share/ShareFile'; +import TrackAnalyzerMenu from './menu/analyzer/TrackAnalyzerMenu'; export let globalNavigate = () => null; @@ -70,6 +72,7 @@ const App = () => { }> }> }> + }> diff --git a/map/src/context/AppContext.js b/map/src/context/AppContext.js index c47277651..f9fd25337 100644 --- a/map/src/context/AppContext.js +++ b/map/src/context/AppContext.js @@ -29,6 +29,7 @@ export const OBJECT_CONFIGURE_MAP = 'configure_map'; export const OBJECT_EXPLORE = 'explore'; export const OBJECT_SEARCH = 'search'; export const OBJECT_GLOBAL_SETTINGS = 'global_settings'; +export const OBJECT_TRACK_ANALYZER = 'track_analyzer'; export const LOCAL_STORAGE_CONFIGURE_MAP = 'configureMap'; export const OBJECT_TYPE_TRAVEL = 'travel'; export const OBJECT_TYPE_SHARE_FILE = 'share_file'; diff --git a/map/src/dialogs/tracks/SaveTrackDialog.jsx b/map/src/dialogs/tracks/SaveTrackDialog.jsx index 783f3cbad..94fa5e757 100644 --- a/map/src/dialogs/tracks/SaveTrackDialog.jsx +++ b/map/src/dialogs/tracks/SaveTrackDialog.jsx @@ -3,7 +3,12 @@ import DialogTitle from '@mui/material/DialogTitle'; import DialogContent from '@mui/material/DialogContent'; import { Alert, Autocomplete, Button, createFilterOptions, LinearProgress, TextField } from '@mui/material'; import AppContext, { isRouteTrack, OBJECT_TYPE_CLOUD_TRACK } from '../../context/AppContext'; -import TracksManager, { DEFAULT_GROUP_NAME, isTrackExists, validName } from '../../manager/track/TracksManager'; +import TracksManager, { + DEFAULT_GROUP_NAME, + getAllGroupNames, + isTrackExists, + validName, +} from '../../manager/track/TracksManager'; import DialogActions from '@mui/material/DialogActions'; import DialogContentText from '@mui/material/DialogContentText'; import { prepareFileName } from '../../util/Utils'; @@ -29,26 +34,6 @@ export default function SaveTrackDialog() { : DEFAULT_GROUP_NAME; } - function getAllGroupNames(groups, parentName = '') { - const groupTitles = []; - - groups.forEach((group) => { - if (group.fullName === DEFAULT_GROUP_NAME) { - // skip default folder - return; - } - const groupName = parentName ? `${parentName}/${group.name}` : group.name; - groupTitles.push({ title: groupName }); - - if (group.subfolders) { - const subGroupTitles = getAllGroupNames(group.subfolders, groupName); - groupTitles.push(...subGroupTitles); - } - }); - - return groupTitles; - } - const closeDialog = ({ uploaded }) => { setProcess(false); if (uploaded) { diff --git a/map/src/manager/GlobalManager.js b/map/src/manager/GlobalManager.js index 0b87581af..35aed1eb9 100644 --- a/map/src/manager/GlobalManager.js +++ b/map/src/manager/GlobalManager.js @@ -24,6 +24,7 @@ export const FAVORITES_URL = 'mydata/favorites/'; export const NAVIGATE_URL = 'navigate/'; export const PLANROUTE_URL = 'plan/'; export const SETTINGS_URL = 'settings/'; +export const TRACK_ANALYZER_URL = 'track-analyzer/'; export const TRAVEL_URL = 'travel/'; export const OLD_LOGIN_URL = 'dialog-account/'; export const LOGIN_URL = 'account/'; diff --git a/map/src/manager/track/TracksManager.js b/map/src/manager/track/TracksManager.js index c985bf65e..9d8dc1bf4 100644 --- a/map/src/manager/track/TracksManager.js +++ b/map/src/manager/track/TracksManager.js @@ -1566,6 +1566,26 @@ export function getTracksArrBounds(files) { return bounds; } +export function getAllGroupNames(groups, parentName = '') { + const groupTitles = []; + + groups.forEach((group) => { + if (group.fullName === DEFAULT_GROUP_NAME) { + // skip default folder + return; + } + const groupName = parentName ? `${parentName}/${group.name}` : group.name; + groupTitles.push({ title: groupName, size: group.files.length }); + + if (group.subfolders) { + const subGroupTitles = getAllGroupNames(group.subfolders, groupName); + groupTitles.push(...subGroupTitles); + } + }); + + return groupTitles; +} + const TracksManager = { loadTracks, prepareName, diff --git a/map/src/menu/MainMenu.js b/map/src/menu/MainMenu.js index 8594122d2..175080fd7 100644 --- a/map/src/menu/MainMenu.js +++ b/map/src/menu/MainMenu.js @@ -26,6 +26,7 @@ import AppContext, { OBJECT_TYPE_POI, OBJECT_TYPE_WEATHER, OBJECT_TYPE_SHARE_FILE, + OBJECT_TRACK_ANALYZER, } from '../context/AppContext'; import TracksMenu from './tracks/TracksMenu'; import ConfigureMap from './configuremap/ConfigureMap'; @@ -40,6 +41,7 @@ import { ReactComponent as NavigationIcon } from '../assets/menu/ic_action_navig import { ReactComponent as PlanRouteIcon } from '../assets/menu/ic_action_plan_route.svg'; import { ReactComponent as ConfigureMapIcon } from '../assets/icons/ic_map_configure_map.svg'; import { ReactComponent as SettingsIcon } from '../assets/icons/ic_action_settings_outlined.svg'; +import { ReactComponent as TrackAnalyzerIcon } from '../assets/icons/ic_action_tool.svg'; import { ReactComponent as TravelIcon } from '../assets/icons/ic_action_activity.svg'; import { ReactComponent as SearchIcon } from '../assets/icons/ic_action_search_dark.svg'; import InformationBlock from '../infoblock/components/InformationBlock'; @@ -69,6 +71,7 @@ import { WEATHER_URL, TRAVEL_URL, SHARE_FILE_MAIN_URL, + TRACK_ANALYZER_URL, } from '../manager/GlobalManager'; import { createUrlParams } from '../util/Utils'; import { useWindowSize } from '../util/hooks/useWindowSize'; @@ -79,6 +82,7 @@ import TravelMenu from './travel/TravelMenu'; import ProFeatures from '../frame/components/pro/ProFeatures'; import { SHARE_TYPE, updateUserRequests } from '../manager/ShareManager'; import { debouncer } from '../context/TracksRoutingCache'; +import TrackAnalyzerMenu from './analyzer/TrackAnalyzerMenu'; export default function MainMenu({ size, @@ -235,6 +239,15 @@ export default function MainMenu({ id: 'se-show-menu-settings', url: MAIN_URL_WITH_SLASH + SETTINGS_URL, }, + { + name: t('web:tracks_analyzer'), + icon: TrackAnalyzerIcon, + component: , + type: OBJECT_TRACK_ANALYZER, + show: true, + id: 'se-show-menu-track-analyzer', + url: MAIN_URL_WITH_SLASH + TRACK_ANALYZER_URL, + }, ]; useEffect(() => { diff --git a/map/src/menu/analyzer/PointField.jsx b/map/src/menu/analyzer/PointField.jsx new file mode 100644 index 000000000..edccc8ccd --- /dev/null +++ b/map/src/menu/analyzer/PointField.jsx @@ -0,0 +1,66 @@ +import { TextField } from '@mui/material/'; +import { Box, Divider, InputAdornment } from '@mui/material'; +import { ReactComponent as PointAIcon } from '../../assets/icons/ic_action_point_a.svg'; +import { ReactComponent as PointBIcon } from '../../assets/icons/ic_action_point_b.svg'; +import { useEffect, useState } from 'react'; +import styles from './trackanalyzer.module.css'; + +const START_POINT = 'start'; +const FINISH_POINT = 'finish'; + +export default function PointField({ name, setPoint, setStartAnalysis }) { + const [pointValue, setPointValue] = useState(''); + + useEffect(() => { + setPoint(pointValue === '' ? null : pointValue); + }, [pointValue]); + + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + if (pointValue !== '') { + setStartAnalysis(true); + } + } + }; + + return ( + + { + setPointValue(e.target.value); + }} + onKeyDown={(e) => handleKeyPress(e)} + InputProps={{ + startAdornment: ( + + {name === START_POINT ? : } + + ), + className: styles.pointInput, + sx: { + '&::before': { + borderBottom: 'none', + }, + '&::after': { + borderBottom: 'none', + }, + '&:hover:not(.Mui-disabled)::before': { + borderBottom: 'none', + }, + }, + }} + > + {name === START_POINT && } + + ); +} diff --git a/map/src/menu/analyzer/TrackAnalyzerMenu.jsx b/map/src/menu/analyzer/TrackAnalyzerMenu.jsx new file mode 100644 index 000000000..08afd49b5 --- /dev/null +++ b/map/src/menu/analyzer/TrackAnalyzerMenu.jsx @@ -0,0 +1,123 @@ +import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material'; +import React, { useContext, useEffect, useState } from 'react'; +import AppContext from '../../context/AppContext'; +import { ReactComponent as CloseIcon } from '../../assets/icons/ic_action_close.svg'; +import EmptyLogin from '../login/EmptyLogin'; +import { useWindowSize } from '../../util/hooks/useWindowSize'; +import TracksSelect from './TracksSelect'; +import PointField from './PointField'; +import TrackAnalyzerTips from './TrackAnalyzerTips'; +import { closeHeader } from '../actions/HeaderHelper'; +import headerStyles from '../trackfavmenu.module.css'; +import { useTranslation } from 'react-i18next'; +import { apiPost } from '../../util/HttpApi'; +import { quickNaNfix } from '../../util/Utils'; +import { getPointsForAnalysis } from './util/PointsManager'; + +export const ALL_GROUP_MARKER = '_all_'; + +export default function TrackAnalyzerMenu() { + const ctx = useContext(AppContext); + + const [, height] = useWindowSize(); + const { t } = useTranslation(); + + const [startPoint, setStartPoint] = useState(null); + const [finishPoint, setFinishPoint] = useState(null); + const [startAnalysis, setStartAnalysis] = useState(false); + const [tracksFolders, setTracksFolders] = useState(null); + + useEffect(() => { + if (!startAnalysis || !tracksFolders || tracksFolders.length === 0) { + return; + } + + if (!startPoint && !finishPoint) { + return; + } + getTracksBySegment().then((res) => { + console.log(res); + }); + }, [startAnalysis, tracksFolders]); + + async function getTracksBySegment() { + const coordinates = getPointsForAnalysis({ + startPoint, + finishPoint, + }); + if (!coordinates) { + return; + } + const folders = tracksFolders?.includes(ALL_GROUP_MARKER) ? null : removeNestedFolders(tracksFolders); + + const request = { + points: coordinates, + folders, + }; + const response = await apiPost(`${process.env.REACT_APP_USER_API_SITE}/mapapi/get-tracks-by-seg`, request, { + apiCache: true, + }); + if (response.ok) { + const text = await response.text(); + return JSON.parse(quickNaNfix(text)); + } + } + + function removeNestedFolders(folders) { + if (!folders) return null; + + const uniqueFolders = new Set(folders); + for (const folder of folders) { + uniqueFolders.forEach((parentFolder) => { + if (folder !== parentFolder && folder.startsWith(parentFolder + '/')) { + uniqueFolders.delete(folder); + } + }); + } + return Array.from(uniqueFolders); + } + + return ( + <> + {ctx.loginUser ? ( + + + + closeHeader({ ctx })} + > + + + + {t('web:tracks_analyzer')} + + + + + + + + + + + + + + ) : ( + + )} + > + ); +} diff --git a/map/src/menu/analyzer/TrackAnalyzerTips.jsx b/map/src/menu/analyzer/TrackAnalyzerTips.jsx new file mode 100644 index 000000000..c2b1a958a --- /dev/null +++ b/map/src/menu/analyzer/TrackAnalyzerTips.jsx @@ -0,0 +1 @@ +export default function TrackAnalyzerTips() {} diff --git a/map/src/menu/analyzer/TracksSelect.jsx b/map/src/menu/analyzer/TracksSelect.jsx new file mode 100644 index 000000000..ef5460e23 --- /dev/null +++ b/map/src/menu/analyzer/TracksSelect.jsx @@ -0,0 +1,225 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { DEFAULT_GROUP_NAME, getAllGroupNames } from '../../manager/track/TracksManager'; +import AppContext from '../../context/AppContext'; +import { + Box, + FormControl, + ListItemIcon, + ListItemText, + MenuItem, + Select, + Switch, + Checkbox, + Typography, +} from '@mui/material'; +import styles from './trackanalyzer.module.css'; +import { ReactComponent as AllTracksIcon } from '../../assets/icons/ic_action_folder.svg'; +import { ReactComponent as FolderIcon } from '../../assets/icons/ic_action_folder.svg'; +import { useWindowSize } from '../../util/hooks/useWindowSize'; +import { ALL_GROUP_MARKER } from './TrackAnalyzerMenu'; +import { useTranslation } from 'react-i18next'; + +const DEFAULT_GROUP_MARKER = '_default_'; + +export default function TrackSelect({ setTracksFolders }) { + const ctx = useContext(AppContext); + const { t } = useTranslation(); + + const [, height] = useWindowSize(); + + const [groups, setGroups] = useState(null); + + const [selectedGroupsNames, setSelectedGroupsNames] = useState([]); + const [selectAll, setSelectAll] = useState(false); + + useEffect(() => { + const folders = getAllGroupNames(ctx.tracksGroups); + const defaultGroup = getDefaultGroup(ctx.tracksGroups); + setGroups(defaultGroup ? [defaultGroup, ...folders] : folders); + }, [ctx.tracksGroups]); + + const handleSelectAll = (checked) => { + setSelectAll(checked); + if (checked) { + const allGroups = [DEFAULT_GROUP_MARKER, ...getAllGroupNames(ctx.tracksGroups).map((group) => group.title)]; + setSelectedGroupsNames(allGroups); + setTracksFolders([ALL_GROUP_MARKER]); + } else { + setSelectedGroupsNames([]); + setTracksFolders([]); + } + }; + + const handleToggleGroup = (groupName) => { + let updatedGroups; + if (selectedGroupsNames.includes(groupName)) { + updatedGroups = selectedGroupsNames.filter((group) => group !== groupName); + } else { + updatedGroups = [...selectedGroupsNames, groupName]; + } + if (updatedGroups.length === Object.values(groups).length + 1) { + setSelectAll(true); + setSelectedGroupsNames([ALL_GROUP_MARKER]); + setTracksFolders([ALL_GROUP_MARKER]); + } else { + setSelectAll(false); + setSelectedGroupsNames(updatedGroups); + setTracksFolders(updatedGroups.length > 0 ? updatedGroups : []); + } + }; + + function getDefaultGroup(groups) { + const defaultG = groups.find((group) => group.fullName === DEFAULT_GROUP_NAME); + if (defaultG) { + return { + title: DEFAULT_GROUP_MARKER, + size: defaultG.files.length, + }; + } + return null; + } + + function getSize(selectedGroupsNames) { + let sum = 0; + selectedGroupsNames.forEach((name) => { + const size = groups.find((group) => group.title === name).size; + sum += size; + }); + return sum; + } + + return ( + <> + {groups !== null && ( + + + { + if (selected.length === 0) { + return ( + + + + + + + Select tracks + + + + ); + } + return ( + + + + + + + {`${t('shared_string_tracks')}: ${getSize(selected)}`} + + + + ); + }} + multiple + MenuProps={{ + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + PaperProps: { + style: { + marginTop: '8px', + maxWidth: '360px', + maxHeight: height / 2, + }, + }, + }} + > + + + + + + + + + {t('shared_string_all_tracks')} + + handleSelectAll(e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + + + + {groups.map((folder) => ( + + + + + + + + {folder.title === DEFAULT_GROUP_MARKER ? 'Tracks' : folder.title} + + handleToggleGroup(folder.title)} + onClick={(e) => e.stopPropagation()} + /> + + + + ))} + + + + + )} + > + ); +} diff --git a/map/src/menu/analyzer/trackanalyzer.module.css b/map/src/menu/analyzer/trackanalyzer.module.css new file mode 100644 index 000000000..d59773d71 --- /dev/null +++ b/map/src/menu/analyzer/trackanalyzer.module.css @@ -0,0 +1,25 @@ +.tracksSelectItem { + padding: 12px 12px !important; + gap: 16px !important; + min-height: 48px !important; + max-height: 48px !important; +} +.icon { + fill: #727272 !important; + min-width: 24px !important; + max-width: 24px !important; +} +.iconSelected { + fill: #237bff !important; + min-width: 24px !important; + max-width: 24px !important; +} +.pointInput { + padding-right: 0px !important; + height: 56px !important; +} +.divider { + margin-left: 55px !important; + margin-bottom: 0px !important; + margin-top: 0px !important; +} diff --git a/map/src/menu/analyzer/util/PointsManager.js b/map/src/menu/analyzer/util/PointsManager.js new file mode 100644 index 000000000..7fe00ba67 --- /dev/null +++ b/map/src/menu/analyzer/util/PointsManager.js @@ -0,0 +1,26 @@ +export function getPointsForAnalysis({ startPoint, finishPoint }) { + const coordinates = []; + if (startPoint) { + coordinates.push(parseCoordinate(startPoint)); + } + if (finishPoint) { + coordinates.push(parseCoordinate(finishPoint)); + } + if (coordinates.length < 1) { + return null; + } + return coordinates; +} + +function parseCoordinate(coord) { + if (!isValidCoordinate(coord)) { + return null; + } + const [lat, lon] = coord.split(',').map(Number); + return { lat, lon }; +} + +function isValidCoordinate(coord) { + const [lat, lon] = coord.split(',').map(Number); + return !isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; +} diff --git a/map/src/menu/route/RouteMenu.js b/map/src/menu/route/RouteMenu.js index 0e936dd67..d8239a584 100644 --- a/map/src/menu/route/RouteMenu.js +++ b/map/src/menu/route/RouteMenu.js @@ -15,6 +15,7 @@ import { Box, Grid, ButtonGroup, + Divider, } from '@mui/material'; import AppContext, { isLocalTrack, @@ -27,6 +28,9 @@ import { TextField } from '@mui/material/'; import { LatLng } from 'leaflet'; import { makeStyles } from '@material-ui/core/styles'; import styles from './routemenu.module.css'; +import btn from './../login/login.module.css'; +import { apiPost } from '../../util/HttpApi'; +import { quickNaNfix } from '../../util/Utils'; const StyledInput = styled('input')({ display: 'none', @@ -408,7 +412,6 @@ export default function RouteMenu() { )} - {openSettings && } > ); diff --git a/map/src/resources/translations/en/translation.json b/map/src/resources/translations/en/translation.json index 5373aa85f..b019ea414 100644 --- a/map/src/resources/translations/en/translation.json +++ b/map/src/resources/translations/en/translation.json @@ -5243,5 +5243,6 @@ "purchases_feature_desc_terrain": "Terrain contour lines, grayscale hillshade and color scale slope indication, to show peaks and lowlands.", "you_can_get_feature_as_part_of_pattern": "Get ā%1$sā as part of the %2$s plan. Comparison:", "shared_string_share": "Share", - "shared_string_remove": "Remove" + "shared_string_remove": "Remove", + "shared_string_all_tracks": "All tracks" } \ No newline at end of file diff --git a/map/src/resources/translations/en/web-translation.json b/map/src/resources/translations/en/web-translation.json index 6b34b48b9..9f8c633db 100644 --- a/map/src/resources/translations/en/web-translation.json +++ b/map/src/resources/translations/en/web-translation.json @@ -71,5 +71,6 @@ "lang_local": "Local", "shared_with_me": "Shared with Me", "show_all_tracks_on_map": "Show all tracks on map", - "hide_all_tracks_from_map": "Hide all tracks from map" + "hide_all_tracks_from_map": "Hide all tracks from map", + "tracks_analyzer": "Tracks analyzer" } \ No newline at end of file