Bu yazıda, Stride'a eklediğim koşu tekrarı özelliğinin motorunu nasıl kurduğumu anlatacağım: bittiğinde rotayı sinematik bir kamerayla yeniden oynatan, çizgisini kendisi çizen, canlı tempo/mesafe/irtifa gösteren ve tüm bunları MP4 videoya render edebilen bir sistem. Tamamı saf MapKit, CoreLocation ve AVFoundation ile — sıfır bağımlılık. Kodun tamamı RunReplayKit olarak açık kaynak.
In this post I'll walk through how I built the engine behind the run replay feature I added to Stride: a system that replays a finished route with a cinematic camera, draws its own line as it goes, shows live pace/distance/elevation, and can render all of it out to an MP4 video. Built entirely with plain MapKit, CoreLocation, and AVFoundation — zero dependencies. The full code is open source as RunReplayKit.
Daha önce GhostRunner ile geçmiş koşunla koşu sırasında yarışmayı ele almıştım. RunReplayKit bunun kardeşi: koşuyu bittikten sonra izlemek.
I previously covered racing against your past run while running with GhostRunner. RunReplayKit is its sibling: watching the run after it's over.
Problem: Ham GPS Verisi Animasyona Uygun Değil
The Problem: Raw GPS Data Isn't Animation-Ready
İlk denemede rota koordinatlarını olduğu gibi oynatırsanız sonuç kötü olur:
If you play back the route coordinates as-is on your first try, the result is bad:
- GPS noktaları eşit aralıklı değil — sinyal iyiyken sık, tünelde seyrek. Sabit hızla ilerlemesi gereken animasyon bir hızlanıp bir yavaşlar.
- Outlier'lar var — GPS bazen 200 metre öteye "zıplar", nokta ekranda ışınlanır.
- Ham çizgi titrek — her küçük GPS sapması animasyonda görünür.
- GPS points aren't evenly spaced — dense when the signal is good, sparse in a tunnel. An animation that's supposed to move at a constant speed ends up speeding up and slowing down.
- There are outliers — GPS sometimes "jumps" 200 meters away, and the dot teleports on screen.
- The raw line is jittery — every small GPS deviation shows up in the animation.
Çözüm üç aşamalı bir ön işleme hattı:
The fix is a three-stage preprocessing pipeline:
// 1. Outlier filtreleme: 150 m'den büyük sıçramaları at
for p in valid.dropFirst() {
if curr.distance(from: prev) <= maxJumpMeters { filtered.append(p) }
}
// 2. Eşit aralıklı yeniden örnekleme (600 nokta)
// Toplam mesafeyi eşit adımlara böl, her adım için
// orijinal segmentler üzerinde lineer interpolasyon yap
let step = total / Double(count - 1)
// 3. Hareketli ortalama ile yumuşatma (pencere = 5)
let slice = coords[lo...hi]
return CLLocationCoordinate2D(
latitude: slice.map(\.latitude).reduce(0, +) / Double(slice.count),
longitude: slice.map(\.longitude).reduce(0, +) / Double(slice.count)
)
// 1. Outlier filtering: drop jumps larger than 150 m
for p in valid.dropFirst() {
if curr.distance(from: prev) <= maxJumpMeters { filtered.append(p) }
}
// 2. Evenly-spaced resampling (600 points)
// Split total distance into equal steps, and for each step
// linearly interpolate over the original segments
let step = total / Double(count - 1)
// 3. Smoothing with a moving average (window = 5)
let slice = coords[lo...hi]
return CLLocationCoordinate2D(
latitude: slice.map(\.latitude).reduce(0, +) / Double(slice.count),
longitude: slice.map(\.longitude).reduce(0, +) / Double(slice.count)
)
Eşit aralıklı örnekleme bu sistemin en kritik kararı. Birazdan göreceğimiz strokeEnd numarası tamamen buna dayanıyor.
Even spacing is the single most important decision in this system. The strokeEnd trick we'll see shortly depends entirely on it.
Rotada Konum Bulmak: Binary Search + Lerp
Finding a Position on the Route: Binary Search + Lerp
Animasyonun her karesinde "koşucu rotanın %37'sindeyse haritada tam olarak nerede?" sorusunu cevaplamamız gerekiyor. Bunun için kümülatif mesafe dizisi tutup binary search ile segmenti bulup içinde lineer interpolasyon yapıyoruz:
On every frame of the animation we need to answer: "if the runner is 37% along the route, where exactly is that on the map?" To do this, we keep a cumulative-distance array, use binary search to find the segment, and linearly interpolate within it:
func interpolated(at progress: Double) -> (coordinate: CLLocationCoordinate2D, index: Int) {
let target = max(0, min(1, progress)) * totalDistance
var lo = 0, hi = coordinates.count - 1
while lo < hi - 1 {
let mid = (lo + hi) / 2
cumulativeDistances[mid] <= target ? (lo = mid) : (hi = mid)
}
let segLen = cumulativeDistances[hi] - cumulativeDistances[lo]
let t = segLen > 0 ? (target - cumulativeDistances[lo]) / segLen : 0.0
let c0 = coordinates[lo], c1 = coordinates[hi]
return (CLLocationCoordinate2D(
latitude: c0.latitude + t * (c1.latitude - c0.latitude),
longitude: c0.longitude + t * (c1.longitude - c0.longitude)
), lo)
}
60 fps'te saniyede 60 kez çağrılacağı için O(log n) olması önemli.
Since this gets called 60 times a second at 60 fps, it being O(log n) matters.
Sinematik Kamera: İşin Sırrı Gecikmede
The Cinematic Camera: the Secret Is in the Lag
Kamerayı doğrudan koşucuya kilitleyip yönünü anlık harekete göre çevirirseniz sonuç izlenemez bir şey olur — her GPS kıvrımında kamera sağa sola savrulur. Ferah, "drone çekimi" hissi veren kamera üç fikirle geliyor:
If you lock the camera directly onto the runner and turn its heading based on instantaneous movement, the result is unstable — the camera swings side to side on every little GPS wiggle. The smooth, "drone shot" feel comes from three ideas:
1. Nokta, zamana değil hedefe doğru "ease" ediyor.
1. The dot eases toward its target, not toward time.
Ham ilerleme yerine her tick'te aradaki farkın %15'i kadar yaklaşıyoruz:
Instead of raw progress, on every tick we close 15% of the gap to the target:
dotProgress += (progress - dotProgress) * 0.15
Bu tek satır, ani duraksamaları ve sıçramaları görünmez kılıyor.
This single line makes sudden stalls and jumps invisible.
2. Kamera 800 metre ilerideki noktaya bakıyor.
2. The camera looks at a point 800 meters ahead.
Yön hesabını koşucunun anlık hareketinden değil, rotanın gidişatından çıkarıyoruz:
We derive the heading from where the route is going, not from the runner's instantaneous movement:
let lookaheadDist = min(dotProgress * totalDistance + 800, totalDistance)
let target = ReplayRoute.bearing(
from: coord,
to: route.interpolated(at: lookaheadDist / totalDistance).coordinate
)
3. Dönüş hızı sınırlı ve sönümlü.
3. Turn speed is capped and damped.
Hedef yöne anında değil, tick başına en fazla 0.6° dönerek yaklaşıyoruz:
Instead of snapping to the target heading instantly, we approach it by turning at most 0.6° per tick:
var diff = target - bearing
if diff > 180 { diff -= 360 }
if diff < -180 { diff += 360 }
bearing += max(-0.6, min(0.6, diff * 0.006))
diff'in -180…180 aralığına katlanması önemli — yoksa 359°'den 1°'ye giderken kamera ters yönde tam tur atar.
Wrapping diff into the -180…180 range matters — otherwise, going from 359° to 1°, the camera would spin all the way around in the wrong direction.
Bu üçlü ReplayCameraSimulator adında tek bir sınıfta yaşıyor. Neden ayrı sınıf? Çünkü birazdan aynı matematiği video export için de kullanacağız.
These three ideas live together in a single class called ReplayCameraSimulator. Why a separate class? Because we'll reuse the exact same math for video export shortly.
Kameranın kendisi ise MapKit tarafında tek satır:
The camera itself, on the MapKit side, is a single line:
mapView.camera = MKMapCamera(
lookingAtCenter: frame.coordinate,
fromDistance: 1100,
pitch: 55,
heading: frame.bearing
)
mapType = .satelliteFlyover ile birleşince 3D şehir üzerinde süzülen bir kamera elde ediyoruz.
Combined with mapType = .satelliteFlyover, this gives us a camera gliding over a 3D city.
Kendini Çizen Rota: strokeEnd Numarası
The Self-Drawing Route: the strokeEnd Trick
Rota çizgisinin koşucuyla birlikte "çizilmesi" için ilk akla gelen yöntem her karede yeni bir polyline oluşturmak — ama bu hem pahalı hem de çizgi ucu ile nokta arasında senkron sorunları çıkarıyor (nokta ilerliyor, çizgi 8 fps'te güncelleniyorsa uç sürekli geride kalıyor).
The first approach that comes to mind for making the route line "draw itself" alongside the runner is creating a new polyline every frame — but that's both expensive and causes sync issues between the line's tip and the dot (the dot moves on, and if the line only updates at 8 fps, the tip keeps falling behind).
Çok daha zarif bir yol var: MKPolylineRenderer, iOS 14'ten beri strokeStart ve strokeEnd destekliyor. Polyline'ı bir kez ekleyip her karede sadece ne kadarının görüneceğini söylüyoruz:
There's a much more elegant way: MKPolylineRenderer has supported strokeStart and strokeEnd since iOS 14. We add the polyline once, and on every frame we just tell it how much of it should be visible:
progressRenderer?.strokeEnd = CGFloat(frame.progress)
progressRenderer?.setNeedsDisplay()
İşte eşit aralıklı örnekleme burada meyvesini veriyor: noktalar eşit aralıklı olduğu için path'in %37'si = mesafenin %37'si. strokeEnd doğrudan ilerleme değerine eşitlenebiliyor; ekstra hesap yok, çizgi ucu her karede noktanın tam altında.
This is where even spacing pays off: because the points are evenly spaced, 37% of the path equals 37% of the distance. strokeEnd can be set directly to the progress value — no extra math, and the tip of the line sits exactly under the dot on every frame.
Altına da rotanın tamamını soluk beyaz ikinci bir polyline olarak koyunca kullanıcı nereye gideceğini de görüyor.
Underneath, we lay down the entire route as a second, faint white polyline, so the user can also see where they're headed.
Canlı Metrikler: Pencereli Tempo
Live Metrics: Windowed Pace
Anlık tempo değeri GPS gürültüsü yüzünden ekranda istikrarsız bir görünüm sergiler (kare kare titreşim yaşanır). Bunun yerine son ~%20'lik mesafenin (min 500 m, max 2 km) ortalamasını alıyoruz:
The instantaneous pace value looks unstable on screen because of GPS noise (it jitters frame to frame). Instead, we average over roughly the last 20% of the distance (min 500 m, max 2 km):
let windowM = max(500.0, min(2000.0, totalDistance * 0.2))
let relevant = samples.filter {
$0.distanceMeters >= distanceMeters - windowM && $0.distanceMeters <= distanceMeters
}
return relevant.map(\.paceSecPerKm).reduce(0, +) / Double(relevant.count)
Üstüne bir kat daha yumuşatma: bu hedef değer saniyede bir örnekleniyor, ekrandaki sayı ise her karede hedefe %4 yaklaşıyor. Sonuç, dijital bir sayaç gibi akan ama asla zıplamayan bir tempo göstergesi.
On top of that there's another layer of smoothing: this target value is sampled once a second, while the number on screen closes 4% of the gap to it every frame. The result is a pace readout that flows like a digital counter but never jumps.
Mesafe ise tam tersi — her karede güncelleniyor ama floor ile tek ondalığa yuvarlanıyor ki 5.1 → 5.2 → 5.3 diye atlamadan saysın.
Distance is the opposite — it updates every frame but gets floored to one decimal place, so it counts 5.1 → 5.2 → 5.3 without jumping.
MP4 Export: Aynı Matematiği Offline Render Etmek
MP4 Export: Rendering the Same Math Offline
Kullanıcı bu tekrarı paylaşmak isteyecek. Ekran kaydı bir seçenek ama izin diyaloğu, bildirim düşme riski, çözünürlük kısıtları derken kırılgan. Bunun yerine videoyu deterministik olarak offline render ediyoruz:
Users are going to want to share this replay. Screen recording is one option, but between the permission dialog, the risk of a notification dropping in, and resolution constraints, it's fragile. Instead, we render the video deterministically, offline:
ReplayCameraSimulator.simulateFramestüm animasyonu 60 fps'te simüle edip 30 fps'e indiriyor — canlı ekranla birebir aynı kare durumları.- Her kare için
MKMapSnapshotter, kameranın o anki pozisyonuyla bir harita görüntüsü alıyor:
ReplayCameraSimulator.simulateFramessimulates the whole animation at 60 fps and downsamples it to 30 fps — identical frame states to the live screen.- For each frame,
MKMapSnapshottercaptures a map image using the camera's position at that instant:
let options = MKMapSnapshotter.Options()
options.camera = MKMapCamera(
lookingAtCenter: state.coordinate,
fromDistance: cameraDistance,
pitch: cameraPitch,
heading: state.bearing
)
snapshot.point(for:)ile rota koordinatları piksele çevrilip çizgi, nokta ve metrik overlayUIGraphicsImageRendererile karenin üzerine çiziliyor.- Kareler
AVAssetWriter+AVAssetWriterInputPixelBufferAdaptorile H.264 MP4'e yazılıyor (1080×1920, dikey — sosyal medya formatı).
snapshot.point(for:)converts the route coordinates to pixels, and the line, dot, and metric overlay are drawn on top of the frame withUIGraphicsImageRenderer.- Frames are written out to an H.264 MP4 with
AVAssetWriter+AVAssetWriterInputPixelBufferAdaptor(1080×1920, portrait — social media format).
Simülatörü paylaşmanın karşılığı burada: videodaki kamera hareketi, ekranda izlenenle piksel piksel aynı. İki ayrı implementasyon tutup senkron kovalamak yerine tek gerçek kaynak var.
This is where sharing the simulator pays off: the camera movement in the video is pixel-for-pixel identical to what you see on screen. Instead of maintaining two separate implementations and chasing sync bugs, there's a single source of truth.
Tek uyarı: her kare bir harita snapshot'ı olduğu için render, replay süresi kadar sürebiliyor. Progress bar şart.
One caveat: since every frame is a map snapshot, rendering can take about as long as the replay itself. A progress bar is a must.
Mimari
Architecture
RunReplaySession ← veri modeli (rota + tempo + metadata)
RunReplayConfiguration ← tema, kamera, süre, export ayarları
ReplayRoute ← geometri: temizlik, örnekleme, interpolasyon
ReplayCameraSimulator ← ortak tick matematiği (ease + bearing)
├── RunReplayViewController ← canlı 60 fps replay (MKMapView)
└── RunReplayExporter ← aynı matematik, offline MP4
ReplayMetricsProvider ← pencereli tempo hesabı
ReplayFormatter ← birim dönüşümü ve formatlama
RunReplaySession ← data model (route + pace + metadata)
RunReplayConfiguration ← theme, camera, duration, export settings
ReplayRoute ← geometry: cleanup, resampling, interpolation
ReplayCameraSimulator ← shared tick math (ease + bearing)
├── RunReplayViewController ← live 60 fps replay (MKMapView)
└── RunReplayExporter ← same math, offline MP4
ReplayMetricsProvider ← windowed pace calculation
ReplayFormatter ← unit conversion and formatting
Kullanımı iki satır:
Using it takes two lines:
let session = RunReplaySession(route: points, name: "Morning Loop", durationSeconds: 1680)
present(RunReplayViewController(session: session), animated: true)
Kapanış
Closing Thoughts
Stride'ın kendisinde bu özellik Mapbox üzerinde çalışıyor (3D terrain ve atmosfer katmanı için) — ama sistemin özü haritadan bağımsız: temizle, örnekle, ease et, ileriye bak, sönümle. Bu yüzden açık kaynak versiyonu herkesin token'sız kullanabilmesi için saf MapKit'le yazdım.
In Stride itself, this feature runs on Mapbox for the 3D terrain and atmosphere layer, but the core algorithm is map-agnostic: it cleans GPS data, evens out movement, looks ahead along the route, and dampens sudden direction changes. That's why I wrote the open-source version in plain MapKit, so anyone can use it without a token.
Kaynaklar
References
- Bu yazıdaki kod saf MapKit / Core Location / AVFoundation kullanıyor: MapKit, Core Location, AVAssetWriter (MP4 export için).
- Stride'daki gerçek uygulama bunun yerine Mapbox Maps SDK üzerinde çalışıyor (3D terrain/atmosfer için): Mapbox iOS SDK — Camera and Animation.
- The code in this post uses plain MapKit / Core Location / AVFoundation: MapKit, Core Location, AVAssetWriter (for MP4 export).
- The actual implementation in Stride instead runs on the Mapbox Maps SDK (for 3D terrain/atmosphere): Mapbox iOS SDK — Camera and Animation.
Kodun tamamı, demo ve dokümantasyon GitHub'da: github.com/efebulbul/RunReplayKit
Sorularınızı ve PR'larınızı beklerim. İyi koşular! 🏃
The full code, demo, and docs are on GitHub: github.com/efebulbul/RunReplayKit
I'd love to hear your questions, and PRs are welcome. Happy running! 🏃