Skip to content

feat(url): deep-link galaxy focus via #focus= hash#14

Merged
rulkens merged 14 commits intomainfrom
feat/deep-link-focus
May 6, 2026
Merged

feat(url): deep-link galaxy focus via #focus= hash#14
rulkens merged 14 commits intomainfrom
feat/deep-link-focus

Conversation

@rulkens
Copy link
Copy Markdown
Owner

@rulkens rulkens commented May 6, 2026

Summary

  • Adds shareable #focus=<id> URLs that reproduce a galaxy selection on reload — paste a link in chat, the recipient lands on the same galaxy.
  • Identifier ladder: famous seed id (m31) > PGC (pgc-2789) > SDSS objID (sdss-…) > 4-decimal RA/Dec fallback (pos@…,…). Synthetic rows aren't link-encodable.
  • Browser Back/Forward navigates through pinned galaxies, SPA-style.
  • All deep-link orchestration lives in one hook (useFocusUrlSync).

Architecture

  • src/services/url/focusUrl.ts — pure codec, no DOM
  • src/services/engine/resolveFocusTarget.ts — pure resolver: FocusTarget + loaded clouds + sidecars → {resolved} | {tier} | {unknown}
  • src/hooks/useFocusUrlSync.ts — single React owner of the URL ↔ selection lifecycle (mount-time hash capture, selection-driven URL writes, drain-against-resolver once engine is ready, supersede-on-focus, popstate for SPA back/forward)
  • EngineHandle.getCloud(source) — new optional method exposing full PointClouds (resolver needs positions for the pos@ fallback)
  • EngineHandle.selectByAlias — gains optional famousMeta / famousXrefs overrides so App can dodge the engine's internal sidecar-load race

tier results leave pendingTarget set; banner UI is a follow-up (Task 5 of the plan).

Selection vs focus split

Two state concepts with distinct triggers and side-effects:

State Trigger Drives
selected Bare canvas click; palette pick; deep-link resolve InfoCard pinned card, halo highlight
focused Focus button, f shortcut, double-click, palette pick, deep-link resolve Camera tween, URL

A casual click pins the galaxy without polluting browser history; only deliberate focus actions add a #focus=… history entry. Engine fires a new onFocusChange callback from every camera-commitment site, so EngineHandle.focusOn(info) is now a single-arg call — App callsites don't have to update focused state manually.

History behavior

  • Selection-driven URL writes use pushState, so each pin is a back-button stop.
  • A popstate listener catches Back/Forward: if the new hash parses, set pendingTarget and let the drain re-resolve; if empty, call engine.clearSelection().
  • The mount path doesn't scrub the hash (an earlier draft did) — the resolver is idempotent and the matches short-circuit prevents history churn.
  • Drain gates on status.kind === 'ready', which guarantees state.cam is constructed before any tween dispatch.

Plan

docs/superpowers/plans/2026-05-06-deep-link-galaxy-focus.md — Tasks 1-4 implemented; Task 5 (banner) deferred; Task 6 = this PR. The history/back-button + focused-split scope was not in the original plan; added during review.

Test plan

  • Click M31 → URL stays bare; InfoCard shows PINNED
  • Click Focus button on the card → URL becomes #focus=m31 (new history entry); camera tweens
  • Press f over a pinned galaxy → same as above
  • Double-click a galaxy → focuses + URL updates
  • Click M31, focus, click M81, focus → Back twice walks M81 → M31 → no-pin
  • Forward redoes the trail
  • Reload #focus=m31 (famous) → camera lands on M31; URL restored
  • Reload #focus=pgc-… (non-famous GLADE row with PGC) → resolves
  • Reload #focus=pos@…,… (2MRS row without PGC) → resolves via nearest-neighbour
  • Reload #focus=sdss-… → resolves
  • Reload #focus=garbage → no crash; pending stays set silently (banner UI in follow-up)
  • Esc / close ✕ on InfoCard clears both selection AND URL hash
  • Tests: 826/826 passing, typecheck clean on both src + tools configs

🤖 Generated with Claude Code

rulkens and others added 13 commits May 6, 2026 02:52
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prevents App's drain effect from re-firing on every parent render while
a focus target is pending — would otherwise re-run the resolver's pos@
nearest-neighbour scan across all loaded clouds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A reload-with-#focus=m31 was racing the famousMeta sidecar load against
the first non-Famous cloud landing.  GLADE typically lands first; the
drain effect would then resolve a famous target against an empty
famousMeta, get unknown, clearPending(), and lose the user's deep link
moments before the sidecar finished loading.

Each FocusTarget kind now gates resolution on the data its resolver
branch actually reads, and pgc-without-alias-map waits for the palette
to warm rather than collapsing to unknown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion

Two coupled fixes for the deep-link drain pipeline:

1) The drain effect treated a transient `unknown` from the resolver
   as a definitive give-up, calling clearPending() and losing the
   target the moment the smallest cloud (typically Famous, ~150
   points) arrived — well before the actual survey clouds the user's
   pos@/pgc/sdss target lived in.  Resolution during loading is
   monotonic: more clouds + sidecars can only ever promote
   `unknown → tier` or `unknown → resolved`, never the other way.
   So drain now never clears on `unknown`; pending is collapsed by a
   small "selection supersedes pending" effect that fires once any
   selection lands (deep-link OR a manual user click).

2) `engine.selectByAlias` was building a correct `PointInfo` from
   the data-side cloud store, but then dispatching it through
   `setSelected(globalIdx)` which re-derives the info via
   `pointInfoFromGlobal`.  That re-derivation reads from the
   renderer-side `loadedSources()`, which lags `state.sources.clouds`
   by an upload-chain tick.  In the deep-link race the renderer
   doesn't have the source yet, the offset is 0, and the round-trip
   resolves to the wrong source — `info` comes back null and the
   React-side selection never updates.  `setSelected` now accepts an
   optional prebuilt `PointInfo` so callers that already have it can
   bypass the round-trip.

Net effect: reloading with `#focus=...` reliably navigates the
camera AND restores the hash to the URL bar via the hook's
selection-effect once `selected` updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two coupled fixes for the famous-id branch of the deep-link drain:

1) The engine and App both fetch the famous-galaxy sidecars
   independently (deliberately, per App.tsx's loader comment) and
   the two copies fall out of sync during cold-load — App's lands
   first because it has no other startup work to do, while the
   engine's is fired only after `loadAllClouds` resolves.  When
   App's drain raced ahead and called `selectByAlias({Famous, ...})`,
   the engine's `buildPointInfo` read its still-empty internal
   famousMeta, `info.famous` came back undefined, and
   `selectionToFocusId` fell through to the placeholder-PGC branch —
   writing `#focus=pgc-<idx>` instead of `#focus=<famous-id>` to the
   URL.  selectByAlias now accepts an optional famousMeta + xrefs
   override so App can pass its already-populated copy and dodge
   the race.  Other call sites (click, hover, palette alias-search)
   leave it undefined and continue to read the engine's copy.

2) The drain effect ran the moment any cloud reported ready, but
   `state.cam` is constructed slightly later — inside the
   `loadAllClouds` resolution, after the bbox of the first arrival
   is known.  selectByAlias's tween dispatch silently bails on a
   null camera, so the URL would update but the camera never moved.
   Drain now gates on `status.kind === 'ready'`, the engine event
   that fires when the render loop starts (post-camera-init).

Also: relax `buildPointInfo`'s `famousMeta` parameter to
`readonly FamousMetaEntry[]` so callers holding a readonly view
(EngineHandle's optional override) can pass it through unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pull the drain + supersede `useEffect`s out of App.tsx and into
useFocusUrlSync, so every effect that touches the `#focus=…`
lifecycle — mount-time hash capture, selection-driven URL writes,
drain-on-engine-ready, and supersede-on-selection — lives in one
place with a single shared rationale comment.

App.tsx loses ~120 lines + the resolveFocusTarget / ALL_SOURCES /
PointCloud imports it no longer needs.  The hook's public surface
becomes useFocusUrlSync({ selected, status, sourceCounts, famousMeta,
famousXrefs, aliasMap, engineHandleRef }) → { pendingTarget }, with
pendingTarget exposed for a future tier-mismatch banner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 6, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
skymap fe7a5c9 Commit Preview URL

Branch Preview URL
May 06 2026, 10:12 AM

Bare canvas clicks no longer pollute browser history.  Selection (the
pin state shown by the InfoCard) and focus (the camera-tween target
that drives the deep-link URL) become separate concepts:

- A canvas click pins the galaxy and shows the InfoCard, but does
  nothing to the URL.
- The Focus button on the InfoCard, the `f` shortcut, double-clicks,
  palette picks, and deep-link arrivals all trigger a camera-focus
  commitment, which updates the URL with a `pushState` history entry.
- Back/Forward steps walk through pinned galaxies as the user expects.

Changes:

- `EngineCallbacks` adds `onFocusChange?: (info | null) => void`.
- Engine fires it from `selectByAlias`, `selectFamous`, `focusOn`,
  `focusOnHome` (with null), and `clearSelection` (with null) — every
  call site that starts a camera commitment.
- `EngineHandle.focusOn` now takes `(info: PointInfo)` instead of
  `(xyz, diameterKpc)`.  The engine extracts xyz/diameter internally
  and fires the callback in one place — App.tsx no longer has to
  setFocused manually at every callsite.
- App.tsx adds a `focused` state alongside `selected`; useFocusUrlSync
  subscribes to `focused` and supersedes pending deep links on focus
  changes (not on bare-click selections, so a deep-link still resolves
  even if the user clicks something else mid-load).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rulkens rulkens merged commit 36cc5b6 into main May 6, 2026
2 checks passed
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.

1 participant