From 7948832e1ed99fe2f55db8ba2933104b80102498 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 21:46:25 -0700 Subject: [PATCH 01/10] feat: route new view first draft --- src/assets/css/main.css | 36 ++++++ src/features/gym/GymTile.jsx | 10 ++ src/features/pokestop/PokestopTile.jsx | 10 ++ src/features/route/RouteLayer.jsx | 95 +++++++++++++++ src/features/route/RouteTile.jsx | 73 ++++++++---- src/features/route/index.js | 2 + src/features/route/routeFlagMarker.js | 26 +++++ src/features/route/useRouteStore.js | 156 +++++++++++++++++++++++++ src/pages/map/components/QueryData.jsx | 16 ++- src/services/queries/route.js | 2 + 10 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 src/features/route/RouteLayer.jsx create mode 100644 src/features/route/routeFlagMarker.js create mode 100644 src/features/route/useRouteStore.js diff --git a/src/assets/css/main.css b/src/assets/css/main.css index e231a06eb..9a58fd1d2 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -88,6 +88,42 @@ body { background-color: #ff4b4d; } +.route-flag { + border: none !important; + background: transparent !important; +} + +.route-flag__wrapper { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + text-shadow: 0 0 2px rgba(0, 0, 0, 0.65); +} + +.route-flag__badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 16px; + padding: 0 4px; + border-radius: 10px; + background: #ff4b4d; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 16px; +} + +.route-flag--active .route-flag__wrapper { + transform: scale(1.1); +} + +.route-flag--active .route-flag__emoji { + filter: drop-shadow(0 0 6px rgba(255, 75, 77, 0.75)); +} + .invasion-exists { border: 4px solid rgb(141, 13, 13); } diff --git a/src/features/gym/GymTile.jsx b/src/features/gym/GymTile.jsx index 459f4abd0..6b0e6f032 100644 --- a/src/features/gym/GymTile.jsx +++ b/src/features/gym/GymTile.jsx @@ -12,6 +12,7 @@ import { useForcePopup } from '@hooks/useForcePopup' import { sendNotification } from '@services/desktopNotification' import { TooltipWrapper } from '@components/ToolTipWrapper' import { getTimeUntil } from '@utils/getTimeUntil' +import { useRouteStore } from '@features/route' import { gymMarker } from './gymMarker' import { GymPopup } from './GymPopup' @@ -38,6 +39,8 @@ const getColor = (team) => { const BaseGymTile = (gym) => { const [markerRef, setMarkerRef] = React.useState(null) const [stateChange, setStateChange] = React.useState(false) + const hasRoutes = useRouteStore((s) => !!s.poiIndex[gym.id]) + const selectPoi = useRouteStore((s) => s.selectPoi) const [ hasRaid, @@ -179,6 +182,13 @@ const BaseGymTile = (gym) => { raidIconSize, ...gym, })} + eventHandlers={{ + click: () => { + if (hasRoutes) { + selectPoi(gym.id) + } + }, + }} > { const [stateChange, setStateChange] = React.useState(false) const [markerRef, setMarkerRef] = React.useState(null) + const hasRoutes = useRouteStore((s) => !!s.poiIndex[pokestop.id]) + const selectPoi = useRouteStore((s) => s.selectPoi) const [ hasLure, @@ -130,6 +133,13 @@ const BasePokestopTile = (pokestop) => { ref={setMarkerRef} position={[pokestop.lat, pokestop.lon]} icon={icon} + eventHandlers={{ + click: () => { + if (hasRoutes) { + selectPoi(pokestop.id) + } + }, + }} > { + const icon = React.useMemo( + () => routeFlagMarker(entry.routes.length, selected), + [entry.routes.length, selected], + ) + + return ( + onSelect(entry.key), + }} + /> + ) +}) + +const ActiveRoute = React.memo(({ selection }) => { + const route = useRouteStore( + React.useCallback( + (state) => state.routeCache[selection.routeId], + [selection.routeId], + ), + ) + + if (!route) return null + return +}) + +export function RouteLayer({ routes }) { + const enabled = useStorage((s) => !!s.filters?.routes?.enabled) + const syncRoutes = useRouteStore((s) => s.syncRoutes) + const poiIndex = useRouteStore((s) => s.poiIndex) + const activeRoutes = useRouteStore((s) => s.activeRoutes) + const activePoiId = useRouteStore((s) => s.activePoiId) + const selectPoi = useRouteStore((s) => s.selectPoi) + const clearSelection = useRouteStore((s) => s.clearSelection) + + React.useEffect(() => { + syncRoutes(routes || []) + }, [routes, syncRoutes]) + + React.useEffect(() => { + if (!enabled) { + clearSelection() + } + }, [enabled, clearSelection]) + + useMapEvents({ + click: ({ originalEvent }) => { + if (!originalEvent.defaultPrevented) { + clearSelection() + } + }, + }) + + if (!enabled) { + return null + } + + const anchors = React.useMemo(() => Object.values(poiIndex), [poiIndex]) + + return ( + <> + {anchors.map((entry) => ( + + ))} + {activeRoutes.map((selection) => ( + + ))} + + ) +} diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index c54baf6c4..e2f93fc91 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -15,11 +15,12 @@ const LINE_OPACITY = 0.33 const MARKER_OPACITY = LINE_OPACITY * 2 /** - * - * @param {import("@rm/types").Route} route - * @returns + * @param {{ + * route: import('@rm/types').Route, + * orientation?: 'forward' | 'reverse', + * }} props */ -const BaseRouteTile = (route) => { +const BaseRouteTile = ({ route, orientation = 'forward' }) => { const [clicked, setClicked] = React.useState(false) const [hover, setHover] = React.useState('') @@ -27,30 +28,49 @@ const BaseRouteTile = (route) => { const lineRef = React.useRef() const [markerRef, setMarkerRef] = React.useState(null) - const waypoints = React.useMemo( - () => [ + const displayRoute = React.useMemo(() => { + if (orientation === 'forward') return route + const reversedWaypoints = [...(route.waypoints || [])] + .map((waypoint) => ({ ...waypoint })) + .reverse() + return { + ...route, + start_lat: route.end_lat, + start_lon: route.end_lon, + start_image: route.end_image, + start_fort_id: route.end_fort_id, + end_lat: route.start_lat, + end_lon: route.start_lon, + end_image: route.start_image, + end_fort_id: route.start_fort_id, + waypoints: reversedWaypoints, + } + }, [orientation, route]) + + const waypoints = React.useMemo(() => { + const internal = displayRoute.waypoints || [] + return [ { - lat_degrees: route.start_lat, - lng_degrees: route.start_lon, - elevation_in_meters: route.waypoints[0]?.elevation_in_meters || 0, + lat_degrees: displayRoute.start_lat, + lng_degrees: displayRoute.start_lon, + elevation_in_meters: internal[0]?.elevation_in_meters || 0, }, - ...route.waypoints, + ...internal, { - lat_degrees: route.end_lat, - lng_degrees: route.end_lon, + lat_degrees: displayRoute.end_lat, + lng_degrees: displayRoute.end_lon, elevation_in_meters: - route.waypoints[route.waypoints.length - 1]?.elevation_in_meters || 1, + internal[internal.length - 1]?.elevation_in_meters || 1, }, - ], - [route], - ) + ] + }, [displayRoute]) const [color, darkened] = React.useMemo( () => [ - `#${route.image_border_color}`, - darken(`#${route.image_border_color}`, 0.2), + `#${displayRoute.image_border_color}`, + darken(`#${displayRoute.image_border_color}`, 0.2), ], - [route.image_border_color], + [displayRoute.image_border_color], ) useMapEvents({ @@ -61,7 +81,7 @@ const BaseRouteTile = (route) => { } }, }) - useForcePopup(route.id, markerRef) + useForcePopup(displayRoute.id, markerRef) return ( <> @@ -71,7 +91,10 @@ const BaseRouteTile = (route) => { ref={position === 'start' ? setMarkerRef : undefined} opacity={hover || clicked ? 1 : MARKER_OPACITY} zIndexOffset={hover === position ? 2000 : hover || clicked ? 1000 : 0} - position={[route[`${position}_lat`], route[`${position}_lon`]]} + position={[ + displayRoute[`${position}_lat`], + displayRoute[`${position}_lon`], + ]} icon={routeMarker(position)} eventHandlers={{ popupopen: () => setClicked(true), @@ -91,7 +114,7 @@ const BaseRouteTile = (route) => { }} > @@ -115,7 +138,7 @@ const BaseRouteTile = (route) => { } }, }} - dashArray={route.reversible ? undefined : '5, 5'} + dashArray={displayRoute.reversible ? undefined : '5, 5'} positions={waypoints.map((waypoint) => [ waypoint.lat_degrees, waypoint.lng_degrees, @@ -132,5 +155,7 @@ const BaseRouteTile = (route) => { export const RouteTile = React.memo( BaseRouteTile, - (prev, next) => prev.updated === next.updated, + (prev, next) => + prev.route.updated === next.route.updated && + prev.orientation === next.orientation, ) diff --git a/src/features/route/index.js b/src/features/route/index.js index 9357e658e..13724a8e4 100644 --- a/src/features/route/index.js +++ b/src/features/route/index.js @@ -3,3 +3,5 @@ export * from './routeMarker' export * from './RoutePopup' export * from './RouteTile' +export * from './RouteLayer' +export * from './useRouteStore' diff --git a/src/features/route/routeFlagMarker.js b/src/features/route/routeFlagMarker.js new file mode 100644 index 000000000..6befb6465 --- /dev/null +++ b/src/features/route/routeFlagMarker.js @@ -0,0 +1,26 @@ +// @ts-check +import { divIcon } from 'leaflet' + +/** + * @param {number} count + * @param {boolean} active + */ +export function routeFlagMarker(count, active) { + const badge = + count > 1 ? `${count}` : '' + const html = ` + + ` + .replace(/\s+ + `${prefix}:${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` + +/** + * @param {Record} poiIndex + * @param {import('@rm/types').Route} route + * @param {'forward' | 'reverse'} orientation + */ +const addPoiEntry = (poiIndex, route, orientation) => { + const isForward = orientation === 'forward' + const poiId = isForward ? route.start_fort_id : route.end_fort_id + const lat = isForward ? route.start_lat : route.end_lat + const lon = isForward ? route.start_lon : route.end_lon + const key = poiId || fallbackKey(lat, lon, isForward ? 'start' : 'end') + + const existing = poiIndex[key] || { + key, + poiId: poiId || key, + lat, + lon, + isFort: !!poiId, + routes: [], + } + if ( + !existing.routes.some( + (ref) => ref.routeId === route.id && ref.orientation === orientation, + ) + ) { + existing.routes = [...existing.routes, { routeId: route.id, orientation }] + } + poiIndex[key] = existing +} + +/** + * @typedef {{ + * routeCache: Record, + * poiIndex: Record, + * activePoiId: string, + * activeRoutes: RouteSelection[], + * syncRoutes: (routes: import('@rm/types').Route[]) => void, + * selectPoi: (poiId: string) => void, + * clearSelection: () => void, + * }} RouteStore + */ +export const useRouteStore = create( + /** @returns {RouteStore} */ + (set) => ({ + routeCache: {}, + poiIndex: {}, + activePoiId: '', + activeRoutes: [], + syncRoutes: (routes) => { + set((state) => { + const poiIndex = {} + const incomingIds = new Set() + const nextRouteCache = { ...state.routeCache } + + routes.forEach((route) => { + if (!route?.id) return + incomingIds.add(route.id) + nextRouteCache[route.id] = route + addPoiEntry(poiIndex, route, 'forward') + if (route.reversible) { + addPoiEntry(poiIndex, route, 'reverse') + } + }) + + const activeRouteIds = new Set( + state.activeRoutes.map((ref) => ref.routeId), + ) + Object.keys(nextRouteCache).forEach((routeId) => { + if (!incomingIds.has(routeId) && !activeRouteIds.has(routeId)) { + delete nextRouteCache[routeId] + } + }) + + const { activePoiId } = state + const activeEntry = activePoiId ? poiIndex[activePoiId] : null + const nextActiveRoutes = activeEntry + ? activeEntry.routes.filter((ref) => nextRouteCache[ref.routeId]) + : state.activeRoutes.filter((ref) => nextRouteCache[ref.routeId]) + + return { + poiIndex, + routeCache: nextRouteCache, + activeRoutes: nextActiveRoutes, + } + }) + }, + selectPoi: (poiId) => { + set((state) => { + if (!poiId) { + return state + } + if (state.activePoiId === poiId) { + return { + ...state, + activePoiId: '', + activeRoutes: [], + } + } + const entry = state.poiIndex[poiId] + if (!entry) { + return state + } + const routes = entry.routes.filter( + (ref) => state.routeCache[ref.routeId], + ) + return { + ...state, + activePoiId: poiId, + activeRoutes: routes, + } + }) + }, + clearSelection: () => + set((state) => + state.activePoiId + ? { + ...state, + activePoiId: '', + activeRoutes: [], + } + : state, + ), + }), +) diff --git a/src/pages/map/components/QueryData.jsx b/src/pages/map/components/QueryData.jsx index 3ecf01d17..81f8a1817 100644 --- a/src/pages/map/components/QueryData.jsx +++ b/src/pages/map/components/QueryData.jsx @@ -11,6 +11,7 @@ import { RobustTimeout } from '@services/apollo/RobustTimeout' import { FILTER_SKIP_LIST } from '@assets/constants' import { Notification } from '@components/Notification' import { GenerateCells } from '@features/s2cell' +import { RouteLayer } from '@features/route' import { useAnalytics } from '@hooks/useAnalytics' import { useProcessError } from '@hooks/useProcessError' @@ -185,14 +186,17 @@ function QueryData({ category, timeout }) { ) : null } + const filteredData = returnData.filter((each) => !hideList.has(each.id)) + + if (category === 'routes') { + return + } + return ( - {returnData.map((each) => { - if (!hideList.has(each.id)) { - return - } - return null - })} + {filteredData.map((each) => ( + + ))} ) } diff --git a/src/services/queries/route.js b/src/services/queries/route.js index a5c48fd5f..c3dee2894 100644 --- a/src/services/queries/route.js +++ b/src/services/queries/route.js @@ -5,8 +5,10 @@ import { gql } from '@apollo/client' const core = gql` fragment CoreRoute on Route { id + start_fort_id start_lat start_lon + end_fort_id end_lat end_lon } From 1b4a2b2d33616f4beecd02f2b65279fb5068e0a8 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 23:20:14 -0700 Subject: [PATCH 02/10] fix: various stuff --- src/assets/css/main.css | 44 +++++------- src/features/drawer/Routes.jsx | 17 +++++ src/features/route/RouteLayer.jsx | 99 ++++++++++++++++++++++----- src/features/route/RoutePopup.jsx | 34 ++++++--- src/features/route/RouteTile.jsx | 74 ++++++++++++++++++-- src/features/route/routeFlagMarker.js | 26 ------- src/features/route/useRouteStore.js | 45 ++++++++++-- 7 files changed, 253 insertions(+), 86 deletions(-) delete mode 100644 src/features/route/routeFlagMarker.js diff --git a/src/assets/css/main.css b/src/assets/css/main.css index 9a58fd1d2..e1467ed72 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -88,40 +88,34 @@ body { background-color: #ff4b4d; } -.route-flag { - border: none !important; - background: transparent !important; +.route-direction { + pointer-events: none; } -.route-flag__wrapper { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 20px; - text-shadow: 0 0 2px rgba(0, 0, 0, 0.65); +.route-direction__arrow { + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 12px solid #00a3ee; + transform-origin: 0 50%; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35)); } -.route-flag__badge { - position: absolute; - top: -6px; - right: -6px; - min-width: 16px; - padding: 0 4px; +.route-count-badge { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; border-radius: 10px; background: #ff4b4d; color: #fff; font-size: 11px; font-weight: 700; - line-height: 16px; -} - -.route-flag--active .route-flag__wrapper { - transform: scale(1.1); -} - -.route-flag--active .route-flag__emoji { - filter: drop-shadow(0 0 6px rgba(255, 75, 77, 0.75)); + line-height: 18px; + border: 1px solid #ffffff; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); } .invasion-exists { diff --git a/src/features/drawer/Routes.jsx b/src/features/drawer/Routes.jsx index 008238457..a065629da 100644 --- a/src/features/drawer/Routes.jsx +++ b/src/features/drawer/Routes.jsx @@ -1,6 +1,9 @@ // @ts-check import * as React from 'react' import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import Switch from '@mui/material/Switch' +import { useTranslation } from 'react-i18next' import { useMemory } from '@store/useMemory' import { useStorage, useDeepStore } from '@store/useStorage' @@ -9,8 +12,13 @@ import { SliderTile } from '@components/inputs/SliderTile' import { CollapsibleItem } from './components/CollapsibleItem' const RouteSlider = () => { + const { t } = useTranslation() const enabled = useStorage((s) => !!s.filters?.routes?.enabled) const [filters, setFilters] = useDeepStore('filters.routes.distance') + const [compactView, setCompactView] = useDeepStore( + 'userSettings.routes.compactView', + true, + ) const baseDistance = useMemory.getState().filters?.routes?.distance /** @type {import('@rm/types').RMSlider} */ @@ -31,6 +39,15 @@ const RouteSlider = () => { return ( + + + setCompactView(checked)} + checked={compactView !== false} + /> + { - const icon = React.useMemo( - () => routeFlagMarker(entry.routes.length, selected), - [entry.routes.length, selected], - ) +const RouteAnchor = React.memo(({ entry, selected, onSelect, routeCount }) => { + const baseIcon = React.useMemo(() => routeMarker('start'), []) + const badgeIcon = React.useMemo(() => { + if (routeCount <= 1) return null + return divIcon({ + className: 'route-count-badge', + html: `${routeCount}`, + iconSize: [18, 18], + iconAnchor: [-8, 20], + }) + }, [routeCount]) return ( - onSelect(entry.key), - }} - /> + <> + {!selected && ( + onSelect(entry.key), + }} + title={routeCount > 1 ? `${routeCount} routes` : ''} + /> + )} + {badgeIcon && ( + + )} + ) }) @@ -43,8 +68,12 @@ const ActiveRoute = React.memo(({ selection }) => { export function RouteLayer({ routes }) { const enabled = useStorage((s) => !!s.filters?.routes?.enabled) + const compactView = useStorage( + (s) => s.userSettings.routes?.compactView ?? true, + ) const syncRoutes = useRouteStore((s) => s.syncRoutes) const poiIndex = useRouteStore((s) => s.poiIndex) + const routeCache = useRouteStore((s) => s.routeCache) const activeRoutes = useRouteStore((s) => s.activeRoutes) const activePoiId = useRouteStore((s) => s.activePoiId) const selectPoi = useRouteStore((s) => s.selectPoi) @@ -55,10 +84,10 @@ export function RouteLayer({ routes }) { }, [routes, syncRoutes]) React.useEffect(() => { - if (!enabled) { + if (!enabled || !compactView) { clearSelection() } - }, [enabled, clearSelection]) + }, [enabled, compactView, clearSelection]) useMapEvents({ click: ({ originalEvent }) => { @@ -68,20 +97,54 @@ export function RouteLayer({ routes }) { }, }) + const anchors = React.useMemo(() => { + if (!compactView) return [] + const epsilon = 1 / 10 ** 6 + const values = Object.values(poiIndex) + return values.map((entry) => { + const seen = new Set() + let count = 0 + values.forEach((candidate) => { + if ( + Math.abs(candidate.lat - entry.lat) <= epsilon && + Math.abs(candidate.lon - entry.lon) <= epsilon + ) { + candidate.routes.forEach((ref) => { + const id = `${ref.routeId}-${ref.orientation}` + if (!seen.has(id) && routeCache[ref.routeId]) { + seen.add(id) + count += 1 + } + }) + } + }) + return { entry, routeCount: count || entry.routes.length || 1 } + }) + }, [compactView, poiIndex, routeCache]) + if (!enabled) { return null } - const anchors = React.useMemo(() => Object.values(poiIndex), [poiIndex]) + if (!compactView) { + return ( + <> + {routes.map((route) => ( + + ))} + + ) + } return ( <> - {anchors.map((entry) => ( + {anchors.map(({ entry, routeCount }) => ( ))} {activeRoutes.map((selection) => ( diff --git a/src/features/route/RoutePopup.jsx b/src/features/route/RoutePopup.jsx index 5200fb46c..c88732a48 100644 --- a/src/features/route/RoutePopup.jsx +++ b/src/features/route/RoutePopup.jsx @@ -131,7 +131,7 @@ function ExpandableWrapper({ disabled = false, children, expandKey, primary }) { * @param {import("@rm/types").Route & { end?: boolean }} props * @returns */ -export function RoutePopup({ end, ...props }) { +export function RoutePopup({ end, inline = false, ...props }) { const [route, setRoute] = React.useState({ ...props, tags: [] }) const { config } = useMemory.getState() const formatDistance = useFormatDistance() @@ -146,6 +146,12 @@ export function RoutePopup({ end, ...props }) { }) const { t } = useTranslation() + React.useEffect(() => { + if (inline && !called) { + getRoute() + } + }, [inline, called, getRoute]) + React.useEffect(() => { if (data?.route) { setRoute({ @@ -206,14 +212,8 @@ export function RoutePopup({ end, ...props }) { } }, [route.shortcode, t]) - return ( - { - if (ref && ref.isOpen() && !called) { - getRoute() - } - }} - > + const content = ( + <> {notification.message} + + ) + + if (inline) { + return content + } + + return ( + { + if (ref && ref.isOpen() && !called) { + getRoute() + } + }} + > + {content} ) } diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index e2f93fc91..898eb3b7b 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -1,7 +1,8 @@ /* eslint-disable react/destructuring-assignment */ // @ts-check import * as React from 'react' -import { Marker, Polyline, useMapEvents } from 'react-leaflet' +import { Marker, Polyline, Popup, useMapEvents } from 'react-leaflet' +import { divIcon } from 'leaflet' import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' @@ -23,6 +24,9 @@ const MARKER_OPACITY = LINE_OPACITY * 2 const BaseRouteTile = ({ route, orientation = 'forward' }) => { const [clicked, setClicked] = React.useState(false) const [hover, setHover] = React.useState('') + const [linePopup, setLinePopup] = React.useState( + /** @type {import('leaflet').LatLngExpression | null} */ (null), + ) /** @type {React.MutableRefObject} */ const lineRef = React.useRef() @@ -78,11 +82,46 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { if (!originalEvent.defaultPrevented) { setClicked(false) setHover('') + setLinePopup(null) } }, }) useForcePopup(displayRoute.id, markerRef) + React.useEffect(() => { + setLinePopup(null) + }, [displayRoute.id, orientation]) + + const directionArrow = React.useMemo(() => { + if (displayRoute.reversible || waypoints.length < 2) { + return null + } + const index = Math.floor((waypoints.length - 1) / 2) + const startPoint = waypoints[index] + const nextPoint = waypoints[index + 1] || startPoint + if (!startPoint || !nextPoint) { + return null + } + const lat = (startPoint.lat_degrees + nextPoint.lat_degrees) / 2 + const lon = (startPoint.lng_degrees + nextPoint.lng_degrees) / 2 + const deltaLat = nextPoint.lat_degrees - startPoint.lat_degrees + const deltaLon = nextPoint.lng_degrees - startPoint.lng_degrees + const angle = (Math.atan2(deltaLat, deltaLon) * 180) / Math.PI + const arrowColor = `#${displayRoute.image_border_color}` + const icon = divIcon({ + className: 'route-direction', + html: `
` + .replace(/\s+ {POSITIONS.map((position) => ( @@ -95,7 +134,11 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { displayRoute[`${position}_lat`], displayRoute[`${position}_lon`], ]} - icon={routeMarker(position)} + icon={ + displayRoute.reversible + ? routeMarker('start') + : routeMarker(position) + } eventHandlers={{ popupopen: () => setClicked(true), popupclose: () => setClicked(false), @@ -123,9 +166,12 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { { + click: ({ originalEvent, latlng }) => { originalEvent.preventDefault() - setClicked((prev) => !prev) + setClicked(true) + if (latlng) { + setLinePopup([latlng.lat, latlng.lng]) + } }, mouseover: ({ target }) => { if (target && !clicked) { @@ -149,6 +195,26 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { weight: 4, }} /> + {directionArrow && ( + + )} + {linePopup && ( + setLinePopup(null), + close: () => setLinePopup(null), + }} + > + + + )} ) } diff --git a/src/features/route/routeFlagMarker.js b/src/features/route/routeFlagMarker.js deleted file mode 100644 index 6befb6465..000000000 --- a/src/features/route/routeFlagMarker.js +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-check -import { divIcon } from 'leaflet' - -/** - * @param {number} count - * @param {boolean} active - */ -export function routeFlagMarker(count, active) { - const badge = - count > 1 ? `${count}` : '' - const html = ` - - ` - .replace(/\s+ `${prefix}:${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` +const EPSILON = 1 / 10 ** PRECISION + +/** + * @param {Record} poiIndex + * @param {RoutePoiIndex | null} entry + * @param {Record} routeCache + * @returns {RouteSelection[]} + */ +const collectNearbyRoutes = (poiIndex, entry, routeCache) => { + if (!entry) return [] + const seen = new Set() + /** @type {RouteSelection[]} */ + const combined = [] + Object.values(poiIndex).forEach((candidate) => { + if ( + Math.abs(candidate.lat - entry.lat) <= EPSILON && + Math.abs(candidate.lon - entry.lon) <= EPSILON + ) { + candidate.routes.forEach((ref) => { + const id = `${ref.routeId}-${ref.orientation}` + if (!seen.has(id) && routeCache[ref.routeId]) { + seen.add(id) + combined.push(ref) + } + }) + } + }) + return combined +} + /** * @param {Record} poiIndex * @param {import('@rm/types').Route} route @@ -105,8 +135,13 @@ export const useRouteStore = create( const { activePoiId } = state const activeEntry = activePoiId ? poiIndex[activePoiId] : null - const nextActiveRoutes = activeEntry - ? activeEntry.routes.filter((ref) => nextRouteCache[ref.routeId]) + const nearbyActiveRoutes = collectNearbyRoutes( + poiIndex, + activeEntry, + nextRouteCache, + ) + const nextActiveRoutes = nearbyActiveRoutes.length + ? nearbyActiveRoutes : state.activeRoutes.filter((ref) => nextRouteCache[ref.routeId]) return { @@ -132,8 +167,10 @@ export const useRouteStore = create( if (!entry) { return state } - const routes = entry.routes.filter( - (ref) => state.routeCache[ref.routeId], + const routes = collectNearbyRoutes( + state.poiIndex, + entry, + state.routeCache, ) return { ...state, From a13db4ba6389f2506cff481c0ad1611237622f5a Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 23:24:51 -0700 Subject: [PATCH 03/10] fix: badge text centered --- src/assets/css/main.css | 13 +++++++++---- src/features/route/RouteLayer.jsx | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/assets/css/main.css b/src/assets/css/main.css index e1467ed72..f1862a81b 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -102,18 +102,23 @@ body { filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35)); } +.route-count-wrapper { + display: inline-flex; + transform: translate(10px, -12px); +} + .route-count-badge { - display: flex; + display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + min-width: 18px; + padding: 0 4px; border-radius: 10px; background: #ff4b4d; color: #fff; font-size: 11px; font-weight: 700; - line-height: 18px; + line-height: 16px; border: 1px solid #ffffff; box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); } diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 88b689b2e..3e9ddcae4 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -17,10 +17,10 @@ const RouteAnchor = React.memo(({ entry, selected, onSelect, routeCount }) => { const badgeIcon = React.useMemo(() => { if (routeCount <= 1) return null return divIcon({ - className: 'route-count-badge', - html: `${routeCount}`, - iconSize: [18, 18], - iconAnchor: [-8, 20], + className: 'route-count-wrapper', + html: `${routeCount}`, + iconSize: [0, 0], + iconAnchor: [0, 0], }) }, [routeCount]) From af7b399bdf9e0fbb8e4f5359a104c077251ac38c Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 23:27:29 -0700 Subject: [PATCH 04/10] fix: align switch --- src/features/drawer/Routes.jsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/features/drawer/Routes.jsx b/src/features/drawer/Routes.jsx index a065629da..b7cb322ae 100644 --- a/src/features/drawer/Routes.jsx +++ b/src/features/drawer/Routes.jsx @@ -39,14 +39,16 @@ const RouteSlider = () => { return ( - + setCompactView(checked)} + checked={compactView !== false} + /> + } + > - setCompactView(checked)} - checked={compactView !== false} - /> Date: Mon, 22 Sep 2025 23:39:29 -0700 Subject: [PATCH 05/10] fix: suggestions --- src/features/route/RouteLayer.jsx | 7 +++---- src/features/route/RouteTile.jsx | 4 +--- src/features/route/useRouteStore.js | 8 ++++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 3e9ddcae4..53ff94394 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -7,7 +7,7 @@ import { useStorage } from '@store/useStorage' import { RouteTile } from './RouteTile' import { routeMarker } from './routeMarker' -import { useRouteStore } from './useRouteStore' +import { useRouteStore, ROUTE_COORD_EPSILON } from './useRouteStore' const ACTIVE_Z_INDEX = 1800 const INACTIVE_Z_INDEX = 900 @@ -99,15 +99,14 @@ export function RouteLayer({ routes }) { const anchors = React.useMemo(() => { if (!compactView) return [] - const epsilon = 1 / 10 ** 6 const values = Object.values(poiIndex) return values.map((entry) => { const seen = new Set() let count = 0 values.forEach((candidate) => { if ( - Math.abs(candidate.lat - entry.lat) <= epsilon && - Math.abs(candidate.lon - entry.lon) <= epsilon + Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON && + Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON ) { candidate.routes.forEach((ref) => { const id = `${ref.routeId}-${ref.orientation}` diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index 898eb3b7b..b0bf6f046 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -110,9 +110,7 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { const arrowColor = `#${displayRoute.image_border_color}` const icon = divIcon({ className: 'route-direction', - html: `
` - .replace(/\s+`, iconSize: [24, 24], iconAnchor: [0, 12], }) diff --git a/src/features/route/useRouteStore.js b/src/features/route/useRouteStore.js index f08636ccf..39b41e935 100644 --- a/src/features/route/useRouteStore.js +++ b/src/features/route/useRouteStore.js @@ -4,6 +4,8 @@ import { create } from 'zustand' const PRECISION = 6 +export const ROUTE_COORD_EPSILON = 1 / 10 ** PRECISION + /** * @typedef {{ * routeId: string, @@ -30,8 +32,6 @@ const PRECISION = 6 const fallbackKey = (lat, lon, prefix) => `${prefix}:${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` -const EPSILON = 1 / 10 ** PRECISION - /** * @param {Record} poiIndex * @param {RoutePoiIndex | null} entry @@ -45,8 +45,8 @@ const collectNearbyRoutes = (poiIndex, entry, routeCache) => { const combined = [] Object.values(poiIndex).forEach((candidate) => { if ( - Math.abs(candidate.lat - entry.lat) <= EPSILON && - Math.abs(candidate.lon - entry.lon) <= EPSILON + Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON && + Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON ) { candidate.routes.forEach((ref) => { const id = `${ref.routeId}-${ref.orientation}` From 6d41fcd1d5190b1f53a5f5a827fb5d2973010b8f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 22 Sep 2025 23:54:18 -0700 Subject: [PATCH 06/10] fix: various improvements --- src/features/route/RouteLayer.jsx | 59 ++++++++++++++++++++--------- src/features/route/useRouteStore.js | 14 ++++++- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 53ff94394..73e56f0e2 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -7,7 +7,11 @@ import { useStorage } from '@store/useStorage' import { RouteTile } from './RouteTile' import { routeMarker } from './routeMarker' -import { useRouteStore, ROUTE_COORD_EPSILON } from './useRouteStore' +import { + useRouteStore, + ROUTE_COORD_EPSILON, + getRouteCoordKey, +} from './useRouteStore' const ACTIVE_Z_INDEX = 1800 const INACTIVE_Z_INDEX = 900 @@ -97,27 +101,42 @@ export function RouteLayer({ routes }) { }, }) + const destinationCoords = React.useMemo(() => { + if (!compactView) return new Set() + const keys = new Set() + activeRoutes.forEach((selection) => { + const route = routeCache[selection.routeId] + if (!route) return + const isForward = selection.orientation === 'forward' + const lat = isForward ? route.end_lat : route.start_lat + const lon = isForward ? route.end_lon : route.start_lon + keys.add(getRouteCoordKey(lat, lon)) + }) + return keys + }, [activeRoutes, routeCache, compactView]) + const anchors = React.useMemo(() => { if (!compactView) return [] const values = Object.values(poiIndex) return values.map((entry) => { - const seen = new Set() - let count = 0 + const uniqueRoutes = new Set() values.forEach((candidate) => { if ( Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON && Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON ) { candidate.routes.forEach((ref) => { - const id = `${ref.routeId}-${ref.orientation}` - if (!seen.has(id) && routeCache[ref.routeId]) { - seen.add(id) - count += 1 + if (routeCache[ref.routeId]) { + uniqueRoutes.add(ref.routeId) } }) } }) - return { entry, routeCount: count || entry.routes.length || 1 } + return { + entry, + routeCount: + uniqueRoutes.size || new Set(entry.routes.map((r) => r.routeId)).size, + } }) }, [compactView, poiIndex, routeCache]) @@ -137,15 +156,21 @@ export function RouteLayer({ routes }) { return ( <> - {anchors.map(({ entry, routeCount }) => ( - - ))} + {anchors.map(({ entry, routeCount }) => { + const entryCoordKey = getRouteCoordKey(entry.lat, entry.lon) + if (destinationCoords.has(entryCoordKey) && entry.key !== activePoiId) { + return null + } + return ( + + ) + })} {activeRoutes.map((selection) => ( + `${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` + const fallbackKey = (lat, lon, prefix) => - `${prefix}:${lat.toFixed(PRECISION)}:${lon.toFixed(PRECISION)}` + `${prefix}:${formatCoordKey(lat, lon)}` + +export const getRouteCoordKey = formatCoordKey + +export const getRoutePoiKey = (route, position) => { + const lat = position === 'start' ? route.start_lat : route.end_lat + const lon = position === 'start' ? route.start_lon : route.end_lon + const fortId = position === 'start' ? route.start_fort_id : route.end_fort_id + return fortId || fallbackKey(lat, lon, position) +} /** * @param {Record} poiIndex From 94e062622d4c9033a362a2c0a2794f831958ba14 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 00:12:23 -0700 Subject: [PATCH 07/10] fix: further refinements --- src/assets/css/main.css | 4 + src/features/route/RouteLayer.jsx | 132 +++++++++++++++++++----------- 2 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/assets/css/main.css b/src/assets/css/main.css index f1862a81b..ba6ef7270 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -123,6 +123,10 @@ body { box-shadow: 0 0 4px rgba(0, 0, 0, 0.4); } +.route-count-badge--destination { + background: #2196f3; +} + .invasion-exists { border: 4px solid rgb(141, 13, 13); } diff --git a/src/features/route/RouteLayer.jsx b/src/features/route/RouteLayer.jsx index 73e56f0e2..09d14f75d 100644 --- a/src/features/route/RouteLayer.jsx +++ b/src/features/route/RouteLayer.jsx @@ -16,47 +16,59 @@ import { const ACTIVE_Z_INDEX = 1800 const INACTIVE_Z_INDEX = 900 -const RouteAnchor = React.memo(({ entry, selected, onSelect, routeCount }) => { - const baseIcon = React.useMemo(() => routeMarker('start'), []) - const badgeIcon = React.useMemo(() => { - if (routeCount <= 1) return null - return divIcon({ - className: 'route-count-wrapper', - html: `${routeCount}`, - iconSize: [0, 0], - iconAnchor: [0, 0], - }) - }, [routeCount]) +const RouteAnchor = React.memo( + ({ + entry, + selected, + onSelect, + routeCount, + variant = 'start', + icon = null, + }) => { + const baseIcon = React.useMemo( + () => icon || routeMarker(variant === 'destination' ? 'end' : 'start'), + [icon, variant], + ) + const badgeIcon = React.useMemo(() => { + if (routeCount <= 1) return null + return divIcon({ + className: 'route-count-wrapper', + html: `${routeCount}`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }) + }, [routeCount, variant]) - return ( - <> - {!selected && ( - onSelect(entry.key), - }} - title={routeCount > 1 ? `${routeCount} routes` : ''} - /> - )} - {badgeIcon && ( - - )} - - ) -}) + return ( + <> + {variant !== 'destination' && !selected && ( + onSelect(entry.key), + }} + title={routeCount > 1 ? `${routeCount} routes` : ''} + /> + )} + {badgeIcon && ( + + )} + + ) + }, +) const ActiveRoute = React.memo(({ selection }) => { const route = useRouteStore( @@ -101,18 +113,26 @@ export function RouteLayer({ routes }) { }, }) - const destinationCoords = React.useMemo(() => { - if (!compactView) return new Set() + const destinationSummary = React.useMemo(() => { + if (!compactView) + return { keys: new Set(), icons: new Map(), counts: new Map() } const keys = new Set() + const icons = new Map() + const counts = new Map() activeRoutes.forEach((selection) => { const route = routeCache[selection.routeId] if (!route) return const isForward = selection.orientation === 'forward' const lat = isForward ? route.end_lat : route.start_lat const lon = isForward ? route.end_lon : route.start_lon - keys.add(getRouteCoordKey(lat, lon)) + const coordKey = getRouteCoordKey(lat, lon) + keys.add(coordKey) + counts.set(coordKey, (counts.get(coordKey) || 0) + 1) + if (!icons.has(coordKey)) { + icons.set(coordKey, routeMarker('end')) + } }) - return keys + return { keys, icons, counts } }, [activeRoutes, routeCache, compactView]) const anchors = React.useMemo(() => { @@ -158,7 +178,25 @@ export function RouteLayer({ routes }) { <> {anchors.map(({ entry, routeCount }) => { const entryCoordKey = getRouteCoordKey(entry.lat, entry.lon) - if (destinationCoords.has(entryCoordKey) && entry.key !== activePoiId) { + const iconOverride = destinationSummary.icons.get(entryCoordKey) + const destinationCount = destinationSummary.counts.get(entryCoordKey) + if (destinationCount && destinationCount > 1) { + return ( + + ) + } + if ( + destinationSummary.keys.has(entryCoordKey) && + entry.key !== activePoiId + ) { return null } return ( @@ -167,7 +205,9 @@ export function RouteLayer({ routes }) { entry={entry} selected={entry.key === activePoiId} onSelect={selectPoi} - routeCount={routeCount} + routeCount={destinationCount || routeCount} + variant="start" + icon={iconOverride || undefined} /> ) })} From 920cbed2029588425c76fb3ce35be11876c10e90 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 10:24:01 -0700 Subject: [PATCH 08/10] fix: irreversible routes --- src/assets/css/main.css | 14 -- src/features/route/RouteTile.jsx | 273 ++++++++++++++++++++++++++----- 2 files changed, 231 insertions(+), 56 deletions(-) diff --git a/src/assets/css/main.css b/src/assets/css/main.css index ba6ef7270..72c7badcd 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -88,20 +88,6 @@ body { background-color: #ff4b4d; } -.route-direction { - pointer-events: none; -} - -.route-direction__arrow { - width: 0; - height: 0; - border-top: 6px solid transparent; - border-bottom: 6px solid transparent; - border-left: 12px solid #00a3ee; - transform-origin: 0 50%; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35)); -} - .route-count-wrapper { display: inline-flex; transform: translate(10px, -12px); diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index b0bf6f046..25ab356e8 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -2,7 +2,9 @@ // @ts-check import * as React from 'react' import { Marker, Polyline, Popup, useMapEvents } from 'react-leaflet' -import { divIcon } from 'leaflet' +import destination from '@turf/destination' +import distance from '@turf/distance' +import { point as turfPoint } from '@turf/helpers' import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' @@ -14,6 +16,144 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) const LINE_OPACITY = 0.33 const MARKER_OPACITY = LINE_OPACITY * 2 +const CHEVRON_SPACING_METERS = 10 +const CHEVRON_MIN_LENGTH_METERS = 2 +const CHEVRON_MAX_LENGTH_METERS = 5 +const CHEVRON_WIDTH_RATIO = 0.75 + +/** @param {{ lat: number, lng: number }} start @param {{ lat: number, lng: number }} end */ +const segmentDistanceInMeters = (start, end) => + distance(turfPoint([start.lng, start.lat]), turfPoint([end.lng, end.lat]), { + units: 'meters', + }) + +/** @param {{ lat: number, lng: number }} start @param {{ lat: number, lng: number }} end */ +const calculateBearing = (start, end) => { + const toRadians = (degrees) => (degrees * Math.PI) / 180 + const toDegrees = (radians) => (radians * 180) / Math.PI + + const lat1 = toRadians(start.lat) + const lat2 = toRadians(end.lat) + const deltaLon = toRadians(end.lng - start.lng) + const y = Math.sin(deltaLon) * Math.cos(lat2) + const x = + Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon) + const bearing = toDegrees(Math.atan2(y, x)) + return (bearing + 360) % 360 +} + +/** + * @param {{ lat: number, lng: number }} origin + * @param {number} bearing + * @param {number} distanceMeters + */ +const movePoint = (origin, bearing, distanceMeters) => { + const result = destination( + turfPoint([origin.lng, origin.lat]), + distanceMeters, + bearing, + { units: 'meters' }, + ) + const [lng, lat] = result.geometry.coordinates + return { lat, lng } +} + +/** + * @param {Array<[number, number]>} latLngTuples + * @returns {Array>} + */ +const generateChevronSegments = (latLngTuples) => { + if (!latLngTuples || latLngTuples.length < 2) { + return [] + } + + const latLngs = latLngTuples.map(([lat, lng]) => ({ lat, lng })) + const segmentLengths = [] + let totalLength = 0 + + for (let index = 0; index < latLngs.length - 1; index += 1) { + const start = latLngs[index] + const end = latLngs[index + 1] + const length = segmentDistanceInMeters(start, end) + segmentLengths.push(length) + totalLength += length + } + + if (totalLength === 0) { + return [] + } + + const desiredSpacing = CHEVRON_SPACING_METERS + const chevronCount = Math.max(1, Math.round(totalLength / desiredSpacing)) + const spacingBetweenCenters = totalLength / chevronCount + const baseLength = spacingBetweenCenters * 0.35 + const denseLength = spacingBetweenCenters * 0.55 + let chevronLength = + spacingBetweenCenters < CHEVRON_MIN_LENGTH_METERS + ? denseLength + : Math.max(CHEVRON_MIN_LENGTH_METERS, baseLength) + chevronLength = Math.min(CHEVRON_MAX_LENGTH_METERS, chevronLength) + const chevronHalfLength = chevronLength / 2 + const chevronWidth = chevronLength * CHEVRON_WIDTH_RATIO + const chevronHalfWidth = chevronWidth / 2 + + const chevronCenters = Array.from( + { length: chevronCount }, + (_, index) => spacingBetweenCenters * index + spacingBetweenCenters / 2, + ) + + const chevrons = [] + + chevronCenters.forEach((targetDistance) => { + let accumulated = 0 + + for (let index = 0; index < segmentLengths.length; index += 1) { + const segmentLength = segmentLengths[index] + if (!segmentLength) { + continue + } + + if (accumulated + segmentLength >= targetDistance) { + const start = latLngs[index] + const end = latLngs[index + 1] + const segmentRatio = (targetDistance - accumulated) / segmentLength + const center = { + lat: start.lat + (end.lat - start.lat) * segmentRatio, + lng: start.lng + (end.lng - start.lng) * segmentRatio, + } + const bearing = calculateBearing(start, end) + const tip = movePoint(center, bearing, chevronHalfLength) + const backCenter = movePoint( + center, + (bearing + 180) % 360, + chevronHalfLength, + ) + const backTop = movePoint( + backCenter, + (bearing + 90) % 360, + chevronHalfWidth, + ) + const backBottom = movePoint( + backCenter, + (bearing + 270) % 360, + chevronHalfWidth, + ) + + chevrons.push([ + [backTop.lat, backTop.lng], + [tip.lat, tip.lng], + [backBottom.lat, backBottom.lng], + ]) + break + } + + accumulated += segmentLength + } + }) + + return chevrons +} /** * @param {{ @@ -31,6 +171,8 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { /** @type {React.MutableRefObject} */ const lineRef = React.useRef() const [markerRef, setMarkerRef] = React.useState(null) + /** @type {React.MutableRefObject} */ + const chevronRef = React.useRef() const displayRoute = React.useMemo(() => { if (orientation === 'forward') return route @@ -77,6 +219,18 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { [displayRoute.image_border_color], ) + const polylinePositions = React.useMemo( + () => + waypoints.map((waypoint) => [waypoint.lat_degrees, waypoint.lng_degrees]), + [waypoints], + ) + + const chevronSegments = React.useMemo( + () => + displayRoute.reversible ? [] : generateChevronSegments(polylinePositions), + [displayRoute.reversible, polylinePositions], + ) + useMapEvents({ click: ({ originalEvent }) => { if (!originalEvent.defaultPrevented) { @@ -92,33 +246,24 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { setLinePopup(null) }, [displayRoute.id, orientation]) - const directionArrow = React.useMemo(() => { - if (displayRoute.reversible || waypoints.length < 2) { - return null - } - const index = Math.floor((waypoints.length - 1) / 2) - const startPoint = waypoints[index] - const nextPoint = waypoints[index + 1] || startPoint - if (!startPoint || !nextPoint) { - return null + React.useEffect(() => { + if (lineRef.current) { + lineRef.current.setStyle({ + color: clicked || hover ? darkened : color, + opacity: displayRoute.reversible + ? clicked || hover + ? 1 + : LINE_OPACITY + : 0, + }) } - const lat = (startPoint.lat_degrees + nextPoint.lat_degrees) / 2 - const lon = (startPoint.lng_degrees + nextPoint.lng_degrees) / 2 - const deltaLat = nextPoint.lat_degrees - startPoint.lat_degrees - const deltaLon = nextPoint.lng_degrees - startPoint.lng_degrees - const angle = (Math.atan2(deltaLat, deltaLon) * 180) / Math.PI - const arrowColor = `#${displayRoute.image_border_color}` - const icon = divIcon({ - className: 'route-direction', - html: `
`, - iconSize: [24, 24], - iconAnchor: [0, 12], - }) - return { - position: [lat, lon], - icon, + if (chevronRef.current) { + chevronRef.current.setStyle({ + color: clicked || hover ? darkened : color, + opacity: clicked || hover ? 1 : LINE_OPACITY, + }) } - }, [displayRoute.image_border_color, displayRoute.reversible, waypoints]) + }, [clicked, color, darkened, displayRoute.reversible, hover]) return ( <> @@ -142,13 +287,28 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { popupclose: () => setClicked(false), mouseover: () => { if (lineRef.current) { - lineRef.current.setStyle({ color: darkened, opacity: 1 }) + lineRef.current.setStyle({ + color: darkened, + opacity: displayRoute.reversible ? 1 : 0, + }) + } + if (chevronRef.current) { + chevronRef.current.setStyle({ color: darkened, opacity: 1 }) } setHover(position) }, mouseout: () => { if (lineRef.current && !clicked) { - lineRef.current.setStyle({ color, opacity: MARKER_OPACITY }) + lineRef.current.setStyle({ + color, + opacity: displayRoute.reversible ? LINE_OPACITY : 0, + }) + } + if (chevronRef.current && !clicked) { + chevronRef.current.setStyle({ + color, + opacity: LINE_OPACITY, + }) } setHover('') }, @@ -167,39 +327,68 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { click: ({ originalEvent, latlng }) => { originalEvent.preventDefault() setClicked(true) + if (lineRef.current) { + lineRef.current.setStyle({ + color: darkened, + opacity: displayRoute.reversible ? 1 : 0, + }) + } + if (chevronRef.current) { + chevronRef.current.setStyle({ color: darkened, opacity: 1 }) + } if (latlng) { setLinePopup([latlng.lat, latlng.lng]) } }, mouseover: ({ target }) => { if (target && !clicked) { - target.setStyle({ color: darkened, opacity: 1 }) + target.setStyle({ + color: darkened, + opacity: displayRoute.reversible ? 1 : 0, + }) + } + if (chevronRef.current && !clicked) { + chevronRef.current.setStyle({ color: darkened, opacity: 1 }) } }, mouseout: ({ target }) => { if (target && !clicked) { - target.setStyle({ color, opacity: LINE_OPACITY }) + target.setStyle({ + color, + opacity: displayRoute.reversible ? LINE_OPACITY : 0, + }) + } + if (chevronRef.current && !clicked) { + chevronRef.current.setStyle({ + color, + opacity: LINE_OPACITY, + }) } }, }} - dashArray={displayRoute.reversible ? undefined : '5, 5'} - positions={waypoints.map((waypoint) => [ - waypoint.lat_degrees, - waypoint.lng_degrees, - ])} + positions={polylinePositions} pathOptions={{ color: clicked || hover ? darkened : color, - opacity: clicked || hover ? 1 : LINE_OPACITY, + opacity: displayRoute.reversible + ? clicked || hover + ? 1 + : LINE_OPACITY + : 0, weight: 4, }} /> - {directionArrow && ( - 0 && ( + )} {linePopup && ( From b9813bcf19007ef70be78a43d2317222c15db9f9 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 23 Sep 2025 10:51:09 -0700 Subject: [PATCH 09/10] fix: use lib --- package.json | 1 + src/features/route/RouteTile.jsx | 299 ++++++++++--------------------- yarn.lock | 17 +- 3 files changed, 112 insertions(+), 205 deletions(-) diff --git a/package.json b/package.json index c5c4609bf..56269d525 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "i18next-http-backend": "2.5.2", "knex": "3.1.0", "leaflet": "1.9.4", + "leaflet-arrowheads": "^1.4.0", "leaflet.locatecontrol": "0.81.0", "lodash": "^4.17.21", "moment-timezone": "^0.5.43", diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index 25ab356e8..8f0537d17 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -2,9 +2,7 @@ // @ts-check import * as React from 'react' import { Marker, Polyline, Popup, useMapEvents } from 'react-leaflet' -import destination from '@turf/destination' -import distance from '@turf/distance' -import { point as turfPoint } from '@turf/helpers' +import 'leaflet-arrowheads' import { darken } from '@mui/material/styles' import { useForcePopup } from '@hooks/useForcePopup' @@ -16,151 +14,7 @@ const POSITIONS = /** @type {const} */ (['start', 'end']) const LINE_OPACITY = 0.33 const MARKER_OPACITY = LINE_OPACITY * 2 -const CHEVRON_SPACING_METERS = 10 -const CHEVRON_MIN_LENGTH_METERS = 2 -const CHEVRON_MAX_LENGTH_METERS = 5 -const CHEVRON_WIDTH_RATIO = 0.75 -/** @param {{ lat: number, lng: number }} start @param {{ lat: number, lng: number }} end */ -const segmentDistanceInMeters = (start, end) => - distance(turfPoint([start.lng, start.lat]), turfPoint([end.lng, end.lat]), { - units: 'meters', - }) - -/** @param {{ lat: number, lng: number }} start @param {{ lat: number, lng: number }} end */ -const calculateBearing = (start, end) => { - const toRadians = (degrees) => (degrees * Math.PI) / 180 - const toDegrees = (radians) => (radians * 180) / Math.PI - - const lat1 = toRadians(start.lat) - const lat2 = toRadians(end.lat) - const deltaLon = toRadians(end.lng - start.lng) - const y = Math.sin(deltaLon) * Math.cos(lat2) - const x = - Math.cos(lat1) * Math.sin(lat2) - - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon) - const bearing = toDegrees(Math.atan2(y, x)) - return (bearing + 360) % 360 -} - -/** - * @param {{ lat: number, lng: number }} origin - * @param {number} bearing - * @param {number} distanceMeters - */ -const movePoint = (origin, bearing, distanceMeters) => { - const result = destination( - turfPoint([origin.lng, origin.lat]), - distanceMeters, - bearing, - { units: 'meters' }, - ) - const [lng, lat] = result.geometry.coordinates - return { lat, lng } -} - -/** - * @param {Array<[number, number]>} latLngTuples - * @returns {Array>} - */ -const generateChevronSegments = (latLngTuples) => { - if (!latLngTuples || latLngTuples.length < 2) { - return [] - } - - const latLngs = latLngTuples.map(([lat, lng]) => ({ lat, lng })) - const segmentLengths = [] - let totalLength = 0 - - for (let index = 0; index < latLngs.length - 1; index += 1) { - const start = latLngs[index] - const end = latLngs[index + 1] - const length = segmentDistanceInMeters(start, end) - segmentLengths.push(length) - totalLength += length - } - - if (totalLength === 0) { - return [] - } - - const desiredSpacing = CHEVRON_SPACING_METERS - const chevronCount = Math.max(1, Math.round(totalLength / desiredSpacing)) - const spacingBetweenCenters = totalLength / chevronCount - const baseLength = spacingBetweenCenters * 0.35 - const denseLength = spacingBetweenCenters * 0.55 - let chevronLength = - spacingBetweenCenters < CHEVRON_MIN_LENGTH_METERS - ? denseLength - : Math.max(CHEVRON_MIN_LENGTH_METERS, baseLength) - chevronLength = Math.min(CHEVRON_MAX_LENGTH_METERS, chevronLength) - const chevronHalfLength = chevronLength / 2 - const chevronWidth = chevronLength * CHEVRON_WIDTH_RATIO - const chevronHalfWidth = chevronWidth / 2 - - const chevronCenters = Array.from( - { length: chevronCount }, - (_, index) => spacingBetweenCenters * index + spacingBetweenCenters / 2, - ) - - const chevrons = [] - - chevronCenters.forEach((targetDistance) => { - let accumulated = 0 - - for (let index = 0; index < segmentLengths.length; index += 1) { - const segmentLength = segmentLengths[index] - if (!segmentLength) { - continue - } - - if (accumulated + segmentLength >= targetDistance) { - const start = latLngs[index] - const end = latLngs[index + 1] - const segmentRatio = (targetDistance - accumulated) / segmentLength - const center = { - lat: start.lat + (end.lat - start.lat) * segmentRatio, - lng: start.lng + (end.lng - start.lng) * segmentRatio, - } - const bearing = calculateBearing(start, end) - const tip = movePoint(center, bearing, chevronHalfLength) - const backCenter = movePoint( - center, - (bearing + 180) % 360, - chevronHalfLength, - ) - const backTop = movePoint( - backCenter, - (bearing + 90) % 360, - chevronHalfWidth, - ) - const backBottom = movePoint( - backCenter, - (bearing + 270) % 360, - chevronHalfWidth, - ) - - chevrons.push([ - [backTop.lat, backTop.lng], - [tip.lat, tip.lng], - [backBottom.lat, backBottom.lng], - ]) - break - } - - accumulated += segmentLength - } - }) - - return chevrons -} - -/** - * @param {{ - * route: import('@rm/types').Route, - * orientation?: 'forward' | 'reverse', - * }} props - */ const BaseRouteTile = ({ route, orientation = 'forward' }) => { const [clicked, setClicked] = React.useState(false) const [hover, setHover] = React.useState('') @@ -171,8 +25,8 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { /** @type {React.MutableRefObject} */ const lineRef = React.useRef() const [markerRef, setMarkerRef] = React.useState(null) - /** @type {React.MutableRefObject} */ - const chevronRef = React.useRef() + /** @type {React.MutableRefObject} */ + const arrowheadsRef = React.useRef(null) const displayRoute = React.useMemo(() => { if (orientation === 'forward') return route @@ -225,10 +79,19 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { [waypoints], ) - const chevronSegments = React.useMemo( - () => - displayRoute.reversible ? [] : generateChevronSegments(polylinePositions), - [displayRoute.reversible, polylinePositions], + const applyArrowheadStyle = React.useCallback( + (targetColor, targetOpacity) => { + const group = arrowheadsRef.current + if (!group) { + return + } + /** @type {any} */ ;(group).eachLayer((layer) => { + if (layer && typeof layer.setStyle === 'function') { + layer.setStyle({ color: targetColor, opacity: targetOpacity }) + } + }) + }, + [], ) useMapEvents({ @@ -247,23 +110,75 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { }, [displayRoute.id, orientation]) React.useEffect(() => { - if (lineRef.current) { - lineRef.current.setStyle({ - color: clicked || hover ? darkened : color, - opacity: displayRoute.reversible - ? clicked || hover - ? 1 - : LINE_OPACITY - : 0, + const line = lineRef.current + if (!line) { + arrowheadsRef.current = null + return undefined + } + + const arrowLine = /** @type {any} */ (line) + if (typeof arrowLine.deleteArrowheads === 'function') { + arrowLine.deleteArrowheads() + } + arrowheadsRef.current = null + + if ( + !displayRoute.reversible && + typeof arrowLine.arrowheads === 'function' + ) { + arrowLine.arrowheads({ + size: '10px', + frequency: '24px', + yawn: 32, + fill: false, + offsets: { + start: '10px', + end: '10px', + }, }) + if (typeof line.redraw === 'function') { + line.redraw() + } + if (typeof arrowLine.getArrowheads === 'function') { + try { + const group = arrowLine.getArrowheads() + arrowheadsRef.current = group || null + } catch (error) { + arrowheadsRef.current = null + } + } + applyArrowheadStyle(color, LINE_OPACITY) } - if (chevronRef.current) { - chevronRef.current.setStyle({ - color: clicked || hover ? darkened : color, - opacity: clicked || hover ? 1 : LINE_OPACITY, + + return () => { + if (typeof arrowLine.deleteArrowheads === 'function') { + arrowLine.deleteArrowheads() + } + arrowheadsRef.current = null + } + }, [applyArrowheadStyle, color, displayRoute.reversible, polylinePositions]) + + React.useEffect(() => { + if (lineRef.current) { + const active = Boolean(clicked || hover) + const lineOpacity = active ? 1 : LINE_OPACITY + lineRef.current.setStyle({ + color: active ? darkened : color, + opacity: lineOpacity, }) } - }, [clicked, color, darkened, displayRoute.reversible, hover]) + applyArrowheadStyle( + clicked || hover ? darkened : color, + clicked || hover ? 1 : LINE_OPACITY, + ) + }, [ + applyArrowheadStyle, + clicked, + color, + darkened, + displayRoute.reversible, + hover, + ]) return ( <> @@ -289,27 +204,22 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { if (lineRef.current) { lineRef.current.setStyle({ color: darkened, - opacity: displayRoute.reversible ? 1 : 0, + opacity: 1, }) } - if (chevronRef.current) { - chevronRef.current.setStyle({ color: darkened, opacity: 1 }) - } + applyArrowheadStyle(darkened, 1) setHover(position) }, mouseout: () => { if (lineRef.current && !clicked) { lineRef.current.setStyle({ - color, - opacity: displayRoute.reversible ? LINE_OPACITY : 0, - }) - } - if (chevronRef.current && !clicked) { - chevronRef.current.setStyle({ color, opacity: LINE_OPACITY, }) } + if (!clicked) { + applyArrowheadStyle(color, LINE_OPACITY) + } setHover('') }, }} @@ -330,12 +240,10 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { if (lineRef.current) { lineRef.current.setStyle({ color: darkened, - opacity: displayRoute.reversible ? 1 : 0, + opacity: 1, }) } - if (chevronRef.current) { - chevronRef.current.setStyle({ color: darkened, opacity: 1 }) - } + applyArrowheadStyle(darkened, 1) if (latlng) { setLinePopup([latlng.lat, latlng.lng]) } @@ -344,26 +252,23 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { if (target && !clicked) { target.setStyle({ color: darkened, - opacity: displayRoute.reversible ? 1 : 0, + opacity: 1, }) } - if (chevronRef.current && !clicked) { - chevronRef.current.setStyle({ color: darkened, opacity: 1 }) + if (!clicked) { + applyArrowheadStyle(darkened, 1) } }, mouseout: ({ target }) => { if (target && !clicked) { target.setStyle({ - color, - opacity: displayRoute.reversible ? LINE_OPACITY : 0, - }) - } - if (chevronRef.current && !clicked) { - chevronRef.current.setStyle({ color, opacity: LINE_OPACITY, }) } + if (!clicked) { + applyArrowheadStyle(color, LINE_OPACITY) + } }, }} positions={polylinePositions} @@ -373,24 +278,10 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { ? clicked || hover ? 1 : LINE_OPACITY - : 0, + : LINE_OPACITY, weight: 4, }} /> - {chevronSegments.length > 0 && ( - - )} {linePopup && ( Date: Tue, 23 Sep 2025 10:57:19 -0700 Subject: [PATCH 10/10] fix: refine code style --- src/features/route/RouteTile.jsx | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/features/route/RouteTile.jsx b/src/features/route/RouteTile.jsx index 8f0537d17..aa1d09350 100644 --- a/src/features/route/RouteTile.jsx +++ b/src/features/route/RouteTile.jsx @@ -85,7 +85,7 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { if (!group) { return } - /** @type {any} */ ;(group).eachLayer((layer) => { + /** @type {any} */ group.eachLayer((layer) => { if (layer && typeof layer.setStyle === 'function') { layer.setStyle({ color: targetColor, opacity: targetOpacity }) } @@ -158,27 +158,21 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { } }, [applyArrowheadStyle, color, displayRoute.reversible, polylinePositions]) + const isActive = Boolean(clicked || hover) + React.useEffect(() => { if (lineRef.current) { - const active = Boolean(clicked || hover) - const lineOpacity = active ? 1 : LINE_OPACITY + const lineOpacity = isActive ? 1 : LINE_OPACITY lineRef.current.setStyle({ - color: active ? darkened : color, + color: isActive ? darkened : color, opacity: lineOpacity, }) } applyArrowheadStyle( - clicked || hover ? darkened : color, - clicked || hover ? 1 : LINE_OPACITY, + isActive ? darkened : color, + isActive ? 1 : LINE_OPACITY, ) - }, [ - applyArrowheadStyle, - clicked, - color, - darkened, - displayRoute.reversible, - hover, - ]) + }, [applyArrowheadStyle, color, darkened, isActive]) return ( <> @@ -273,12 +267,8 @@ const BaseRouteTile = ({ route, orientation = 'forward' }) => { }} positions={polylinePositions} pathOptions={{ - color: clicked || hover ? darkened : color, - opacity: displayRoute.reversible - ? clicked || hover - ? 1 - : LINE_OPACITY - : LINE_OPACITY, + color: isActive ? darkened : color, + opacity: displayRoute.reversible && isActive ? 1 : LINE_OPACITY, weight: 4, }} />