Repository navigation
feat(unbounded): put the volunteer on their own globe - #229
Conversation
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
There was a problem hiding this comment.
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
GeoResolverto support “lookup caller” (NoneIP) and addresolve_own()with success-only caching semantics. - Add
originto thespark://unboundedsnapshot 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.
|
Warning Review limit reached
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
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
There was a problem hiding this comment.
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
origincan arrive after the initial peer set (async geo lookup), but this component only re-runs its reactive work onpeerschanges. If the backend returns the samepeersarray instance across polls/events,originupdates won’t triggerlayoutArcs()/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 whenarcs.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
There was a problem hiding this comment.
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 framingHOMEand mentions HOME being pushed to the limb, but the implementation now frames peers plus the resolvedoriginusing 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;
`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
There was a problem hiding this comment.
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 whenarcs.length === 0, which prevents drawing the volunteer’s own dot even whenoriginis already resolved (e.g. sharing is on but no peers yet, or peers exist but are temporarily geo-less). SinceownDotis derived fromorigin, 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 untiloriginexists. This mismatch makes the culling logic harder to reason about (especially now thatArcPath.visibleis 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
There was a problem hiding this comment.
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 isARC_RIDE(and noARC_LIFTexists). 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.
…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
There was a problem hiding this comment.
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 whenarcs.length === 0, which prevents projectingownDoteven whenoriginis 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 thearcs.length === 0guard 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
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.
/lookupwith 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:
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 failuretauri-plugin-spark-vpn: 53 tests pass; the payload contract tests now coveroriginpresent and nullsvelte-check419 files clean;vitest38 tests;vite buildcleancargo fmt --checkandcargo clippy --all-targets -D warningsclean on all three cratesNote
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