Skip to content

Commit 88f0f1a

Browse files
authored
feat: add gps coverage chart (#1678)
1 parent 399808f commit 88f0f1a

10 files changed

Lines changed: 825 additions & 2 deletions

File tree

src/locale/en.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
"singleline_map_page_description": "Display of bus route on map by user-given bus operator, vehicle number, route number, route, date & time",
3232
"singleline_map_page_route": "By Route",
3333
"singleline_map_page_vehicle_id": "By Vehicle ID",
34+
"gps_coverage_title": "GPS coverage over time",
35+
"gps_coverage_description": "Density of GPS reports over time. Each column is the gap between two consecutive reports.",
36+
"gps_coverage_tooltip_duration": "{{duration}}",
37+
"gps_coverage_tooltip_distance": "{{distance}} apart",
38+
"gps_coverage_unit_meter": "m",
39+
"gps_coverage_unit_km": "km",
40+
"gps_coverage_tooltip_focus_start": "Last reported position",
41+
"gps_coverage_vehicle_label": "Vehicle:",
42+
"gps_coverage_no_pings": "No GPS reports received for this vehicle",
43+
"gps_coverage_one_ping": "Only one GPS report received for this vehicle",
3444
"bus_tooltip_footer_previous_location": "Previous location",
3545
"bus_tooltip_footer_next_location": "Next location",
3646
"bus_icon_alt": "Bus icon",

src/locale/he.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
"singleline_map_page_description": "תצוגה של מסלול קו על מפה ע\"פ נתוני חברת אוטובוסים, מספר רכב, קו, מסלול, תאריך ושעה",
3232
"singleline_map_page_route": "לפי מסלול",
3333
"singleline_map_page_vehicle_id": "לפי מספר רכב",
34+
"gps_coverage_title": "כיסוי GPS לאורך זמן",
35+
"gps_coverage_description": "צפיפות דיווחי GPS לאורך זמן. כל עמודה מייצגת מרווח בין 2 דיווחים עוקבים.",
36+
"gps_coverage_tooltip_duration": "{{duration}}",
37+
"gps_coverage_tooltip_distance": "{{distance}} הפרש",
38+
"gps_coverage_unit_meter": "מטר",
39+
"gps_coverage_unit_km": "קילומטר",
40+
"gps_coverage_tooltip_focus_start": "המיקום האחרון שדווח",
41+
"gps_coverage_vehicle_label": "רכב:",
42+
"gps_coverage_no_pings": "לא התקבלו דיווחי GPS עבור רכב זה",
43+
"gps_coverage_one_ping": "התקבל דיווח GPS אחד בלבד עבור רכב זה",
3444
"bus_tooltip_footer_previous_location": "המיקום הקודם",
3545
"bus_tooltip_footer_next_location": "המיקום הבא",
3646
"bus_icon_alt": "סמל אוטובוס",

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,26 @@ export const plannedRouteLineColor = 'black'
2222
export const plannedRouteStopMarkerPath = `${import.meta.env.BASE_URL}marker-bus-stop.png`
2323
export const plannedRouteStopMarker = getIcon(plannedRouteStopMarkerPath, 20, 25)
2424

25-
export function MapContent({ positionGroups, plannedRouteStops, showNavigationButtons }: MapProps) {
25+
export function MapContent({
26+
positionGroups,
27+
plannedRouteStops,
28+
showNavigationButtons,
29+
focusTarget,
30+
}: MapProps) {
2631
const [tileUrl, setTileUrl] = useState('https://tile-a.openstreetmap.fr/hot/{z}/{x}/{y}.png')
2732
const map = useMap()
2833
const { i18n } = useTranslation()
2934

3035
useRecenterOnDataChange({ positionGroups, plannedRouteStops })
3136

37+
// Fly to (and scroll into view) an externally requested location — e.g. clicking a
38+
// coverage-gap's geomarker focuses the last-seen ping before the bus went dark.
39+
useEffect(() => {
40+
if (!map || !focusTarget) return
41+
map.flyTo(focusTarget.loc, Math.max(map.getZoom(), 16))
42+
map.getContainer().scrollIntoView({ behavior: 'smooth', block: 'center' })
43+
}, [map, focusTarget])
44+
3245
useEffect(() => {
3346
const handleLanguageChange = (lng: string) => {
3447
const newUrl =

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function MapWithLocationsAndPath({
1313
positionGroups,
1414
plannedRouteStops,
1515
showNavigationButtons,
16+
focusTarget,
1617
}: MapProps) {
1718
return (
1819
<MapShell
@@ -26,6 +27,7 @@ export function MapWithLocationsAndPath({
2627
positionGroups={positionGroups}
2728
plannedRouteStops={plannedRouteStops}
2829
showNavigationButtons={showNavigationButtons}
30+
focusTarget={focusTarget}
2931
/>
3032
</MapShell>
3133
)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@ export interface Path {
2727
vehicleRef: string
2828
}
2929

30+
/** A request to fly the map to a location; `seq` bumps so repeated requests for the same
31+
* location still re-trigger the fly-to (clicking the same ping twice). */
32+
export interface FocusTarget {
33+
loc: [number, number]
34+
seq: number
35+
}
36+
3037
export interface MapProps {
3138
positionGroups: PositionGroup[]
3239
plannedRouteStops?: BusStop[]
3340
showNavigationButtons?: boolean
41+
/** When set/changed, the map flies to this location (e.g. a coverage-gap ping). */
42+
focusTarget?: FocusTarget | null
3443
}
3544

3645
export function toPoint(location: SiriVehicleLocationWithRelatedPydanticModel): Point {
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Point } from 'src/pages/components/map-related/map-types'
2+
import {
3+
classifyGap,
4+
distanceMeters,
5+
gapSeverity,
6+
medianPingInterval,
7+
pingGaps,
8+
} from './gpsCoverage'
9+
10+
/**
11+
* Unit tests for the per-ride GPS coverage measure used by the SingleLineMap strip.
12+
* The strip is built from the elapsed time *between consecutive pings* (not fixed
13+
* clock buckets), so a bus that stops reporting surfaces as one long gap. The gap
14+
* extraction and the median-relative classification are locked here.
15+
*/
16+
17+
// Minimal Point factory — only recordedAtTime matters for coverage.
18+
const ping = (recordedAtTime: number): Point => ({
19+
loc: [0, 0],
20+
color: 0,
21+
recordedAtTime,
22+
})
23+
24+
const MIN = 60_000
25+
const base = 1_000_000
26+
27+
describe('pingGaps', () => {
28+
it('returns no gaps for fewer than two pings', () => {
29+
expect(pingGaps([])).toEqual([])
30+
expect(pingGaps([ping(base)])).toEqual([])
31+
})
32+
33+
it('ignores pings without a valid timestamp', () => {
34+
expect(pingGaps([ping(base), { loc: [0, 0], color: 0 }])).toEqual([])
35+
})
36+
37+
it('produces one gap per consecutive pair, spanning the whole ride contiguously', () => {
38+
const gaps = pingGaps([ping(base), ping(base + 10_000), ping(base + 30_000)])
39+
expect(gaps).toEqual([
40+
{ startMs: base, endMs: base + 10_000, gapMs: 10_000, startLoc: [0, 0], endLoc: [0, 0] },
41+
{
42+
startMs: base + 10_000,
43+
endMs: base + 30_000,
44+
gapMs: 20_000,
45+
startLoc: [0, 0],
46+
endLoc: [0, 0],
47+
},
48+
])
49+
// contiguous: each gap starts where the previous ended
50+
for (let i = 1; i < gaps.length; i++) {
51+
expect(gaps[i].startMs).toBe(gaps[i - 1].endMs)
52+
}
53+
})
54+
55+
it('sorts out-of-order pings before computing gaps', () => {
56+
const gaps = pingGaps([ping(base + 30_000), ping(base), ping(base + 10_000)])
57+
expect(gaps.map((g) => g.gapMs)).toEqual([10_000, 20_000])
58+
})
59+
60+
it('surfaces a long dropout as a single wide gap', () => {
61+
const gaps = pingGaps([ping(base), ping(base + 15_000), ping(base + 15_000 + 5 * MIN)])
62+
expect(gaps.map((g) => g.gapMs)).toEqual([15_000, 5 * MIN])
63+
})
64+
65+
it('collapses pings that share a recordedAtTime (SIRI re-ingestion duplicates)', () => {
66+
// base appears twice (same instant, same place) — the duplicate must not create a
67+
// zero-length gap or a second gap starting at the same startMs.
68+
const gaps = pingGaps([ping(base), ping(base), ping(base + 10_000)])
69+
expect(gaps).toEqual([
70+
{ startMs: base, endMs: base + 10_000, gapMs: 10_000, startLoc: [0, 0], endLoc: [0, 0] },
71+
])
72+
})
73+
74+
it('keeps every gap startMs unique even with duplicate timestamps', () => {
75+
const gaps = pingGaps([ping(base), ping(base), ping(base + 10_000), ping(base + 10_000)])
76+
const starts = gaps.map((g) => g.startMs)
77+
expect(new Set(starts).size).toBe(starts.length)
78+
})
79+
80+
it('carries each bounding ping location onto the gap', () => {
81+
const a: Point = { loc: [32.1, 34.8], color: 0, recordedAtTime: base }
82+
const b: Point = { loc: [32.2, 34.9], color: 0, recordedAtTime: base + 10_000 }
83+
const [gap] = pingGaps([a, b])
84+
expect(gap.startLoc).toEqual([32.1, 34.8])
85+
expect(gap.endLoc).toEqual([32.2, 34.9])
86+
})
87+
})
88+
89+
describe('distanceMeters', () => {
90+
// Thin [lat, lon]-tuple adapter over geolib.getDistance; these pin the tuple order, not geolib.
91+
it('is 0 for identical points', () => {
92+
expect(distanceMeters([32, 34], [32, 34])).toBe(0)
93+
})
94+
95+
it('measures ~111 km per degree of latitude', () => {
96+
const d = distanceMeters([0, 0], [1, 0])
97+
expect(d).toBeGreaterThan(111_000)
98+
expect(d).toBeLessThan(111_400)
99+
})
100+
101+
it('reads the tuple as [lat, lon] (not [lon, lat])', () => {
102+
// At latitude 32°, a degree of longitude is much shorter than a degree of latitude;
103+
// a swapped adapter would make these equal.
104+
const oneLat = distanceMeters([32, 34], [33, 34])
105+
const oneLon = distanceMeters([32, 34], [32, 35])
106+
expect(oneLon).toBeLessThan(oneLat)
107+
})
108+
})
109+
110+
describe('medianPingInterval', () => {
111+
it('is 0 with fewer than two pings', () => {
112+
expect(medianPingInterval([])).toBe(0)
113+
expect(medianPingInterval([ping(base)])).toBe(0)
114+
})
115+
116+
it('computes the median gap between consecutive pings', () => {
117+
// gaps: 10s, 10s, 30s -> median 10s
118+
expect(
119+
medianPingInterval([
120+
ping(base),
121+
ping(base + 10_000),
122+
ping(base + 20_000),
123+
ping(base + 50_000),
124+
]),
125+
).toBe(10_000)
126+
})
127+
128+
it('averages the two middle gaps for an even count', () => {
129+
// gaps: 10s, 20s -> median (10+20)/2 = 15s
130+
expect(medianPingInterval([ping(base), ping(base + 10_000), ping(base + 30_000)])).toBe(15_000)
131+
})
132+
})
133+
134+
describe('classifyGap', () => {
135+
// 15s cadence; bands break at the sparse threshold (2× = 30s) and dropout (4× = 60s).
136+
const median = 15_000
137+
138+
it('treats everything as ok when there is no baseline', () => {
139+
expect(classifyGap(10 * MIN, 0)).toBe('ok')
140+
})
141+
142+
it('flags a near-cadence gap as ok up to the sparse threshold', () => {
143+
expect(classifyGap(median, median)).toBe('ok')
144+
expect(classifyGap(30_000, median)).toBe('ok') // exactly 2×
145+
})
146+
147+
it('flags a moderately stretched gap as sparse', () => {
148+
expect(classifyGap(30_001, median)).toBe('sparse') // just past 2×
149+
expect(classifyGap(60_000, median)).toBe('sparse') // exactly 4×
150+
})
151+
152+
it('flags a long gap as a dropout', () => {
153+
expect(classifyGap(60_001, median)).toBe('gap') // just past 4×
154+
expect(classifyGap(5 * MIN, median)).toBe('gap')
155+
})
156+
})
157+
158+
describe('gapSeverity', () => {
159+
// 15s cadence; severity ramps 0 at 1× to 1 at the 4× dropout threshold (60s).
160+
const median = 15_000
161+
162+
it('is 0 when there is no baseline', () => {
163+
expect(gapSeverity(10 * MIN, 0)).toBe(0)
164+
})
165+
166+
it('is 0 at or below the median cadence', () => {
167+
expect(gapSeverity(median, median)).toBe(0)
168+
expect(gapSeverity(median / 2, median)).toBe(0)
169+
})
170+
171+
it('reaches 1 at (and clamps above) the dropout threshold', () => {
172+
expect(gapSeverity(60_000, median)).toBe(1) // exactly 4×
173+
expect(gapSeverity(10 * MIN, median)).toBe(1) // far beyond
174+
})
175+
176+
it('ramps linearly between the median and the dropout threshold', () => {
177+
expect(gapSeverity(37_500, median)).toBeCloseTo(0.5) // ratio 2.5, halfway from 1× to 4×
178+
})
179+
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { getDistance } from 'geolib'
2+
import { Point } from 'src/pages/components/map-related/map-types'
3+
4+
/**
5+
* A gap up to this multiple of the ride's median interval is normal jitter (`ok`).
6+
* Between this and {@link GAP_FACTOR} the coverage is `sparse`; above it, a `gap`.
7+
*/
8+
export const SPARSE_FACTOR = 2
9+
export const GAP_FACTOR = 4
10+
11+
export interface PingGap {
12+
/** recordedAtTime of the earlier ping, epoch ms. */
13+
startMs: number
14+
/** recordedAtTime of the later ping, epoch ms. */
15+
endMs: number
16+
/** Elapsed time between the two consecutive pings (endMs - startMs). */
17+
gapMs: number
18+
/** [lat, lon] of the earlier ping — the last known position before the gap. */
19+
startLoc: [number, number]
20+
/** [lat, lon] of the later ping — the first position after the gap. */
21+
endLoc: [number, number]
22+
}
23+
24+
export type Density = 'gap' | 'sparse' | 'ok'
25+
26+
/**
27+
* Sorted, valid (positive-timestamp) pings for a ride, collapsing pings that share a
28+
* `recordedAtTime`. The SIRI pipeline re-ingests the same observation across snapshots, so a
29+
* ride routinely carries rows with identical `recordedAtTime` (and lat/lon) but distinct ids
30+
* that survive the upstream `uniqBy(id)`. Same time means same position, so keeping the first
31+
* row per timestamp is lossless and avoids meaningless zero-length gaps.
32+
*/
33+
function sortedTimedPings(positions: Point[]): { t: number; loc: [number, number] }[] {
34+
const sorted = positions
35+
.filter((p) => (p.recordedAtTime ?? 0) > 0)
36+
.map((p) => ({ t: p.recordedAtTime as number, loc: p.loc }))
37+
.sort((a, b) => a.t - b.t)
38+
// Equal timestamps are now adjacent — drop every ping whose time matches its predecessor.
39+
return sorted.filter((p, i) => i === 0 || p.t !== sorted[i - 1].t)
40+
}
41+
42+
/**
43+
* The elapsed time between each pair of consecutive pings across the ride. Each gap also
44+
* carries its two bounding ping locations (for the strip's distance readout and map focus).
45+
* Returns [] for fewer than two valid pings.
46+
*/
47+
export function pingGaps(positions: Point[]): PingGap[] {
48+
const pings = sortedTimedPings(positions)
49+
const gaps: PingGap[] = []
50+
for (let i = 1; i < pings.length; i++) {
51+
gaps.push({
52+
startMs: pings[i - 1].t,
53+
endMs: pings[i].t,
54+
gapMs: pings[i].t - pings[i - 1].t,
55+
startLoc: pings[i - 1].loc,
56+
endLoc: pings[i].loc,
57+
})
58+
}
59+
return gaps
60+
}
61+
62+
/**
63+
* Number of valid, distinct-time pings for a ride — i.e. how many points remain after
64+
* dropping zero-timestamp rows and collapsing duplicate `recorded_at_time`s. A strip needs
65+
* at least two (one gap); below that the caller can tell "reported nothing" (0) from
66+
* "reported once" (1) to show a specific notice instead of a blank strip.
67+
*/
68+
export function distinctPingCount(positions: Point[]): number {
69+
return sortedTimedPings(positions).length
70+
}
71+
72+
/**
73+
* Median gap (ms) between consecutive pings — the ride's natural reporting cadence,
74+
* used as the baseline every gap is judged against. Returns 0 with fewer than two pings.
75+
*/
76+
export function medianPingInterval(positions: Point[]): number {
77+
const gaps = pingGaps(positions).map((g) => g.gapMs)
78+
if (gaps.length === 0) return 0
79+
gaps.sort((a, b) => a - b)
80+
const mid = Math.floor(gaps.length / 2)
81+
return gaps.length % 2 === 0 ? (gaps[mid - 1] + gaps[mid]) / 2 : gaps[mid]
82+
}
83+
84+
/** Great-circle distance between two [lat, lon] points, in meters (via geolib). */
85+
export function distanceMeters(a: [number, number], b: [number, number]): number {
86+
return getDistance({ latitude: a[0], longitude: a[1] }, { latitude: b[0], longitude: b[1] })
87+
}
88+
89+
/**
90+
* Classify a single inter-ping gap relative to the ride's median cadence:
91+
* - `ok` — within {@link SPARSE_FACTOR}× the median (normal jitter)
92+
* - `sparse` — between {@link SPARSE_FACTOR}× and {@link GAP_FACTOR}× (degraded)
93+
* - `gap` — beyond {@link GAP_FACTOR}× (the bus effectively stopped reporting)
94+
*
95+
* With no usable baseline (median <= 0, e.g. a single ping) everything is `ok`.
96+
*/
97+
export function classifyGap(gapMs: number, medianMs: number): Density {
98+
if (medianMs <= 0) return 'ok'
99+
if (gapMs > medianMs * GAP_FACTOR) return 'gap'
100+
if (gapMs > medianMs * SPARSE_FACTOR) return 'sparse'
101+
return 'ok'
102+
}
103+
104+
/**
105+
* Continuous severity of a gap on a 0–1 scale, for coloring the strip with a smooth
106+
* gradient instead of the three discrete {@link classifyGap} bands: 0 when the gap is at
107+
* (or below) the median cadence, ramping linearly to 1 once it reaches the
108+
* {@link GAP_FACTOR}× dropout threshold. With no usable baseline (median <= 0) it is 0.
109+
*/
110+
export function gapSeverity(gapMs: number, medianMs: number): number {
111+
if (medianMs <= 0) return 0
112+
const ratio = gapMs / medianMs
113+
return Math.min(1, Math.max(0, (ratio - 1) / (GAP_FACTOR - 1)))
114+
}

0 commit comments

Comments
 (0)