Skip to content

Commit 795976e

Browse files
authored
feat: Compact Route View (#1131)
1 parent 9144e54 commit 795976e

File tree

13 files changed

+746
-56
lines changed

13 files changed

+746
-56
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"i18next-http-backend": "2.5.2",
150150
"knex": "3.1.0",
151151
"leaflet": "1.9.4",
152+
"leaflet-arrowheads": "^1.4.0",
152153
"leaflet.locatecontrol": "0.81.0",
153154
"lodash": "^4.17.21",
154155
"moment-timezone": "^0.5.43",

src/assets/css/main.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,31 @@ body {
8888
background-color: #ff4b4d;
8989
}
9090

91+
.route-count-wrapper {
92+
display: inline-flex;
93+
transform: translate(10px, -12px);
94+
}
95+
96+
.route-count-badge {
97+
display: inline-flex;
98+
align-items: center;
99+
justify-content: center;
100+
min-width: 18px;
101+
padding: 0 4px;
102+
border-radius: 10px;
103+
background: #ff4b4d;
104+
color: #fff;
105+
font-size: 11px;
106+
font-weight: 700;
107+
line-height: 16px;
108+
border: 1px solid #ffffff;
109+
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
110+
}
111+
112+
.route-count-badge--destination {
113+
background: #2196f3;
114+
}
115+
91116
.invasion-exists {
92117
border: 4px solid rgb(141, 13, 13);
93118
}

src/features/drawer/Routes.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// @ts-check
22
import * as React from 'react'
33
import ListItem from '@mui/material/ListItem'
4+
import ListItemText from '@mui/material/ListItemText'
5+
import Switch from '@mui/material/Switch'
6+
import { useTranslation } from 'react-i18next'
47

58
import { useMemory } from '@store/useMemory'
69
import { useStorage, useDeepStore } from '@store/useStorage'
@@ -9,8 +12,13 @@ import { SliderTile } from '@components/inputs/SliderTile'
912
import { CollapsibleItem } from './components/CollapsibleItem'
1013

1114
const RouteSlider = () => {
15+
const { t } = useTranslation()
1216
const enabled = useStorage((s) => !!s.filters?.routes?.enabled)
1317
const [filters, setFilters] = useDeepStore('filters.routes.distance')
18+
const [compactView, setCompactView] = useDeepStore(
19+
'userSettings.routes.compactView',
20+
true,
21+
)
1422
const baseDistance = useMemory.getState().filters?.routes?.distance
1523

1624
/** @type {import('@rm/types').RMSlider} */
@@ -31,6 +39,17 @@ const RouteSlider = () => {
3139

3240
return (
3341
<CollapsibleItem open={enabled}>
42+
<ListItem
43+
secondaryAction={
44+
<Switch
45+
color="secondary"
46+
onChange={(_, checked) => setCompactView(checked)}
47+
checked={compactView !== false}
48+
/>
49+
}
50+
>
51+
<ListItemText primary={t('compact_route_view', 'Compact Route View')} />
52+
</ListItem>
3453
<ListItem>
3554
<SliderTile
3655
slide={slider}

src/features/gym/GymTile.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useForcePopup } from '@hooks/useForcePopup'
1212
import { sendNotification } from '@services/desktopNotification'
1313
import { TooltipWrapper } from '@components/ToolTipWrapper'
1414
import { getTimeUntil } from '@utils/getTimeUntil'
15+
import { useRouteStore } from '@features/route'
1516

1617
import { gymMarker } from './gymMarker'
1718
import { GymPopup } from './GymPopup'
@@ -38,6 +39,8 @@ const getColor = (team) => {
3839
const BaseGymTile = (gym) => {
3940
const [markerRef, setMarkerRef] = React.useState(null)
4041
const [stateChange, setStateChange] = React.useState(false)
42+
const hasRoutes = useRouteStore((s) => !!s.poiIndex[gym.id])
43+
const selectPoi = useRouteStore((s) => s.selectPoi)
4144

4245
const [
4346
hasRaid,
@@ -179,6 +182,13 @@ const BaseGymTile = (gym) => {
179182
raidIconSize,
180183
...gym,
181184
})}
185+
eventHandlers={{
186+
click: () => {
187+
if (hasRoutes) {
188+
selectPoi(gym.id)
189+
}
190+
},
191+
}}
182192
>
183193
<Popup position={[gym.lat, gym.lon]}>
184194
<GymPopup

src/features/pokestop/PokestopTile.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Marker, Popup, Circle } from 'react-leaflet'
66
import { useMarkerTimer } from '@hooks/useMarkerTimer'
77
import { basicEqualFn, useMemory } from '@store/useMemory'
88
import { useStorage } from '@store/useStorage'
9+
import { useRouteStore } from '@features/route'
910
import { useForcePopup } from '@hooks/useForcePopup'
1011
import { TooltipWrapper } from '@components/ToolTipWrapper'
1112

@@ -20,6 +21,8 @@ import { usePokestopMarker } from './usePokestopMarker'
2021
const BasePokestopTile = (pokestop) => {
2122
const [stateChange, setStateChange] = React.useState(false)
2223
const [markerRef, setMarkerRef] = React.useState(null)
24+
const hasRoutes = useRouteStore((s) => !!s.poiIndex[pokestop.id])
25+
const selectPoi = useRouteStore((s) => s.selectPoi)
2326

2427
const [
2528
hasLure,
@@ -130,6 +133,13 @@ const BasePokestopTile = (pokestop) => {
130133
ref={setMarkerRef}
131134
position={[pokestop.lat, pokestop.lon]}
132135
icon={icon}
136+
eventHandlers={{
137+
click: () => {
138+
if (hasRoutes) {
139+
selectPoi(pokestop.id)
140+
}
141+
},
142+
}}
133143
>
134144
<Popup position={[pokestop.lat, pokestop.lon]}>
135145
<PokestopPopup

src/features/route/RouteLayer.jsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// @ts-check
2+
import * as React from 'react'
3+
import { Marker, useMapEvents } from 'react-leaflet'
4+
import { divIcon } from 'leaflet'
5+
6+
import { useStorage } from '@store/useStorage'
7+
8+
import { RouteTile } from './RouteTile'
9+
import { routeMarker } from './routeMarker'
10+
import {
11+
useRouteStore,
12+
ROUTE_COORD_EPSILON,
13+
getRouteCoordKey,
14+
} from './useRouteStore'
15+
16+
const ACTIVE_Z_INDEX = 1800
17+
const INACTIVE_Z_INDEX = 900
18+
19+
const RouteAnchor = React.memo(
20+
({
21+
entry,
22+
selected,
23+
onSelect,
24+
routeCount,
25+
variant = 'start',
26+
icon = null,
27+
}) => {
28+
const baseIcon = React.useMemo(
29+
() => icon || routeMarker(variant === 'destination' ? 'end' : 'start'),
30+
[icon, variant],
31+
)
32+
const badgeIcon = React.useMemo(() => {
33+
if (routeCount <= 1) return null
34+
return divIcon({
35+
className: 'route-count-wrapper',
36+
html: `<span class="route-count-badge route-count-badge--${variant}">${routeCount}</span>`,
37+
iconSize: [0, 0],
38+
iconAnchor: [0, 0],
39+
})
40+
}, [routeCount, variant])
41+
42+
return (
43+
<>
44+
{variant !== 'destination' && !selected && (
45+
<Marker
46+
position={[entry.lat, entry.lon]}
47+
icon={baseIcon}
48+
zIndexOffset={INACTIVE_Z_INDEX}
49+
riseOnHover
50+
eventHandlers={{
51+
click: () => onSelect(entry.key),
52+
}}
53+
title={routeCount > 1 ? `${routeCount} routes` : ''}
54+
/>
55+
)}
56+
{badgeIcon && (
57+
<Marker
58+
position={[entry.lat, entry.lon]}
59+
icon={badgeIcon}
60+
interactive={false}
61+
keyboard={false}
62+
pane="tooltipPane"
63+
zIndexOffset={
64+
selected ? ACTIVE_Z_INDEX + 200 : INACTIVE_Z_INDEX + 200
65+
}
66+
/>
67+
)}
68+
</>
69+
)
70+
},
71+
)
72+
73+
const ActiveRoute = React.memo(({ selection }) => {
74+
const route = useRouteStore(
75+
React.useCallback(
76+
(state) => state.routeCache[selection.routeId],
77+
[selection.routeId],
78+
),
79+
)
80+
81+
if (!route) return null
82+
return <RouteTile route={route} orientation={selection.orientation} />
83+
})
84+
85+
export function RouteLayer({ routes }) {
86+
const enabled = useStorage((s) => !!s.filters?.routes?.enabled)
87+
const compactView = useStorage(
88+
(s) => s.userSettings.routes?.compactView ?? true,
89+
)
90+
const syncRoutes = useRouteStore((s) => s.syncRoutes)
91+
const poiIndex = useRouteStore((s) => s.poiIndex)
92+
const routeCache = useRouteStore((s) => s.routeCache)
93+
const activeRoutes = useRouteStore((s) => s.activeRoutes)
94+
const activePoiId = useRouteStore((s) => s.activePoiId)
95+
const selectPoi = useRouteStore((s) => s.selectPoi)
96+
const clearSelection = useRouteStore((s) => s.clearSelection)
97+
98+
React.useEffect(() => {
99+
syncRoutes(routes || [])
100+
}, [routes, syncRoutes])
101+
102+
React.useEffect(() => {
103+
if (!enabled || !compactView) {
104+
clearSelection()
105+
}
106+
}, [enabled, compactView, clearSelection])
107+
108+
useMapEvents({
109+
click: ({ originalEvent }) => {
110+
if (!originalEvent.defaultPrevented) {
111+
clearSelection()
112+
}
113+
},
114+
})
115+
116+
const destinationSummary = React.useMemo(() => {
117+
if (!compactView)
118+
return { keys: new Set(), icons: new Map(), counts: new Map() }
119+
const keys = new Set()
120+
const icons = new Map()
121+
const counts = new Map()
122+
activeRoutes.forEach((selection) => {
123+
const route = routeCache[selection.routeId]
124+
if (!route) return
125+
const isForward = selection.orientation === 'forward'
126+
const lat = isForward ? route.end_lat : route.start_lat
127+
const lon = isForward ? route.end_lon : route.start_lon
128+
const coordKey = getRouteCoordKey(lat, lon)
129+
keys.add(coordKey)
130+
counts.set(coordKey, (counts.get(coordKey) || 0) + 1)
131+
if (!icons.has(coordKey)) {
132+
icons.set(coordKey, routeMarker('end'))
133+
}
134+
})
135+
return { keys, icons, counts }
136+
}, [activeRoutes, routeCache, compactView])
137+
138+
const anchors = React.useMemo(() => {
139+
if (!compactView) return []
140+
const values = Object.values(poiIndex)
141+
return values.map((entry) => {
142+
const uniqueRoutes = new Set()
143+
values.forEach((candidate) => {
144+
if (
145+
Math.abs(candidate.lat - entry.lat) <= ROUTE_COORD_EPSILON &&
146+
Math.abs(candidate.lon - entry.lon) <= ROUTE_COORD_EPSILON
147+
) {
148+
candidate.routes.forEach((ref) => {
149+
if (routeCache[ref.routeId]) {
150+
uniqueRoutes.add(ref.routeId)
151+
}
152+
})
153+
}
154+
})
155+
return {
156+
entry,
157+
routeCount:
158+
uniqueRoutes.size || new Set(entry.routes.map((r) => r.routeId)).size,
159+
}
160+
})
161+
}, [compactView, poiIndex, routeCache])
162+
163+
if (!enabled) {
164+
return null
165+
}
166+
167+
if (!compactView) {
168+
return (
169+
<>
170+
{routes.map((route) => (
171+
<RouteTile key={route.id} route={route} />
172+
))}
173+
</>
174+
)
175+
}
176+
177+
return (
178+
<>
179+
{anchors.map(({ entry, routeCount }) => {
180+
const entryCoordKey = getRouteCoordKey(entry.lat, entry.lon)
181+
const iconOverride = destinationSummary.icons.get(entryCoordKey)
182+
const destinationCount = destinationSummary.counts.get(entryCoordKey)
183+
if (destinationCount && destinationCount > 1) {
184+
return (
185+
<RouteAnchor
186+
key={`${entry.key}-destination`}
187+
entry={entry}
188+
selected={entry.key === activePoiId}
189+
onSelect={selectPoi}
190+
routeCount={destinationCount}
191+
variant="destination"
192+
icon={iconOverride || undefined}
193+
/>
194+
)
195+
}
196+
if (
197+
destinationSummary.keys.has(entryCoordKey) &&
198+
entry.key !== activePoiId
199+
) {
200+
return null
201+
}
202+
return (
203+
<RouteAnchor
204+
key={entry.key}
205+
entry={entry}
206+
selected={entry.key === activePoiId}
207+
onSelect={selectPoi}
208+
routeCount={destinationCount || routeCount}
209+
variant="start"
210+
icon={iconOverride || undefined}
211+
/>
212+
)
213+
})}
214+
{activeRoutes.map((selection) => (
215+
<ActiveRoute
216+
key={`${selection.routeId}-${selection.orientation}`}
217+
selection={selection}
218+
/>
219+
))}
220+
</>
221+
)
222+
}

0 commit comments

Comments
 (0)