Every ride I import into HealthKit from a Chinese bike computer renders wrong at the
start in the Fitness and Health apps, and the same record is wrong on iOS 26 as well as
on iOS 27.2 (iPhone 15 Pro, mainland China, AutoNavi base map). Filed as FB25306175.
WHAT HAPPENS
The first ~49 samples of an HKWorkoutRoute are drawn about 415 m east and 222 m south of
the coordinates actually stored, and a straight line joins that piece back to the rest of
the track. The green start marker is on the displaced piece. Every remaining sample, and
the red end marker, are drawn exactly where stored.
On this record (2794 points, 1 Hz, 17649 m drawn) the displaced block is the first 209 m
of riding — about 53 seconds The displacement is one more application of the local
WGS-84 → GCJ-02 transform, which these coordinates already contain: measured from
screenshots at native 1179×2556, 417 m E / 219 m S on the full-screen map and 418 m E /
220 m S on the summary map, against a true local offset of 415.2 m E / 222.4 m S.
THE STORED DATA IS CORRECT
HKWorkoutRouteQuery returns all 2794 CLLocation values unchanged; the descriptor query
returns the same count and the same first/last values. One workout, one route.
The same coordinates drawn with SwiftUI Map + MapPolyline on the same phone render
correctly end to end, including the start.
The same unchanged workout, opened twice with no write in between, was correct the
first time and offset after leaving and re-entering. So the data is not the variable.
THE WRITE PATH
The head unit writes GCJ-02 into record.position_lat / position_long. That is what makes
the track land on the road here: MKMapSnapshotter on the mainland base map puts the
GCJ-02 values exactly on two known intersections, and the WGS-84 values about 470 m
north-west of them, and MapKit does no conversion for us. So they are written verbatim:
let config = HKWorkoutConfiguration()
config.activityType = .cycling
config.locationType = .outdoor
let builder = HKWorkoutBuilder(healthStore: store, configuration: config, device: nil)
try await builder.beginCollection(at: firstPoint.timestamp.addingTimeInterval(-1))
// CLLocation per record, coordinates as recorded, horizontalAccuracy 10,
// verticalAccuracy 10 (or -1 where the record has no altitude), recorded altitude
// and speed, course computed from the previous point.
let routeBuilder = builder.seriesBuilder(for: HKSeriesType.workoutRoute()) as? HKWorkoutRouteBuilder
for batch in locations.chunked(into: 1000) {
try await routeBuilder?.insertRouteData(batch)
}
try await builder.endCollection(at: lastPoint.timestamp.addingTimeInterval(10))
let workout = await builder.finishWorkout()WHAT IS NOT THE CAUSE
Established with controlled synthetic routes written from the same start point:
probe sample interval m per point displaced length displaced time samples
E 1 s 10 ~480 m ~48 s ~49
F 2 s 10 ~480 m ~96 s ~49
G 1 s 20 ~950 m ~48 s ~49
The block follows SAMPLE COUNT, not time and not distance. Also ruled out, each by
writing a variant: lap and pause/resume events (present, absent, and a 206-point probe
carrying all three); the first point's metadata (altitude 0 / course -1 /
verticalAccuracy -1); valid versus -1 values for horizontalAccuracy, verticalAccuracy,
speed and course; dropping an isolated leading point; back-dating the first timestamp to
the workout start; trimming the leading 200 m (the offset simply re-appears at the new
start, which still begins with 49 samples). Writing WGS-84 instead inverts the symptom —
the leading block then looks right and everything else goes ~470 m north-west — which is
what you would expect if only the leading block is converted a second time.
SCOPE
This is not my one file. Three separate real rides, two cities, two head units, plus
synthetic routes, all behave identically:
MAGENE C706_54634 Suzhou 2026-10-23 17.61 km 2797 records / 2794 stored points
MAGENE C706_54634 Suzhou 2026-10-22 12.66 km 2076 records
MAGENE C606P_33206 Chengdu 2026-10-20 63.30 km 12069 records
Apple Watch recordings made in mainland China render correctly, and a Garmin Edge 1050
file recorded in the US imported through the same code path renders correctly. The common
factor is coordinates that are already GCJ-02 when they reach HKWorkoutRouteBuilder —
which is every Chinese bike computer, every Chinese running app, and every GPX export
from a Chinese service. So any third-party importer here hits this.
WHAT I AM ASKING
Does the workout map apply a datum conversion to a leading block of route samples, and
is that block size fixed?
What is the official coordinate contract for HKWorkoutRoute locations for apps working
with GCJ-02 data in mainland China?
Is there a supported way to opt out, and what decides whether a given route enters
this state?
Which component owns the conversion — Health/Fitness route rendering or MapKit?
I am deliberately not pre-converting the first ~49 points: that would store mixed datums
in HealthKit, wrong for the Health app itself, for exports, for every third-party reader,
and wrong for all of them the moment this is fixed.
Happy to share the one-tap sample project, the exported JSON with all 2794 stored CLLocation
values, the original .fit, and the probe E/F/G screenshots — reply here and I'll attach them.
1
0
60