Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
19aa34a
fix(telescope): read a paper cited by a pre-2007 arXiv number
alowpoly Oct 5, 2026
02af124
fix(renderer): leave out the sheets through a body when its nebula is…
alowpoly Oct 5, 2026
7edb22c
feat(stars): add Eta Carinae inside the Homunculus Nebula
alowpoly Oct 5, 2026
d0401fb
feat(navigation): zoom in on a nebula into the body its walls surround
alowpoly Oct 5, 2026
9363a85
feat(telescope): let a transcribed power law say how it follows from …
alowpoly Oct 5, 2026
ea59d05
feat(stars): draw Eta Carinae's wind with its measured size and limb
alowpoly Oct 5, 2026
2b7008b
fix(renderer): stop a surface's patches blinking around a body and at…
alowpoly Oct 5, 2026
408c8a1
docs(stars): recapture Eta Carinae's evidence with the final renderer
alowpoly Oct 5, 2026
5fa7f62
Merge remote-tracking branch 'origin/main' into feat/eta-carinae
alowpoly Oct 5, 2026
5a52120
chore(stars): pin the world files and Eta Carinae's thumbnail after m…
alowpoly Oct 5, 2026
ff3905f
Merge remote-tracking branch 'origin/main' into feat/eta-carinae
alowpoly Oct 5, 2026
8907a48
fix(site): read the surrounding banks from disk so the entry endpoint…
alowpoly Oct 5, 2026
60e04aa
Merge remote-tracking branch 'origin/main' into feat/eta-carinae
alowpoly Oct 5, 2026
889bf61
fix(renderer): stop a nebula's sheets blinking around a body
alowpoly Oct 5, 2026
3bfb11f
Merge remote-tracking branch 'origin/main' into feat/eta-carinae
alowpoly Oct 5, 2026
3df0174
Merge remote-tracking branch 'origin/main' into feat/eta-carinae
alowpoly Oct 5, 2026
2da9e60
chore(stars): pin the world files after merging main
alowpoly Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/navigation-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ aliases and classification tabs use this inventory.
| Object | A package with a catalogue entry: a route, a prepared world frame and a scene loader. A planet, a star, a system, a galaxy, a nebula and a cluster of galaxies are all this. `?dataset=` selects one of its datasets. |
| Parent | The one object an object is inside (`parent` in its object.json). The Observable Universe is the root, with none. The registry refuses a missing parent, an unknown parent or a loop ([object-tree.ts](../packages/objects/src/registry/object-tree.ts)). |
| Zoom facts | An object seen from inside (the Milky Way, the Local Group, the Nearby and the Observable Universe) authors when the camera's view hands over to it and how its page frames the camera (`properties.zoom`). Its scene has no body and is seen from inside, centred on the star the zoom came from (the Sun on a page opened cold). |
| Rendering resource | A volume, image bank, point field or other prepared content, attached to the object it draws for (`properties.host`). A resource descriptor alone does not publish a destination. An image bank whose light lies on walls around its middle says so (`properties.surrounds`, written by its bake): the world draws the first such bank of an object around a body that stands inside that object, as a nebula's walls around its central star, leaving out the sheets the camera stands on. A galaxy's photograph, one sheet seen from outside, is not drawn around a star inside it. |
| Rendering resource | A volume, image bank, point field or other prepared content, attached to the object it draws for (`properties.host`). A resource descriptor alone does not publish a destination. An image bank whose light lies on walls around its middle says so (`properties.surrounds`, written by its bake): the world draws the first such bank of an object around a body that stands inside that object, as a nebula's walls around its central star, leaving out the sheets the camera stands on and the sheets through the body, and with its sheets and patches taken out of their shared 3D scenes, so the browser neither cuts one along another's plane nor leaves one undrawn. A galaxy's photograph, one sheet seen from outside, is not drawn around a star inside it. |
| Dataset view | A selectable `(objectId, datasetId)` presentation, which may combine several products and published sources. |
| Published source | A scientific work, release or product identified in the source catalogue; a local file hash identifies retained bytes separately. |

Expand All @@ -34,7 +34,10 @@ body from outside, so it takes the view once the camera is outside it: as far fr
the body as that object's centre and its radius. A star of the Large Magellanic Cloud
zooms out into the Cloud, M87* into M87, M87 into the Virgo Cluster. That scene is
then left the way any body's is ([overview-selection.mts](../site/overview-selection.mts),
[inside-view.mts](../site/inside-view.mts)). A system can be inside another: a star
[inside-view.mts](../site/inside-view.mts)). The way back in needs no click where the object's walls
surround a body: its entry names that body (`inner`, [surrounded-body.mts](../site/surrounded-body.mts)), and
a zoom in that reaches the nearest view the object's scene allows goes on into the body's scene, which draws
the walls around it. The Homunculus Nebula zooms in to Eta Carinae, NGC 2392 to HD 59088. A system can be inside another: a star
bound to another, with bodies of its own, hosts its system inside that star's. The
brown dwarfs Epsilon Indi Ba and Bb are Epsilon Indi B, inside Epsilon Indi A's
system, and zooming out of Epsilon Indi B hands the view to that system the same way.
Expand Down
1 change: 1 addition & 0 deletions docs/performance/motion-freezes-membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ These change paint every frame on purpose, and each has a budget:
| Batched star points (`universe/batched-spatial-points.ts`, `universe/point-layer.ts`) | SVG paths of round-capped dots (`M x y h.01` in pixels: a zero-length cap is painted twice by WebKit, and under a group scaled down Chrome on a GPU draws the caps eight times as wide while a turn warps the layer), one per prepared paint color with its alpha byte, all in the world's one svg layer, over the galaxies' pictures and under the markers and labels (on the iPad at the Milky Way a layer per bank left 37 and 77 frames over 20 ms in 690 and 202 MB of layers, three shared layers 25 and 25 and 130 MB, 2026-10-03; zooming out of Earth three layers held 60.5 MB and one holds 40 MB, 2026-10-04); a bank dims by its strokes' opacity, since a group's opacity is an offscreen pass on each repaint; a turn or a zoom warps the layer's last paint, and dots a zoom adds arrive through the pacer; a bank that arrives during a zoom is read, styled and resolved in the pacer's slices and drawn once it is whole (the nearby galaxies' 39,916 points were one frame of 172 to 218 ms on the iPad and are frames of at most 33 ms, 2026-10-03); a camera whose travel moves the dots repaints them as it goes, without the margin a turn warps in, a third of the dots written, which the pause paints back; every repaint (the travelling one, the dots a zoom adds, the pause's exact one) writes the share of the dots the frame can afford: the dots are dealt into eight slots, a paint writes whole slots in turn and leaves the rest as they were, a few frames old, without projecting their points; the share is the field's part of the pacer's budget and starts at one slot after a rest (zooming in from the observable universe, the first frame set 162 KB of dot text and ran the frame loop 26 to 28 ms; it sets 22 KB and the frame is 23 to 24 ms where it was 32 to 39, and the longest frame loop of two zooms out of Earth is 13 to 14 ms where it was 18 to 27, iPad timed by the page, 2026-10-04) | One retained path per color; a color with over 2,000 points has one per slot, and its overlapping dots add up across them | Camera motion changes paint, never DOM shape |
| Earth's lighting frame (`rendering/prepared-material.ts`) | `background-position` on one layer | one layer | Pending an iPad measurement |
| Sky faces (`sky/prepared-sky-runtime.ts`) | `visibility` and the first `background-image` as a face crosses the view edge | the faces in view (at most 3) | A face's layer is about 85 MB at 3x; staging one ahead or keeping one through a spin would multiply memory |
| Sheets drawn around a body (`image-layers/prepared-image-layer-runtime.ts` `order`) | `z-index` of one sheet as the camera passes its plane | one write a passing, on a sheet that is left out at that moment | Around a body the sheets are out of the browser's depth sort, which did not draw the sheet beside the camera at some camera places; their paint order follows the side of the camera each is on |

The footer readout (distance, coordinates, the scale ruler) holds its last reading while the camera moves, and reads
once it stops. Texture levels and the body-wide seam step also wait for the camera to stop. Nothing about them has to
Expand Down
30 changes: 30 additions & 0 deletions packages/astronomy/data/bodies/eta-carinae.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "eta-carinae",
"classification": "star",
"order": 5777,
"physical": {
"name": "Eta Carinae",
"horizonsCode": null,
"meanRadiusKm": 905105700,
"gravitationalParameterKm3PerS2": 0,
"parent": null,
"effectiveTemperatureK": 9400
},
"physicalNotes": "Radius 1301 solar radii from Weigelt et al. (2007), arXiv:astro-ph/0609715, Table 2: the wind's K-band continuum light (2.174 µm, VLTI/AMBER) falls to a tenth of its central brightness at a diameter of 5.15 mas (±4%); half of that at 2,350 pc is 6.05 AU, 1,301 solar radii (https://arxiv.org/abs/astro-ph/0609715): 905,105,700 km at 695,700 km per solar radius. No mass is measured, so GM is 0, the records' unpublished value. Temperature 9400 K from Groh et al. (2012), arXiv:1204.1963, Table 2: the primary's effective temperature where its wind turns opaque (Rosseland optical depth 2/3) (https://arxiv.org/abs/1204.1963). presentationUp: the display axis is a sky-plane convention; the spin axis's position angle on the sky is not measured.",
"star": {
"rightAscensionDegrees": 161.2647407537247,
"declinationDegrees": -59.68445877770452,
"positionEpochJulianYear": 2016,
"distanceParsecs": 2350,
"properMotionRaMasPerYear": 0,
"properMotionDecMasPerYear": 0,
"radialVelocityKmPerS": -25,
"presentationUp": "display-axis",
"sources": {
"position": "Gaia DR3 source 5350358584482202880 (Gaia Collaboration 2023, A&A 674, A1), ICRS at epoch J2016.0, from the archived row src/objects/eta-carinae/source/photometry/gaia-dr3-source.csv; cross-identification HD 93308, HR 4210: https://simbad.cds.unistra.fr/simbad/sim-id?Ident=Gaia+DR3+5350358584482202880",
"distance": "Smith (2006), ApJ 644, 1151: 2350 ± 50 pc from the Homunculus's shape and expansion, the distance its nebula's page stands at (https://arxiv.org/abs/astro-ph/0602464): 2350 pc; Gaia DR3 gives this source no parallax (a two-parameter solution)",
"properMotion": "Gaia DR3 source 5350358584482202880 is a two-parameter solution with no proper motion; zero is assumed (at 2,350 pc, 1 mas/yr would be 11 km/s across the sky)",
"radialVelocity": "SIMBAD's radial velocity for Gaia DR3 5350358584482202880 (quality E), from 1953GCRV..C......0W; Gaia DR3 measures none (https://ui.adsabs.harvard.edu/abs/1953GCRV..C......0W/abstract)"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ test('selection follows baked plane normals and sample density instead of bank n
// Retained DOM stand-in: exercise resource demand without browser image fetching.
class ImageElement {
readonly children: ImageElement[] = [];
parent: ImageElement | null = null;
readonly style: Record<string, string> = {};
readonly dataset: Record<string, string> = {};
className = '';
removed = false;
readonly ownerDocument: ImageDocument;
constructor(ownerDocument: ImageDocument) { this.ownerDocument = ownerDocument; }
appendChild(child: ImageElement) { this.children.push(child); }
insertBefore(child: ImageElement, before: ImageElement) { this.children.splice(this.children.indexOf(before), 0, child); }
remove() { this.removed = true; }
// A child that is appended moves: it leaves the parent it had.
private adopt(child: ImageElement) { child.detach(); child.parent = this; }
private detach() { if (this.parent) this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; }
appendChild(child: ImageElement) { this.adopt(child); this.children.push(child); }
insertBefore(child: ImageElement, before: ImageElement) { this.adopt(child); this.children.splice(this.children.indexOf(before), 0, child); }
remove() { this.detach(); this.removed = true; }
}
class ImageDocument {
readonly elements: ImageElement[] = [];
Expand Down Expand Up @@ -90,7 +94,7 @@ test('image textures are demanded once when their retained axis first contribute
assert.equal(resolveResource.mock.callCount(), 2);
});

test('around a body, a sheet within one sampling step of the camera is left out, by its prepared corners, and draws again farther away', () => {
test('around a body, a sheet within one sampling step of the camera or of the body is left out, by its prepared corners', () => {
const document = new ImageDocument(), host = document.createElement(), before = document.createElement();
host.appendChild(before);
const style = { width: '1px', height: '1px', transform: 'translate3d(0,0,0)', backgroundSize: '1px 1px', backgroundPosition: '0px 0px' };
Expand All @@ -107,7 +111,8 @@ test('around a body, a sheet within one sampling step of the camera is left out,
};
const runtime = mountPreparedCssImageLayers({ host: host as unknown as HTMLElement, before: before as unknown as Element, payload, resolveResource: path => `/prepared/${path}` });
const opacity = () => ['behind', 'middle', 'ahead'].map(id => document.elements.find(element => element.dataset.imageLayerLeaf === id)!.style.opacity ?? '');
const publish = (positionM: readonly [number, number, number], around = true) => runtime.publish({
// The body stands far behind every sheet unless a case says otherwise.
const publish = (positionM: readonly [number, number, number], around: readonly [number, number, number] | false = [0, 0, -90]) => runtime.publish({
world: { referenceFrame: 'fixture', epochJdTt: 123, pose: { positionM, orientationXyzw: [0, 0, 0, 1] } }, viewport: { focalPixels: 600, principalOffsetPixels: [0, 0] } }, around);
publish([0, 0, .2], false);
assert.deepEqual(opacity(), ['', '', ''], 'as its page\'s own subject a bank draws every sheet');
Expand All @@ -119,6 +124,20 @@ test('around a body, a sheet within one sampling step of the camera is left out,
assert.deepEqual(opacity(), ['', '', ''], 'a sheet beside the camera, past its edge, draws');
publish([0, 0, .2]); publish([0, 0, .2], false);
assert.deepEqual(opacity(), ['', '', ''], 'left-out sheets draw again when the bank is its page\'s subject');
publish([0, 0, 50], [0, 0, 0]);
assert.equal(opacity()[1], '0', 'the sheet through the body is left out however far the camera is: the body is drawn in its place');
publish([0, 0, 50]);
assert.equal(opacity()[1], '', 'and draws again around a body that stands elsewhere');
// Around a body the sheets are out of their scene, under one flat element, and paint farthest first on each side of
// the camera; back as the page's subject they are in their scene again, sorted by the browser.
const over = () => ['behind', 'middle', 'ahead'].map(id => document.elements.find(element => element.dataset.imageLayerLeaf === id)!.style.zIndex ?? '');
assert.deepEqual(over(), ['0', '1', '2'], 'in front of them all, the nearest sheet is painted last');
publish([0, 0, .5]);
assert.deepEqual(over(), ['0', '1', '0'], 'between two sheets, each side paints toward the camera');
publish([0, 0, -5]);
assert.deepEqual(over(), ['2', '1', '0'], 'behind them all, the order is the other way');
publish([0, 0, -5], false);
assert.deepEqual(over(), ['', '', ''], 'in its own scene again a sheet has no paint order of its own');
});

test('a stack mounts a camera for each of its scenes, and a stack without leaves is not mounted and takes no part', () => {
Expand Down Expand Up @@ -154,9 +173,30 @@ test('a stack mounts a camera for each of its scenes, and a stack without leaves
assert.deepEqual(named('css-volume-camera').map(camera => camera.style.perspective), ['600px', '600px']);
assert.deepEqual(resolveResource.mock.calls.map(call => call.arguments), [['flat.png'], ['atlas.png']]);
assert.equal(leaf('a').style.backgroundImage, leaf('b').style.backgroundImage);
// Around a body the patches leave their runs' scenes for one flat element that carries the perspective, in the stack's
// order, each with the camera's transform before its own; as the page's subject again they are back in their runs.
const world = { referenceFrame: 'fixture', epochJdTt: 123, pose: { positionM: [0, 0, 10] as const, orientationXyzw: [0, 0, 0, 1] as const } };
const projection = named('css-volume-projection')[0]!, cameras = named('css-volume-camera'), ids = (parent: ImageElement) => parent.children.map(element => element.dataset.imageLayerLeaf);
const viewport = { focalPixels: 600, principalOffsetPixels: [3, 4] as const };
runtime.publish({ world, viewport });
const camera = first!.style.transform;
runtime.publish({ world, viewport }, [0, 0, -90]);
assert.equal(projection.children.length, 1);
const flat = projection.children[0]!;
assert.deepEqual([flat.className, flat.style.transformStyle, flat.style.perspective, flat.style.perspectiveOrigin], ['css-volume-mesh', 'flat', '600px', '3px 4px']);
assert.deepEqual(ids(flat), ['flat', 'a', 'b']);
assert.ok(flat.children.every(element => element.style.transform === `${camera} ${style.transform}`), 'each patch carries the camera before its own transform');
runtime.publish({ world: { ...world, pose: { ...world.pose, positionM: [0, 0, 12] } }, viewport }, [0, 0, -90]);
assert.ok(flat.children.every(element => element.style.transform !== `${camera} ${style.transform}` && element.style.transform.endsWith(` ${style.transform}`)), 'and follows it');
runtime.publish({ world, viewport });
assert.deepEqual(projection.children, cameras);
assert.deepEqual(named('css-volume-mesh').filter(mesh => mesh !== flat).map(ids), [['flat'], ['a', 'b']]);
assert.ok([leaf('flat'), leaf('a'), leaf('b')].every(element => element.style.transform === style.transform));
assert.ok(first!.style.transform === camera && second!.style.transform === camera && cameras.every(each => each.style.perspective === '600px'));
// Seen edge-on the stack is still the only one that draws: it keeps the whole weight.
publish([Math.SQRT1_2, 0, 0, Math.SQRT1_2]);
assert.equal(named('css-volume-projection')[0]!.style.opacity, '0.999');
// A stack of patches is drawn at 1, not under the ceiling a stack of sheets is held to.
assert.equal(named('css-volume-projection')[0]!.style.opacity, '1');
assert.equal(named('css-volume-projection')[0]!.style.visibility, 'visible');
assert.deepEqual(imageLayerAxisWeights([Math.SQRT1_2, 0, 0, Math.SQRT1_2], views.filter(view => view.axis === 'z')), { z: 1 });
});
Loading
Loading