diff --git a/map/src/infoblock/components/tabs/TrackTabList.js b/map/src/infoblock/components/tabs/TrackTabList.js index edc566a212..2f9f7eecbe 100644 --- a/map/src/infoblock/components/tabs/TrackTabList.js +++ b/map/src/infoblock/components/tabs/TrackTabList.js @@ -37,7 +37,14 @@ export default class TrackTabList { // } list = list.concat( - Object.keys(tabs).map((item) => ) + Object.keys(tabs).map((item) => ( + + )) ); this.state.tabList = list; diff --git a/map/src/infoblock/components/tabs/WaypointsTab.jsx b/map/src/infoblock/components/tabs/WaypointsTab.jsx index 915cdea4bb..7009bc0f00 100644 --- a/map/src/infoblock/components/tabs/WaypointsTab.jsx +++ b/map/src/infoblock/components/tabs/WaypointsTab.jsx @@ -1,46 +1,48 @@ -import { useContext, useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState, useRef } from 'react'; import AppContext, { isLocalTrack } from '../../../context/AppContext'; import { Alert, Box, Button, Collapse, Grid, IconButton, MenuItem, Switch, Tooltip, Typography } from '@mui/material'; -import L from 'leaflet'; import { Cancel, ExpandLess, ExpandMore, KeyboardDoubleArrowDown, KeyboardDoubleArrowUp } from '@mui/icons-material'; import PointManager from '../../../manager/PointManager'; -import TracksManager from '../../../manager/track/TracksManager'; +import TracksManager, { getResolvedPointsGroups, isWptGroupShown } from '../../../manager/track/TracksManager'; import { confirm } from '../../../dialogs/GlobalConfirmationDialog'; import { useWindowSize } from '../../../util/hooks/useWindowSize'; import { createPoiIcon } from '../../../map/markers/MarkerOptions'; import isEmpty from 'lodash-es/isEmpty'; +import { updateGroupsVisibility } from '../../../manager/track/TrackAppearanceManager'; // distinct component -const WaypointGroup = ({ ctx, group, points, defaultOpen, massOpen, massVisible }) => { +const WaypointGroup = ({ + ctx, + group, + points, + defaultOpen, + defaultVisible = true, + massOpen, + massVisible, + debouncerTimer, +}) => { const [open, setOpen] = useState(defaultOpen); const switchOpen = () => setOpen(!open); - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(defaultVisible); + const switchVisible = (e) => { e.stopPropagation(); - setVisible(!visible); + const newVisible = !visible; + setVisible(newVisible); + updateGroupsVisibility(ctx, [group], !newVisible, debouncerTimer); }; const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); - // visibility control - useEffect(() => { - mounted && - points.forEach((p) => { - if (p.layer?._icon?.style) { - p.layer._icon.style.display = visible ? '' : 'none'; - } - }); - }, [visible]); - useEffect(() => { mounted && setOpen(massOpen); }, [massOpen]); useEffect(() => { - mounted && setVisible(massVisible); - }, [massVisible]); + setVisible(defaultVisible); + }, [defaultVisible, massVisible]); const point = points[0].wpt; const iconHTML = createPoiIcon({ point, color: point.color, background: point.background, icon: point.icon }) @@ -89,7 +91,7 @@ const WaypointGroup = ({ ctx, group, points, defaultOpen, massOpen, massVisible - + @@ -245,43 +247,11 @@ export default function WaypointsTab() { } function getSortedPoints() { - const wpts = []; - - if (ctx.selectedGpxFile.wpts) { - const layers = getLayers(); - const wptsMap = Object.fromEntries( - ctx.selectedGpxFile.wpts - .filter((wpt) => wpt.lat != null && wpt.lon != null && !isNaN(wpt.lat) && !isNaN(wpt.lon)) - .map((wpt, index) => [ - parseFloat(wpt.lat).toFixed(6) + ',' + parseFloat(wpt.lon).toFixed(6), - { wpt, index }, - ]) - ); - - layers.forEach((layer) => { - if (layer instanceof L.Marker) { - const coord = layer.getLatLng(); - const mapped = wptsMap[coord.lat.toFixed(6) + ',' + coord.lng.toFixed(6)]; - mapped && wpts.push({ wpt: mapped.wpt, index: mapped.index, layer }); - } - }); - } - const az = (a, b) => (a > b) - (a < b); - - return wpts.sort((a, b) => { - const aName = a.wpt.name; - const bName = b.wpt.name; - - const aCat = a.wpt.category; - const bCat = b.wpt.category; - - if (aCat !== bCat) { - return az(aCat, bCat); - } - - return az(aName, bName); - }); + return (ctx.selectedGpxFile.wpts || []) + .map((wpt, index) => ({ wpt, index })) + .filter(({ wpt }) => wpt.lat != null && wpt.lon != null && !Number.isNaN(wpt.lat) && !Number.isNaN(wpt.lon)) + .sort((a, b) => az(a.wpt.category, b.wpt.category) || az(a.wpt.name, b.wpt.name)); } function getSortedGroups() { @@ -299,12 +269,28 @@ export default function WaypointsTab() { return groups; } + function isMassVisible() { + const pointsGroups = getResolvedPointsGroups(ctx.selectedGpxFile); + const groupKeys = Object.keys(pointsGroups || {}); + return groupKeys.length > 0 && groupKeys.some((g) => isWptGroupShown(pointsGroups, g)); + } + const [showMass, setShowMass] = useState(false); const [massOpen, setMassOpen] = useState(false); - const [massVisible, setMassVisible] = useState(true); + const [massVisible, setMassVisible] = useState(isMassVisible()); + const debouncerTimer = useRef(null); + + useEffect(() => { + setMassVisible(isMassVisible()); + }, [ctx.selectedGpxFile?.info?.pointsGroups]); const switchMassOpen = () => setMassOpen(!massOpen); - const switchMassVisible = () => setMassVisible(!massVisible); + const switchMassVisible = () => { + const newMassVisible = !massVisible; + setMassVisible(newMassVisible); + const groupNames = Object.keys(getResolvedPointsGroups(ctx.selectedGpxFile) || {}); + updateGroupsVisibility(ctx, groupNames, !newMassVisible, debouncerTimer); + }; const pointsChangedString = useMemo(() => { const name = ctx.selectedGpxFile.name; @@ -317,6 +303,7 @@ export default function WaypointsTab() { const groups = getSortedGroups(); const keys = Object.keys(groups); const trackName = ctx.selectedGpxFile.name; + const pointsGroups = getResolvedPointsGroups(ctx.selectedGpxFile); setShowMass(keys.length > 1); @@ -329,8 +316,10 @@ export default function WaypointsTab() { group={g} points={groups[g]} defaultOpen={keys.length === 1} + defaultVisible={isWptGroupShown(pointsGroups, g)} massVisible={massVisible} massOpen={massOpen} + debouncerTimer={debouncerTimer} /> ))} @@ -339,7 +328,7 @@ export default function WaypointsTab() { return ( <> - + {ctx.createTrack && ctx.selectedGpxFile?.wpts && !isEmpty(ctx.selectedGpxFile.wpts) && ( @@ -364,7 +353,7 @@ export default function WaypointsTab() { {showMass && ( - + )} diff --git a/map/src/manager/FavoritesManager.js b/map/src/manager/FavoritesManager.js index ea834d62a8..5a62329271 100644 --- a/map/src/manager/FavoritesManager.js +++ b/map/src/manager/FavoritesManager.js @@ -257,9 +257,9 @@ function createGroup(file) { function addHidden({ pointsGroups, groupName, favArr, mapId, menuId }) { groupName = normalizeFavoritePointsGroupName(groupName); let hidden = false; - if (pointsGroups && pointsGroups[groupName]) { - if (pointsGroups[groupName].ext.hidden !== undefined) { - hidden = pointsGroups[groupName].ext.hidden; + if (pointsGroups?.[groupName]) { + if (pointsGroups[groupName].hidden !== undefined) { + hidden = pointsGroups[groupName].hidden; } else { hidden = isHidden(pointsGroups, groupName); } diff --git a/map/src/manager/track/SaveTrackManager.js b/map/src/manager/track/SaveTrackManager.js index 9af0296fd1..4c43229b2e 100644 --- a/map/src/manager/track/SaveTrackManager.js +++ b/map/src/manager/track/SaveTrackManager.js @@ -16,6 +16,7 @@ import TracksManager, { GPX_FILE_EXT, KMZ_FILE_EXT, } from './TracksManager'; +import { syncCloudTrackInfo } from './TrackAppearanceManager'; import cloneDeep from 'lodash-es/cloneDeep'; import isEmpty from 'lodash-es/isEmpty'; import { @@ -27,12 +28,16 @@ import { import Utils from '../../util/Utils'; import { updateSortList } from '../../menu/actions/SortActions'; import { deleteLocalTrack, saveTrackToLocalStorage } from '../../context/LocalTrackStorage'; -import { SMART_TYPE } from '../../menu/share/shareConstants'; export function saveTrackToLocal({ ctx, track, selected = true, overwrite = false, cloudAutoSave = false } = {}) { const newLocalTracks = [...ctx.localTracks]; - const originalName = track.name + GPX_FILE_EXT; + if (!track?.name) { + ctx.setRoutingErrorMsg('⚠️ Cannot save nameless local track.'); + return; + } + + const originalName = removeFileExtension(track.name) + GPX_FILE_EXT; let localName = TracksManager.prepareName(originalName, true); // find free name @@ -132,7 +137,10 @@ export async function saveTrackToCloud({ // close possibly loaded Cloud track (clean up layers) ctx.mutateGpxFiles((o) => o[params.name] && (o[params.name].url = null)); const res = await apiPost(`${process.env.REACT_APP_USER_API_SITE}/mapapi/upload-file`, data, { params }); - if (res && res?.data?.status === 'ok') { + if (res?.data?.status === 'ok') { + if (type !== FavoritesManager.FAVORITE_FILE_TYPE) { + await syncCloudTrackInfo(ctx, params.name); + } // re-download gpx const downloadFile = { ...currentFile, ...params }; if (open) { diff --git a/map/src/manager/track/TrackAppearanceManager.js b/map/src/manager/track/TrackAppearanceManager.js new file mode 100644 index 0000000000..08838115bb --- /dev/null +++ b/map/src/manager/track/TrackAppearanceManager.js @@ -0,0 +1,144 @@ +import { apiPost } from '../../util/HttpApi'; +import { getResolvedPointsGroups, INFO_FILE_EXT } from './TracksManager'; +import { isCloudTrack, isLocalTrack } from '../../context/AppContext'; +import isEmpty from 'lodash-es/isEmpty'; + +const VISIBILITY_DEBOUNCE_MS = 1000; + +/** Strip `points` arrays from groups and their `ext` (they are large and not needed in `.info`). */ +export function sanitizePointsGroups(pointsGroups = {}) { + const result = {}; + for (const [name, group] of Object.entries(pointsGroups)) { + const { points: _pts, ext: _ext, ...rest } = group || {}; + result[name] = rest; + } + return result; +} + +/** Build the canonical `.info` payload for a track (pointsGroups + future fields). */ +export function buildInfoPayload(gpxFile) { + const pointsGroups = getResolvedPointsGroups(gpxFile); + return { + ...gpxFile?.info, + pointsGroups: isEmpty(pointsGroups) ? {} : sanitizePointsGroups(pointsGroups), + }; +} + +function findInfoFile(ctx, infoFileName) { + return ctx.listFiles?.uniqueFiles?.find((f) => f?.name === infoFileName); +} + +/** + * Upload (create or update) a track's `.info` file on the server. + * + * @param {Object} ctx App context (used to register newly created `.info` in `listFiles`) + * @param {Object} gpxFile Object with at least `name` and `info` (the payload to persist) + * @param {string} infoFileName Cloud path for the `.info` file + * @param {Object|null} infoFile Existing file entry from `listFiles` (carries `updatetimems` for update) + * @returns {Promise} + */ +export async function createOrUpdateInfoFile(ctx, gpxFile, infoFileName, infoFile) { + if (!gpxFile?.name) { + return false; + } + const convertedData = new TextEncoder().encode(JSON.stringify(gpxFile.info)); + const zipped = require('pako').gzip(convertedData, { to: 'Uint8Array' }); + const blob = new Blob([zipped.buffer], { type: 'application/json' }); + const body = new FormData(); + body.append('file', blob, infoFileName); + + const updatetime = infoFile?.updatetimems ?? null; + const params = updatetime == null ? { name: infoFileName } : { name: infoFileName, updatetime }; + + const res = await apiPost(`${process.env.REACT_APP_USER_API_SITE}/mapapi/update-info`, body, { params }); + + if (res?.data?.updatetime) { + if (infoFile) { + infoFile.updatetimems = res.data.updatetime; + } else { + ctx.listFiles?.uniqueFiles?.push({ name: infoFileName, updatetimems: res.data.updatetime }); + } + return true; + } + return false; +} + +/** + * Sync the current track's `.info` to cloud after GPX upload. + * Skips the request when nothing has changed (infoChanged flag). + */ +export async function syncCloudTrackInfo(ctx, cloudGpxName) { + const selectedFile = ctx.selectedGpxFile; + if (!selectedFile) return; + + const infoFileName = cloudGpxName + INFO_FILE_EXT; + const infoFile = findInfoFile(ctx, infoFileName); + + if (infoFile && !selectedFile.infoChanged) return; + + const payload = buildInfoPayload(selectedFile); + + if (!infoFile && isEmpty(payload.pointsGroups)) return; + + const success = await createOrUpdateInfoFile( + ctx, + { name: selectedFile?.name || cloudGpxName, info: payload }, + infoFileName, + infoFile + ); + if (success) { + ctx.setSelectedGpxFile((prev) => ({ ...prev, infoChanged: false })); + } +} + +/** + * Toggle `hidden` flag on waypoint groups and persist the change. + * - Local track: sets `updateLayers` flag to rebuild map markers. + * - Cloud track: debounces and uploads `.info` to the server. + */ +export function updateGroupsVisibility(ctx, groupNames, hidden, debouncerTimer) { + ctx.setSelectedGpxFile((prevFile) => { + const updatedPointsGroups = sanitizePointsGroups(getResolvedPointsGroups(prevFile) || {}); + + (groupNames || Object.keys(updatedPointsGroups)).forEach((name) => { + const group = updatedPointsGroups[name] || {}; + updatedPointsGroups[name] = { ...group, hidden }; + }); + + const updatedGpxFile = { + ...prevFile, + infoChanged: true, + info: { + ...prevFile.info, + pointsGroups: updatedPointsGroups, + }, + }; + + if (isLocalTrack(ctx) && ctx.createTrack?.enable) { + updatedGpxFile.updateLayers = true; + } + + if (isCloudTrack(ctx)) { + const infoFileName = updatedGpxFile.name + INFO_FILE_EXT; + const infoFile = findInfoFile(ctx, infoFileName); + + if (debouncerTimer.current) { + clearTimeout(debouncerTimer.current); + } + debouncerTimer.current = setTimeout(async () => { + debouncerTimer.current = null; + const success = await createOrUpdateInfoFile(ctx, updatedGpxFile, infoFileName, infoFile); + if (success) { + ctx.setSelectedGpxFile((cur) => ({ ...cur, cloudRedrawWpts: true, infoChanged: false })); + } else { + ctx.setTrackErrorMsg({ + title: 'Visibility error', + msg: 'Failed to save waypoint group visibility. Please try again.', + }); + } + }, VISIBILITY_DEBOUNCE_MS); + } + + return updatedGpxFile; + }); +} diff --git a/map/src/manager/track/TracksManager.js b/map/src/manager/track/TracksManager.js index 08f2b8ca79..b0bea4bb7f 100644 --- a/map/src/manager/track/TracksManager.js +++ b/map/src/manager/track/TracksManager.js @@ -28,11 +28,21 @@ import { compressJSONToBlob } from '../../util/GzipCompression'; export const GPX_FILE_TYPE = 'GPX'; export const GPX_FILE_EXT = '.gpx'; +export const INFO_FILE_EXT = '.info'; export const KMZ_FILE_EXT = '.kmz'; -export const EMPTY_FILE_NAME = '__folder__.info'; +export const EMPTY_FILE_NAME = `__folder__${INFO_FILE_EXT}`; const GET_SRTM_DATA = 'get-srtm-data'; const GET_ANALYSIS = 'get-analysis'; export const PROFILE_LINE = 'line'; + +export function getResolvedPointsGroups(file) { + return file?.info?.pointsGroups ?? file?.pointsGroups; +} + +export function isWptGroupShown(pointsGroups, groupName) { + return pointsGroups?.[groupName]?.hidden !== true; +} + const PROFILE_CAR = 'car'; const PROFILE_GAP = 'gap'; export const NAN_MARKER = 99999; @@ -74,6 +84,8 @@ export function prepareLocalTrack(track) { // tracks: prepareTrack.tracks, // tracks[] will be back wpts: prepareTrack.wpts, pointsGroups: prepareTrack.pointsGroups, + /** Cloud / .info payload (e.g. pointsGroups visibility); was omitted and lost on IndexedDB round-trip */ + info: prepareTrack.info, ext: prepareTrack.ext, analysis: prepareAnalysis(prepareTrack.analysis), selected: false, @@ -1561,7 +1573,7 @@ export async function openTrackOnMap({ export function preparedGpxFile({ file, sharedFile = false, oldFile = null }) { const URL = `${process.env.REACT_APP_USER_API_SITE}/mapapi/download-file`; const qs = `?type=${encodeURIComponent(file.type)}&name=${encodeURIComponent(file.name)}&shared=${sharedFile ? 'true' : 'false'}`; - const qsInfo = `?type=${encodeURIComponent(file.type)}&name=${encodeURIComponent(file.name + '.info')}`; + const qsInfo = `?type=${encodeURIComponent(file.type)}&name=${encodeURIComponent(file.name + INFO_FILE_EXT)}`; if (oldFile) { return { url: oldFile.url ? URL + qs : null, diff --git a/map/src/map/layers/CloudTrackLayer.js b/map/src/map/layers/CloudTrackLayer.js index 9ec7c1bc39..85939ca0a6 100644 --- a/map/src/map/layers/CloudTrackLayer.js +++ b/map/src/map/layers/CloudTrackLayer.js @@ -2,7 +2,11 @@ import { useContext, useEffect, useState } from 'react'; import AppContext, { isCloudTrack, OBJECT_TYPE_CLOUD_TRACK } from '../../context/AppContext'; import { useMap } from 'react-leaflet'; import TrackLayerProvider, { redrawWptsOnLayer, WPT_SIMPLIFY_THRESHOLD } from '../util/TrackLayerProvider'; -import TracksManager, { fitBoundsOptions, getTracksArrBounds } from '../../manager/track/TracksManager'; +import TracksManager, { + fitBoundsOptions, + getResolvedPointsGroups, + getTracksArrBounds, +} from '../../manager/track/TracksManager'; import { encodeString, useMutator } from '../../util/Utils'; import { INFO_MENU_URL, MAIN_URL_WITH_SLASH, MENU_INFO_OPEN_SIZE, TRACKS_URL } from '../../manager/GlobalManager'; import { clusterMarkers } from '../util/Clusterizer'; @@ -256,7 +260,8 @@ const CloudTrackLayer = () => { } else if (ctxTrack.cloudRedrawWpts) { // skip processing if layer is removed if (ctxTrack.gpx && map.hasLayer(ctxTrack.gpx)) { - redrawWptsOnLayer({ layer: ctxTrack.gpx }); + const pg = getResolvedPointsGroups(ctxTrack); + redrawWptsOnLayer({ layer: ctxTrack.gpx, pointsGroups: pg }); ctx.setSelectedGpxFile((o) => ({ ...o, cloudRedrawWpts: false })); } } else if (ctxTrack.showPoint) { diff --git a/map/src/map/layers/LocalClientTrackLayer.js b/map/src/map/layers/LocalClientTrackLayer.js index d38caf2e04..d14a2c6b7b 100644 --- a/map/src/map/layers/LocalClientTrackLayer.js +++ b/map/src/map/layers/LocalClientTrackLayer.js @@ -7,7 +7,11 @@ import TrackLayerProvider, { TEMP_LAYER_FLAG, WPT_SIMPLIFY_THRESHOLD, } from '../util/TrackLayerProvider'; -import TracksManager, { fitBoundsOptions, isEmptyTrack } from '../../manager/track/TracksManager'; +import TracksManager, { + fitBoundsOptions, + getResolvedPointsGroups, + isEmptyTrack, +} from '../../manager/track/TracksManager'; import isEmpty from 'lodash-es/isEmpty'; import cloneDeep from 'lodash-es/cloneDeep'; import EditablePolyline from '../util/creator/EditablePolyline'; @@ -407,7 +411,8 @@ export default function LocalClientTrackLayer() { } function localRedrawWpts() { - redrawWptsOnLayer({ layer: ctxTrack.layers }); + const pg = getResolvedPointsGroups(ctxTrack); + redrawWptsOnLayer({ layer: ctxTrack.layers, pointsGroups: pg }); ctx.setSelectedGpxFile((o) => ({ ...o, localRedrawWpts: false })); } diff --git a/map/src/map/util/TrackLayerProvider.js b/map/src/map/util/TrackLayerProvider.js index 040fc23641..d670674526 100644 --- a/map/src/map/util/TrackLayerProvider.js +++ b/map/src/map/util/TrackLayerProvider.js @@ -2,7 +2,12 @@ import L from 'leaflet'; import MarkerOptions, { createPoiIcon, DEFAULT_ICON_SIZE, DEFAULT_WPT_COLOR } from '../markers/MarkerOptions'; import cloneDeep from 'lodash-es/cloneDeep'; import indexOf from 'lodash-es/indexOf'; -import TracksManager, { GPX_FILE_TYPE, isProtectedSegment } from '../../manager/track/TracksManager'; +import TracksManager, { + getResolvedPointsGroups, + GPX_FILE_TYPE, + isProtectedSegment, + isWptGroupShown, +} from '../../manager/track/TracksManager'; import { getFavoriteId } from '../../manager/FavoritesManager'; import EditablePolyline from './creator/EditablePolyline'; import { clusterMarkers, addMarkerTooltip, removeTooltip } from './Clusterizer'; @@ -24,7 +29,7 @@ export const DEFAULT_TRACK_LINE_WEIGHT = 7; function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE, simplifyWpts = false }) { const layers = []; - const emptyInfo = data?.info && data.info.width === '' && data.info.color === '#00000000'; + const emptyInfo = data?.info?.width === '' && data.info.color === '#00000000'; const trackAppearance = data?.info && !emptyInfo ? { @@ -49,7 +54,18 @@ function createLayersByTrackData({ data, ctx, map, groupId, type = GPX_FILE_TYPE } } }); - parseWpt({ points: data.wpts, layers, ctx, data, map, simplify: simplifyWpts, groupId, type }); + const trackPointsGroups = getResolvedPointsGroups(data); + parseWpt({ + points: data.wpts, + layers, + ctx, + data, + map, + simplify: simplifyWpts, + groupId, + type, + pointsGroups: trackPointsGroups, + }); const trackName = data?.name; if (trackName) { @@ -449,6 +465,7 @@ function parseWpt({ simplify = false, groupId = null, type = GPX_FILE_TYPE, + pointsGroups = null, }) { const zoom = map.getZoom(); const lat = map.getCenter().lat; @@ -461,6 +478,7 @@ function parseWpt({ isFavorites: true, }) : null; + const resolvedPointsGroups = pointsGroups ?? getResolvedPointsGroups(data); points?.forEach((point) => { let opt = {}; const icon = createPoiIcon({ point, color: point.color, background: point.background, icon: point.icon }); @@ -487,7 +505,7 @@ function parseWpt({ if (point.name) { opt.name = point.name; } - opt.category = point.category ? point.category : 'favorites'; + opt.category = point.category ?? ''; opt.groupId = groupId; if (point.desc) { opt.desc = point.desc; @@ -505,6 +523,7 @@ function parseWpt({ return; } marker.options.idObj = getFavoriteId(marker); + bindWptVisibilityOnAdd(marker, resolvedPointsGroups); if (ctx && map && data) { if (type === GPX_FILE_TYPE) { marker.on('click', (e) => { @@ -719,16 +738,31 @@ function createEditableTempLPolyline(start, end, map, ctx) { return polylineTemp; } -export function redrawWptsOnLayer({ layer }) { - if (layer) { - layer.getLayers().forEach((l) => { - if (l instanceof L.Marker && l.options?.wpt) { - if (l._icon?.style) { - l._icon.style.display = null; // visible - } - } - }); +// set visibility each time the marker is added to the map (Leaflet 'add' event) +// visible - '' , hidden - 'none' +export function bindWptVisibilityOnAdd(marker, pointsGroups) { + if (!marker?.options?.wpt || !pointsGroups) { + return; } + marker.on('add', (e) => { + const el = e.target?._icon; + if (!el?.style) { + return; + } + const category = e.target.options?.category ?? ''; + el.style.display = isWptGroupShown(pointsGroups, category) ? '' : 'none'; + }); +} + +// for updating markers +export function redrawWptsOnLayer({ layer, pointsGroups }) { + if (!layer || !pointsGroups) return; + layer.getLayers().forEach((l) => { + if (l instanceof L.Marker && l.options?.wpt && l._icon?.style) { + const category = l.options.category || ''; + l._icon.style.display = isWptGroupShown(pointsGroups, category) ? '' : 'none'; + } + }); } const TrackLayerProvider = { diff --git a/map/src/map/util/creator/EditableMarker.js b/map/src/map/util/creator/EditableMarker.js index b566fe0256..d5ba9c88fe 100644 --- a/map/src/map/util/creator/EditableMarker.js +++ b/map/src/map/util/creator/EditableMarker.js @@ -3,7 +3,11 @@ import MarkerOptions from '../../markers/MarkerOptions'; import TrackLayerProvider from '../TrackLayerProvider'; import indexOf from 'lodash-es/indexOf'; import cloneDeep from 'lodash-es/cloneDeep'; -import TracksManager, { isPointUnrouted } from '../../../manager/track/TracksManager'; +import TracksManager, { + getResolvedPointsGroups, + isPointUnrouted, + isWptGroupShown, +} from '../../../manager/track/TracksManager'; import TracksRoutingCache from '../../../context/TracksRoutingCache'; export default class EditableMarker { @@ -25,6 +29,13 @@ export default class EditableMarker { let options; let point; if (marker) { + if (marker.options?.wpt) { + const pg = getResolvedPointsGroups(this.track); + const cat = marker.options.category ?? ''; + if (!isWptGroupShown(pg, cat)) { + return null; + } + } point = marker.getLatLng(); options = marker.options; } else if (this.point) { diff --git a/map/src/map/util/creator/LocalTrackLayerHelper.js b/map/src/map/util/creator/LocalTrackLayerHelper.js index 87a6f25e71..dc0d36d1ea 100644 --- a/map/src/map/util/creator/LocalTrackLayerHelper.js +++ b/map/src/map/util/creator/LocalTrackLayerHelper.js @@ -1,8 +1,8 @@ import EditableMarker from './EditableMarker'; import L from 'leaflet'; -import TrackLayerProvider, { TEMP_LAYER_FLAG, WPT_SIMPLIFY_THRESHOLD } from '../TrackLayerProvider'; +import TrackLayerProvider, { redrawWptsOnLayer, TEMP_LAYER_FLAG, WPT_SIMPLIFY_THRESHOLD } from '../TrackLayerProvider'; import isEmpty from 'lodash-es/isEmpty'; -import TracksManager from '../../../manager/track/TracksManager'; +import TracksManager, { getResolvedPointsGroups } from '../../../manager/track/TracksManager'; import EditablePolyline from './EditablePolyline'; import { LOCAL_TRACKS_LAYERS_ID } from '../../layers/LocalClientTrackLayer'; import { addLayerToMap } from '../MapManager'; @@ -57,6 +57,7 @@ export function updateLayers({ map, ctx, localLayers, ctxTrack, points, wpts, tr if (points?.length > 0) { TrackLayerProvider.parsePoints({ map, ctx, points, layers, draggable: true }); } + const trackPointsGroups = getResolvedPointsGroups(ctxTrack); if (wpts?.length > 0) { TrackLayerProvider.parseWpt({ points: wpts, @@ -64,6 +65,7 @@ export function updateLayers({ map, ctx, localLayers, ctxTrack, points, wpts, tr map, ctx, simplify: wpts?.length >= WPT_SIMPLIFY_THRESHOLD, + pointsGroups: trackPointsGroups, }); } layers = createEditableLayers(map, ctx, ctxTrack, layers); @@ -97,6 +99,7 @@ export function updateLayers({ map, ctx, localLayers, ctxTrack, points, wpts, tr }); trackLayers.options.type = LOCAL_TRACKS_LAYERS_ID; addLayerToMap(map, trackLayers, 'update-local-track-layers'); + redrawWptsOnLayer({ layer: trackLayers, pointsGroups: trackPointsGroups }); // add active tracks to map for visibility during editing if (!isEmpty(localLayers)) { @@ -123,7 +126,9 @@ function createEditableLayers(map, ctx, ctxTrack, layers) { layers.forEach((layer) => { if (layer instanceof L.Marker) { const editableMarker = new EditableMarker(map, ctx, null, layer, ctxTrack).create(); - res.push(editableMarker); + if (editableMarker) { + res.push(editableMarker); + } } else if (layer instanceof L.Polyline) { const editablePolyline = new EditablePolyline(map, ctx, null, layer, ctxTrack).create(); res.push(editablePolyline); diff --git a/map/src/menu/settings/CloudSettings.jsx b/map/src/menu/settings/CloudSettings.jsx index 193fa2cb31..2a0e62168c 100644 --- a/map/src/menu/settings/CloudSettings.jsx +++ b/map/src/menu/settings/CloudSettings.jsx @@ -5,6 +5,7 @@ import { apiGet } from '../../util/HttpApi'; import devList from '../../resources/apple_device_model_list.json'; import AppContext from '../../context/AppContext'; import { fmt } from '../../util/dateFmt'; +import { INFO_FILE_EXT } from '../../manager/track/TracksManager'; export default function CloudSettings({ setOpenCloudSettings }) { const ctx = useContext(AppContext); @@ -23,7 +24,7 @@ export default function CloudSettings({ setOpenCloudSettings }) { }, }); if (response.ok) { - const res = response.data.allFiles.filter((f) => !f.name.toLowerCase().endsWith('.info')); + const res = response.data.allFiles.filter((f) => !f.name.toLowerCase().endsWith(INFO_FILE_EXT)); setFilesLoading(false); preparedDevices(res); setAllFilesVersions(res); diff --git a/tests/selenium/gpx/test-track-wpt.gpx b/tests/selenium/gpx/test-track-wpt.gpx index bcbbc6a423..8d4077be2f 100644 --- a/tests/selenium/gpx/test-track-wpt.gpx +++ b/tests/selenium/gpx/test-track-wpt.gpx @@ -1,5 +1,5 @@ - + @@ -13,47 +13,61 @@ VELO-6 6/13/2016 9:37:59 PM 6/13/2016 9:37:59 PM + groupA 0.000000 VELO-7 6/13/2016 9:38:12 PM 6/13/2016 9:38:12 PM + groupA 0.000000 VELO-2 6/13/2016 10:17:21 PM 6/13/2016 10:17:21 PM + groupA 0.000000 VELO-3 6/13/2016 10:17:33 PM 6/13/2016 10:17:33 PM + groupB 0.000000 VELO-4 6/13/2016 10:18:44 PM 6/13/2016 10:18:44 PM + groupB 0.000000 VELO-5 6/13/2016 10:26:34 PM 6/13/2016 10:26:34 PM + groupB 0.000000 VELO-1 6/13/2016 10:28:10 PM 6/13/2016 10:28:10 PM + groupB 0.000000 VELO-BAZA 6/14/2016 11:20:28 AM 6/14/2016 11:20:28 AM + groupB + + + + + + diff --git a/tests/selenium/src/tests/tracks/89-wpt-visibility.mjs b/tests/selenium/src/tests/tracks/89-wpt-visibility.mjs new file mode 100644 index 0000000000..a4e60a21da --- /dev/null +++ b/tests/selenium/src/tests/tracks/89-wpt-visibility.mjs @@ -0,0 +1,139 @@ +'use strict'; + +import { By } from 'selenium-webdriver'; +import { clickBy, waitBy, assert } from '../../lib.mjs'; + +import actionOpenMap from '../../actions/map/actionOpenMap.mjs'; +import actionLogIn from '../../actions/login/actionLogIn.mjs'; +import actionLogOut from '../../actions/login/actionLogOut.mjs'; +import actionUploadGpx from '../../actions/actionUploadGpx.mjs'; +import actionLocalToCloud from '../../actions/tracks/actionLocalToCloud.mjs'; +import actionIdleWait from '../../actions/actionIdleWait.mjs'; +import actionDeleteTracksByPattern from '../../actions/tracks/actionDeleteTracksByPattern.mjs'; +import { deleteTrack, getFiles } from '../../util.mjs'; +import { driver } from '../../options.mjs'; + +export default async function test() { + await actionOpenMap(); + await actionLogIn(); + + const tracks = getFiles({ folder: 'gpx' }); + const trackName = 'test-track-wpt'; + + for (const track of tracks) { + await actionDeleteTracksByPattern(track.name); + } + + // Upload track with multiple waypoint groups + await actionUploadGpx({ mask: trackName + '.gpx' }); + await clickBy(By.id('se-show-menu-planroute')); + await actionLocalToCloud({ mask: trackName }); + + // Open track + await clickBy(By.id('se-cloud-track-' + trackName)); + + // Open Waypoints tab + await clickBy(By.css("[testid='se-tab-waypoints']")); + await waitBy(By.id('se-waypoints-tab-content')); + + const massSwitch = await driver.findElement(By.id('se-wpt-mass-visibility-switch')); + const isOn = await massSwitch.isSelected(); // true = ON, false = OFF + // Toggle mass visibility switch all groups should be visible after + if (isOn) { + await massSwitch.click(); + await massSwitch.click(); + } else { + await massSwitch.click(); + } + const isOnAfter = await massSwitch.isSelected(); + await assert(isOnAfter, 'Switch should be ON after click'); + + // Get initial marker count (find all visible waypoint markers on map) + const initialMarkers = await getVisibleWaypointMarkers(); + const initialCount = initialMarkers.length; + await assert(initialCount === 9, `Track should have 9 waypoints, got ${initialCount}`); + + // Test: Toggle first group visibility OFF + await clickBy(By.id('se-wpt-group-visibility-groupA')); + await actionIdleWait({ idle: 1000 }); + const afterFirstHide = await getVisibleWaypointMarkers(); + await assert( + afterFirstHide.length === 6, + `First group should be hidden. Expected 6, got ${afterFirstHide.length}` + ); + + // log out and log in again + await actionLogOut(); + await clickBy(By.id('se-login-button')); + await actionLogIn(); + + // open track + await clickBy(By.id('se-show-menu-tracks')); + await clickBy(By.id('se-cloud-track-' + trackName)); + const afterReload = await getVisibleWaypointMarkers(); + await assert( + afterReload.length === 6, + `First group should be hidden. Expected 6, got ${afterReload.length}` + ); + await clickBy(By.css("[testid='se-tab-waypoints']")) + + // Test: Toggle second group visibility OFF + await clickBy(By.id('se-wpt-group-visibility-groupB')); + await actionIdleWait({ idle: 1000 }); + + const afterSecondHide = await getVisibleWaypointMarkers(); + await assert( + afterSecondHide.length === 1, + `Both groups should be hidden. Expected 1, got ${afterSecondHide.length}` + ); + + // Test: Toggle first group visibility ON + await clickBy(By.id('se-wpt-group-visibility-groupA')); + await actionIdleWait({ idle: 1000 }); + + // log out and log in again + await actionLogOut(); + await clickBy(By.id('se-login-button')); + await actionLogIn(); + + // open track + await clickBy(By.id('se-show-menu-tracks')); + await clickBy(By.id('se-cloud-track-' + trackName)); + + const afterFirstShow = await getVisibleWaypointMarkers(); + await assert( + afterFirstShow.length === 4, + `First group should be visible again. Expected 4, got ${afterFirstShow.length}` + ); + await clickBy(By.css("[testid='se-tab-waypoints']")) + + // Test: Toggle second group visibility ON + await clickBy(By.id('se-wpt-group-visibility-groupB')); + await actionIdleWait({ idle: 1000 }); + + const afterBothShow = await getVisibleWaypointMarkers(); + await assert( + afterBothShow.length === 9, + `Both groups should be visible. Expected 9, got ${afterBothShow.length}` + ); + + // Close track and cleanup + await clickBy(By.id('se-button-back')); + await deleteTrack(trackName); +} + +// Helper function to count visible waypoint markers on the map +async function getVisibleWaypointMarkers() { + // Find all waypoint markers that are visible (display !== 'none') + const markers = await driver.findElements(By.className('leaflet-marker-icon')); + + const visibleMarkers = []; + for (const marker of markers) { + const display = await marker.getCssValue('display'); + if (display !== 'none') { + visibleMarkers.push(marker); + } + } + + return visibleMarkers; +} \ No newline at end of file