RFC-017 — Surface Hotspots · LOD architecture, texture pipeline, ground-view skybox, and hand-authored hardware models
Orrery · Open RFC · v0.1 · May 2026
Status: Draft Author: product Closes into: ADR-059 (LOD architecture + per-frame dispatcher), ADR-060 (orbital-imagery texture pipeline + provenance discipline), ADR-061 (ground-view panorama + skybox framework), ADR-062 (hand-authored hardware model authoring contract). Slice gate: v0.7 Why this is an RFC: PRD-014 promises a four-tier progressive disclosure on lunar + Mars landing sites (silhouette → 3D model → orbital-mosaic patch → ground-level panorama), tracked by camera distance, georeferenced to within 50 m, lazy-loaded for mobile, fully attributed, and parity across 14 locales. The technical questions cluster five ways: LOD orchestration (Three.js LOD primitive vs. manual swap; how to detect "close enough" — distance vs. screen-projected size; what happens during the transition); texture pipeline (LROC NAC / HiRISE source URLs, crop + georegistration, JPEG re-encode, alpha-feathering at patch edge); ground-view skybox (panorama equirectangular projection, camera FOV at ground level, return-to-orbit transition, mobile prefetch policy); model authoring (per-mission Three.js builders mirroring
earth-satellite-models.ts; coordinate conventions; how the lander sits on Tier-2 terrain at the right scale + orientation); test + perf strategy (Playwright assertions at each tier, per-frame budget on a Pixel 6 baseline, asset-eviction policy for memory pressure).This is the single highest-ambition feature in the v0.6→v1.0 arc. Getting the architecture right at v0.7 is what makes V2 panoramas and V3 full-inventory tractable. Getting it wrong forces a rewrite at v0.8.
Context
Today /moon and /mars render a sphere mesh with a texture (LRO mosaic for the Moon, MOLA-derived shaded-relief for Mars), parented to which are surface markers (THREE.Group instances anchored via the existing latLonToUnitSphere helper from moon-projection / mars-projection). Each marker is a small Three.js primitive composition (octahedron + cylinder, or — for /mars Soviet petal landers — a stylised petal-capsule) with an inline buildLabel() group and a halo ring (#119). The camera is an orbital camera with camR ∈ [planet_radius × 1.5, planet_radius × 30].
There is no LOD system today. Every marker renders identically regardless of camera distance. There is no patch-of-high-resolution-texture-laid-locally pattern anywhere in the codebase. There is no skybox / panorama mode. The closest existing pattern is the texture field on the Sun/Earth mesh — a single texture per body, no progressive disclosure.
The data we'd ingest:
| Source | Resolution | Coverage | Licence | Notes |
|---|---|---|---|---|
| LROC NAC (Lunar Reconnaissance Orbiter Camera, Narrow-Angle) | 0.5 m/pixel | Every Apollo + Luna + Surveyor + Chang'e site imaged ≥ 1× | PD-NASA (+ optional ASU LROC team attribution) | Released as PDS-formatted IMG + processed mosaics |
| HiRISE (Mars Reconnaissance Orbiter) | 25 cm/pixel | Every active + many failed Mars landers imaged | PD-NASA | Released as JPEG2000 + GeoTIFF |
| Apollo Hasselblad panoramas | High-res, equirectangular conversions exist | Every EVA covered | PD-NASA | Lunar Surface Journal hosts the stitched panoramas |
| Curiosity / Perseverance Mastcam-Z panoramas | 5K+ × 2K equirectangular | Hundreds available across sols | PD-NASA / JPL-Caltech / MSSS | NASA APOD + Mars Trek host the stitched products |
PRD-014 commits to 5 Showcase + 7 Standard V1 hotspots — 12 total. The architecture must scale to V3's ~60 without re-design.
Open Questions
OQ-1 — LOD orchestration: THREE.LOD primitive vs. manual per-marker dispatcher?
Three options:
| Option | How it works | Pros | Cons |
|---|---|---|---|
A. THREE.LOD primitive | Built-in scene-graph node that swaps children based on camera.position distance. Add 3 LOD levels per marker; Three.js does the dispatch every frame. | Zero custom code. Standard pattern. Renderer-loop integrated. | LOD distance is a scalar; we want screen-projected pixel-size (a tilted patch viewed at glancing angle reads smaller than head-on). Doesn't natively cross-fade between levels. |
| B. Manual per-frame dispatcher | Each marker stores a tier: 0|1|2|3 state. A function in the existing render loop reads camera.position and each marker's world position, derives the tier per-marker, calls setVisible(group, child, tier). | Full control. Easy to add screen-size criterion. Easy to add cross-fade. Easy to add per-marker overrides (Tier-3 sticky once entered). | More code than A. More risk of leaks if not carefully written. |
| C. Hybrid | Use THREE.LOD as the data container for Tier 0–2 (the Three-managed common case), wrap with a small dispatcher for Tier 3 (ground-view) which is fundamentally a different camera mode and doesn't fit LOD semantics. | Best of both: stock pattern for visual tiers, custom for the "leave-orbit" tier. | Two systems to maintain. |
Recommendation: B (manual per-frame dispatcher). The screen-size criterion matters a lot for /mars (Curiosity at Gale Crater vs. Viking 1 at Chryse Planitia — same lat/lon range, but the camera-to-marker chord length differs when the Mars sphere occludes one of them). Cross-fade between Tier 1 and Tier 2 is mandatory per PRD-014 §UX-detail; THREE.LOD has no native cross-fade and forces a hard pop. Manual dispatcher gives both, with ~25 LOC of bookkeeping per marker. The per-frame cost is negligible — 60 markers × 3 distance comparisons = 180 ops/frame.
The dispatcher contract — locked into ADR-059:
// In each marker's render-state struct:
type HotspotMarker = {
position: THREE.Vector3; // world-space
tierGroup: Record<0 | 1 | 2, THREE.Group>; // per-tier children
currentTier: 0 | 1 | 2;
fadeProgress: number; // 0..1 between currentTier and nextTier
};
// Per-frame:
for (const m of hotspotMarkers) {
const screenSize = projectedRadius(m.position, camera); // px
const targetTier =
screenSize > 120 ? 2 :
screenSize > 20 ? 1 : 0;
if (targetTier !== m.currentTier) startFade(m, targetTier);
if (m.fadeProgress < 1) advanceFade(m, dt);
applyTierVisibility(m);
}projectedRadius is a cheap math op (no actual projection): 1 / (cameraDistance / focalLength)-ish, accurate to a few percent for the LOD threshold purpose.
OQ-2 — Tier-2 texture patch: how do we lay a 2048×2048 JPEG of LROC NAC onto a curved moon sphere at the right place?
The moon mesh is a THREE.SphereGeometry with a single LROC global mosaic texture. We can't replace the texture per-site (that would re-upload 80 MB to GPU per site visit). We need a patch approach: a small disc-or-quad mesh laid on top of the sphere at the site's lat/lon, with its own per-site texture.
Options:
| Option | Approach | Pros | Cons |
|---|---|---|---|
A. Decal-projection (Three.js DecalGeometry) | Project a planar texture onto the underlying sphere mesh, clipped to a small region | Native to Three.js. Per-vertex follows the sphere curvature. Alpha-feathering at edge works. | Complex API. The decal geometry must be regenerated when the moon mesh rotates (which it does — Moon is tidally locked, slow rotation). |
| B. Surface-patch quad with custom shader | Small disc-mesh tangent to the sphere at the site, with a fragment shader that masks pixels outside the patch radius and feathers the alpha. | Simple. Stable. Doesn't depend on moon rotation. The patch is parented to moonMesh so it rotates with it. | Slight geometric mismatch at the edge (the planar disc doesn't curve with the sphere). At 1 km patch radius on a 1737 km-radius Moon, the curvature mismatch is ~0.3 m at the edge — invisible. |
| C. Re-render the moon texture with the patch composited | Compose a CPU canvas with the patch laid into the global mosaic; upload as a new texture per-visit. | Pixel-perfect. | Texture upload latency (≥ 30 ms even at 4K). Memory pressure if multiple patches at once. |
Recommendation: B (surface-patch quad). The simplicity wins. Patch size = 1 km surface diameter × pixel-density-of-MoonRadius-on-screen-at-Tier-2 ≈ 200×200 px at Tier 2 zoom. At a 2048×2048 source texture that's massive oversampling — sharp at every reasonable zoom. The curvature mismatch is sub-mm. Parented to the moonMesh so the patch corotates with the Moon's surface tide-lock rotation. No re-render of the moon texture.
Implementation:
function makeHotspotPatch(opts: {
lat: number;
lon: number;
radiusKm: number;
surfaceRadiusKm: number;
texturePath: string;
alphaFeatherFrac: number; // 0.05 = soft 5% edge fade
}): THREE.Mesh {
// Disc geometry: planar, normal at the lat/lon point pointing radially out.
const surfacePoint = latLonToUnitSphere(opts.lat, opts.lon)
.multiplyScalar(opts.surfaceRadiusKm + 0.001); // hair-elevated to avoid z-fighting
// Tangent-plane disc, oriented so its normal points outward.
// ...
}The + 0.001 km elevation (1 metre above the sphere surface) avoids z-fighting with the base mesh while remaining sub-pixel from any reasonable zoom.
Locked into ADR-059.
OQ-3 — Georegistration: how do we ensure the LROC patch lands at the published Apollo 11 lat/lon to ±50 m?
The site lat/lon from the existing mission data are accurate to published-coordinate precision (Apollo 11: 0.6741°N, 23.4730°E from the Lunar Surface Journal, ~10 m of historical-claim precision). The challenge is that we're laying a finite-area image (1 km × 1 km) over a curved surface, and we need the centre of the image to lie at that lat/lon, with the image's "up" aligned to lunar north.
Three steps:
- Authoring: when fetching a patch image, the curator crops the LROC mosaic such that the published lat/lon falls at pixel (1024, 1024) of a 2048 × 2048 crop. The patch covers 1 km × 1 km — i.e., ~0.5 m/px native LROC NAC. The image rotates so lunar north is up. This is a manual / curated step per site, scripted in
scripts/fetch-hotspot-imagery.ts. - Manifest: each hotspot's JSON manifest records
{ lat, lon, radius_km, image_orientation_deg }. The orientation defaults to 0 (lunar north up) for V1; non-zero allowed for V3+ if a stitched-from-non-polar mosaic needs it. - Render: the surface-patch quad is positioned at
latLonToUnitSphere(lat, lon) × surfaceRadiusKm, sized to coverradius_km, oriented so its texture's +V axis points towardlatLonToUnitSphere(lat + 0.001, lon)(i.e., one tiny step toward lunar north).
Total registration uncertainty:
- Published lat/lon: ±5–30 m site-dependent.
- LROC NAC product georeferencing: ±5 m on flat ground.
- Three.js single-precision float at lunar scale: ~1 m positioning precision.
- Patch quad's planar approximation of sphere: ±0.3 m at edge.
Realistic delivered georegistration: ±20 m at site centre, ±50 m at patch edge. The PRD-committed ±50 m is met with margin. Sites that exceed this (rare) carry a georegistration_note string surfaced in the panel.
ADR-060 captures the contract.
OQ-4 — Texture asset pipeline: which sources, how to ingest, where to host?
Sources have different ingestion paths:
| Source | Direct URL pattern | Crop format | Re-encode | Provenance |
|---|---|---|---|---|
| LROC NAC mosaics | NASA PDS via WMS endpoint, or pre-rendered ASU LROC Quickmap PNGs | TIFF or PNG, lossless | → JPEG quality 88, 2048² | defaultLicenseForAgency('NASA') = PD-NASA; add author "NASA/GSFC/Arizona State University" |
| HiRISE rover-site images | NASA UAhirise.org map browser, JPEG2000 source | JPEG2000 → 2048² JPEG | → JPEG quality 88 | PD-NASA |
| Apollo panoramas | NASA Lunar Surface Journal hosts stitched 30K × 5K equirectangular JPGs | already JPEG | → JPEG quality 85, 4096 × 2048 | PD-NASA + photographer name (Aldrin, Armstrong, Cernan, Schmitt…) |
| Mars rover panoramas | NASA Mars 2020 + MSL raw image archives + JPL APOD | already JPEG | → JPEG quality 85, 4096 × 2048 | PD-NASA + camera (Mastcam-Z, Pancam) |
Pipeline contract (ADR-060):
static/data/hotspots/
moon/
apollo-11.json { lat, lon, radius_km, model_id, annotations, source_urls }
apollo-17.json
...
mars/
curiosity.json
perseverance.json
...
static/images/hotspots/
moon/
apollo-11/
patch.jpg # 2048x2048 LROC NAC, georegistered
panorama-1.jpg # 4096x2048 equirectangular (V2+)
mars/
curiosity/
patch.jpg # 2048x2048 HiRISE
panorama-1.jpg # 4096x2048 Mastcam-Z (V2+)
scripts/fetch-hotspot-imagery.ts
- Per V1 hotspot: download from source URL, crop, georegister, re-encode, write patch.jpg.
- For panoramas: download from source URL, sanity-check equirectangular ratio is 2:1, re-encode.
- Idempotent: skips files that already exist.
scripts/build-image-provenance.ts
- New section: walk static/images/hotspots/**, register each with its agency + license + source URL from the manifest.
- Falls back to "buildWikimediaEntry" for Commons-hosted sources, "buildNasaEntry" for NASA-direct.
scripts/validate-data.ts
- Fail-closed: every manifest must reference an existing patch.jpg.
- Fail-closed: every annotation must have a name string in en-US overlay.
- Fail-closed: every annotation's coordinates must be within the patch radius.Locked into ADR-060.
OQ-5 — Ground-view (Tier 3) skybox: equirectangular projection or cube-map?
Mars and Apollo panoramas are stitched as equirectangular (2:1 aspect, longitude × latitude). Three.js supports both equirectangular textures (on a large inverted sphere) and cube-maps (on a CubeTexture).
| Option | Pros | Cons |
|---|---|---|
| A. Equirectangular on inverted sphere | Source assets are already equirectangular — no conversion. Single 4096 × 2048 JPEG ≈ 5–8 MB. Sphere inverted, camera at centre. | Slight pole-distortion in the texture (longitudinal stretching near the up/down poles). Doesn't matter for Apollo panoramas (the photographer never looked straight up or down). |
| B. Cube-map | No pole distortion. 6 textures per face = sharper at corners. | Authoring requires equirectangular → cube-map conversion (equirectangularToCubemap() shader). Larger total file size (6 faces × similar resolution = ~30 MB). Each face must be re-encoded. |
Recommendation: A (equirectangular). Source assets are already in the format. File size is the smaller one. Pole distortion is invisible for landing-site panoramas (no one pans straight up to inspect the sky — the action is on the horizon).
Implementation:
function enterGroundView(hotspotId: string, panoramaIndex: number) {
// Load panorama texture (lazy).
const texture = await new THREE.TextureLoader().loadAsync(panoramaPath);
texture.colorSpace = THREE.SRGBColorSpace;
texture.mapping = THREE.EquirectangularReflectionMapping;
// Inverted sphere — camera inside, sees the texture.
const skyGeo = new THREE.SphereGeometry(1000, 64, 32);
const skyMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
const sky = new THREE.Mesh(skyGeo, skyMat);
scene.add(sky);
// Animate camera from current Tier-2 position to ground level.
// ...
// Disable orbit controls; enable look-around (no panning, no zooming).
controls.enableZoom = false;
controls.enablePan = false;
controls.minDistance = 0.001;
controls.maxDistance = 0.001; // camera fixed at centre
}The panorama's "up" axis must be aligned with the lunar local-up at that site — meaning the texture's vertical centreline corresponds to looking north (or whatever the original photographer's frame was). The manifest records the panorama's reference azimuth so the texture rotates correctly.
ADR-061 captures the contract.
OQ-6 — 3D model authoring: hand-coded Three.js builders vs. glTF imports?
PRD-014 commits to engineering-correct hand-authored models. Two paths:
| Option | Path | Pros | Cons |
|---|---|---|---|
A. Hand-coded TS builders (extend the earth-satellite-models.ts pattern) | A new mars-lander-models.ts and moon-lander-models.ts, each exporting a BUILDERS: Record<id, (color: string) => THREE.Group> map. Each lander/rover is 30–80 lines of BoxGeometry + CylinderGeometry + CapsuleGeometry. | Same pattern as the existing /earth pin work. Tiny scene-mass. No glTF loader dependency. Build-step-free. Fully reviewable in PRs. | Engineering-blueprint visual style, not photo-real. The user said WIRED-level, we deliver schematic-elegant — clearly attribution-grade-NASA-engineering-drawing-style, not Disneyland-render. |
| B. glTF imports (Three.js GLTFLoader) | Per-mission .glb files (~500 KB each compressed) authored in Blender, imported on demand. | Photo-real possible. | New loader dependency (three/examples/jsm/loaders/GLTFLoader.js is ~50 KB). New asset class (.glb), new provenance discipline (3D-model licensing). 60 models × 500 KB = 30 MB scene mass. Author or source per model. Where do we source open-licence engineering-correct .glb files for every Apollo / Mars lander? NASA has some (Curiosity yes, Apollo LM no). The licensing review for the rest dominates. |
Recommendation: A (hand-coded TS builders). The /earth pattern proves it works at WIRED quality — the Hubble, ISS, Starlink builders read as engineering-blueprint-elegant, not toy. We extend that style to landers/rovers. Each builder is ~50 lines of code, fully readable in PR review, fully attributable (cite the engineering reference for each dimension), and scales to V3 (60 models) at < 300 KB total scene mass. The "no compromise" PRD-014 promise is not "photo-real" — it's visual + dimensional accuracy at the right tier + the photo-real comes from the texture pipeline (Tier 2 mosaic + Tier 3 panorama). The models are the supporting cast.
The authoring contract — ADR-062:
// In static/data/hotspots/{planet}/{id}.json:
{
"id": "apollo-11",
"model_id": "apollo-lm-eagle",
"model_scale_m": 7.0,
...
}
// In src/lib/moon-lander-models.ts:
export const BUILDERS: Record<string, (color: string) => THREE.Group> = {
'apollo-lm-eagle': buildApolloLM,
'apollo-lm-falcon': buildApolloLM_JmissionStretchedLegs,
// ...
'soviet-luna-9': buildLuna9_Capsule,
'chang-e-5-lander': buildChangeLander,
// ...
};
function buildApolloLM(color: string): THREE.Group {
// Descent stage — octagonal aluminum + Inconel tube.
// Per NASA TN D-7700: 3.23 m wide across the 8 faces, 2.18 m tall.
// ...
// Ascent stage — 4.04 m wide × 3.76 m tall.
// ...
// 4 splayed legs — 4.27 m to footpad centre.
// ...
// Ladder, US flag, S-band, MESA, descent engine bell, foil texture.
}Each builder must reference its engineering source in a code comment (ADR-062 §1.3). Public dimensions for every flown lander/rover exist in NASA TN reports, JPL drawings, ASU/MIT thesis dimensions. Failure modes (Beagle 2, Schiaparelli) get reconstructed-from-published-data builders too.
V1 builders required (12): apollo-lm (covers Apollo 11, 12, 14), apollo-lm-extended (covers 15, 16, 17), luna-sample-return, change-lander, viking-tripod, curiosity-class-rover, perseverance-rover, sojourner, phoenix-class-lander, soviet-mars-petal-lander, mars-3-petal (variant), chandrayaan-3-vikram.
OQ-7 — Memory eviction policy: what happens when 12 hotspots have all been visited and the user keeps exploring?
Tier 2 patch = 3–4 MB JPEG → 4–6 MB GPU texture (decoded). Tier 3 panorama = 5–8 MB JPEG → 8–12 MB GPU texture. Visit all 12 V1 hotspots at both tiers = 144 MB GPU resident. A Pixel 6 GPU memory budget is ~512 MB shared with the OS. Other GPU-resident textures (moon mosaic = 80 MB, mars mosaic = 80 MB) eat half the budget already.
Eviction policy (ADR-059):
type LoadedTier = { hotspotId: string; tier: 2 | 3; texture: THREE.Texture; lastVisit: number };
const loadedTiers: LoadedTier[] = [];
const MAX_LOADED = 6; // 6 hotspots × ~20 MB = ~120 MB ceiling
function getOrLoad(hotspotId: string, tier: 2 | 3): Promise<THREE.Texture> {
const existing = loadedTiers.find(x => x.hotspotId === hotspotId && x.tier === tier);
if (existing) {
existing.lastVisit = performance.now();
return Promise.resolve(existing.texture);
}
if (loadedTiers.length >= MAX_LOADED) {
// Evict LRU: dispose() texture, splice out.
loadedTiers.sort((a, b) => a.lastVisit - b.lastVisit);
const evict = loadedTiers.shift()!;
evict.texture.dispose();
}
// Load new...
}LRU based on visit time. 6-slot LRU is enough for "user explores 6 sites in a row, comes back to the first" without re-fetching. Loaded tiers persist across same-route navigation; cleared on route change away from /moon or /mars (the textures are route-specific anyway).
OQ-8 — Mobile + saveData: what's the prefetch policy?
Three connection states matter:
| Browser API state | Policy |
|---|---|
navigator.connection.saveData === true | Never prefetch. Tier 2 patches load only on explicit Visit click. Tier 3 panoramas load only on explicit Stand at site click. |
connection.effectiveType === '2g' || '3g' | Defer prefetch by 2 s; if camera is approaching Tier 2 (projectedRadius > 100 px) and stays there ≥ 1 s, prefetch the Tier 2 patch. Don't prefetch Tier 3. |
Default ('4g', '5g', desktop, unknown) | Prefetch Tier 2 patch when camera approaches a marker's > 80 px projected size. Prefetch Tier 3 panorama on Tier-2 hover (user dwells on the site for ≥ 2 s). |
A "tap to load" affordance appears in the Tier 2 patch's loading state when prefetch is suppressed (saveData true). The Tier 2 patch is replaced with a small placeholder: Tap to load high-resolution imagery (4 MB).
Locked into ADR-061.
OQ-9 — Test strategy: how do we e2e the Tier transitions without flake?
Each Tier transition is a camera animation over 300–600 ms. Asserting "the user is now at Tier 2" purely from DOM is impossible (Three.js renders to canvas, no DOM nodes per marker). The existing pattern (data-attribute mirroring on a hidden <div data-testid="render-state"> per ADR-030 §Layer 2) extends naturally.
<div
data-testid="hotspot-render-state"
data-active-hotspot-id={activeHotspotId}
data-active-tier={activeTier}
data-tier-fade-progress={fadeProgress.toFixed(3)}
data-camera-distance-km={cameraDistanceKm.toFixed(1)}
aria-hidden="true"
/>Per-hotspot smoke test:
test('apollo-11 hotspot tiers up cleanly', async ({ page }) => {
await page.goto('/moon');
await page.click('[data-marker-id="apollo-11"]');
await page.click('text=Visit landing site');
// Wait for Tier 2 transition to settle.
const renderState = page.locator('[data-testid="hotspot-render-state"]');
await expect(renderState).toHaveAttribute('data-active-tier', '2', { timeout: 3_000 });
// Patch texture loaded.
await expect(renderState).toHaveAttribute('data-active-hotspot-id', 'apollo-11');
// Annotations rendered — assert at least 3 are visible.
const annotations = page.locator('.hotspot-annotation');
await expect(annotations).toHaveCount(/* per-site */, { timeout: 2_000 });
expect(consoleErrors).toEqual([]);
});V1 commits to one smoke test per of the 12 Showcase + Standard hotspots, both desktop + mobile-chromium. e2e budget: 24 tests × ~3 s each = 72 s added to the suite (already at ~6:30 local, ~23 min CI). Within the 40-min CI ceiling per the recent ci(e2e) timeout bump.
OQ-10 — How do hotspots interact with the existing layer-filter chips (LAYERS · SOI · GRAVITY · APSIDES …)?
The layer chips are designed to hide or reveal layers of the rendered scene. Hotspots are a new scene-layer family. Two integration paths:
| Option | Approach | Pros | Cons |
|---|---|---|---|
A. Hotspots get a new chip: HOTSPOTS · ON / OFF | Single chip turns the whole progressive system on/off. Off = today's behaviour. | Familiar. Minimal new UX. | Coarse — a power user who wants Tier 0 only can't get it without turning off everything. |
B. New 3-state chip: HOTSPOTS · AUTO / LOW / HIGH | AUTO = progressive (default), LOW = pin Tier 0 everywhere, HIGH = pin Tier 2 on selected marker. | Power users get the control. | Adds a control surface. Educational labelling needed. |
| C. No chip; hotspots are always on, only the existing chips remain | Default-on, no opt-out. | Simplest. | Reduced-motion users / low-power devices have no way to dial back. |
Recommendation: B. Per PRD-014 §UX-detail. AUTO is the headline experience; LOW is for low-end devices that can't sustain 30 fps on the transition; HIGH is for "I want the close-up right now" power-user mode. The chip lives in the existing .ctrl-row.chips cluster on /moon + /mars. State is URL-shareable via ?hotspots=low query param.
ADR-059 captures.
Closes into
| ADR | Title | Decision focus |
|---|---|---|
| ADR-059 | Hotspot LOD architecture + per-frame dispatcher | Manual-dispatcher pattern (OQ-1), surface-patch quads (OQ-2), eviction policy (OQ-7), chip integration (OQ-10) |
| ADR-060 | Orbital-imagery texture pipeline + georegistration discipline | Source URLs per body (OQ-4), georegistration tolerance ±50 m (OQ-3), provenance contract per asset |
| ADR-061 | Ground-view skybox + panorama policy | Equirectangular projection (OQ-5), prefetch policy (OQ-8), reduced-motion + accessibility |
| ADR-062 | Hand-authored hardware model authoring contract | TS-builder pattern with engineering-citation comments (OQ-6), V1 model list, naming convention |
Slice plan (matching the V1 PRD scope)
| Slice | Scope | Ships |
|---|---|---|
| S1 | Hotspot manifest schema + 1 demo site (Apollo 11) + LOD dispatcher | Tier 0 + 1 transition for Apollo 11 only. Cross-fade working. |
| S2 | Surface-patch texture pipeline + apollo-11 patch.jpg fetched + georegistered + provenance row | Tier 2 patch for Apollo 11. Click marker → Visit → patch fades in. |
| S3 | 5 more Moon hotspots (Apollo 17, Chang'e 5, Luna 9, Apollo 12, Apollo 15) | Tier 0–2 for 6 moon hotspots. |
| S4 | 6 Mars hotspots (Curiosity, Perseverance, Viking 1, Phoenix, InSight, Sojourner) | Tier 0–2 for all V1 mars sites. |
| S5 | Annotations system + 3–6 per Showcase site | Tier 2 annotations visible + tappable. |
| S6 | i18n × 13 wave-2/3 locales for hotspot strings | 13/13 parity. |
| S7 | HOTSPOTS · AUTO / LOW / HIGH chip + URL state | UX surface complete. |
| S8 | e2e × 12 hotspots × desktop + mobile-chromium | Test coverage; CI green. |
| S9 | Preflight + Cmd-K verify + ship gate | V1 deploy. |
V2 is a separate slice plan (ground-view skybox + panorama prefetch + 5 Showcase sites lit up).
V1 estimate: 2 person-weeks at Marko-supervised cadence.
RFC-017 · v0.1 draft · May 2026 · Marko Dragoljevic