DEV Community

Lina Atar
Lina Atar

Posted on

Your Location History Should Not Need a Cloud: Inside Timeline Visualizer’s Local-First Pipeline

Location history may be the most intimate dataset an ordinary phone creates.

It can reveal where a person sleeps, works, receives medical care, meets friends, practices a faith, attends protests, takes children to school, and travels when nobody expects them at home. A year of coordinates is not merely a travel diary. It is a behavioral map.

That makes location visualization a perfect test for local-first software. The application should be able to transform raw personal data into something useful without turning the developer’s server into a second location-history provider.

Timeline Visualizer, a recently trending open-source project, takes this approach. The user exports a Timeline JSON file, selects dates and rendering options, and creates an animated travel video on the device. Android, browser, Swift, and desktop implementations explore the same core pipeline: parse changing export formats, normalize coordinates, filter implausible signals, interpolate sparse travel, control a camera, fetch map tiles, render frames, encode video, and preserve privacy boundaries.

The result looks like a moving line on a map. The engineering beneath it touches data modeling, geodesy, rendering, caching, mobile lifecycle, file permissions, and privacy threat modeling.

Local-first is a data-flow decision

An application is not local-first merely because it has a mobile interface. The important question is where authoritative data is processed and where it must travel.

For a Timeline visualizer, a strong local-first path is:

User-selected JSON
      |
      v
Local parser -> normalized route -> local renderer -> local MP4
      |
      +-> optional map-tile requests for visible regions
Enter fullscreen mode Exit fullscreen mode

The route file does not need to be uploaded. The generated frames do not need to be sent to an encoding service. The output can be written directly to user-controlled storage.

This design reduces server cost and removes a high-value centralized dataset. It also moves complexity onto phones and browsers: memory limits, background execution, codecs, file access, and heterogeneous hardware become product concerns.

Privacy is often an architectural trade, not a checkbox.

The export file is the API

The application does not need direct account access if the user can provide an export. That reduces authorization scope and avoids storing refresh tokens.

It also means the export format becomes an external API that may change without coordination.

Different generations of Timeline data can contain semantic segments, visits, activities, paths, direct arrays, nested objects, raw location signals, and several coordinate encodings. Mobile platforms may export different top-level shapes.

A resilient parser starts by recognizing structure rather than assuming one schema:

def detect_format(data):
    if isinstance(data, list):
        return "direct_array"
    if "semanticSegments" in data:
        return "semantic_segments"
    if "timelineObjects" in data:
        return "legacy_objects"
    if "locations" in data:
        return "raw_locations"
    raise TimelineParseError("Unsupported Timeline export shape")
Enter fullscreen mode Exit fullscreen mode

Format detection should produce a user-facing explanation, not a low-level key error.

Normalize early, preserve provenance

Downstream rendering should not care whether a coordinate arrived as latLng, integer E7 fields, a geo: string, or decimal degrees.

The parser can normalize every useful signal into one internal model:

type NormalizedPoint = {
  latitude: number;
  longitude: number;
  timestamp?: number;
  accuracyMeters?: number;
  source: "visit" | "activity" | "path" | "raw";
  sourceIndex: number;
  confidence?: number;
};
Enter fullscreen mode Exit fullscreen mode

The source and sourceIndex fields matter. If a point looks wrong, the application should be able to explain where it came from. Normalization must not erase provenance.

The original file should remain unchanged. Filters and transformations belong to a derived representation so users can disable them or compare results.

Coordinate parsing is an adversarial boundary

Even a user-selected file should be treated as untrusted input. It may be corrupt, unexpectedly large, partially downloaded, or deliberately malicious.

fun parseCoordinate(value: Any): Coordinate {
    val coordinate = when (value) {
        is String -> parseCoordinateString(value)
        is Map<*, *> -> parseCoordinateObject(value)
        else -> throw ParseError("Unsupported coordinate value")
    }

    require(coordinate.latitude in -90.0..90.0)
    require(coordinate.longitude in -180.0..180.0)
    require(coordinate.latitude.isFinite())
    require(coordinate.longitude.isFinite())
    return coordinate
}
Enter fullscreen mode Exit fullscreen mode

Parsers should bound nesting, string length, array size, and allocation. A billion declared points should fail before the renderer runs out of memory.

Typed errors help the interface distinguish unsupported format, invalid coordinates, no data in the selected period, missing encoder, and network failure during tile preparation.

Semantic segments and raw signals are not equivalent

Semantic Timeline data has already been interpreted into visits, trips, and activities. It is generally easier to visualize and less noisy.

Raw location records are closer to sensor observations. They may include accuracy values, repeated stationary points, impossible jumps, and gaps. Falling back to raw data can recover an otherwise unusable export, but the result is an estimate.

The interface should disclose this difference. A smooth animation can make uncertain data look authoritative.

Processed Timeline available -> use visits and trips
Processed Timeline absent    -> offer raw fallback
Raw fallback selected        -> show noise warning and excluded-point count
Enter fullscreen mode Exit fullscreen mode

The application should never silently substitute raw signals and present them as equivalent history.

Filtering GPS outliers without rewriting someone’s past

GPS data can jump hundreds of kilometers and return seconds later. A naive renderer draws a dramatic false trip.

A conservative filter can detect an isolated out-and-back spike:

def is_isolated_spike(previous, current, following, max_speed_kph=1200):
    dt1 = max(seconds(previous.time, current.time), 1)
    dt2 = max(seconds(current.time, following.time), 1)

    speed_in = distance_km(previous, current) / (dt1 / 3600)
    speed_out = distance_km(current, following) / (dt2 / 3600)
    baseline = distance_km(previous, following)

    return (
        speed_in > max_speed_kph and
        speed_out > max_speed_kph and
        baseline < 2.0
    )
Enter fullscreen mode Exit fullscreen mode

This rule is intentionally narrow. It removes a point only when the surrounding path strongly suggests a temporary excursion.

Aggressive smoothing can erase real travel, border crossings, ferry routes, or unusual flights. The filter should report how many points it excluded and allow the user to disable it.

Data cleaning is an interpretation. Visibility makes it accountable.

Accuracy is not the same as truth

A location point may include an accuracy radius. A reported position with 800-meter uncertainty should not be rendered with the same confidence as one with 10-meter uncertainty.

Repeated uncertain points near a stationary location can be collapsed to reduce visual jitter. But averaging across a genuine short trip can move the path somewhere the person never went.

One approach is to use accuracy and time together:

function mayCollapse(a: Point, b: Point): boolean {
  const combinedUncertainty = (a.accuracy ?? 0) + (b.accuracy ?? 0);
  const separation = haversineMeters(a, b);
  const elapsed = Math.abs(a.time - b.time);

  return separation <= combinedUncertainty && elapsed < 10 * 60_000;
}
Enter fullscreen mode Exit fullscreen mode

The threshold is a policy, not a fact. It should be tested against real trips and exposed through conservative presets rather than hidden magic.

Great-circle interpolation prevents impossible long routes

Two distant coordinates on a globe should not always be interpolated linearly in latitude and longitude. Long flights are better represented along a great-circle path.

Spherical interpolation can be expressed with unit vectors:

def slerp(a, b, t):
    va = latlon_to_unit_vector(a)
    vb = latlon_to_unit_vector(b)
    omega = acos(clamp(dot(va, vb), -1, 1))

    if omega < 1e-9:
        return a

    result = (
        sin((1 - t) * omega) / sin(omega) * va +
        sin(t * omega) / sin(omega) * vb
    )
    return unit_vector_to_latlon(result)
Enter fullscreen mode Exit fullscreen mode

This produces a natural route over the globe and gives the camera intermediate positions instead of teleporting from departure to destination.

Interpolation does not claim the person followed that exact path. It is a visual bridge between sparse observations. The UI or documentation should make that distinction clear.

The international date line breaks naive geometry

Longitude jumps from +180 degrees to -180 degrees. Two points on opposite sides of the date line may be physically close but numerically almost 360 degrees apart.

A naive bounding box can zoom out to show the entire planet.

function unwrapLongitude(previous: number, current: number): number {
  let candidate = current;
  while (candidate - previous > 180) candidate -= 360;
  while (candidate - previous < -180) candidate += 360;
  return candidate;
}
Enter fullscreen mode Exit fullscreen mode

Unwrapped longitudes can be used for route continuity. Rendering and tile selection later map them back into the world’s repeating horizontal coordinate system.

Edge cases like this are why map visualization cannot be reduced to drawing lines between decimal pairs.

Web Mercator is convenient and distorted

Most web map tiles use Web Mercator. Latitude and longitude are projected into planar coordinates so square tiles can be addressed by zoom, x, and y.

def web_mercator(lat, lon):
    lat = clamp(lat, -85.05112878, 85.05112878)
    x = (lon + 180.0) / 360.0
    sin_lat = sin(radians(lat))
    y = 0.5 - log((1 + sin_lat) / (1 - sin_lat)) / (4 * pi)
    return x, y
Enter fullscreen mode Exit fullscreen mode

The projection dramatically enlarges high-latitude regions. Distances should be calculated on the sphere, not measured directly from projected pixels.

Projection is a rendering choice. It should not silently become the analytical model.

Distance calculation needs a defined earth model

For travel summaries, the Haversine formula is often accurate enough:

function haversineKm(a: Point, b: Point): number {
  const R = 6371.0088;
  const dLat = radians(b.lat - a.lat);
  const dLon = radians(b.lon - a.lon);

  const h = Math.sin(dLat / 2) ** 2 +
    Math.cos(radians(a.lat)) *
    Math.cos(radians(b.lat)) *
    Math.sin(dLon / 2) ** 2;

  return 2 * R * Math.asin(Math.sqrt(h));
}
Enter fullscreen mode Exit fullscreen mode

Summing every noisy raw point can overestimate travel because GPS jitter adds tiny segments. Summing only semantic legs may underestimate local movement.

The application should label raw-derived distance as an estimate and keep internal units consistent. Conversion to miles belongs at the presentation layer.

Route simplification must preserve the story

A year of location history can contain hundreds of thousands of points. Rendering every point in every frame is expensive and visually unnecessary.

Simplification algorithms such as Ramer-Douglas-Peucker remove points that contribute little to the visible path. The tolerance should depend on map scale and output resolution.

def simplify_for_zoom(points, meters_per_pixel, pixel_tolerance=0.75):
    tolerance_meters = meters_per_pixel * pixel_tolerance
    return rdp(points, tolerance_meters)
Enter fullscreen mode Exit fullscreen mode

Important semantic anchors—arrivals, departures, city transitions, and user-selected highlights—should be protected from removal even when they are geometrically redundant.

The objective is not minimum points. It is minimum points that preserve the travel narrative at the chosen scale.

Time compression is a storytelling function

A ten-hour flight and a ten-minute walk should not consume video time in strict proportion to duration or distance. Strict proportionality makes local segments invisible or long transfers dominate the entire film.

A compression curve can allocate display time sublinearly:

function visualWeight(distanceKm: number, exponent = 0.85): number {
  return Math.pow(Math.max(distanceKm, 0.01), exponent);
}

function allocateFrames(legs: Leg[], totalFrames: number): number[] {
  const weights = legs.map(leg => visualWeight(leg.distanceKm));
  const sum = weights.reduce((a, b) => a + b, 0);
  return weights.map(w => Math.max(1, Math.round(totalFrames * w / sum)));
}
Enter fullscreen mode Exit fullscreen mode

The exponent becomes an editorial control. Stronger compression gives local trips more screen time relative to long flights.

This changes animation timing, not route geometry. Keeping those transformations separate avoids accidental claims that the journey itself was different.

Camera movement is a control system

A camera that perfectly centers every new point jitters constantly. A camera that never moves loses local detail. Good tracking uses damping, dead zones, look-ahead, and different speeds for zooming in and out.

function updateCamera(camera: Camera, target: Bounds): Camera {
  const desired = fitBounds(target, camera.aspectRatio);

  return {
    center: lerp(camera.center, desired.center, 0.14),
    zoom: desired.zoom < camera.zoom
      ? lerp(camera.zoom, desired.zoom, 0.24)
      : lerp(camera.zoom, desired.zoom, 0.05),
  };
}
Enter fullscreen mode Exit fullscreen mode

Zooming out quickly prevents the marker from leaving the frame. Zooming in slowly avoids seasickness. A dead zone lets the marker move locally without forcing the map to chase every street.

Camera behavior is where data visualization becomes cinematography.

Fixed, steady, and dynamic modes encode different priorities

A fixed camera preserves geographic context but may make local travel tiny. A dynamic camera follows detail but can create aggressive motion. A steady camera compromises between them.

Presets are more understandable than dozens of raw tuning values:

fixed:
  follow: false
  zoom_change: none

steady:
  follow: smooth
  zoom_out: medium
  zoom_in: slow

dynamic:
  follow: leg-aware
  zoom_out: fast
  zoom_in: medium
Enter fullscreen mode Exit fullscreen mode

The preview is essential because no textual name can fully communicate motion. Users should be able to inspect the result before spending time on final encoding.

Map tiles are the hidden network leak

A local renderer still needs a basemap. If it requests online raster tiles, the tile provider receives an IP address, user agent, and z/x/y identifiers for viewed areas.

Those identifiers can reveal geographic regions from the selected Timeline even though the JSON file never leaves the device.

This is an excellent example of honest privacy modeling:

Not transmitted:
- Timeline JSON
- full coordinate list
- generated frames
- video title
- final MP4

Transmitted to tile provider:
- requested map tile coordinates
- zoom levels
- normal network metadata
Enter fullscreen mode Exit fullscreen mode

“No upload” does not mean “no network-derived location exposure.” A local-first application should disclose the residual flow before the first file is processed.

Tile requests can be minimized and bounded

The renderer can compute every tile needed for the selected camera path, deduplicate requests, bound concurrency, and cache results locally.

async def prepare_tiles(frames, cache, workers=4):
    required = set()
    for frame in frames:
        required.update(tiles_for_view(frame.bounds, frame.zoom))

    missing = [tile for tile in required if not cache.has(tile)]
    await bounded_map(missing, cache.fetch_atomic, concurrency=workers)
Enter fullscreen mode Exit fullscreen mode

Preparing tiles before encoding prevents a half-rendered video with blank map sections. It also lets the application fail early with a clear network message.

Atomic cache writes avoid corrupted tiles when the task is cancelled or the process stops during a download.

Offline rendering begins after online preparation

If the basemap is remote, truly offline rendering is possible only after all required tiles are cached.

This suggests a staged pipeline:

1. Parse and normalize
2. Plan route and camera
3. Enumerate required tiles
4. Fetch and verify missing tiles
5. Render frames without network
6. Encode MP4
7. Commit final output
Enter fullscreen mode Exit fullscreen mode

Separating stages makes progress clearer and cancellation safer. A network failure during stage 4 cannot produce a misleading “successful” video.

It also improves privacy reasoning: after tile preparation, no additional network request should be necessary.

Video rendering is a resource pipeline

A 30-second video at 30 frames per second requires 900 frames. A 1080p portrait export can consume substantial CPU, memory, battery, and storage.

The renderer should stream frames to the encoder rather than keep them all in memory.

with ffmpeg_writer(output_temp, fps=30, codec="h264") as writer:
    for frame_index in range(total_frames):
        state = timeline.state_at(frame_index / total_frames)
        bitmap = renderer.draw(state)
        writer.append(bitmap)
        progress.report(frame_index + 1, total_frames)
Enter fullscreen mode Exit fullscreen mode

On mobile, a foreground service or equivalent background mechanism may be necessary so the operating system does not kill a long export when the screen turns off.

The pipeline must also handle thermal throttling. Rendering speed can change dramatically after the phone heats up.

Progress estimates should wait for stable throughput

A naive estimate assumes every frame costs the same and reports a countdown immediately. Tile preparation, camera complexity, cache state, device temperature, and encoder startup violate that assumption.

A better estimator uses a recent throughput window and hides the estimate while variance is high.

function estimateRemaining(samples: ThroughputSample[], remainingFrames: number) {
  const recent = samples.slice(-20);
  if (recent.length < 8) return null;

  const rates = recent.map(s => s.frames / s.seconds);
  if (coefficientOfVariation(rates) > 0.25) return null;

  return remainingFrames / median(rates);
}
Enter fullscreen mode Exit fullscreen mode

“42% complete” can be reliable even when “3 minutes remaining” is not. Hiding false precision improves trust.

Cancellation must leave no broken artifact

The user may cancel during tile loading, frame rendering, encoding, metadata writing, or final storage copy.

The safest output pattern is temporary-then-commit:

suspend fun export(destination: Uri) {
    val temporary = cache.createTempFile("journey", ".mp4.part")
    try {
        renderer.encodeTo(temporary)
        verifyMp4(temporary)
        storage.atomicCopy(temporary, destination)
    } finally {
        temporary.delete()
    }
}
Enter fullscreen mode Exit fullscreen mode

If cancellation occurs, the temporary file is removed. The final name appears only after verification succeeds.

On storage providers without true atomic rename, the application needs an explicit cleanup path and should not add the incomplete video to its library index.

Cache keys need source identity and versioning

Parsing a large Timeline file repeatedly is expensive. A normalized-point cache can make later use much faster.

The cache must be invalidated when the source changes or the parser semantics change.

type CacheKey = {
  sourceUri: string;
  sourceSize: number;
  sourceModifiedAt: number;
  parserVersion: number;
  filterVersion: number;
};
Enter fullscreen mode Exit fullscreen mode

A manually reselected file can force reprocessing even if metadata appears unchanged. Users need a way to escape a stale or damaged cache.

Cached normalized points are still sensitive location data. They belong in private app storage, should be excluded from unnecessary backups, and should be deleted when cache is cleared.

Remembering a file should not copy it invisibly

Mobile document APIs can grant persistent access to a user-selected URI. The application can reopen the file later without making its own duplicate.

This preserves user ownership: moving or revoking the file causes the application to return to the selection flow.

The trade-off is reliability. External storage providers may change metadata or revoke permission. The UI should distinguish “file unavailable” from “data deleted.”

For generated videos, an app may keep a small local index with title, URI, duration, and thumbnail while the MP4 remains in user-visible storage. Removing an index entry should not silently delete the video; deletion needs a separate confirmed action.

Browser rendering has different lifecycle constraints

A web implementation avoids installation and can process a selected file without uploading it. Modern browsers can provide canvas rendering and H.264 encoding, but long jobs depend on the tab remaining alive.

The browser may suspend background work, reclaim memory, or lose the file handle after refresh. Download-based output replaces direct media-library integration.

Web workers can keep parsing and rendering off the UI thread:

const worker = new Worker("renderer-worker.js", { type: "module" });

worker.postMessage({
  timelineFile,
  settings,
  output: "mp4",
});

worker.onmessage = event => {
  if (event.data.type === "progress") updateProgress(event.data);
  if (event.data.type === "complete") saveBlob(event.data.video);
};
Enter fullscreen mode Exit fullscreen mode

Large binary messages should use transferable objects to avoid expensive copies.

A local-first threat model is still necessary

The main threats are not limited to a malicious developer server.

They include a hostile Timeline file, excessive memory allocation, leaked map-tile requests, cached location data in backups, accidental sharing of a generated video, overbroad storage permissions, malicious APK mirrors, vulnerable media encoders, and thumbnails that remain after the visible video is deleted.

The application can reduce risk through:

  • user-selected file access;
  • no account login;
  • no analytics or developer-operated server;
  • bounded parsing;
  • private caches;
  • explicit tile-provider disclosure;
  • source verification for direct APK distribution;
  • temporary output cleanup;
  • separate remove and delete actions;
  • narrow network permissions;
  • reproducible tests with synthetic data.

Local-first moves the trust boundary. It does not remove the need to define it.

Test data should be synthetic, not someone’s life

Location pipelines need fixtures for flights, commutes, stationary periods, bad accuracy, duplicate timestamps, raw-only exports, missing fields, polar coordinates, and date-line crossings.

Using a real developer Timeline in the repository would create a permanent privacy incident.

Synthetic generators can create adversarial cases:

def synthetic_dateline_trip():
    return [
        point(lat=37.7, lon=179.7, time="2026-01-01T10:00:00Z"),
        point(lat=37.8, lon=-179.8, time="2026-01-01T10:30:00Z"),
        point(lat=37.9, lon=-178.9, time="2026-01-01T11:00:00Z"),
    ]
Enter fullscreen mode Exit fullscreen mode

Golden images can verify camera and tile behavior, while numeric tests verify distance and interpolation. Property tests can assert that normalized coordinates remain in range and that simplification never reorders time.

Privacy needs a deletion story

Sensitive derived data includes normalized caches, downloaded tiles, thumbnails, presets, video-library indexes, partial outputs, logs, and the final videos themselves.

Each category needs a clear owner and deletion path.

Clear cache       -> normalized points, map tiles, temporary files
Clear app storage -> settings, index, thumbnails, private state
Remove from list  -> index record only
Delete video      -> confirmed deletion of the MP4
Uninstall         -> private app data, not user-owned exported videos
Enter fullscreen mode Exit fullscreen mode

Users should not need to guess whether a “remove” button deletes the underlying file.

The architecture respects the user’s right to inspect

An export-based tool has an underrated advantage: the user possesses the input in an ordinary file. They can archive it, inspect it, use another parser, or delete it without asking the application provider.

The generated MP4 is also portable. The useful artifact does not remain trapped behind an account.

This is local-first as product philosophy: user-owned inputs, user-controlled processing, and user-owned outputs.

The application still has responsibilities—format compatibility, safe parsing, clear privacy disclosure, and reliable deletion—but it does not become the permanent custodian of the person’s history.

What developers can learn from Timeline Visualizer

Treat exports as unstable external APIs and normalize them behind one typed model.

Preserve source provenance through every transformation.

Distinguish semantic data from raw signals and disclose uncertainty.

Use spherical geometry for global travel and projected geometry for map rendering.

Separate route geometry, animation timing, and camera movement.

Plan all external resources before a long deterministic render.

Stream frames to the encoder and commit output only after verification.

Make progress estimates evidence-based and hide them when unstable.

Model tile requests as a privacy disclosure even when the primary file stays local.

Keep synthetic test fixtures instead of real personal histories.

The broader local-first opportunity

Many personal datasets can be transformed locally: health exports, financial statements, photo libraries, workout histories, browser archives, smart-home logs, and message backups.

Cloud processing is attractive because servers are easy to update and scale. For bounded one-person datasets, the user’s device may already provide enough compute.

The local-first question is not “Can this run offline forever?” It is “Which sensitive data flows are actually necessary?” A map tile request may be necessary; uploading the complete route probably is not. A codec update may need the network; every video frame probably does not.

Architectures improve when each outbound byte must justify itself.

Final thought: a beautiful map should not require surrendering the journey

Timeline Visualizer turns a private archive into an understandable story. The visible output is emotional and simple: a marker moves, cities appear, and a year of travel becomes a few minutes.

The strongest part of the project is not the animation. It is the decision that the person’s history can remain on the person’s device while the transformation happens.

Delivering that promise requires serious engineering: defensive parsing, coordinate normalization, conservative filtering, spherical interpolation, projection, camera control, tile planning, bounded concurrency, background rendering, cancellation, cache invalidation, and honest network disclosure.

Local-first software is not simpler software. It accepts more client complexity in exchange for less unnecessary trust.

For location history, that exchange is worth making.

Top comments (0)