Skip to content

Commit a8acdef

Browse files
authored
feat: point vehicle location markers along their SIRI bearing (#1826)
1 parent 4be4048 commit a8acdef

17 files changed

Lines changed: 685 additions & 50 deletions

public/marker-dot.png

-5.99 KB
Binary file not shown.

src/pages/components/map-related/MapContent.tsx

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Icon, IconOptions, Layer } from 'leaflet'
1+
import { Layer } from 'leaflet'
22
import { useCallback, useEffect, useState } from 'react'
33
import { useTranslation } from 'react-i18next'
44
import { TileLayer, useMap } from 'react-leaflet'
@@ -7,21 +7,6 @@ import { MapPlannedRouteLayer } from './MapLayers/MapPlannedRouteLayer'
77
import { MapRouteLayer } from './MapLayers/MapRouteLayer'
88
import { useRecenterOnDataChange } from './useRecenterOnDataChange'
99

10-
const getIcon = (path: string, width: number = 10, height: number = 10): Icon<IconOptions> => {
11-
return new Icon<IconOptions>({
12-
iconUrl: path,
13-
iconSize: [width, height],
14-
})
15-
}
16-
17-
export const actualRouteLineColor = 'orange'
18-
export const actualRouteStopMarkerPath = `${import.meta.env.BASE_URL}marker-dot.png`
19-
export const actualRouteStopMarker = getIcon(actualRouteStopMarkerPath, 20, 20)
20-
21-
export const plannedRouteLineColor = 'black'
22-
export const plannedRouteStopMarkerPath = `${import.meta.env.BASE_URL}marker-bus-stop.png`
23-
export const plannedRouteStopMarker = getIcon(plannedRouteStopMarkerPath, 20, 25)
24-
2510
export function MapContent({
2611
positionGroups,
2712
plannedRouteStops,

src/pages/components/map-related/MapIndex.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,19 @@ import type { ReactNode } from 'react'
22

33
export function MapIndex({
44
lineColor,
5-
imgSrc,
5+
icon,
66
title,
77
subtitle,
88
}: {
99
lineColor: string
10-
imgSrc: string
10+
icon: ReactNode
1111
title: string
1212
subtitle?: ReactNode
1313
}) {
1414
return (
1515
<div className="map-index-item">
1616
<div className="map-index-item-config">
17-
<div className="map-index-item-icon">
18-
<img src={imgSrc} alt="" />
19-
</div>
17+
<div className="map-index-item-icon">{icon}</div>
2018
<div className="map-index-item-line" style={{ backgroundColor: lineColor }} />
2119
</div>
2220
<div className="map-index-item-title">

src/pages/components/map-related/MapLayers/MapIndexLayer.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ const meta = {
1010
title: 'Map/Layers/MapIndexLayer',
1111
component: MapIndexLayer,
1212
parameters: { layout: 'centered' },
13+
// Every legend rule in map.scss is nested under `.map-info` and its `.map-legend` slot, so
14+
// mounted bare the legend gets no grid and glyphs at their intrinsic size. The outer size is
15+
// needed because `.map-legend` is absolutely positioned within it.
16+
decorators: [
17+
(Story) => (
18+
<div className="map-info" style={{ width: 340, height: 210 }}>
19+
<div className="map-legend">
20+
<Story />
21+
</div>
22+
</div>
23+
),
24+
],
1325
} satisfies Meta<typeof MapIndexLayer>
1426

1527
export default meta

src/pages/components/map-related/MapLayers/MapIndexLayer.test.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { render, screen, within } from '@testing-library/react'
22
import { MemoryRouter } from 'react-router'
33
import type { PositionGroup } from '../map-types'
4+
import { SPEED_BANDS } from '../vehicleBearingGlyph'
45
import { MapIndexLayer } from './MapIndexLayer'
56

6-
// MapIndexLayer only needs the path/color constants out of MapContent, so stub
7-
// them instead of pulling the whole leaflet map module into the test.
8-
vi.mock('../MapContent', () => ({
9-
actualRouteStopMarkerPath: 'actual-marker.png',
7+
// MapIndexLayer only needs the path/color constants out of mapMarkers, so stub them instead
8+
// of pulling leaflet into the test.
9+
vi.mock('../mapMarkers', () => ({
1010
plannedRouteStopMarkerPath: 'planned-marker.png',
1111
plannedRouteLineColor: 'black',
1212
}))
@@ -44,7 +44,9 @@ describe('MapIndexLayer', () => {
4444
})
4545

4646
expect(screen.queryByRole('link')).not.toBeInTheDocument()
47-
expect(screen.getByText('(', { exact: false }).closest('bdi')).toHaveTextContent('(99)')
47+
// scoped to the row: the speed key's own title is parenthesised too
48+
const item = document.querySelector('.map-index-item') as HTMLElement
49+
expect(within(item).getByText('(', { exact: false }).closest('bdi')).toHaveTextContent('(99)')
4850
})
4951

5052
it('renders one actual-route legend row per position group', () => {
@@ -91,4 +93,19 @@ describe('MapIndexLayer', () => {
9193
// title present, but no parenthesised subtitle span
9294
expect(within(item as HTMLElement).queryByText('(', { exact: false })).not.toBeInTheDocument()
9395
})
96+
97+
it('keys the speed ramp only once a ride is on the map to use it on', () => {
98+
const { container, rerender } = renderLayer({ showPlannedRoute: true, positionGroups: [] })
99+
expect(container.querySelector('.map-speed-index')).not.toBeInTheDocument()
100+
101+
rerender(
102+
<MemoryRouter>
103+
<MapIndexLayer showPlannedRoute positionGroups={[group({ label: '12-345-67' })]} />
104+
</MemoryRouter>,
105+
)
106+
const key = container.querySelector('.map-speed-index')!
107+
expect(key).toBeInTheDocument()
108+
// the standing glyph plus one arrow per speed band
109+
expect(key.querySelectorAll('.map-speed-index-band')).toHaveLength(SPEED_BANDS.length + 1)
110+
})
94111
})

src/pages/components/map-related/MapLayers/MapIndexLayer.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@ import type { ReactNode } from 'react'
55
import { useTranslation } from 'react-i18next'
66
import { Link } from 'react-router'
77
import type { PositionGroup } from '../map-types'
8-
import {
9-
actualRouteStopMarkerPath,
10-
plannedRouteLineColor,
11-
plannedRouteStopMarkerPath,
12-
} from '../MapContent'
138
import { MapIndex } from '../MapIndex'
9+
import { plannedRouteLineColor, plannedRouteStopMarkerPath } from '../mapMarkers'
10+
import { MapSpeedIndex } from '../MapSpeedIndex'
11+
import { SPEED_BANDS, VehicleBearingGlyph } from '../vehicleBearingGlyph'
1412

1513
interface MapIndexLayerProps {
1614
showPlannedRoute?: boolean
@@ -51,7 +49,7 @@ export function MapIndexLayer({ showPlannedRoute, positionGroups = [] }: MapInde
5149
{showPlannedRoute && (
5250
<MapIndex
5351
lineColor={plannedRouteLineColor}
54-
imgSrc={plannedRouteStopMarkerPath}
52+
icon={<img src={plannedRouteStopMarkerPath} alt="" />}
5553
title={t('plannedRoute')}
5654
/>
5755
)}
@@ -61,11 +59,13 @@ export function MapIndexLayer({ showPlannedRoute, positionGroups = [] }: MapInde
6159
<MapIndex
6260
key={idx}
6361
lineColor={group.color}
64-
imgSrc={actualRouteStopMarkerPath}
62+
// A mid-ramp arrow stands for the whole family; MapSpeedIndex below spells the bands out.
63+
icon={<VehicleBearingGlyph band={SPEED_BANDS[SPEED_BANDS.length - 2]} />}
6564
title={t('actualRoute')}
6665
subtitle={vehicleSubtitle(group, t)}
6766
/>
6867
))}
68+
{positionGroups.length > 0 && <MapSpeedIndex />}
6969
</div>
7070
)
7171
}

src/pages/components/map-related/MapLayers/MapPlannedRouteLayer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Marker, Polyline } from 'react-leaflet'
22
import type { BusStop } from 'src/model/busStop'
3-
import { plannedRouteLineColor, plannedRouteStopMarker } from '../MapContent'
3+
import { plannedRouteLineColor, plannedRouteStopMarker } from '../mapMarkers'
44

55
interface MapPlannedRouteLayerProps {
66
plannedRouteStops?: BusStop[]

src/pages/components/map-related/MapLayers/MapRouteLayer.tsx

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import { Fragment, useCallback, useRef } from 'react'
33
import { Marker, Polyline, Popup } from 'react-leaflet'
44
import { useAgencyList } from 'src/hooks/useAgencyList'
55
import { busIcon, busIconPath } from '../../utils/BusIcon'
6-
import type { PositionGroup } from '../map-types'
7-
import { actualRouteStopMarker } from '../MapContent'
6+
import type { Point, PositionGroup } from '../map-types'
7+
import { rideEndMarker, vehicleBearingMarker, vehicleStandingMarker } from '../mapMarkers'
8+
import {
9+
bearingZIndex,
10+
BOOKEND_Z_INDEX,
11+
isStanding,
12+
speedBand,
13+
STANDING_Z_INDEX,
14+
} from '../vehicleBearingGlyph'
815
import { BusToolTip } from './BusToolTip'
916
import BusToolTipFooter from './BusToolTipFooter'
1017

@@ -14,6 +21,18 @@ interface MapRouteLayerProps {
1421
navigateMarkers: (groupIndex: number, id: number, marker: Layer) => void
1522
}
1623

24+
/** `Point.color` holds the ping's velocity, not a colour — see `toPoint`. */
25+
function pingMarker({ bearing, color: velocity }: Point) {
26+
// Only the standing badge can say "heading unknown" (it drops its needle); a moving ping has
27+
// to point somewhere, and `toPoint` has already defaulted a missing SIRI bearing to 0 anyway.
28+
return isStanding(velocity)
29+
? { icon: vehicleStandingMarker(bearing), zIndexOffset: STANDING_Z_INDEX }
30+
: {
31+
icon: vehicleBearingMarker(bearing ?? 0, velocity),
32+
zIndexOffset: bearingZIndex(speedBand(velocity)),
33+
}
34+
}
35+
1736
export function MapRouteLayer({
1837
positionGroups,
1938
showNavigationButtons,
@@ -42,22 +61,29 @@ export function MapRouteLayer({
4261
/>
4362
{group.positions.map((pos, i) => {
4463
const markerKey = `${groupIndex}-${i}`
45-
const icon =
64+
// A one-ping ride keeps the operator's logo — it never got to finish.
65+
const { icon, zIndexOffset } =
4666
i === 0
47-
? busIcon({
48-
// eslint-disable-next-line i18next/no-literal-string -- icon lookup key, not user text
49-
operator_id: pos.operator?.toString() || 'default',
50-
name: agencyList.find((agency) => agency.operatorRef === pos.operator)
51-
?.agencyName,
52-
})
53-
: actualRouteStopMarker
67+
? {
68+
icon: busIcon({
69+
// eslint-disable-next-line i18next/no-literal-string -- icon lookup key, not user text
70+
operator_id: pos.operator?.toString() || 'default',
71+
name: agencyList.find((agency) => agency.operatorRef === pos.operator)
72+
?.agencyName,
73+
}),
74+
zIndexOffset: BOOKEND_Z_INDEX,
75+
}
76+
: i === group.positions.length - 1
77+
? { icon: rideEndMarker, zIndexOffset: BOOKEND_Z_INDEX }
78+
: pingMarker(pos)
5479
return (
5580
<Marker
5681
ref={(ref) => {
5782
markerRef.current[markerKey] = ref
5883
}}
5984
position={pos.loc}
6085
icon={icon}
86+
zIndexOffset={zIndexOffset}
6187
key={markerKey}>
6288
<Popup minWidth={300} maxWidth={700}>
6389
<BusToolTip position={pos} icon={busIconPath(pos.operator!)}>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useTranslation } from 'react-i18next'
2+
import {
3+
SPEED_BANDS,
4+
speedBandLabel,
5+
STANDING_LABEL,
6+
VehicleBearingGlyph,
7+
VehicleStandingGlyph,
8+
} from './vehicleBearingGlyph'
9+
10+
export function MapSpeedIndex() {
11+
const { t } = useTranslation()
12+
13+
return (
14+
<div className="map-speed-index">
15+
<div className="map-speed-index-title">{`${t('velocity')} (${t('kmh')})`}</div>
16+
<div className="map-speed-index-bands">
17+
<div className="map-speed-index-band">
18+
<VehicleStandingGlyph />
19+
<bdi>{STANDING_LABEL}</bdi>
20+
</div>
21+
{SPEED_BANDS.map((band) => (
22+
<div className="map-speed-index-band" key={band}>
23+
<VehicleBearingGlyph band={band} />
24+
<bdi>{speedBandLabel(band)}</bdi>
25+
</div>
26+
))}
27+
</div>
28+
</div>
29+
)
30+
}

src/pages/components/map-related/map-types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { SiriVehicleLocationWithRelatedPydanticModel } from '@hasadna/open-bus-api-client'
22
import { BusStop } from 'src/model/busStop'
33

4-
export const ROUTE_COLORS = ['#f97316', '#3b82f6', '#22c55e', '#a855f7', '#ef4444']
4+
/** Deliberately no red — that belongs to the ping speed ramp (`.ping-arrow--slow`), and red
5+
* arrows over a red line read as neither. */
6+
export const ROUTE_COLORS = ['#f97316', '#3b82f6', '#22c55e', '#a855f7', '#0891b2']
57

68
export interface Point {
79
loc: [number, number]

0 commit comments

Comments
 (0)