diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index e319efea0..34b56e9b0 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -662,6 +662,14 @@ "routes": "Routes", "route_type": "Route Type", "routes_subtitle": "View in game routes and relevant information about them on the map", + "routes_from_poi": "{{count}} route{{count === 1 ? '' : 's'}} {{type}} here", + "routes_from_poi_one": "1 route {{type}} here", + "routes_from_poi_other": "{{count}} routes {{type}} here", + "unknown_route": "Unknown Route", + "starting": "starting", + "ending": "ending", + "route_displayed": "Route displayed on map", + "route_click_map_to_hide": "Click anywhere on the map to hide the route", "description": "Description", "additional_info": "Additional Info", "duration": "Duration", diff --git a/src/features/route/EnhancedRouteTile.jsx b/src/features/route/EnhancedRouteTile.jsx new file mode 100644 index 000000000..ca2acd2ca --- /dev/null +++ b/src/features/route/EnhancedRouteTile.jsx @@ -0,0 +1,13 @@ +// @ts-check +import * as React from 'react' + +import { SimplifiedRouteTile } from './SimplifiedRouteTile' + +/** + * Enhanced route tile that supports both old and new route display modes + * @param {import("@rm/types").Route} route + */ +export function EnhancedRouteTile(route) { + // Use the SimplifiedRouteTile approach which works with the individual route processing + return +} diff --git a/src/features/route/RouteClusterManager.jsx b/src/features/route/RouteClusterManager.jsx new file mode 100644 index 000000000..1ffe11551 --- /dev/null +++ b/src/features/route/RouteClusterManager.jsx @@ -0,0 +1,146 @@ +// @ts-check +import * as React from 'react' + +import { useStorage } from '@store/useStorage' +import { RoutePoiMarker } from './RoutePoiMarker' + +/** + * Groups routes by their POI coordinates with proximity tolerance + * For reversible routes, both endpoints are treated as both start AND end points + * @param {import("@rm/types").Route[]} routes + * @param {number} tolerance - Coordinate tolerance for grouping (default: 0.00001) + * @returns {Map} + */ +function clusterRoutesByPoi(routes, tolerance = 0.00001) { + const clusters = new Map() + + routes.forEach((route) => { + // For reversible routes, both endpoints are starting AND ending points + if (route.reversible) { + // Add route to start point cluster (which serves as both start and end) + const startKey = findOrCreateCluster( + clusters, + route.start_lat, + route.start_lon, + tolerance, + 'both', + ) + clusters.get(startKey).routes.push(route) + + // Add route to end point cluster (which serves as both start and end) + const endKey = findOrCreateCluster( + clusters, + route.end_lat, + route.end_lon, + tolerance, + 'both', + ) + // Only add to end cluster if it's different from start cluster + if (endKey !== startKey) { + clusters.get(endKey).routes.push(route) + } + } else { + // For non-reversible routes, only the start point is a starting point + const startKey = findOrCreateCluster( + clusters, + route.start_lat, + route.start_lon, + tolerance, + 'start', + ) + clusters.get(startKey).routes.push(route) + } + }) + + return clusters +} + +/** + * Find existing cluster or create new one based on coordinate proximity + */ +function findOrCreateCluster(clusters, lat, lon, tolerance, poiType) { + // Check if there's an existing cluster within tolerance + const existingCluster = Array.from(clusters.entries()).find(([, cluster]) => { + const latDiff = Math.abs(cluster.lat - lat) + const lonDiff = Math.abs(cluster.lon - lon) + return latDiff <= tolerance && lonDiff <= tolerance + }) + + if (existingCluster) { + return existingCluster[0] + } + + // Create new cluster + const key = `${lat.toFixed(6)},${lon.toFixed(6)}` + clusters.set(key, { + lat, + lon, + routes: [], + poiType, + }) + return key +} + +/** + * Route clustering manager that collects and groups routes + */ +export function RouteClusterManager({ children }) { + const useSimplifiedRoutes = useStorage( + (s) => s.settings?.useSimplifiedRoutes ?? true, + ) + const [allRoutes, setAllRoutes] = React.useState([]) + + // Collect routes from child components + const addRoute = React.useCallback((route) => { + setAllRoutes((prev) => { + // Avoid duplicates + if (prev.find((r) => r.id === route.id)) { + return prev + } + return [...prev, route] + }) + }, []) + + const removeRoute = React.useCallback((routeId) => { + setAllRoutes((prev) => prev.filter((r) => r.id !== routeId)) + }, []) + + // Provide context for route collection + const contextValue = React.useMemo( + () => ({ + addRoute, + removeRoute, + useSimplifiedRoutes, + }), + [addRoute, removeRoute, useSimplifiedRoutes], + ) + + // Render clustered routes if simplified mode is enabled + const clusteredRoutes = React.useMemo(() => { + if (!useSimplifiedRoutes || allRoutes.length === 0) { + return [] + } + return Array.from(clusterRoutesByPoi(allRoutes).values()) + }, [allRoutes, useSimplifiedRoutes]) + + return ( + + {children} + + {/* Render clustered POI markers in simplified mode */} + {useSimplifiedRoutes && + clusteredRoutes.map((cluster) => ( + + ))} + + ) +} + +// Context for route collection +const RouteClusterContext = React.createContext(null) diff --git a/src/features/route/RoutePoiMarker.jsx b/src/features/route/RoutePoiMarker.jsx new file mode 100644 index 000000000..06664520a --- /dev/null +++ b/src/features/route/RoutePoiMarker.jsx @@ -0,0 +1,107 @@ +// @ts-check +import * as React from 'react' +import { Marker, Polyline } from 'react-leaflet' + +import { routeMarker } from './routeMarker' +import { RoutePoiPopup } from './RoutePoiPopup' + +/** + * Component for displaying route POI markers with simplified view + * @param {{ + * lat: number + * lon: number + * routes: import("@rm/types").Route[] + * poiType: 'start' | 'end' + * }} props + */ +export function RoutePoiMarker({ lat, lon, routes, poiType }) { + const [selectedRoute, setSelectedRoute] = React.useState(null) + const [showRoutes, setShowRoutes] = React.useState(false) + + // Use the existing route marker icon from the original implementation + const flagIcon = React.useMemo(() => routeMarker('start'), []) + + const handleMarkerClick = React.useCallback(() => { + setShowRoutes(true) + }, []) + + const handleRouteSelect = React.useCallback((route) => { + setSelectedRoute(route) + }, []) + + const handleRouteDeselect = React.useCallback(() => { + setSelectedRoute(null) + }, []) + + const handlePopupClose = React.useCallback(() => { + setShowRoutes(false) + setSelectedRoute(null) + }, []) + + // Group routes by their reversibility and type for better organization + const organizedRoutes = React.useMemo( + () => + routes.map((route) => ({ + ...route, + waypoints: [ + { + lat_degrees: route.start_lat, + lng_degrees: route.start_lon, + elevation_in_meters: route.waypoints[0]?.elevation_in_meters || 0, + }, + ...route.waypoints, + { + lat_degrees: route.end_lat, + lng_degrees: route.end_lon, + elevation_in_meters: + route.waypoints[route.waypoints.length - 1] + ?.elevation_in_meters || 1, + }, + ], + })), + [routes], + ) + + return ( + <> + + + + + {/* Render selected route path */} + {selectedRoute && ( + [ + waypoint.lat_degrees, + waypoint.lng_degrees, + ])} + pathOptions={{ + color: `#${selectedRoute.image_border_color}`, + opacity: 0.8, + weight: 4, + dashArray: selectedRoute.reversible ? undefined : '5, 5', + }} + eventHandlers={{ + click: () => { + // Keep the route visible when clicking on it + }, + }} + /> + )} + + ) +} diff --git a/src/features/route/RoutePoiPopup.jsx b/src/features/route/RoutePoiPopup.jsx new file mode 100644 index 000000000..5ffd13fb2 --- /dev/null +++ b/src/features/route/RoutePoiPopup.jsx @@ -0,0 +1,256 @@ +// @ts-check +import * as React from 'react' +import { Popup } from 'react-leaflet' +import { useLazyQuery } from '@apollo/client' +import { useTranslation } from 'react-i18next' +import Grid2 from '@mui/material/Unstable_Grid2' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemText from '@mui/material/ListItemText' +import ListItemAvatar from '@mui/material/ListItemAvatar' +import Avatar from '@mui/material/Avatar' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import CloseIcon from '@mui/icons-material/Close' +import DirectionsIcon from '@mui/icons-material/Directions' +import SwapHorizIcon from '@mui/icons-material/SwapHoriz' + +import { Query } from '@services/queries' +import { formatInterval } from '@utils/formatInterval' +import { useFormatDistance } from './useFormatDistance' + +/** + * Popup component for POI with routes + * @param {{ + * routes: import("@rm/types").Route[] + * poiType: 'start' | 'end' + * selectedRoute: import("@rm/types").Route | null + * onRouteSelect: (route: import("@rm/types").Route) => void + * onRouteDeselect: () => void + * showRoutes: boolean + * }} props + */ +export function RoutePoiPopup({ + routes, + poiType, + selectedRoute, + onRouteSelect, + onRouteDeselect, + showRoutes, +}) { + const { t } = useTranslation() + const formatDistance = useFormatDistance() + const [enrichedRoutes, setEnrichedRoutes] = React.useState(routes) + + const [getRoute] = useLazyQuery(Query.routes('getOne')) + + // Fetch detailed route data when popup opens + React.useEffect(() => { + if (showRoutes && routes.length > 0) { + // Fetch detailed data for all routes + const fetchRouteDetails = async () => { + const detailedRoutes = await Promise.all( + routes.map(async (route) => { + try { + const { data } = await getRoute({ variables: { id: route.id } }) + if (data?.route) { + return { + ...route, + ...data.route, + tags: data.route.tags || [], + } + } + return route + } catch (error) { + // Silently handle failed route fetches and use basic route data + return route + } + }), + ) + setEnrichedRoutes(detailedRoutes) + } + + fetchRouteDetails() + } + }, [showRoutes, routes, getRoute]) + + if (!showRoutes) return null + + const handleRouteClick = (route) => { + if (selectedRoute?.id === route.id) { + onRouteDeselect() + } else { + onRouteSelect(route) + } + } + + const getRouteSubtitle = (route) => { + const distance = formatDistance(route.distance_meters || 0) + const duration = formatInterval((route.duration_seconds || 0) * 1000).str + const type = t(`route_type_${route.type || 0}`) + return `${distance} • ${duration} • ${type}` + } + + const getRouteIcon = (route) => + poiType === 'start' + ? route.start_image || route.image + : route.end_image || route.image + + return ( + + + {/* Header */} + + + {t( + `routes_from_poi_${enrichedRoutes.length === 1 ? 'one' : 'other'}`, + { + count: enrichedRoutes.length, + type: + poiType === 'both' + ? t('available') + : poiType === 'start' + ? t('starting') + : t('ending'), + }, + )} + + + + + + + {/* Routes List */} + + + {enrichedRoutes.map((route, index) => ( + + + handleRouteClick(route)} + sx={{ + borderRadius: 1, + '&.Mui-selected': { + backgroundColor: 'primary.light', + '&:hover': { + backgroundColor: 'primary.main', + }, + }, + }} + > + + + + + + {route.name || `Route ${route.id}`} + + {route.reversible && ( + + )} + + } + secondary={getRouteSubtitle(route)} + /> + {/* Move route tags outside ListItemText to avoid nesting issues */} + {route.tags && route.tags.length > 0 && ( +
+ {route.tags.slice(0, 3).map((tag) => ( + + ))} + {route.tags.length > 3 && ( + + )} +
+ )} + +
+
+ {index < enrichedRoutes.length - 1 && ( + + )} +
+ ))} +
+
+ + {/* Selected Route Details */} + {selectedRoute && ( + + + {t('route_displayed')} + + + {t('route_click_map_to_hide')} + + {selectedRoute.description && ( + + {selectedRoute.description} + + )} + + )} +
+
+ ) +} diff --git a/src/features/route/SimplifiedRouteTile.jsx b/src/features/route/SimplifiedRouteTile.jsx new file mode 100644 index 000000000..e27eb6a30 --- /dev/null +++ b/src/features/route/SimplifiedRouteTile.jsx @@ -0,0 +1,164 @@ +// @ts-check +import * as React from 'react' +import { useMapEvents } from 'react-leaflet' + +import { RoutePoiMarker } from './RoutePoiMarker' + +// Global route collection for proper clustering +const globalRouteStore = { + routes: new Map(), + clusters: new Map(), + renderers: new Set(), + + addRoute(route) { + this.routes.set(route.id, route) + this.updateClusters() + }, + + removeRoute(routeId) { + this.routes.delete(routeId) + this.updateClusters() + }, + + updateClusters() { + // Clear existing clusters + this.clusters.clear() + + // Group routes by POI coordinates + const allRoutes = Array.from(this.routes.values()) + const tolerance = 0.00001 + + allRoutes.forEach((route) => { + // For reversible routes, both endpoints are starting AND ending points + if (route.reversible) { + this.addToCluster( + route.start_lat, + route.start_lon, + route, + 'both', + tolerance, + ) + // Only add end cluster if it's different from start + const startKey = `${route.start_lat.toFixed(6)},${route.start_lon.toFixed(6)}` + const endKey = `${route.end_lat.toFixed(6)},${route.end_lon.toFixed(6)}` + if (startKey !== endKey) { + this.addToCluster( + route.end_lat, + route.end_lon, + route, + 'both', + tolerance, + ) + } + } else { + // For non-reversible routes, only start point + this.addToCluster( + route.start_lat, + route.start_lon, + route, + 'start', + tolerance, + ) + } + }) + + // Notify all renderers + this.renderers.forEach((renderer) => renderer.forceUpdate()) + }, + + addToCluster(lat, lon, route, poiType, tolerance) { + // Find existing cluster within tolerance + const existingCluster = Array.from(this.clusters.values()).find( + (cluster) => { + const latDiff = Math.abs(cluster.lat - lat) + const lonDiff = Math.abs(cluster.lon - lon) + return latDiff <= tolerance && lonDiff <= tolerance + }, + ) + + if (existingCluster) { + // Add to existing cluster if not already there + if (!existingCluster.routes.find((r) => r.id === route.id)) { + existingCluster.routes.push(route) + } + } else { + // Create new cluster + const key = `${lat.toFixed(6)},${lon.toFixed(6)}` + this.clusters.set(key, { + lat, + lon, + routes: [route], + poiType, + }) + } + }, + + getClusters() { + return Array.from(this.clusters.values()) + }, + + addRenderer(renderer) { + this.renderers.add(renderer) + }, + + removeRenderer(renderer) { + this.renderers.delete(renderer) + }, +} + +/** + * Simplified route tile component that groups routes by POI + * @param {{ routes: import("@rm/types").Route[] }} props + */ +export function SimplifiedRouteTile({ routes }) { + const [, forceUpdate] = React.useReducer((x) => x + 1, 0) + + // Register this component as a renderer + React.useEffect(() => { + const renderer = { forceUpdate } + globalRouteStore.addRenderer(renderer) + return () => globalRouteStore.removeRenderer(renderer) + }, []) + + // Register routes with global store + React.useEffect(() => { + routes.forEach((route) => globalRouteStore.addRoute(route)) + return () => { + routes.forEach((route) => globalRouteStore.removeRoute(route.id)) + } + }, [routes]) + + // Handle map clicks to close popups when clicking outside + useMapEvents({ + click: ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) { + // This could be used for closing popups, but currently handled by individual markers + } + }, + }) + + // Only render clusters from the first route tile to avoid duplicates + const shouldRender = + routes.length > 0 && + routes[0].id === Array.from(globalRouteStore.routes.keys())[0] + + if (!shouldRender) { + return null + } + + const clusters = globalRouteStore.getClusters() + + return ( + <> + {clusters.map((poi) => ( + + ))} + + ) +} diff --git a/src/features/route/index.js b/src/features/route/index.js index 9357e658e..727340fd5 100644 --- a/src/features/route/index.js +++ b/src/features/route/index.js @@ -3,3 +3,8 @@ export * from './routeMarker' export * from './RoutePopup' export * from './RouteTile' +export * from './RoutePoiMarker' +export * from './RoutePoiPopup' +export * from './SimplifiedRouteTile' +export * from './EnhancedRouteTile' +export * from './RouteClusterManager' diff --git a/src/pages/map/tileObject.js b/src/pages/map/tileObject.js index 71ccdcc5e..7a14cf44e 100644 --- a/src/pages/map/tileObject.js +++ b/src/pages/map/tileObject.js @@ -6,7 +6,7 @@ import { GymTile as gyms } from '@features/gym' import { DeviceTile as devices } from '@features/device' import { NestTile as nests } from '@features/nest' import { PortalTile as portals } from '@features/portal' -import { RouteTile as routes } from '@features/route' +import { EnhancedRouteTile as routes } from '@features/route' import { WeatherTile as weather } from '@features/weather' import { SpawnpointTile as spawnpoints } from '@features/spawnpoint' import { ScanCellTile as scanCells } from '@features/scanCell'