Skip to content

feat(unbounded): put the volunteer on their own globe - #229

Merged
myleshorton merged 7 commits into
mainfrom
fisk/unbounded-own-location
Aug 23, 2026
Merged

myleshorton merged 7 commits into
mainfrom
fisk/unbounded-own-location

Conversation

@myleshorton

Copy link
Copy Markdown
Contributor

The Unbounded globe drew no marker for the volunteer at all. It plotted only the people being helped, and the camera parked on them — so with a peer in China the view sat between China and Japan, and the screen read as though it were showing the volunteer somewhere they had never been. Reported from a US machine that appeared to be in Japan.

The reference doesn't work that way: every arc runs peer → the volunteer's own looked-up coordinates. That's the piece spark was missing, and the reason the map had no answer to "where am I".

Where the location comes from

The geo service itself. /lookup with no address geolocates the caller (/lookup/<ip> a specific one), so this needs no knowledge of our external address and no STUN round trip — one more path on a host the sharing pool already talks to.

Three deliberate choices about when and whether to ask:

Choice Why
Only while sharing Asking where we are is an outbound request that shows the geo service our address. It happens because the user turned the feature on, never because they opened the tab. Dropped when the session ends, so a later start re-resolves instead of pinning us where we used to be.
Off the critical path, in its own task The sharing loop keeps peer accounting, the tray label and the UI moving. A dead geo host must not delay the first peer join by its timeout. The screen's existing 2s poll picks the answer up.
Cached on success only The opposite of peer lookups, deliberately. Remembering a failure is right there — it stops a dead host costing a round trip per join — but there is exactly one of these per process, and a transient failure at startup would otherwise leave the volunteer off their own map for the entire session.

On the globe

Arcs run peer → us, with us drawn as a cyan dot a size up from the peers' green, so the two ends of a connection are never confused. The reference draws its own end nearly transparent; ours is solid, because this is precisely the question the screen kept failing to answer.

Two bugs that only surfaced once it was wired

The camera could blank the globe. It averaged latitudes arithmetically and longitudes on the unit circle, separately. For points spread across half the globe — Denver plus peers from Turkey to China — that lands almost anywhere, and it put the aim far enough from our end that every arc failed the limb cull and the globe went empty. It now averages 3D unit vectors (the true spherical centre) and weights our end by the peer count, since our end is one foot of every arc while each peer is one foot of one.

A geo failure blanked it too. The dots were computed from the arcs, so a pending or failed self lookup left a bare sphere while the user was actively helping people. Peers are now drawn whether or not we know where we are; only the arcs wait.

Verification

  • spark-sharing: 52 tests pass, including two new ones — that the self lookup passes no address, and that it caches success but retries failure
  • tauri-plugin-spark-vpn: 53 tests pass; the payload contract tests now cover origin present and null
  • svelte-check 419 files clean; vitest 38 tests; vite build clean
  • cargo fmt --check and cargo clippy --all-targets -D warnings clean on all three crates
  • Rendered both paths in a browser: normally one arc with a green peer dot and a cyan dot on us; with the self lookup forced to fail, no arcs but all three peers still on the map

Note

The self lookup answers where our traffic appears to come from. With a tunnel up that is the exit, not the desk — inherent to asking a remote service, and true of the reference too. For a volunteer, which is when this runs, there is no tunnel in the way.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

The Unbounded globe drew no marker for the volunteer at all. It plotted only the
people being helped, and the camera parked on them — so with a peer in China the
view sat between China and Japan, and the screen read as though it were showing the
VOLUNTEER somewhere they have never been. Reported from a US machine that appeared
to be in Japan.

The reference does not work that way: every arc runs peer -> the volunteer's own
looked-up coordinates. That is the piece spark was missing, and the reason the map
had no answer to "where am I".

The volunteer's location comes from the geo service itself. `/lookup` with no
address geolocates the CALLER (`/lookup/<ip>` a specific one), so this needs no
knowledge of our external address and no STUN round trip — one more path on a host
the sharing pool already talks to.

Three deliberate choices about when and whether to ask:

  - ONLY while sharing. Asking where we are is an outbound request that shows the
    geo service our address, so it happens because the user turned the feature on,
    never because they opened the tab. It is dropped when the session ends, so a
    later start re-resolves rather than pinning us where we used to be.
  - OFF the critical path, in its own task. The sharing loop is what keeps peer
    accounting, the tray label and the UI moving; a dead geo host must not delay the
    first peer join by its timeout. The screen's 2s poll picks the answer up.
  - Cached on SUCCESS ONLY, unlike peer lookups. Remembering a failure is right for
    peers — it stops a dead host costing a round trip per join — but there is one of
    these per process, and a transient failure at startup would otherwise leave the
    volunteer off their own map for the whole session.

On the globe, arcs now run peer -> us, with us drawn as a cyan dot a size up from
the peers' green so the two ends of a connection are never confused. The reference
draws its own end nearly transparent; ours is solid, because this is the question
the screen kept failing to answer.

Two things that only showed up once it was wired:

The camera was averaging latitudes arithmetically and longitudes on the unit circle,
separately. For points spread across half the globe — Denver plus peers from Turkey
to China — that lands almost anywhere, and it put the aim far enough from our end
that EVERY arc was culled and the globe went blank. It now averages 3D unit vectors,
which is the true spherical centre, and weights our end by the peer count, since it
is one foot of every arc while each peer is one foot of one.

And the dots are now computed independently of the arcs. Tying them together meant a
pending or failed self lookup left a bare sphere while the user was actively helping
people. Peers are drawn whether or not we know where we are; only the arcs wait.

Verified by rendering both paths: normally one arc with a green peer dot and a cyan
dot on us, and with the self lookup forced to fail, no arcs but all three peers
still on the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds “self/origin” geolocation to the Unbounded globe so the volunteer is represented on the map (arcs run peer → volunteer), and adjusts the UI/contract to carry this origin through the Tauri plugin to the Svelte globe renderer.

Changes:

  • Extend GeoResolver to support “lookup caller” (None IP) and add resolve_own() with success-only caching semantics.
  • Add origin to the spark://unbounded snapshot payload and plumb it through the backend types and Unbounded page into the globe component.
  • Update the globe rendering logic to draw peers even when origin is missing and to reframe the camera using a spherical/vector mean including the origin.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
spark-sharing/src/geo.rs Adds resolve_own() and updates fetch path handling to support /lookup (caller geolocation).
gui-tauri/tauri-plugin-spark-vpn/src/unbounded.rs Stores origin in plugin state, includes it in snapshot/status payloads, and spawns origin lookup during sharing.
gui-tauri/src/routes/unbounded/+page.svelte Initializes status.origin and passes it into <Globe>.
gui-tauri/src/lib/spark_backend.ts Extends the UnboundedStatus contract with origin and updates the mock backend behavior.
gui-tauri/src/lib/spark_backend.test.ts Updates mock-backend unbounded test expectations to include origin: null.
gui-tauri/src/lib/Globe.svelte Renders a distinct “own” dot and draws arcs peer → origin; updates camera framing logic.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@coderabbitai

coderabbitai Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@myleshorton, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ac65d53d-6f23-4860-8407-9e1d8061b02a

📥 Commits

Reviewing files that changed from the base of the PR and between 7804547 and 3e5f2da.

📒 Files selected for processing (6)
  • gui-tauri/src/lib/Globe.svelte
  • gui-tauri/src/lib/spark_backend.test.ts
  • gui-tauri/src/lib/spark_backend.ts
  • gui-tauri/src/routes/unbounded/+page.svelte
  • gui-tauri/tauri-plugin-spark-vpn/src/unbounded.rs
  • spark-sharing/src/geo.rs
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/unbounded-own-location

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Two review findings, both real.

The lookup task was fire-and-forget and unscoped. A lookup still in flight when the
user pressed stop would write the origin back AFTER stop had cleared it, so
`unbounded_status` then reported where we are while sharing was off — contradicting
the one property this lookup's placement exists to guarantee. It is now scoped by
`stop_epoch`, the same signal `unbounded_start` uses across its own pre-gate fetch,
and re-read after every await rather than once, since there are now several.

And it only ever attempted once. `resolve_own` deliberately does not cache failures
so that it CAN be retried — there is exactly one per process, and remembering a blip
would leave the volunteer off their own map for the whole session — but nothing was
retrying, which wasted that and left the behaviour no better than caching the failure
would have been. There was even a test asserting the retry-ability of something no
caller used. It now backs off across a bounded handful of attempts (0s, 5s, 20s, 60s,
180s), which is the right shape for decoration: a geo host that is down costs a few
attempts, not a standing timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

gui-tauri/src/lib/Globe.svelte:48

  • origin can arrive after the initial peer set (async geo lookup), but this component only re-runs its reactive work on peers changes. If the backend returns the same peers array instance across polls/events, origin updates won’t trigger layoutArcs() / parkCamera(), so the volunteer dot and peer→origin arcs can remain missing until an unrelated camera/peer change.

This issue also appears on line 172 of the same file.

  let {
    peers = [],
    origin = null,
  }: {
    peers: UnboundedPeer[];

gui-tauri/src/lib/Globe.svelte:176

  • layoutArcs() returns early when arcs.length === 0, which prevents rendering the volunteer’s own dot when sharing is on but there are no peers yet (or no peers with geo). Since the goal is to show “where am I”, the origin dot should be able to render even with zero arcs.
  function layoutArcs() {
    if (!globe || !el || arcs.length === 0) {
      paths = [];
      peerDots = [];
      ownDot = null;

…rence does

The globe was missing every arc animation the reference has, and was static besides.
Both are read out of the reference rather than guessed.

ARCS GROW IN. Lantern passes `animateOnAdd: true` and leaves the durations at
`PointConnectionStyle`'s defaults, which are 1000ms of growth and 500ms of fade, both
linear — its painter is a plain `elapsed / duration` clamp with no curve. The
direction is not arbitrary either: the reference's path starts at the PEER and ends at
the volunteer, and its growth extracts the path from 0 forward, so a connection
reaches out from the person being helped towards whoever is helping them. Done here
with `pathLength="1"` and a dash offset, which normalises the maths so one dash covers
the arc however long it is on screen, and makes the growth independent of `d` — the
camera can move mid-growth and the same fraction stays drawn.

That last property is why arcs are no longer dropped when culled. A culled arc stays
in the list and is HIDDEN, so its element and its running animation survive: otherwise
the growth restarted every time the camera swung a peer past the threshold, and an arc
added while off-face animated from scratch on arrival instead of already being drawn,
where the reference — whose growth is a function of elapsed time since the connection
was added — shows it finished.

THE GLOBE SPINS. `isRotating: PlatformUtils.isDesktop, rotationSpeed: 0.04`, and ours
was static, which is the most visible way it failed to look like the recording. The
reference's figure does not transfer: the package subtracts a fixed angle PER FRAME, so
its real speed depends on refresh rate — ~15 deg/s at 60Hz, double on a 120Hz panel. So
the rate came from the recording, by cross-correlating a band of the sphere between
frames a second apart; the windows where a focus turn is not overriding the spin agree
on ~21 deg/s, between those two figures.

The spin has two consequences. The arcs are an SVG overlay, so nothing moves them when
the camera does — they would visibly detach and float. Re-projecting now runs on a
standing frame loop instead of the bounded one that followed a transition, which also
subsumes the `onZoom` hook and drag handling. It is gated on the same visibility signal
as the renderer, and while it runs the renderer is running anyway for the spin. And the
spin is suspended across a camera turn: `pointOfView` animates the camera directly while
autoRotate advances the azimuth every frame, so leaving both on has them fighting over
one camera and the turn lands somewhere neither intended.

Also here: the atmosphere is a soft wide bloom again rather than the tight rim it had
been narrowed to. The reference is explicit — `atmosphereOpacity: 0.18,
atmosphereBlur: 22`, commented "Soft bloom, not a rim: a small blur reads as a hard
teal ring" — which is exactly what narrowing it had produced. globe.gl exposes no
opacity, so the faintness comes from a paler colour spread wider.

All of it is off under `prefers-reduced-motion`: no spin, and arcs drawn complete.
Verified by measurement, not by eye — the dash offset runs 1 to 0 linearly over 1000ms
and the opacity 0 to 1 over 500ms; reduced motion reports `animation-name: none` with
the arc already complete and 0.9px of drift over 1.5s against ~20px with the spin on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

gui-tauri/src/lib/Globe.svelte:387

  • parkCamera's doc comment still refers to framing HOME and mentions HOME being pushed to the limb, but the implementation now frames peers plus the resolved origin using a 3D vector mean. Updating the comment will prevent confusion about what the camera is actually targeting and why.
  function parkCamera(animateMs: number) {
    if (!globe || arcs.length === 0) return;
    // The origin is part of the signature: it arrives AFTER the first peers (a network round trip),
    // and the camera has to re-frame when it does or our end stays off the visible face.
    const sig = [...arcs.map((a) => a.id), origin ? `${origin.lat},${origin.lon}` : "-"].join("|");
    if (sig === parkedOn) return;

Comment thread gui-tauri/src/lib/Globe.svelte Resolved
`layoutArcs` said it was NOT on a render loop, which stopped being true when the
standing projection loop replaced the bounded one — precisely the sort of stale claim
that misleads later perf reasoning. And `parkCamera`'s header still described framing
HOME and averaging longitudes on the unit circle; the endpoint is the volunteer's own
location now, and the averaging is over 3D unit vectors, which its own body already
explained. The header contradicted the code below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

gui-tauri/src/lib/Globe.svelte:238

  • layoutArcs() returns early when arcs.length === 0, which prevents drawing the volunteer’s own dot even when origin is already resolved (e.g. sharing is on but no peers yet, or peers exist but are temporarily geo-less). Since ownDot is derived from origin, this early return makes the “where am I” marker disappear unless at least one arc exists.
  function layoutArcs() {
    if (!globe || !el || arcs.length === 0) {
      paths = [];
      peerDots = [];
      ownDot = null;
      return;

gui-tauri/src/lib/Globe.svelte:273

  • This comment says that with “no origin … the arc is still emitted, just not drawable”, but the code immediately does if (!far) continue;, which omits the arc path entirely until origin exists. This mismatch makes the culling logic harder to reason about (especially now that ArcPath.visible is used to keep DOM elements alive for animations).
      // Both feet have to be on the face — an arc with one foot past the limb draws as a hairpin off
      // the edge — so no origin, or an origin round the back, means dots without arcs. The arc is
      // still emitted, just not drawable: see `ArcPath.visible`.
      if (!far) continue;

… the earth

The arcs were bulging the wrong way. They were raised perpendicular to their own
chord IN SCREEN SPACE, which always bulges up the screen — so an arc whose real path
leans some other way leaned up anyway, and the result read as a bridge thrown over
the globe rather than a route lying on it.

Two fixes, and the second is the one that matters.

The control point is now the GREAT-CIRCLE MIDPOINT of the two feet, lifted and
projected, which is the reference's own construction (`line_helper.dart` normalises
the mean of the endpoint vectors, scales it past the surface, and projects that).
The direction then comes from where the real path is furthest from the chord, so
each arc leans the way its own route leans.

And the LIFT IS NOT A CONSTANT. A quadratic Bézier only lies on a circular arc when
its control point sits at `1 / cos(omega / 2)` radii, omega being the separation of
the feet — so any single figure is correct at exactly one separation and wrong
everywhere else, bowing inside the surface for wide pairs and ballooning over it for
narrow ones. That is the angle error. It is solved per arc now, then ridden above by
a fixed amount so the arc clears the silhouette instead of grazing it.

`ARC_RIDE` was measured against the recording, whose arcs crest at ~1.15 radii with a
median of 0.90: at 0.14 the arc tracks the ground exactly and never rises past the
edge, at 0.45 the crests land at 0.92-1.07 and it lifts over the limb as the
recording's do while still following its great circle.

Also fixes a blank globe this exposed. Framing aimed at a weighted mean of every
endpoint as unit vectors, and points spread across half the globe are all northern,
so their z components add while x and y cancel: the mean drifted towards the POLE, and
clamping the latitude back down left a longitude belonging to none of them. Our end
ended up round the back and every arc was culled — nothing drawn while the user was
actively helping people. The aim now walks the great circle from our end towards the
peers' centroid, which cannot leave the two behind, and stops short of the cull so our
end is always on the visible face: it is one foot of every arc, so hiding it costs
everything, where hiding a peer costs one arc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

gui-tauri/src/lib/Globe.svelte:335

  • This in-function comment refers to ARC_LIFT, but the constant being applied is ARC_RIDE (and no ARC_LIFT exists). That mismatch makes the explanation hard to follow when revisiting this math.
      // Lifted by `ARC_LIFT` radii. Perspective does most of the work: a point this far out is much
      // closer to the camera than the surface is, so a modest angular offset from the view axis
      // projects a long way from the disc's centre — which is how the reference's crest reaches ~1.15
      // radii while both feet sit inside half a radius of the middle.

Comment thread gui-tauri/src/lib/Globe.svelte Resolved
Comment thread gui-tauri/src/lib/Globe.svelte Resolved
…write

`ARC_LIFT` was renamed `ARC_RIDE` when the lift became angle-dependent, and two
comments still pointed at the old name — including the module header, which is where
someone tuning the geometry starts. And `ArcPath.visible` still said it required "a
usable perpendicular", which was a condition of the screen-space construction that no
longer exists; it means both feet on the visible face and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

gui-tauri/src/lib/Globe.svelte:281

  • layoutArcs() bails out when arcs.length === 0, which prevents projecting ownDot even when origin is already resolved. This means the volunteer marker can disappear whenever there are no peers with geo (including the common “sharing on but no peers yet” state). Remove the arcs.length === 0 guard so the origin dot can still be drawn.
    if (!globe || !el || arcs.length === 0) {
      paths = [];
      peerDots = [];
      ownDot = null;
      return;

…itting a Bézier

The arcs towered over the globe. Fitting one quadratic Bézier and tuning its lift
could never fix that, because the control point that puts a quadratic ON a circular
arc sits at `1 / cos(omega / 2)` radii and DIVERGES as the feet separate — already 3.9
radii at 150 degrees. So a lift small enough for a wide pair flattened every narrow
one, and large enough for a narrow pair sent the wide ones over the top of the frame.
Both of the previous constructions were tuning around that, which is why each fix
moved the problem rather than removing it.

The route is now walked directly: 32 samples slerped along the great circle between the
two feet, each projected, with the altitude following a `sin(pi * t)` bell — on the
ground at both feet and `ARC_RIDE` radii up at the middle. Exact at every separation,
with no control point, no perpendicular and no clamp, and the crest is a hard ceiling
rather than something that emerges from a fit. 0.15 puts it at 1.15 radii, which is
where the recording's crest sits.

It also foreshortens correctly, which is the property that makes an arc look like it
belongs on the sphere: a route whose midpoint faces the camera reads as flat against
the surface, and one whose midpoint is out near the limb lifts clear of it.

And the line now BREAKS where the route passes behind the sphere, rather than being
drawn straight across the face — the same reason the reference clips its paths at the
sphere intersection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4uMnvGU3dM11aXJbLc9hk
@myleshorton
myleshorton merged commit 17caeeb into main Aug 23, 2026
17 checks passed
@myleshorton
myleshorton deleted the fisk/unbounded-own-location branch August 23, 2026 18:35
@myleshorton
myleshorton restored the fisk/unbounded-own-location branch August 23, 2026 18:37
@myleshorton
myleshorton deleted the fisk/unbounded-own-location branch August 23, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants