The whole reason Ghost Car exists is to answer one stubborn question: was today's drive actually faster than yesterday's? The trouble is that two recordings of the same trip never line up. They start at different times, sit at lights for different lengths, and occasionally take a slightly different turn. Comparing them by clock time alone gives you a number that looks precise and means almost nothing. So I spent a while on a comparison engine whose only job is to make the match fair before any timing is shown.
The problem with raw recordings
Imagine you record the same commute on Tuesday and Thursday. On Tuesday you tap record in the driveway and pull out immediately. On Thursday you sit for forty seconds waiting on a passenger. Both drives cover the same road, but if you overlay them by elapsed time from the start, Thursday looks permanently behind by forty seconds, even on stretches where it was genuinely quicker.
The clock is the wrong reference. What you actually want is to pin the two drives together at a meaningful shared moment: the same intersection, the same on-ramp, the same point where the routes split apart or rejoin. Once they share an anchor, every other difference becomes real signal instead of noise. Each drive is stored as a SwiftData @Model whose coordinate trail lives as encoded Data and is decoded on demand into an array of CLLocationCoordinate2D, so the engine always works against a plain array of points and a single duration value.
Six ways to anchor
I landed on six alignment modes, expressed as a small ComparisonMode enum:
- Start location: line both drives up at their first recorded point.
- End location: line them up at the finish, which is handy when two routes converge on the same destination from different beginnings.
- First and last divergence: the points where two paths split apart, for comparing a shared leg before the routes part ways.
- First and last convergence: the points where two paths rejoin after taking different roads, so you can study the stretch that follows on equal footing.
Start and end are the easy two. The interesting four are route-aware: a route can split and rejoin more than once on a real commute (a detour, a parallel side street, a fork around a closure), so "first" and "last" let you pick which split or merge you actually care about. Each mode answers a slightly different question, and you choose the one that matches what you are trying to learn.
A deliberately dumb data shape
The thing that made this tractable was refusing to mutate the recordings. Each drive in a comparison is described by a lightweight DriveSegment: a reference to the drive, a start index into its coordinate array, an end index, a time offset, plus a color and a label for the UI. The engine never touches the stored points. It only decides which slice of each drive participates and how far to shift it in time so the chosen anchors coincide.
struct DriveSegment: Identifiable {
let id: UUID
let drive: Drive
var startIndex: Int
var endIndex: Int
var timeOffset: TimeInterval
let color: Color
let label: String
var coordinates: [CLLocationCoordinate2D] {
let coords = drive.coordinates
guard startIndex >= 0, endIndex < coords.count, startIndex <= endIndex
else { return [] }
return Array(coords[startIndex...endIndex])
}
}
Because a segment is just a few numbers over an existing array, shifting an anchor is cheap, reversible, and never destructive. Switch from start alignment to last-convergence alignment and the engine recomputes offsets in place; nothing is re-imported and nothing is lost. The bounds check in coordinates matters more than it looks: a trimmed segment can momentarily have a start that runs past its end, and returning an empty slice instead of crashing keeps the rest of the pipeline calm.
Trimming the idle first
Before any anchoring happens, the engine can shave off the dead time at the ends of a drive. That Thursday driveway wait is the obvious case. Idle detection walks inward from an end, estimates speed between consecutive points, and counts how many points sit below a threshold (5 mph by default) before motion really starts.
let avgTimePerPoint = drive.duration / Double(coords.count - 1)
for i in 0..<(coords.count - 1) {
let meters = distance(from: coords[i], to: coords[i + 1])
let mph = (meters / 1609.34) / (avgTimePerPoint / 3600.0)
if mph < threshold { idleCount += 1 } else { break }
}
// startIndex moves to idleCount; the parked time never enters the comparison
This is intentionally approximate. I do not have a per-point speed in the stored data, so I derive it from average time per point and the distance between neighbors. It is good enough to find the "car is clearly parked" prefix, which is all this step needs to do. By default I trim the start but not the end.
Finding divergence and convergence
The route-aware modes were the most fun to work out. To find where two paths diverge, the detector steps through both coordinate arrays together and looks for the first place the paths separate beyond both a distance radius and a heading angle. Distance alone is not enough: two drives can drift several meters apart in the same lane without meaningfully diverging. So I also compute the bearing of each path's next step and require those headings to disagree before calling it a true split. The defaults that felt right after testing on my own commutes were a 30 meter convergence radius (about 100 feet) and a 30 degree divergence angle.
Bearing is the standard great-circle formula:
let dLon = lon2 - lon1
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
let bearing = (atan2(y, x).radiansToDegrees + 360)
.truncatingRemainder(dividingBy: 360)
Two practical decisions shaped the detector. First, it samples every fifth point rather than every point. GPS trails are dense, and on a long drive checking every pair was needlessly slow for no extra accuracy, so striding by five kept it responsive. Second, convergence detection is stateful: it only fires a convergence event once the paths have first been more than twice the radius apart and then come back within the radius. Without that wasDiverged latch, two drives that simply travel close together the whole way would report a "convergence" at every step, which is meaningless.
A fair comparison is not a feature you bolt on at the end. It is the thing the whole app is organized around, so the math that produces it felt worth keeping as clean as I could.
The edge case that mattered
The trickiest part is what happens when there is no anchor. Two recordings of the identical route never diverge at all, and forcing a divergence anchor onto them would be nonsense. So when the detector returns no qualifying events, alignment falls back to start alignment instead of erroring or planting a garbage anchor:
let events = findDivergencePoints(segments, radius: ..., angleThreshold: ...)
guard !events.isEmpty else {
return alignByStartLocation(segments) // sensible default, never a crash
}
This is the kind of edge case you only meet by running the thing on real drives. The first time I compared two near-identical commutes in divergence mode and got an empty event list, the honest answer was "there is nothing to anchor on," and the most useful thing the app could do was quietly behave like start alignment rather than pretend otherwise.
Keeping the math pure
One decision paid for itself many times over: the alignment logic is pure static functions with no dependency on SwiftUI or the map layer. Anchor detection and offset computation take drives and a settings struct in, and return an array of segments out. They know nothing about views or rendering, and almost everything is marked nonisolated so it can run off the main actor without tripping Swift's concurrency checks. Clearing those isolation warnings meant the heavy walks over coordinate arrays never block the UI.
That separation also made the engine easy to test. I can feed it two synthetic coordinate arrays, ask for last-convergence alignment, and assert on the exact segment indices and offsets it returns, without standing up a single view. When a comparison felt off in the app, I could reproduce it in a test in seconds. The map and the scrubber became thin consumers of a well-defined result.
Takeaways
- Clock time is the wrong reference for comparing trips. Anchor on a shared physical moment instead.
- Describe each drive as a segment of indices plus a time offset over the original array, and never mutate the recording.
- Route-aware anchors need both a distance radius and a heading test, and convergence needs a "was diverged" latch so it does not fire on parallel paths.
- Sample sparsely on dense GPS trails, and always have a sane fallback for when no anchor exists.
- Pure,
nonisolatedfunctions that take coordinates and return offsets are easy to test, easy to trust, and easy to keep off the main thread.