Author SHA1 Message Date
SenrokaiandClaude Opus 5 7a112e4bb3 Answer the review: HYG's own last-resort name is a designation too
Junie: a HYG star that fell all the way through the ETL's naming chain --
no proper name, Bayer, Flamsteed, HD, Gliese or HIP -- is called "HYG <id>",
and with `source: 'hyg'` the predicate was looking for a lower-case "hyg "
prefix and calling it named. None in the current catalogue, but the path is
in `tools/etl/fetchStars.ts` and a refresh could walk it.

Fixed in the table rather than in the predicate: `hyg: 'HYG'` next to
`gaia: 'Gaia DR3'`, so the encoder, the decoder and the predicate all read the
one rule. The sourceless case reads the same entry instead of repeating it.

npm test 609/609, build and ETL typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 20:16:11 +02:00
SenrokaiandClaude Opus 5 f14e252b19 Name the neighbours that have a name, before the ones that only have a number
The neighbour ring exists to say where you are. Since the catalogue refresh it
has been spending one of its four places in Sol on "Gaia DR3 5853498713190525696"
-- a nineteen-digit survey id for the star printed beside it as Proxima
Centauri, the same star twice -- and that duplicate row pushed Barnard's Star
off the ring altogether. 91.9% of the refreshed catalogue is named that way.

Named stars now come first, and survey designations fill in only where fewer
than four named ones are in reach. The line between the two is the one the
catalogue format already draws: a name is a designation when it is what the
star's source would generate for it. Judged by the prefix rather than by
rebuilding "prefix id" from the row, because the number after "Gaia DR3" is the
survey's own id, which the 32-bit row id cannot hold -- a round trip through
the id would have called every one of those stars named.

The preference lives on the index as `nearestPreferring`: the preferred pass
exhausts the search before the fill runs, so a named star is never outranked by
a nearer unnamed one. That is the whole point of asking.

The end-to-end spec names Barnard's Star again, on purpose. The four nearest
named stars to the Sun are a fact about space, not about which catalogue was
refreshed last, and without this change that is exactly the label that
vanished -- checked by running the spec with the preference stashed: it fails
on that line, and passes with it back.

npm test 609/609, npx playwright test 16/16 under CI=true --workers=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 20:06:04 +02:00
Senrokai 14f3fe8e66 Merge pull request #13 from avalon-vanguard/fix/catalogue-refresh-e2e
Follow the catalogue rather than a star it used to rank fourth
2026-08-27 19:50:09 +02:00
SenrokaiandClaude Opus 5 82d70ce871 Follow the catalogue rather than a star it used to rank fourth
The scheduled refresh on 24 August took the star catalogue from 68 388 rows to
447 410 and from 820 kB to 5.4 MB. No branch in the seven-PR chain had ever seen
it -- they were all cut before -- so nothing tested the combination until the
chain landed on main. Two things broke there.

The neighbour ring named Barnard's Star, and the spec clicked it by name. The
refresh is a Gaia DR3 merge that carries the same physical star twice: Proxima
Centauri at 1.296 pc and Gaia DR3 5853498713190525696 at 1.302 pc are one star,
as are Barnard's Star at 1.824 and Gaia DR3 4472832130942575872 at 1.828. The
extra row pushed Barnard's from fourth-nearest to fifth, off a ring that shows
four. The ring is doing exactly what it says; the spec was asserting the
catalogue's contents. It now reads whichever star the ring names and follows
that one, so the next refresh cannot demote it.

Four other assertions were timing out at Playwright's default five seconds --
not because anything was wrong, but because every one of these tests boots that
catalogue and the suite boots several at once on a software rasterizer. The
heavy waits already carried explicit longer timeouts; the default now matches.
Capping the worker count would have worked too, and would have cost every run
the time whether or not the machine needed it.

npm test 607/607, npx playwright test 16/16 at the default worker count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fcUfL82nvyh9VebX1Fz6w
2026-08-27 19:39:26 +02:00
Senrokai 42f304b30b Merge pull request #12 from avalon-vanguard/feat/hud-about
Say what is written about a world, when asked
2026-08-27 19:23:34 +02:00
Senrokai 4e2575674d Merge pull request #11 from avalon-vanguard/feat/hud-2d
Draw it flat: an orthographic plan view
2026-08-27 19:23:18 +02:00
Senrokai 3612d722c5 Merge pull request #10 from avalon-vanguard/feat/hud-bookmarks
Keep a place, and come back to it
2026-08-27 19:23:01 +02:00
Senrokai 6c5cc1ca19 Merge pull request #9 from avalon-vanguard/feat/hud-routes
Route between stars, through the crossings a chosen range allows
2026-08-27 19:22:44 +02:00
Senrokai 911d876f55 Merge pull request #7 from avalon-vanguard/feat/hud-neighbours
Name the neighbours, from inside the system
2026-08-27 19:22:28 +02:00
Senrokai 0c190539fe Merge pull request #6 from avalon-vanguard/feat/hud-scene
Point at what is selected: arcs, a leader to the card, rings on the systems
2026-08-27 19:22:12 +02:00
Senrokai c11087cb8f Merge pull request #8 from avalon-vanguard/chore/junie-align
Pin the reviewer, bound its run, and wake it when a pull request reopens
2026-08-27 19:19:32 +02:00
Senrokai ee02147a78 Merge pull request #5 from avalon-vanguard/feat/hud-dock
Dock the HUD: every tool and readout on one rail along the bottom
2026-08-27 19:19:19 +02:00
github-actions[bot] 46cb923849 Refresh the astronomical catalogues
Scheduled re-run of the ETL against the live archives. Gated on the unit suite and a production build in this same run, because a GITHUB_TOKEN push triggers no CI of its own.
2026-08-24 06:05:56 +00:00
Senrokai 59c2d8483e Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:42:40 +02:00
SenrokaiandClaude Fable 5 e808f50faa Answer the review: the plan view clipped a system, and pulled its neighbours inward
Two more findings against this projection, both of the same shape as the last
two: something written to the camera that happens to be live, where the plan
view derives from the other one.

The system's own depth range — a near plane a five-hundredth of an
astronomical unit out, a far plane twenty thousand — was set on the active
camera. Entering a system with the plan view already on therefore wrote it to
a camera that re-derives near and far from the perspective one every frame, so
the range never applied and the system clipped. All three unit-space depth
writes go to the perspective camera now, which is the one they are reasoned in.

And the ring of neighbour names collapsed toward the middle of the frame. Its
placement unprojected a point on the ring, treated the offset from the camera
as a direction, and stepped a fixed distance along it — which is a perspective
construction. A parallel projection has no vanishing point to step towards:
every ray through the frame is the view direction, so normalising threw the
sideways part away. Measured before and after, from inside Sol: the two names
sat 319 and 335 pixels out under perspective, 104 and gone under the plan, and
323 and 335 with the unprojected point used as what it already is.

Verified: build clean, 596/596 unit, 16/16 end-to-end on the branch this merges
into, and the ring measured on both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:42:37 +02:00
SenrokaiandClaude Fable 5 7c16ad8627 Answer the review: a null byte in the source, and a language that may not be there
Two findings, and the first is the kind a person does not catch.

The article cache keyed on `${name}\0${qualifier}` — with the null byte written
into the file rather than escaped into the string. Git calls a file with one of
those binary and stops diffing it, and every editor between here and a reader
does something different with it. The separator was the right idea, because a
name can contain a space and `("Kepler-22 b", none)` and `("Kepler-22", "b")`
are different questions; it just has to be spelled `\0`.

And `navigator.language` is optional in the DOM's own typings and missing in
some embedded engines, where splitting it would have thrown on the first press
of About rather than falling back to English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:41:38 +02:00
Senrokai 971aa66303 Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:20:03 +02:00
SenrokaiandClaude Fable 5 8ceea02875 Wait for the boot as long as the boot takes
The specs added over this series each opened with an untimed check that the
canvas was there, which gives it Playwright's default five seconds. That is
the one wait in this suite that cannot be made faster: eight workers share a
software rasterizer, and what they are all waiting on is the same first paint.
Under a full parallel run five seconds is a coin toss, and it came up wrong on
the route-plotting spec.

Thirty, like every other wait in here that crosses a camera flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:20:00 +02:00
Senrokai 005ba07750 Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 15:13:28 +02:00
SenrokaiandClaude Fable 5 b05324337c Answer the review: the plan view was flat against the wrong plane
Two findings, both right, and the second was the feature not doing what its own
commit message said.

The depth range stopped being updated under a plan view. It is worked out in
perspective terms — near from the distance, far from eight times it — and the
orthographic camera derives its own range from that one, so skipping the
calculation left the far plane wherever it had been when the projection
changed. Flying out to the whole Galaxy from a plan view clipped away most of
it. The range is written to the perspective camera whichever one is live now,
and the plan view goes on deriving from it every frame.

And the galaxy-scale plan looked down the celestial pole. "The plane the
current scale is read against" is this system's orbital plane inside a system,
and the galactic plane outside one — but the fallback was the scene's own z,
which is the Earth's rotation axis. The normal is the north galactic pole now,
and up is the direction of the galactic centre, so a plan of the Galaxy is
laid out the way the model that draws it is described. The arms are face-on.

Verified: build clean, 596/596 unit, 16/16 end-to-end, and the Milky Way
photographed flat from 28.3 kpc with nothing clipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:13:24 +02:00
Senrokai 687bc3b01c Merge branch 'feat/hud-bookmarks' into feat/hud-2d 2026-08-21 15:08:43 +02:00
SenrokaiandClaude Fable 5 d397aaa7e0 Let the Solar System be kept
The Sun's catalogue id is 0, and the readout's keep control was shown by
`@if (keepableStarId(); as starId)` — which reads zero as "there is no star
here". Of the six hundred and thirty-four systems the map can be inside, the
one nobody could keep was Sol.

Checked against null now, with a test that keeps star zero, because this is the
sort of thing that comes back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 15:08:40 +02:00
Senrokai 18e9d85a1b Merge branch 'feat/hud-2d' into feat/hud-about 2026-08-21 14:56:03 +02:00
SenrokaiandClaude Fable 5 b2cb307b60 Merge the review fixes, and take Junie's on the hit radius with them
Carries the shared reference-viewport module and the cached card lookup up from
the branch they were reviewed on, and answers the one comment left against this
one.

The orthographic branch of the star field's hit test multiplied the angular size
by the frustum's half-height and then divided the result by that same
half-height. The two cancel: `setProjection` had already sized the sprite as
`angular * halfHeight / tan(REFERENCE_FOV/2)`, so dividing back out by the
half-height leaves the reference field of view and nothing else. Both
projections are one formula over a different angle now — which is also one
fewer division by a number that is zero if the frustum ever degenerates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:55:59 +02:00
Senrokai 50c0351870 Merge branch 'feat/hud-routes' into feat/hud-bookmarks 2026-08-21 14:52:35 +02:00
Senrokai a381c02cd0 Merge branch 'feat/hud-neighbours' into feat/hud-routes 2026-08-21 14:51:48 +02:00
Senrokai f42337c841 Merge branch 'feat/hud-scene' into feat/hud-neighbours
# Conflicts:
#	src/app/features/galaxy-system/galaxy-system-scene.component.ts
2026-08-21 14:51:12 +02:00
SenrokaiandClaude Fable 5 e853fe312e Answer the review: one reference viewport, one lookup for the card
Two of the three comments were worth taking.

The star field and the rings drawn over it each carried their own copy of the
reference viewport and field of view the angular sizes are figured against.
They agree today, and nothing would have told anyone when they stopped: a ring
would just sit a little wide of its star at some window sizes. One module now
holds the three constants and says what they are for.

The leader line to the object card looked the card's panel up by selector on
every frame it was drawn. The host element is stable and the panel inside it
only changes when a different body is selected, so the lookup is derived once
per change instead of sixty times a second.

Left alone: replacing `positions.set([x, y, z], i * 3)` with an index-by-index
loop to avoid a temporary array per host. It runs once, over six hundred and
thirty-four stars, at bootstrap, and the version with the temporary reads
better than the version without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:50:19 +02:00
SenrokaiandClaude Fable 5 bd37bb8b96 Say what is written about a world, when asked
Every figure this map shows is a measurement or something derived from one,
and it says which. What it could not do was tell you what a place is — a
radius and an eccentricity do not say that Titan is the only moon with a dense
atmosphere. An About control on a body's panel now fetches the Wikipedia lead,
and it is labelled as what it is: prose from another site, credited and linked
back, below the line where this map's own figures end.

Only on the press. Nothing is fetched while a body loads, and nothing is
fetched twice.

Three things the encyclopedia does that had to be handled, all found by asking
it rather than by guessing:

It redirects, generously — "Proxima Cen b" lands on "Proxima Centauri b" and
"Kepler-22 b" on "Kepler-22b" — so the catalogue's own names can be sent as
they are, with no mapping table to maintain.

It disambiguates. "Titan" is a list of everything called Titan, and so are
"Mercury" and "Io". Wikipedia says so in the response, and this app happens to
know the kind, so a disambiguation is retried as "Titan (moon)". Only in
English: every wiki words its own qualifiers, and inventing a translation of
one would be inventing an article title.

And it rate-limits, which it did to me while I was checking the above. A
refusal to answer is not an empty answer, so the two are separate outcomes:
"Wikipedia has no article on this" is about the world, "Wikipedia could not be
reached" is about this minute — and only the first is remembered, so a second
press is allowed to try again.

The extract is capped and scrollable. A lead can run a dozen lines, and this
panel is anchored to the top of a viewport that may be shorter than the prose.

Verified: build clean, 606/606 unit including twelve for the lookup chain, 16/16
end-to-end including three that stub the encyclopedia — this suite tests the
panel, not Wikipedia — design detector clean, and the real thing exercised by
hand against Earth, Titan and Proxima Cen b at three viewport sizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-21 14:40:51 +02:00
SenrokaiandClaude Fable 5 d9bd913458 Draw it flat: an orthographic plan view
A perspective camera leans everything away from the centre of the frame. In a
system that means the orbits are ellipses whose shape depends on where they
happen to sit on screen, so two planets on the same circular orbit do not look
like they are on the same circle. Plan view, in the Display panel, swaps the
projection for a parallel one and swings to look down the plane the current
scale is read against — the galactic plane out in the field, this system's own
orbital plane inside one. Circles are circles again, wherever they are.

Both halves are the feature and neither alone is it. The projection is what
makes the shape honest; the swing is what makes it worth looking at. Orbiting
still works afterwards, so a plan is where the view starts rather than a cage.

The engine now holds both cameras and keeps them in step, rather than making
one on demand: a camera that exists only while it is being looked through is a
camera whose pose is always one swap out of date. The orthographic frustum is
derived, never stored — it is the perspective camera's own frustum at the
current orbit distance, made parallel — which is why the camera flights work
through it untouched. They move the camera; the frame follows.

Three things had to be taught that a projection had changed.

Sprites. three.js turns an angular size into a world size only when it is
compiling against a perspective camera (SpriteNodeMaterial: `camera
.isPerspectiveCamera && sizeAttenuation === false`). Under a parallel one that
step is silently skipped and every star in the field collapses to a thousandth
of a parsec. The same arithmetic is now done in the node graph behind a
uniform, so one material serves both cameras without being recompiled — and
picking follows it exactly, since a star has to be clickable where it is drawn.

Depth. A parallel camera does not back away as its frame grows, so at galactic
framing the backdrop shell and half the Milky Way sit behind its own plane. Its
depth range is symmetric about it instead, which a linear depth buffer can
afford and a perspective one could not.

And distance. Half the map was keyed on how far back the camera was pulled —
the scale ladder, the crossfade, the label radius, the range readout — which
under a parallel projection says nothing at all, because the frustum sets the
extent. They all read one honest equivalent now: the distance a perspective
camera would need to frame the same thing.

Two defects found while verifying, both mine, both from this change:

The per-frame work was computed against the camera captured at bootstrap while
the renderer drew through the other one, so after a swap every label was
projected by a camera nobody was looking through.

And the zoom limits were derived from the orbit limits, which are in whichever
unit space the view is in. Reading them on the frame the scene swaps parsecs
for astronomical units pinned the zoom at the ratio between the two, and
leaving a system landed the view three kiloparsecs out. Zoom is a plain
multiplier on a frame the distance already sets, so it is bounded by a factor.

Verified: build clean, 595/595 unit including a new spec for the projection
arithmetic, 13/13 end-to-end including two that flatten a system and check the
ladder still knows how far out it is, design detector clean, screenshots of
both scales in both projections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 20:11:31 +02:00
SenrokaiandClaude Fable 5 307fd41be8 Keep a place, and come back to it
Restores the commit reverted off the routing branch, which is where it was
committed by mistake. The change is unmodified; only its branch is.

The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has been
kept, and choosing one goes there — a star by flying into its system, a body by
opening its page.

Local storage, not an account. This map asks nobody to sign in, and a list of
stars somebody liked is not worth a server. Every read of that store is
defensive, because it is a string a person can edit, another tab can write, and
a browser can refuse to hand over at all: a bad entry is skipped rather than
losing the rest, duplicates are collapsed since two entries for one place would
each toggle the other's control, the list is bounded so a hand-edited store
cannot decide how much this renders, and where storage is denied outright the
bookmarks still work for the visit — they just do not outlive it.

The name is stored alongside the id rather than looked up, so the list reads
before the catalogues have loaded, and a bookmark to something a later
catalogue no longer holds still says what it was instead of decaying into a
bare number.

The tab is offered even when it is empty, and says what the mark does: a tab
that appears only once you have already found the feature is a tab that never
taught anyone anything.

Choosing a kept place hands the panel back to the readout, the same move as
choosing a search result and for the same reason. That behaviour is what the
end-to-end spec caught missing — the readout it asserted on did not exist,
because the panel just used was still covering it.

Verified: build clean, 587/587 unit, 11/11 end-to-end, design detector clean,
screenshots at 1440x900 and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 19:12:58 +02:00
Senrokai 9cdd8f9388 Revert "Keep a place, and come back to it"
This reverts commit bd3a5a4. The bookmarks work was committed onto this branch
by mistake — it belongs to its own pull request, and it has one, branched from
this branch's own tip. Reverting rather than rewinding because the branch is
published and a pull request is open against it: the diff this pull request
shows is what matters, and after this it shows the routing change alone.
2026-08-20 19:10:24 +02:00
SenrokaiandClaude Fable 5 bd3a5a48c9 Keep a place, and come back to it
The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has
been kept, and choosing one goes there — a star by flying into its system, a
body by opening its page.

Local storage, not an account. This map asks nobody to sign in, and a list of
stars somebody liked is not worth a server. Every read of that store is
defensive, because it is a string a person can edit, another tab can write,
and a browser can refuse to hand over at all: a bad entry is skipped rather
than losing the rest, duplicates are collapsed since two entries for one place
would each toggle the other's control, the list is bounded so a hand-edited
store cannot decide how much this renders, and where storage is denied
outright the bookmarks still work for the visit — they just do not outlive it.

The name is stored alongside the id rather than looked up. That way the list
reads before the catalogues have loaded, and a bookmark to something a later
catalogue no longer holds still says what it was instead of decaying into a
bare number.

The tab is offered even when it is empty, and says what the mark does. A tab
that appears only once you have already found the feature is a tab that never
taught anyone anything.

Choosing a kept place hands the panel back to the readout, which is the same
move as choosing a search result and for the same reason: the panel has done
its job and the thing to look at is now the scene. That behaviour is what the
end-to-end spec caught missing — the readout it asserted on did not exist,
because the panel that had just been used was still covering it.

Verified: build clean, 587/587 unit, 11/11 end-to-end including a spec that
keeps Earth, leaves the page, comes back to it from the list and forgets it,
and one that keeps Proxima Centauri, flies out to the field and flies back in
by what was kept. Design detector clean, screenshots at 1440x900 and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 19:07:46 +02:00
SenrokaiandClaude Fable 5 68a919bd84 Route between stars, through the crossings a chosen range allows
The map could say where a star is and what is near it, and nothing about
getting from one to another. This adds the question and the answer: pick a
departure and a destination, choose how far a single crossing may be, and get
the chain — how many jumps, how far in total, and every star on the way, each
one a step you can fly to.

A jump link is not a feature of space. There are no corridors out there; a
link is a question asked of the catalogue, which is why the range is the
user's control rather than a constant. Two facts about that catalogue decide
what the answers look like, and both are stated in the code because they read
as defects otherwise. It is magnitude-limited, so it is dense around the Sun
and thins with distance — within 50 pc a 3 pc range links 99% of it into one
piece, while over the whole 250 pc reach the same range leaves most stars
alone. And a gap in it is a gap in what has been catalogued, not in what is
there.

That is why "no route" is not the end of the answer. Where no chain exists at
the range asked for, the panel says which range would open one — the chain
whose longest hop is as short as possible, found by the same search with the
cost of arriving somewhere being the worst hop taken rather than the sum — and
offers that number as a control to accept.

Departure defaults to wherever the view already is, so one field is usually
enough. Sol to Vega at 3 pc: four jumps, 10 pc, by way of Barnard's Star,
Struve 2398 B and HD 155876. Narrow it to 0.8 pc and it says 2.26 would reach.

The graph is drawn as one buffer of line segments and the route as a second,
brighter one over it, with the graph stepping back while a route is up: near
the Sun the links are a haze, and a thread through a bright cloud is not a
thread. Both fade out with the local layer, since from outside the Galaxy the
graph is a smear.

Two measurements shaped this. Asking the index for each star's neighbours in
turn — sixty-eight thousand sorted lists, thrown away — took eight seconds; the
grid now walks its own cells once and pairs them, which takes a quarter of one.
And the range control emits per pixel dragged, so the rebuild waits for the
hand to settle.

Three defects fixed on the way, all older than the routing:

hud-acquire animated with fill-mode `both`, which leaves its closing keyframe
applied for good — and that keyframe carries a clip-path. Every panel wearing
it has been clipping its own box ever since, so anything that had to escape
one was cut away and could not even be clicked. Nothing had needed to escape
until this panel's dropdown opened upward.

The routing fields returned nothing when typed into before the catalogue
finished loading, and stayed nothing until the next keystroke. The options are
derived from the query and the index together now, so they appear when the
second of the two arrives, whichever that is.

And a link was `3-7` walking one way and `7-3` walking the other, which is two
links to anything comparing them.

Verified: build clean, 571/571 unit, 9/9 end-to-end including two new specs —
one plotting Sol to Sirius, one narrowing the range until there is no route and
accepting the one it names — design detector clean, screenshots at 1440x900
and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 18:39:22 +02:00
SenrokaiandClaude Fable 5 c1e7f6363d Pin the reviewer, bound its run, and wake it when a pull request reopens
Junie has been reviewing pull requests here since the key was added, and eight
of the last eight runs succeeded. What it was not, was configured like the
sibling repositories, and three of those differences are worth closing.

The action was referenced as `@v1`. It is the only third-party action in this
repository and the only one handed a repository secret, so its definition
should not be able to change under us. `v1` and `v1.7.5` resolve to the same
commit today — the pin is not about which code runs now, it is about who gets
to decide that later.

There was no timeout. A run that goes wrong hangs rather than stops, and the
pull request shows a pending check until Actions gives up on its own six hours
later. Forty-five minutes, not the thirty the siblings use: the longest review
this repository has actually had ran thirty-five, on the largest diff so far,
and a ceiling that cuts a successful review short is worse than none.

And a pull request closed and reopened had had no review since it was closed.

Left alone deliberately: the absent-key step, which says so in the run summary
instead of failing a pull request for a reason that has nothing to do with its
code, and the lack of a `branches:` filter — work here stacks feature onto
feature, and filtering on main would skip every pull request in a chain but the
last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:59:15 +02:00
SenrokaiandClaude Fable 5 a6b30e84f0 Answer the review: a gradient over the names, a zero-distance neighbour, two lost docblocks
Three findings out of forty-one raised survived being argued against, and all
three were real.

The vignette was painting over the label layer. It lived in the HUD component,
which sits after the label host in the same stack with neither carrying a
z-index, so paint order was tree order and a decorative gradient was laid over
the names — worst at the edge of the frame, which is precisely where the
neighbour ring is. Composited, the ghost's distance line fell to 4.02:1, under
the floor the CSS next to it claims. The gradient is scene chrome rather than
HUD chrome, so it moves down between the canvas and the labels; the authored
contrast then holds as written, and every label near the edge — planets
included — is read against the sky rather than through a wash of void.

A binary companion printed "0.00 pc". A catalogue holds a close pair as two
rows at one position, so the nearest neighbour to one of them is the other,
zero away — the exact string the readout deliberately suppresses for a star's
distance from itself. The query now asks wide and drops any separation that
prints as no separation, compared through the formatter rather than against a
hand-picked epsilon so the rule survives the formatter changing.

And the edit that added all this had been spliced between updateSystemLabels'
docblock and its body, leaving a paragraph about labelling planets sitting
over the neighbour resolution and the method it described with nothing, plus
two divergent copies of the same fourteen lines seventy-five apart. Both are
back where they belong.

Verified: build clean, 558/558 unit, 7/7 end-to-end, design detector clean,
screenshots re-read in Sol and Proxima Centauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:40:10 +02:00
SenrokaiandClaude Fable 5 44c6a1f15e Name the neighbours, from inside the system
A system view could say everything about the star it was inside and nothing
about where that star was. The four nearest catalogue stars are now named
around the edge of it, each with its distance, each a button that flies there
— so a chain of neighbours can be walked without pulling back out to the field
between hops.

These are bearings, not sky positions, and that is the one deliberate
compromise here. A true direction was tried first and does not work: at this
field of view the visible cone is about 30 degrees, so on average one
neighbour in fifteen falls inside the frame — measured, not guessed, at one
label of four in Sol and none at all after a small orbit. What survives the
ring is the half of the direction a viewer can act on, which way to turn to
face it, and the ring reads as instrument rather than as scene because it sits
at a fixed radius. Real distance was never an option: Proxima is 268 000 AU
from Sol, thirteen far planes out, so the distance goes on the type line.

Proximity is answered by a new pure module rather than by a scan. A uniform
grid over the catalogue answers both "the k nearest to this star" and "every
star within n parsecs", the second being what the jump-link graph in the next
PR is built from — one scan per node, and the quadratic would show. Its spec
pins the grid against a brute-force sweep of a pseudo-random cloud, because a
spatial index is an optimisation and never a different answer.

Where the ring meets the HUD, the HUD wins: placement is given the boxes the
readout, the strip and the object card occupy, and slides a name along the
ring until it clears them, or drops it rather than print it half hidden. That
rule is a pure function with its own spec.

Four defects found while verifying this, three of them older than it:

The dock's flex column was pointer-events-auto and as wide as its strip, so
an invisible band above the strip swallowed every click in it — including,
but not only, a neighbour's. The column is transparent now and each surface
opts back in.

The ring was sized against the frame's height alone, which on a phone held
upright put it a viewport and a half wide: no neighbour was reachable on any
portrait screen. It is sized against the shorter side.

Picking a search result reopened the readout, which on a narrow viewport is a
sheet over most of the scene — reopening it onto whatever was just flown to.
Below sm it now folds away.

A selectable label's two lines are adjacent spans, so it announced as
"Sirius2.64 pc"; it carries an explicit label saying what it does.

Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that
flies Sol to Barnard's Star by its label, design detector clean, screenshots
at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path
walked: both names are in the tab order, focusable, with the accent ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 17:06:25 +02:00
SenrokaiandClaude Fable 5 7591bcc0ea Point at what is selected: arcs, a leader to the card, rings on the systems
Four things the scene did not yet say, all about where to look.

Selection. Hovering or pinning a body raised its card in the corner, but
nothing in the scene said which point the card was about. Two hairline arcs
now bracket the body — the one mark borrowed from the ARK's control disc — and
a leader runs from their rim to the card's near edge, in screen space, once
per frame, because the body moves and the card's height depends on its
content. The selected body's own label swaps to the left of its point, since
the leader leaves the right and would otherwise cross the text.

Labels choose a side. Right by default; left when the text would run off the
right of the view, or into the reach of a label already placed to the right,
and never left when that would run off the left. The overlay hangs the label's
near edge on the point either way, so the hairline still meets the star.

Rings on the systems. A faint accent ring on every star known to host
planets — the one binary fact about a point of light worth reading at a
glance from the neighbourhood, since it is the one thing that says "there is
somewhere to go here". Drawn the way the star field draws stars, as
unattenuated instanced sprites with the ring a band of the quad's own uv, so
they sit on the field's points at any zoom; a first cut as three.js Points
rendered nothing at all under the WebGPU renderer. 634 of them are a lot at
the overview, so they are faint, small, fade with the local layer, and have
their own toggle — Systems — in the dock's Display tab.

Verified: build clean, 537/537 unit, 6/6 end-to-end, design detector clean,
screenshots at the overview, at 90 pc, and in Sol and Proxima with a body
hovered and pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-19 02:38:06 +02:00
SenrokaiandClaude Fable 5 8b507427d8 Answer the review: sky toggles in system view too, one media query, idempotent openSearch
Three findings from the automated review, all confirmed before acting.

The Sky toggle did nothing in system view. Its write lived only in the galaxy
crossfade, which the tick parks while the system group is up — and the sky is
still on screen there. It is now also written on toggle, in applyDisplay. The
suggested form of that fix crashed the scene on mount: the effect that calls
applyDisplay fires once at construction, before the engine has a scene, and
getScene() throws — the dock rendered no tabs at all. Guarded on
engine.isInitialized; verified with a screenshot of Proxima with the sky off.

isWideViewport built a fresh MediaQueryList on every document pointer-down;
one module-level query is read instead. And openSearch in the e2e support
clicked the Search tab unconditionally, which would fold it closed if a test
ever called it while already open; it now checks aria-selected first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-18 21:35:52 +02:00
SenrokaiandClaude Fable 5 4a1cc5240a Dock the HUD: every tool and readout on one rail along the bottom
The overlay had grown by accretion: a search box floating top-centre, a
readout panel bottom-left, a range readout bottom-right, and nothing that
said these were parts of one instrument. This puts them on one rail across
the bottom of the viewport — the dock — with a tab strip pinned to the bottom
edge and whichever panel is open growing upward from it. The top of the
screen keeps only the scale ladder and the nameplate, so the map itself is
what fills the frame.

Three tabs. SEARCH is the old search, with its field pinned to the bottom of
the panel and the results growing upward above it, so the thing being typed
into never moves while the list grows. READOUT is the old bottom-left panel.
DISPLAY is new: five layer toggles — labels, orbits, grid, deep sky, sky —
each a real scene object switched by visibility, except the ones the galaxy
crossfade already rewrites every frame, whose toggles fold into that
crossfade instead of fighting it. The range readout sits on the strip itself,
so it is readable whatever is open.

Behaviour worth stating: choosing a search result hands the panel straight
back to the readout, since the thing to look at is now the scene. `/` opens
the search from anywhere. Below `sm` the dock is the strip alone; a tap opens
a panel as a sheet, a tap on the scene folds it away. The body-detail page
gets the same dock with only the search — the info panel is its reading.

Two things found on the way. CSS2DRenderer gives every label its own
z-index for depth order, and the label host created no stacking context, so
labels painted over every HUD panel; `isolate` on the host keeps them under.
And starmap-hud's readout tests were really tests of the panel that moved,
so they moved with it.

Verified: build clean, 535/535 unit, 6/6 end-to-end, design detector clean,
screenshots at 1440×900 and 390×844 across galaxy, galactic, system,
body-detail, all three tabs and the layers-off state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-18 20:57:10 +02:00
54 changed files with 4241 additions and 223 deletions
+6 -1
View File
@@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner
{ "name": "Earth", "kind": "Planet" }, { "name": "Earth", "kind": "Planet" },
{ "name": "Mercury", "kind": "Planet" }, { "name": "Mercury", "kind": "Planet" },
{ "name": "Venus", "kind": "Planet" } { "name": "Venus", "kind": "Planet" }
],
"neighbours": [
{ "name": "Proxima Centauri", "distance": "1.30 pc" },
{ "name": "Barnard's Star", "distance": "1.82 pc" }
] ]
} }
``` ```
`probe` accepts the same four view names. Labels are read from the CSS2D layer `probe` accepts the same four view names. Labels are read from the CSS2D layer
(`.map-label` > `.map-label-name` + `.map-label-kind`). (`.map-label` > `.map-label-name` + `.map-label-kind`). `neighbours` is the ring of nearby
stars named from inside a system (`.map-label--ghost`); it is absent where there are none.
## Run (human path) ## Run (human path)
+9 -3
View File
@@ -211,21 +211,27 @@ async function probe(page, view) {
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind. // Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind.
const labels = await page.locator('.map-label').evaluateAll((nodes) => // Neighbouring stars named from inside a system carry .map-label--ghost; they are reported
// apart from the system's own bodies, since they are not in the system being probed.
const all = await page.locator('.map-label').evaluateAll((nodes) =>
nodes nodes
.filter((n) => n.offsetParent !== null) .filter((n) => n.offsetParent !== null)
.map((n) => ({ .map((n) => ({
name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '', name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '',
kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null,
ghost: n.classList.contains('map-label--ghost')
})) }))
); );
const labels = all.filter((label) => !label.ghost).map(({ name, kind }) => ({ name, kind }));
const neighbours = all.filter((label) => label.ghost).map(({ name, kind }) => ({ name, distance: kind }));
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
title: (await title(page).textContent())?.trim(), title: (await title(page).textContent())?.trim(),
level: (await level(page).textContent())?.trim(), level: (await level(page).textContent())?.trim(),
labelCount: labels.length, labelCount: labels.length,
labels labels,
...(neighbours.length ? { neighbours } : {})
}, },
null, null,
2 2
+17 -2
View File
@@ -5,7 +5,11 @@ name: Junie review
# it reads well, and a review comment should never be able to turn the build red. # it reads well, and a review comment should never be able to turn the build red.
on: on:
pull_request: pull_request:
types: [opened, synchronize, ready_for_review] # `reopened` because a pull request closed and reopened has had no review since it was
# closed, and `ready_for_review` because the draft guard below would otherwise skip a pull
# request opened as a draft forever. No `branches:` filter: work here stacks feature onto
# feature, so filtering on main would skip every pull request in a chain but the last.
types: [opened, synchronize, reopened, ready_for_review]
# A review of the previous push is stale the moment a new one lands, so supersede it rather than # A review of the previous push is stale the moment a new one lands, so supersede it rather than
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref # letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
@@ -25,6 +29,11 @@ jobs:
review: review:
name: Review the diff name: Review the diff
runs-on: ubuntu-latest runs-on: ubuntu-latest
# A ceiling, not a target: a run that goes wrong hangs rather than stops, and the pull
# request shows a pending check until it does. Set above the longest review this repository
# has actually had — 35 minutes, on the largest diff so far — rather than at the sibling
# repositories' 30, which would have cut that one short.
timeout-minutes: 45
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets # Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather # from `pull_request` runs on forked branches, so the job would fail on a missing key rather
# than say anything useful about the code. # than say anything useful about the code.
@@ -54,7 +63,13 @@ jobs:
# clone is never used. # clone is never used.
fetch-depth: 1 fetch-depth: 1
- uses: JetBrains/junie-github-action@v1 # Pinned to a commit rather than to `v1`: this is the only third-party action here and it
# is handed a repository secret, so its definition should not be able to change under us.
# `v1` resolves to this same commit today; the pin is about who gets to move it. The pin
# covers this definition only — the composite pulls its own dependencies by tag and fetches
# the Junie CLI over the network. Bump by resolving the new release's commit, never by
# moving a tag name.
- uses: JetBrains/junie-github-action@3f6a906f11c6f67c76efaf3d3264bbb615f9ce29 # v1.7.5
if: env.HAS_JUNIE_KEY == 'true' if: env.HAS_JUNIE_KEY == 'true'
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the # An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
# log, but it is not a reason to hold a pull request whose tests pass. # log, but it is not a reason to hold a pull request whose tests pass.
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from '@playwright/test';
/** Every request the article service makes, whichever language it is asking in. */
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
const SUMMARY = {
type: 'standard',
titles: { normalized: 'Titan (moon)' },
extract: 'Titan is the largest moon of Saturn.',
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } }
};
test.describe('About', () => {
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
let requests = 0;
await page.route(isWikipedia, async (route) => {
requests++;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
});
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
expect(requests).toBe(0);
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
const credit = page.getByRole('link', { name: /Wikipedia/ });
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
expect(requests).toBeGreaterThan(0);
});
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
});
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
await page.route(isWikipedia, (route) => route.abort('failed'));
await page.goto('/body/titan');
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/could not be reached/)).toBeVisible();
// And the retry actually retries, rather than reading back a remembered failure.
await page.unroute(isWikipedia);
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Bookmarks', () => {
test('keeps a body, comes back to it in a later visit, and forgets it', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/body/earth');
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Keep Earth' }).click();
await expect(page.getByRole('button', { name: 'Forget Earth' })).toHaveAttribute('aria-pressed', 'true');
// A later visit, on a different page: kept places outlive the one they were kept from.
await page.goto('/');
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Bookmarks' }).click();
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
await expect(kept).toHaveCount(1);
await kept.getByRole('button').first().click();
// Choosing it goes there, which for a body is its page.
await expect(page).toHaveURL(/\/body\/earth$/, { timeout: 30_000 });
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Forget Earth' }).click();
await expect(page.getByRole('button', { name: 'Keep Earth' })).toBeVisible();
await page.getByRole('tab', { name: 'Bookmarks' }).click();
await expect(page.locator('#dock-panel-bookmarks')).toContainText('Nothing kept yet');
});
test('keeps the system the view is inside, and flies back to it', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri');
await page.getByRole('button', { name: /Proxima Centauri/ }).first().click();
const readout = page.getByTestId('hud-title');
await expect(readout).toHaveText('Proxima Centauri', { timeout: 30_000 });
await page.getByRole('button', { name: 'Keep Proxima Centauri' }).click();
// Back out to the field, then return by what was kept rather than by searching again.
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
await expect(readout).toHaveText('Local Stars', { timeout: 30_000 });
await page.getByRole('tab', { name: 'Bookmarks' }).click();
await page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Proxima Centauri' }).getByRole('button').first().click();
await expect(readout).toHaveText('Proxima Centauri', { timeout: 45_000 });
});
});
+7 -3
View File
@@ -9,8 +9,12 @@ test.describe('Galaxy view', () => {
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => { test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible(); // Generously timed on purpose: this suite shares one software rasterizer, and the boot
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible(); // it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
await expect(backButtonLocator(page)).toHaveCount(0); await expect(backButtonLocator(page)).toHaveCount(0);
// The readout panel's own title, not just the text anywhere on screen: the selected-object // The readout panel's own title, not just the text anywhere on screen: the selected-object
// banner across the top names the same thing, so a bare text match is ambiguous. // banner across the top names the same thing, so a bare text match is ambiguous.
@@ -22,7 +26,7 @@ test.describe('Galaxy view', () => {
// up to more than the default per-test budget. // up to more than the default per-test budget.
test.setTimeout(90_000); test.setTimeout(90_000);
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible(); await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Milky Way' }).click(); await page.getByRole('button', { name: 'Milky Way' }).click();
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Neighbour jump', () => {
test('a neighbour named from inside one system flies into that one', async ({ page }) => {
// Two full camera flights on a software rasterizer shared with the rest of the suite: into
// Sol, then out and into the star its label names. See the same note on camera-flight.
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Sol');
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
const readout = page.getByTestId('hud-title');
await expect(readout).toHaveText('Sol', { timeout: 30_000 });
// Its nearest neighbours are named around the edge of the view; each is a button that flies
// there. Barnard's Star by name, on purpose: the ring ranks named stars ahead of survey
// designations, and the four nearest named to the Sun — Proxima, Rigil Kentaurus, Toliman,
// Barnard's — are a fact about space, not about which catalogue was refreshed last. Before
// that preference, a Gaia row duplicating Proxima took fourth place and this very label was
// the one that vanished, so it is also the regression test for it. Barnard's rather than the
// Alpha Centauri trio because those three share one bearing and are decluttered down to
// whichever the label pass reaches first.
const neighbour = page.getByRole('button', { name: /Barnard's Star/ });
await expect(neighbour).toBeVisible({ timeout: 30_000 });
await neighbour.click();
await expect(readout).toHaveText("Barnard's Star", { timeout: 45_000 });
// And from there the walk goes on: the new system names its own neighbours. Which ones is
// not asserted — several of Barnard's nearest share a bearing, so which of them survives
// the declutter is a property of the view, not a fact about the catalogue.
await expect(page.locator('.map-label--ghost')).not.toHaveCount(0, { timeout: 30_000 });
});
});
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
test.describe('Plan view', () => {
test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => {
test.setTimeout(120_000);
await page.goto('/?stars=4000');
const searchInput = await openSearch(page);
await searchInput.fill('Sol');
await page.getByRole('button', { name: /^Sol\b/ }).first().click();
await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 });
// Where the planets are on screen before and after: under a plan view they lie on one
// circle around the star, so the spread of their distances from it collapses.
const spread = async () =>
page.evaluate(() => {
const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')];
const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const radii = labels.map((label) => {
const box = label.getBoundingClientRect();
return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y);
});
return radii.length;
});
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
await page.getByRole('tab', { name: 'Display' }).click();
const plan = page.getByRole('button', { name: 'Plan view', exact: true });
await expect(plan).toHaveAttribute('aria-pressed', 'false');
await plan.click();
await expect(plan).toHaveAttribute('aria-pressed', 'true');
// The scene survives the swap: it is still this system, still labelled, still readable.
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
await page.getByRole('tab', { name: 'Readout' }).click();
await expect(page.getByTestId('hud-title')).toHaveText('Sol');
await page.getByRole('tab', { name: 'Display' }).click();
await plan.click();
await expect(plan).toHaveAttribute('aria-pressed', 'false');
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
});
test('keeps the scale ladder honest about how far out the view is', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 });
await page.getByRole('tab', { name: 'Display' }).click();
await page.getByRole('button', { name: 'Plan view', exact: true }).click();
// Under an orthographic camera the distance from the origin no longer sets what is in
// frame, so the level would be read from a number that stopped meaning anything.
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood');
await page.getByRole('button', { name: 'Milky Way' }).click();
await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 });
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
test.describe('Route plotting', () => {
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
// it waits on is the slowest thing in it. The default five seconds is a coin toss
// under a full parallel run.
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Routes' }).click();
// Sirius is 2.64 pc from the Sun, so the default 3 pc range crosses it in one.
await page.locator('#route-from').fill('Sol');
const departure = page.locator('#dock-panel-routes ul li button').first();
await expect(departure).toBeVisible({ timeout: 30_000 });
await departure.click();
await page.locator('#route-to').fill('Sirius');
const destination = page.locator('#dock-panel-routes ul li button').first();
await expect(destination).toBeVisible();
await destination.click();
await page.getByRole('button', { name: 'Plot route' }).click();
await expect(page.getByTestId('route-summary')).toHaveText(/1 jump · 2\.6\d pc/);
// The chain itself, departure first, each step a way to fly there.
const steps = page.getByTestId('route-steps').getByRole('button');
await expect(steps).toHaveCount(2);
await expect(steps.first()).toContainText('Sol');
await expect(steps.last()).toContainText('Sirius');
});
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/?stars=4000');
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Routes' }).click();
await page.locator('#route-from').fill('Sol');
const departure = page.locator('#dock-panel-routes ul li button').first();
await expect(departure).toBeVisible({ timeout: 30_000 });
await departure.click();
// Narrowed until nothing the catalogue holds is within reach of the Sun: its nearest
// neighbour is 1.30 pc away, so half a parsec strands it.
await page.locator('#route-range').fill('0.5');
await page.locator('#route-to').fill('Sirius');
const destination = page.locator('#dock-panel-routes ul li button').first();
await expect(destination).toBeVisible();
await destination.click();
await page.getByRole('button', { name: 'Plot route' }).click();
const summary = page.getByTestId('route-summary');
await expect(summary).toContainText('No route at this range', { timeout: 30_000 });
// And the answer, not just the refusal: the range that would open one, offered as a control.
const raise = summary.getByRole('button');
await expect(raise).toContainText(/pc would reach/);
await raise.click();
await expect(page.getByTestId('route-summary')).toContainText(/jump/, { timeout: 30_000 });
});
});
+8 -5
View File
@@ -1,25 +1,28 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { openSearch } from './support/open-search';
import { backButtonLocator } from './support/wait-for-back-button'; import { backButtonLocator } from './support/wait-for-back-button';
test.describe('Search-driven navigation', () => { test.describe('Search-driven navigation', () => {
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => { test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Proxima Centauri'); await searchInput.fill('Proxima Centauri');
const result = page.getByRole('button', { name: /Proxima Centauri/ }); const result = page.getByRole('button', { name: /Proxima Centauri/ });
await expect(result).toBeVisible(); await expect(result).toBeVisible();
await result.click(); await result.click();
// Selecting a result clears the search query immediately (before the flight even starts). // Selecting a result hands the dock straight back to the readout (before the flight even
await expect(searchInput).toHaveValue(''); // starts): the thing to look at is now the scene, and the search panel folds away with it.
await expect(page.getByRole('tab', { name: 'Readout' })).toHaveAttribute('aria-selected', 'true');
await expect(searchInput).toHaveCount(0);
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 }); await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
}); });
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => { test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('Earth'); await searchInput.fill('Earth');
const result = page.getByText('Earth', { exact: true }); const result = page.getByText('Earth', { exact: true });
@@ -36,7 +39,7 @@ test.describe('Search-driven navigation', () => {
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => { test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
await page.goto('/?stars=4000'); await page.goto('/?stars=4000');
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…'); const searchInput = await openSearch(page);
await searchInput.fill('E'); await searchInput.fill('E');
await expect(page.getByTestId('search-results')).toHaveCount(0); await expect(page.getByTestId('search-results')).toHaveCount(0);
+14
View File
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';
/**
* The search lives in the dock along the bottom, behind its own tab, so a test that wants to
* type has to open it first — the way a user does, or with the `/` shortcut. Returns the field.
*/
export async function openSearch(page: Page): Promise<Locator> {
// Clicking the active tab folds it closed, so only click when it is not already open.
const tab = page.getByRole('tab', { name: 'Search' });
if ((await tab.getAttribute('aria-selected')) !== 'true') {
await tab.click();
}
return page.getByPlaceholder('Search stars, planets, exoplanets…');
}
+6
View File
@@ -11,6 +11,12 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: 'html', reporter: 'html',
// Well above Playwright's 5 s, which was a fair ceiling when the star catalogue was 820 kB and
// is not now that the scheduled refresh has it at 5.4 MB and 447 410 rows: every one of these
// tests boots that catalogue, and the suite boots several at once on a software rasterizer.
// The heavy waits already carry their own longer timeouts; this is the same judgement applied
// to the assertions that were left on the default. A ceiling costs nothing when it is not hit.
expect: { timeout: 15_000 },
use: { use: {
baseURL: 'http://localhost:4300', baseURL: 'http://localhost:4300',
trace: 'on-first-retry' trace: 'on-first-retry'
-1
View File
@@ -4,5 +4,4 @@
aria-hidden="true" aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]" class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div> ></div>
<app-search></app-search>
<router-outlet></router-outlet> <router-outlet></router-outlet>
+1 -3
View File
@@ -1,11 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SearchComponent } from './features/search/search.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, SearchComponent], imports: [RouterOutlet],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
+159
View File
@@ -0,0 +1,159 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ArticleService } from './article.service';
function summary(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
type: 'standard',
titles: { normalized: 'Titan (moon)' },
extract: 'Titan is the largest moon of Saturn.',
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
...overrides
}),
{ status: 200 }
);
}
function missing(): Response {
return new Response('{}', { status: 404 });
}
/** What Wikipedia sends anyone who asks too quickly. */
function rateLimited(): Response {
return new Response('You are making too many requests to the API.', { status: 429 });
}
function service(): ArticleService {
TestBed.resetTestingModule();
return TestBed.inject(ArticleService);
}
/** The language each call was made in, in order, so the fallback chain can be asserted. */
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
}
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
}
describe('ArticleService', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('navigator', { language: 'en-GB' });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
fetchMock.mockResolvedValue(summary());
const result = await service().lookup('Titan');
expect(result).toEqual({
status: 'found',
article: {
title: 'Titan (moon)',
extract: 'Titan is the largest moon of Saturn.',
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
language: 'en'
}
});
});
it('asks in the browser’s language first and English second', async () => {
vi.stubGlobal('navigator', { language: 'fr-FR' });
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
expect(result.status).toBe('found');
});
it('asks English only once, when English is what the browser is set to', async () => {
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
it('names the kind when the plain name lands on a list of other things', async () => {
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
const result = await service().lookup('Titan', 'moon');
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
expect(result.status).toBe('found');
});
it('says there is nothing written rather than pretending, when nothing is', async () => {
fetchMock.mockResolvedValue(missing());
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
});
it('tells being unable to ask apart from there being no answer', async () => {
fetchMock.mockRejectedValue(new TypeError('offline'));
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
fetchMock.mockResolvedValue(rateLimited());
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
});
it('asks once per body, however many times it is asked for', async () => {
fetchMock.mockResolvedValue(summary());
const articles = service();
await articles.lookup('Titan');
await articles.lookup('Titan');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('remembers that there is nothing, but never that it could not ask', async () => {
const articles = service();
fetchMock.mockResolvedValue(missing());
await articles.lookup('Nowhere');
await articles.lookup('Nowhere');
const afterMissing = fetchMock.mock.calls.length;
fetchMock.mockRejectedValue(new TypeError('offline'));
await articles.lookup('Elsewhere');
const afterFirstFailure = fetchMock.mock.calls.length;
await articles.lookup('Elsewhere');
// One round of requests for the missing page, then nothing more; but a failure to reach
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
expect(afterMissing).toBe(1);
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
});
it('treats an article with nothing in it as no article', async () => {
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
});
it('does not send a browser language that is not one', async () => {
vi.stubGlobal('navigator', { language: 'not a language tag' });
fetchMock.mockResolvedValue(summary());
await service().lookup('Titan');
expect(languagesAsked(fetchMock)).toEqual(['en']);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { Injectable } from '@angular/core';
/** A Wikipedia summary, as much of it as this app shows. */
export interface Article {
readonly title: string;
readonly extract: string;
/** The article itself, so a reader can go and check. */
readonly url: string;
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
readonly language: string;
}
/**
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
* same, and only one of them is about the world.
*/
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
const FALLBACK_LANGUAGE = 'en';
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
* "Proxima Centauri b" without this app having to know that. */
function summaryUrl(language: string, title: string): string {
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
}
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
function browserLanguage(): string {
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
const primary = tag.split('-')[0]?.toLowerCase();
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
}
/**
* Wikipedia summaries, fetched only when asked for.
*
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
* where it came from and linked back to the article — the same rule the derived surfaces follow,
* for the same reason.
*
* Asked for in the browser's language first and English second, because the catalogue's names
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
*/
@Injectable({ providedIn: 'root' })
export class ArticleService {
private readonly cache = new Map<string, ArticleLookup>();
/**
* The article for `name`, or why there is none.
*
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
* English rather than guessing at a translation.
*/
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
const key = `${name}\0${qualifier ?? ''}`;
const cached = this.cache.get(key);
if (cached) {
return cached;
}
const language = browserLanguage();
const attempts: { language: string; title: string }[] = [
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
{ language: FALLBACK_LANGUAGE, title: name },
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
];
let reachedWikipedia = false;
for (const attempt of attempts) {
const result = await this.fetchSummary(attempt.language, attempt.title);
if (result === 'unreachable') {
continue;
}
reachedWikipedia = true;
if (result) {
const found: ArticleLookup = { status: 'found', article: result };
this.cache.set(key, found);
return found;
}
}
// Not cached when Wikipedia could not be reached: that is a fact about the network this
// minute, and the next press should be allowed to ask again.
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
if (reachedWikipedia) {
this.cache.set(key, outcome);
}
return outcome;
}
/**
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
* answer at all, including the rate limit it returns to anyone who asks too fast.
*/
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
let response: Response;
try {
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
} catch {
return 'unreachable';
}
if (response.status === 404) {
return null;
}
if (!response.ok) {
return 'unreachable';
}
try {
const body = (await response.json()) as {
type?: string;
extract?: string;
titles?: { normalized?: string };
content_urls?: { desktop?: { page?: string } };
};
const extract = body.extract?.trim();
if (!extract || body.type === 'disambiguation') {
return null;
}
return {
title: body.titles?.normalized ?? title,
extract,
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
language
};
} catch {
return 'unreachable';
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import { NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest';
import { EngineService } from './engine.service';
/**
* The projection half of the engine, which is the half that can be tested without a GPU: no
* renderer is created, the two cameras are placed by hand, and what is asserted is the
* arithmetic that keeps them showing the same thing.
*/
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
// The two cameras are private, because nothing outside should choose between them by hand.
Object.assign(engine as unknown as Record<string, unknown>, { perspective, orthographic });
return { engine, perspective, orthographic };
}
/** Half the height of what a perspective camera frames at `distance`, in world units. */
function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number {
return distance * Math.tan((camera.fov * Math.PI) / 360);
}
describe('EngineService projection', () => {
let engine: EngineService;
let perspective: THREE.PerspectiveCamera;
let orthographic: THREE.OrthographicCamera;
beforeEach(() => {
({ engine, perspective, orthographic } = engineWithCameras());
});
it('draws through the perspective camera until told otherwise', () => {
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
});
it('frames the same extent through either camera, which is the point of the swap', () => {
perspective.position.set(0, 0, 200);
engine.setProjection('orthographic', 200);
expect(engine.getCamera()).toBe(orthographic);
expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6);
// And as wide as the frame is, not as wide as it is tall.
expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6);
});
it('carries the pose across, so the swap changes the projection and not the view', () => {
perspective.position.set(3, 4, 12);
perspective.lookAt(0, 0, 0);
engine.setProjection('orthographic', 13);
expect(orthographic.position.toArray()).toEqual(perspective.position.toArray());
expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray());
});
it('sees behind itself, because a parallel camera does not back away from what it frames', () => {
engine.setProjection('orthographic', 100);
// A perspective camera pulls back as its frame grows and leaves the scene in front of it. An
// orthographic one does not move at all, so half the Galaxy ends up behind its own plane —
// and a near plane in front would clip it away. Parallel depth is linear, so the precision
// that a perspective near plane is guarding for does not apply.
expect(orthographic.near).toBe(-perspective.far);
expect(orthographic.far).toBe(perspective.far);
});
it('goes back, and hands out the perspective camera again', () => {
engine.setProjection('orthographic', 100);
engine.setProjection('perspective', 100);
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6);
});
it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => {
engine.setProjection('orthographic', 100);
const framed = engine.visibleHalfHeight(100);
orthographic.zoom = 2;
// Zoomed in twice: half as much in frame. Distance says nothing about it, which is why
// nothing downstream may read the camera's distance under this projection.
expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6);
expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6);
});
it('never divides by a camera sitting on its own target', () => {
expect(() => engine.setProjection('orthographic', 0)).not.toThrow();
expect(Number.isFinite(orthographic.top)).toBe(true);
});
it('widens rather than magnifies when the window gets wider', () => {
engine.setProjection('orthographic', 100);
const height = orthographic.top - orthographic.bottom;
// No renderer, so resize returns early — the frustum is re-fitted by hand the same way.
orthographic.left = (-height / 2) * (21 / 9);
orthographic.right = (height / 2) * (21 / 9);
expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6);
expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6);
});
});
+96 -10
View File
@@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void; export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
/** Which projection the scene is drawn through. */
export type Projection = 'perspective' | 'orthographic';
/** Either camera, as everything downstream of the projection sees it. */
export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
/** /**
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera, * Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
* and the render loop. The loop always runs outside Angular's zone so per-frame work never * and the render loop. The loop always runs outside Angular's zone so per-frame work never
@@ -20,7 +26,14 @@ export class EngineService {
private canvas?: HTMLCanvasElement; private canvas?: HTMLCanvasElement;
private renderer?: THREE.WebGPURenderer; private renderer?: THREE.WebGPURenderer;
private scene?: THREE.Scene; private scene?: THREE.Scene;
private camera?: THREE.PerspectiveCamera; private perspective?: THREE.PerspectiveCamera;
/**
* Built alongside the perspective one and kept in step with it, rather than made on demand:
* the two share a position, an orientation and a depth range, and a camera that only exists
* while it is being looked through is a camera whose state is always one swap out of date.
*/
private orthographic?: THREE.OrthographicCamera;
private projection: Projection = 'perspective';
private running = false; private running = false;
constructor(private readonly ngZone: NgZone) {} constructor(private readonly ngZone: NgZone) {}
@@ -33,8 +46,71 @@ export class EngineService {
return this.requireInitialized(this.scene); return this.requireInitialized(this.scene);
} }
getCamera(): THREE.PerspectiveCamera { /** The camera the scene is currently drawn through. */
return this.requireInitialized(this.camera); getCamera(): SceneCamera {
return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective);
}
/**
* The perspective camera, whichever is active. For the handful of places that need a field of
* view to reason with — framing a system, sizing a star — and that go on meaning the same
* thing in either projection because the sizes were tuned against this one.
*/
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.perspective);
}
get currentProjection(): Projection {
return this.projection;
}
/**
* Switches projection, carrying the pose across. The orthographic frustum is sized to show
* the same extent at `distanceToTarget` that the perspective camera showed from there, so the
* swap changes how the scene is projected and not how much of it is in frame.
*/
setProjection(projection: Projection, distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
this.projection = projection;
orthographic.zoom = 1;
orthographic.position.copy(perspective.position);
orthographic.quaternion.copy(perspective.quaternion);
this.frameOrthographic(distanceToTarget);
}
/**
* Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera
* would show from there. Called every frame while that projection is active, which is what
* makes the camera flights work through it: they move the camera, and the frame follows.
*
* The depth range is symmetric about the camera rather than starting in front of it. An
* orthographic camera does not pull back as its frame grows, so at galactic framing the
* backdrop shell and half the Milky Way lie behind its own plane and would be clipped away.
* A parallel projection has linear depth, so the precision argument that makes a perspective
* near plane worth guarding does not apply here.
*/
frameOrthographic(distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360);
orthographic.top = halfHeight;
orthographic.bottom = -halfHeight;
orthographic.left = -halfHeight * perspective.aspect;
orthographic.right = halfHeight * perspective.aspect;
orthographic.far = perspective.far;
orthographic.near = -perspective.far;
orthographic.updateProjectionMatrix();
}
/** Half the height of what is in frame at the target, in world units, under either camera. */
visibleHalfHeight(distanceToTarget: number): number {
if (this.projection === 'orthographic') {
const orthographic = this.requireInitialized(this.orthographic);
return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
}
return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360);
} }
getRenderer(): THREE.WebGPURenderer { getRenderer(): THREE.WebGPURenderer {
@@ -52,8 +128,10 @@ export class EngineService {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.camera.position.set(0, 0, 5); this.perspective.position.set(0, 0, 5);
this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
this.orthographic.position.copy(this.perspective.position);
const { width, height } = this.canvasSize(); const { width, height } = this.canvasSize();
this.resize(width, height); this.resize(width, height);
@@ -99,11 +177,18 @@ export class EngineService {
* Updates the camera aspect ratio and renderer drawing buffer size. * Updates the camera aspect ratio and renderer drawing buffer size.
*/ */
resize(width: number, height: number): void { resize(width: number, height: number): void {
if (!this.renderer || !this.camera || width <= 0 || height <= 0) { if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) {
return; return;
} }
this.camera.aspect = width / height; const aspect = width / height;
this.camera.updateProjectionMatrix(); this.perspective.aspect = aspect;
this.perspective.updateProjectionMatrix();
// The orthographic frustum keeps its height and re-fits its width, so a window getting wider
// shows more to the sides rather than magnifying what was already there.
const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2;
this.orthographic.left = -halfHeight * aspect;
this.orthographic.right = halfHeight * aspect;
this.orthographic.updateProjectionMatrix();
this.renderer.setSize(width, height, false); this.renderer.setSize(width, height, false);
} }
@@ -116,7 +201,8 @@ export class EngineService {
this.renderer?.dispose(); this.renderer?.dispose();
this.renderer = undefined; this.renderer = undefined;
this.scene = undefined; this.scene = undefined;
this.camera = undefined; this.perspective = undefined;
this.orthographic = undefined;
this.canvas = undefined; this.canvas = undefined;
} }
@@ -128,7 +214,7 @@ export class EngineService {
callback(deltaSeconds, elapsedSeconds); callback(deltaSeconds, elapsedSeconds);
} }
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera)); this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera());
} }
private canvasSize(): { width: number; height: number } { private canvasSize(): { width: number; height: number } {
@@ -1,5 +1,5 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core'; import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -12,8 +12,10 @@ import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBO
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { Bookmark } from '../../shared/state/bookmarks.store';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model'; import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component'; import { InfoPanelComponent } from './info-panel.component';
@@ -37,14 +39,14 @@ const GLOW_SCALE = 2.6;
@Component({ @Component({
selector: 'app-body-detail-scene', selector: 'app-body-detail-scene',
providers: [EngineService], providers: [EngineService],
imports: [ChevronIconComponent, InfoPanelComponent, RouterLink], imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) { @if (viewModel()) {
<app-info-panel [body]="viewModel()!" /> <app-info-panel [body]="viewModel()!" />
} @else if (notFound()) { } @else if (notFound()) {
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text xl:top-4"> <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
<p class="type-eyebrow text-accent">No record</p> <p class="type-eyebrow text-accent">No record</p>
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p> <p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a <a
@@ -56,10 +58,23 @@ const GLOW_SCALE = 2.6;
</a> </a>
</div> </div>
} }
<!-- Search and what has been kept: there is no scene readout here, the info panel is
the reading, and the panel's own control is what keeps this body. -->
<app-hud-dock (bookmarkChosen)="goToBookmark($event)" />
</div> </div>
` `
}) })
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
/** A kept place, revisited from this page: a star means leaving it for the map. */
goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') {
this.navigationStore.selectStar(Number(bookmark.id));
void this.router.navigate(['/']);
} else {
void this.router.navigate(['/body', String(bookmark.id)]);
}
}
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas'); private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private controls?: OrbitControls; private controls?: OrbitControls;
@@ -84,6 +99,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
private readonly engine: EngineService, private readonly engine: EngineService,
private readonly dataLoader: DataLoaderService, private readonly dataLoader: DataLoaderService,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly navigationStore: NavigationStore private readonly navigationStore: NavigationStore
) {} ) {}
@@ -1,6 +1,9 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Article, ArticleService } from '../../core/data/article.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { bodyReadouts } from './body-readouts'; import { bodyReadouts } from './body-readouts';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
@@ -17,12 +20,10 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
*/ */
@Component({ @Component({
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
template: ` template: `
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem <!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
wide and centred, so this right-anchored 20rem panel only clears it once the viewport <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
passes ~1088px — at sm they still overlap and the search would cover the back button. -->
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text xl:top-4">
<button <button
type="button" type="button"
(click)="goBack()" (click)="goBack()"
@@ -32,21 +33,105 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
System System
</button> </button>
<header class="px-4 pt-4 pb-3"> <header class="flex items-start gap-2 px-4 pt-4 pb-3">
<div class="min-w-0 flex-1">
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1> <h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</div>
<button
type="button"
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
[attr.aria-pressed]="bookmarks.has('body', body().id)"
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
</button>
</header> </header>
<app-readout-sections [readouts]="readouts()" /> <app-readout-sections [readouts]="readouts()" />
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
measurement or something derived from one; this is a person's paragraph on another
site, so it says whose and links back to it. -->
@if (article(); as found) {
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
<a
[href]="found.url"
target="_blank"
rel="noopener"
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>Wikipedia · {{ found.language }}</a
>
</div>
} @else if (aboutState() !== 'idle') {
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
@switch (aboutState()) {
@case ('loading') {
Asking Wikipedia…
}
@case ('none') {
Wikipedia has no article on {{ body().name }}.
}
@case ('unavailable') {
Wikipedia could not be reached.
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
}
}
</p>
} @else {
<button
type="button"
(click)="loadArticle()"
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
About
</button>
}
</div> </div>
` `
}) })
export class InfoPanelComponent { export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>(); readonly body = input.required<BodyDetailViewModel>();
readonly readouts = computed(() => bodyReadouts(this.body())); readonly bookmarks = inject(BookmarksStore);
constructor(private readonly router: Router) {} private readonly articles = inject(ArticleService);
readonly article = signal<Article | null>(null);
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
constructor(private readonly router: Router) {
// The panel is reused as the route's parameter changes, so what was asked about one body
// must not still be showing under the next one's name.
effect(() => {
this.body();
this.article.set(null);
this.aboutState.set('idle');
});
}
/**
* Fetches the article, on the press and not before. The kind goes with the name because
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
*/
async loadArticle(): Promise<void> {
this.aboutState.set('loading');
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
if (result.status === 'found') {
this.article.set(result.article);
this.aboutState.set('idle');
return;
}
this.article.set(null);
this.aboutState.set(result.status);
}
readonly readouts = computed(() => bodyReadouts(this.body()));
goBack(): void { goBack(): void {
void this.router.navigate(['/']); void this.router.navigate(['/']);
@@ -0,0 +1,19 @@
/**
* How a size in pixels becomes a size the scene can draw.
*
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
* share of the screen however far away it is and whatever the window is doing. Pixels are what
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
* is a statement someone can check by looking — so the two are related through a reference
* viewport and field of view, and the pixel figures are exact only at that height.
*
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
* others.
*/
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
export const REFERENCE_FOV_DEGREES = 55;
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
@@ -1,4 +1,6 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { SceneCamera } from '../../core/engine/engine.service';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose { export interface CameraPose {
@@ -27,7 +29,7 @@ export class CameraRigController {
}; };
constructor( constructor(
private readonly camera: THREE.PerspectiveCamera, private readonly camera: SceneCamera,
private readonly controls: OrbitControls private readonly controls: OrbitControls
) {} ) {}
@@ -64,7 +64,9 @@ const EARTH: BodyRecord = {
class FakeEngineService { class FakeEngineService {
private readonly scene = new THREE.Scene(); private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>(); private readonly tickCallbacks = new Set<EngineTickCallback>();
projection: 'perspective' | 'orthographic' = 'perspective';
get isInitialized(): boolean { get isInitialized(): boolean {
return true; return true;
@@ -78,10 +80,37 @@ class FakeEngineService {
return this.scene; return this.scene;
} }
getCamera(): THREE.PerspectiveCamera { getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
return this.projection === 'orthographic' ? this.orthographic : this.camera;
}
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.camera; return this.camera;
} }
get currentProjection(): 'perspective' | 'orthographic' {
return this.projection;
}
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
this.projection = projection;
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
this.orthographic.top = halfHeight;
this.orthographic.bottom = -halfHeight;
this.orthographic.left = -halfHeight * this.camera.aspect;
this.orthographic.right = halfHeight * this.camera.aspect;
this.orthographic.zoom = 1;
this.orthographic.position.copy(this.camera.position);
this.orthographic.quaternion.copy(this.camera.quaternion);
this.orthographic.updateProjectionMatrix();
}
visibleHalfHeight(distanceToTarget: number): number {
return this.projection === 'orthographic'
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
}
onTick(callback: EngineTickCallback): () => void { onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback); this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback); return () => this.tickCallbacks.delete(callback);
@@ -1,18 +1,26 @@
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core'; import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { dateToJulianDate } from '../../shared/astro/constants'; import { dateToJulianDate } from '../../shared/astro/constants';
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy'; import {
GALACTIC_BASIS_EQUATORIAL,
MILKY_WAY_ARMS,
SUN_GALACTOCENTRIC_RADIUS_PC,
galacticCentrePositionPc,
galacticToEquatorial
} from '../../shared/astro/galaxy';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service'; import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model'; import { BodyRecord } from '../../shared/models/body.model';
import { DeepSkyRecord } from '../../shared/models/deepsky.model'; import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
import { isDesignation } from '../../shared/models/star-catalog';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { Bookmark } from '../../shared/state/bookmarks.store';
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
import { CameraRigController } from './camera-rig-controller'; import { CameraRigController } from './camera-rig-controller';
import { DeepSkyRenderer } from './deep-sky-renderer'; import { DeepSkyRenderer } from './deep-sky-renderer';
@@ -22,10 +30,18 @@ import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramin
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity'; import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model'; import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
import { HudReadout, StarmapHudComponent } from './starmap-hud.component'; import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component';
import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.component';
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component'; import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay'; import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { HostStarRings } from './host-star-rings';
import { JumpLinkRenderer } from './jump-link-renderer';
import { ReservedBox, ringPlacement } from './label-ring';
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer'; import { SystemOrbitsRenderer } from './system-orbits-renderer';
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */ /** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
@@ -48,6 +64,47 @@ const LABEL_MAX_COUNT = 15;
* Nearer stars win the space; see `spreadLabels`. * Nearer stars win the space; see `spreadLabels`.
*/ */
const LABEL_MIN_SEPARATION_NDC = 0.12; const LABEL_MIN_SEPARATION_NDC = 0.12;
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
const LABEL_EDGE_NDC = 0.7;
/** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */
const LABEL_REACH_NDC = 0.3;
/** How long the range control has to be still before the graph is rebuilt at its value. */
const JUMP_LINK_REBUILD_DELAY_MS = 250;
/**
* How far in or out the plan view may be zoomed from the extent its distance frames. Under a
* parallel projection the wheel changes the frame rather than the distance, so the orbit limits
* stop applying and this is what stands in for them.
*/
const PLAN_ZOOM_SPAN = 64;
/** How many matches each routing field offers, and how little may be typed to get any. */
const ROUTE_OPTION_COUNT = 6;
const MIN_ROUTE_QUERY_LENGTH = 2;
/**
* The widest crossing `minimumRangeBetween` will consider when saying what a route would need.
* Beyond this the catalogue is one component and the answer stops being informative.
*/
const ROUTE_RANGE_CEILING_PC = 30;
/** How many neighbouring stars are named from inside a system. */
const NEIGHBOUR_COUNT = 4;
/**
* How far out from the centre of the view a neighbour's name sits, as a fraction of the frame's
* half-height. Clear of the scale rail at the top and the dock at the bottom.
*/
const NEIGHBOUR_RING_NDC = 0.74;
/**
* How far in front of the camera a neighbour's name is planted, in AU. Any depth projects to
* the same place on the ring, but not to the same stability: unprojecting at the middle of the
* depth buffer lands ~0.008 AU from the eye, where a hundredth of a degree of camera drift
* swings the label across the screen. Out here the same drift moves it by a pixel.
*/
const NEIGHBOUR_DEPTH_AU = 500;
/** Radius of the selection arcs, in pixels — the leader line starts at their rim. */
const SELECTION_RADIUS_PX = 14;
const HUD_ACCENT = 0x4dd7ff;
/** /**
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather * How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
* than near the camera, so proximity is meaningless for them — the brightest handful are simply * than near the camera, so proximity is meaningless for them — the brightest handful are simply
@@ -151,30 +208,62 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
@Component({ @Component({
selector: 'app-galaxy-system-scene', selector: 'app-galaxy-system-scene',
providers: [EngineService], providers: [EngineService],
imports: [StarmapHudComponent, SystemObjectCardComponent], imports: [HudDockComponent, StarmapHudComponent, SystemObjectCardComponent],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div> <!-- Between the canvas and the labels, not up in the HUD where it used to live: everything
<app-starmap-hud in this stack paints in tree order, so from there a decorative gradient was laid over
[level]="navigationStore.viewLevel()" the names near the edge of the frame — which is exactly where the neighbour ring is. -->
<div aria-hidden="true" class="hud-vignette pointer-events-none absolute inset-0"></div>
<!-- isolate: CSS2DRenderer gives every label its own z-index for depth ordering; without a
stacking context here those indices escape and the labels paint over the HUD. -->
<div #labelHost class="pointer-events-none absolute inset-0 isolate overflow-hidden"></div>
<!-- Leader from the selected body to its card, drawn in screen space and repositioned in
the render loop; visibility is toggled there too, so no change detection per frame. -->
<svg aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full text-accent/60">
<line #leader x1="0" y1="0" x2="0" y2="0" stroke="currentColor" stroke-width="1" visibility="hidden" />
</svg>
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" (levelSelected)="goToLevel($event)" />
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
<app-hud-dock
[eyebrow]="hudEyebrow()" [eyebrow]="hudEyebrow()"
[title]="hudTitle()" [title]="hudTitle()"
[subtitle]="hudSubtitle()" [subtitle]="hudSubtitle()"
[readouts]="hudReadouts()" [readouts]="hudReadouts()"
[note]="hudNote()" [note]="hudNote()"
[range]="hudRange()" [range]="hudRange()"
(levelSelected)="goToLevel($event)" [display]="display()"
[routing]="true"
[routeResult]="routeResult()"
[routeOptions]="routeOptions()"
[currentStar]="currentStarOption()"
[keepableStarId]="navigationStore.selectedStarId()"
defaultTab="readout"
(displayChange)="display.set($event)"
(routeQuery)="onRouteQuery($event)"
(routeRequested)="onRouteRequested($event)"
(routeStarSelected)="navigationStore.selectStar($event)"
(jumpRangeChange)="jumpRangePc.set($event)"
(bookmarkChosen)="goToBookmark($event)"
/> />
@if (objectCard(); as card) {
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
}
</div> </div>
` `
}) })
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas'); private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost'); private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
private readonly objectCardRef = viewChild<SystemObjectCardComponent, ElementRef<HTMLElement>>(SystemObjectCardComponent, { read: ElementRef });
private readonly dockRef = viewChild<HudDockComponent, ElementRef<HTMLElement>>(HudDockComponent, { read: ElementRef });
/**
* The card's own box, looked up when the card changes rather than in the render loop that
* draws the leader to it. The host element is a stable wrapper; the panel inside it is what
* moves, and it is only replaced when a different body is selected.
*/
private readonly objectCardElement = computed(() => this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]') ?? null);
private readonly raycaster = new THREE.Raycaster(); private readonly raycaster = new THREE.Raycaster();
private readonly galaxyGroup = new THREE.Group(); private readonly galaxyGroup = new THREE.Group();
@@ -190,6 +279,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
readonly hudReadouts = signal<readonly HudReadout[]>([]); readonly hudReadouts = signal<readonly HudReadout[]>([]);
readonly hudNote = signal(''); readonly hudNote = signal('');
readonly hudRange = signal(''); readonly hudRange = signal('');
/** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */
readonly display = signal<HudDisplay>(DEFAULT_HUD_DISPLAY);
/** /**
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is * The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
@@ -199,14 +290,58 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private enterableSystems = 0; private enterableSystems = 0;
private pinnedBodyId: string | null = null; private pinnedBodyId: string | null = null;
private hoveredBodyId: string | null = null; private hoveredBodyId: string | null = null;
/** The body the card is about — what the selection mark brackets and the leader line leaves. */
private cardBodyId: string | null = null;
private controls?: OrbitControls; private controls?: OrbitControls;
private rig?: CameraRigController; private rig?: CameraRigController;
private starField?: StarFieldRenderer; private starField?: StarFieldRenderer;
private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood;
private jumpLinks?: JumpLinkRenderer;
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
readonly jumpRangePc = signal(3);
readonly routeResult = signal<RouteResult | null>(null);
/**
* Matches for whichever routing field is being typed into. Stars only: a route is a chain of
* stars, and offering a moon as a destination would be offering a place that leads nowhere.
*
* Derived rather than assigned, because the two things it needs arrive in either order — the
* catalogue is still loading when the dock is already up, and a query typed before it lands
* used to return nothing and stay nothing until the next keystroke.
*/
readonly routeOptions = computed<readonly RouteStarOption[]>(() => {
const query = this.routeQuery().trim();
const index = this.starSearchIndex();
if (query.length < MIN_ROUTE_QUERY_LENGTH || index.length === 0) {
return [];
}
return rankSearchResults(index, query, ROUTE_OPTION_COUNT).flatMap((entry) =>
entry.starId === undefined ? [] : [{ id: entry.starId, name: entry.name, subtitle: entry.subtitle }]
);
});
private readonly routeQuery = signal('');
/** The range the drawn graph was last built at, so a redraw is skipped when nothing moved. */
private drawnJumpRangePc: number | null = null;
private jumpLinkRebuild?: ReturnType<typeof setTimeout>;
/** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */
private neighbours: readonly { star: StarRecord; distancePc: number; direction: THREE.Vector3 }[] = [];
/**
* The HUD boxes the ring prints around, read on the label pass rather than per frame: each
* read is a forced layout, and the panels move when a tab is switched, not between frames.
*/
private reserved: readonly ReservedBox[] = [];
/** Scratch for the per-frame ring maths, so holding the ring still allocates nothing. */
private readonly ringBearing = new THREE.Vector3();
private readonly ringInverse = new THREE.Quaternion();
private readonly ringPoint = new THREE.Vector3();
private deepSky?: DeepSkyRenderer; private deepSky?: DeepSkyRenderer;
private deepSkyLabels: readonly LabeledPoint[] = []; private deepSkyLabels: readonly LabeledPoint[] = [];
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */ /** Stars with at least one catalogued body, which are the ones the map can be flown into. */
private starIdsWithBodies = new Set<number>(); private starIdsWithBodies = new Set<number>();
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
private milkyWay?: MilkyWayRenderer; private milkyWay?: MilkyWayRenderer;
private galacticLabels: readonly LabeledPoint[] = []; private galacticLabels: readonly LabeledPoint[] = [];
private galacticGrid?: PolarGridPlane; private galacticGrid?: PolarGridPlane;
@@ -246,6 +381,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.reconcileSelection(selectedStarId); this.reconcileSelection(selectedStarId);
} }
}); });
effect(() => this.applyDisplay(this.display()));
effect(() => this.applyProjection(this.display().plan));
// Reads both signals, so flipping the layer on and dragging the range each land here. The
// rebuild is a quarter-second of walking the catalogue, and the range control emits per
// pixel dragged, so it waits for the hand to settle rather than running once per pixel.
effect(() => {
this.jumpRangePc();
this.display().jumpLinks;
clearTimeout(this.jumpLinkRebuild);
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
});
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@@ -260,6 +406,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove); this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
this.controls?.dispose(); this.controls?.dispose();
this.starField?.dispose(); this.starField?.dispose();
this.hostRings?.dispose();
this.jumpLinks?.dispose();
clearTimeout(this.jumpLinkRebuild);
this.deepSky?.dispose(); this.deepSky?.dispose();
this.milkyWay?.dispose(); this.milkyWay?.dispose();
this.galacticGrid?.dispose(); this.galacticGrid?.dispose();
@@ -317,9 +466,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const scene = this.engine.getScene(); const scene = this.engine.getScene();
const camera = this.engine.getCamera(); const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION); camera.position.copy(GALAXY_OVERVIEW_POSITION);
camera.near = GALAXY_NEAR_PC; // The perspective camera whichever one is live: it is where the depth range is reasoned,
camera.far = GALAXY_FAR_PC; // and the plan view re-derives its own from it every frame. Writing to the active camera
camera.updateProjectionMatrix(); // put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas); this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true; this.controls.enableDamping = true;
@@ -346,6 +499,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
]); ]);
this.stars = stars; this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star])); this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.starSearchIndex.set(
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
);
this.bodies = bodies; this.bodies = bodies;
this.exoplanets = exoplanets; this.exoplanets = exoplanets;
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun. // Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
@@ -358,6 +515,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search)); this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
this.galaxyGroup.add(this.starField.object); this.galaxyGroup.add(this.starField.object);
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
this.galaxyGroup.add(this.hostRings.object);
this.jumpLinks = new JumpLinkRenderer(HUD_ACCENT);
this.galaxyGroup.add(this.jumpLinks.object);
this.milkyWay = new MilkyWayRenderer(); this.milkyWay = new MilkyWayRenderer();
this.galacticLabels = this.milkyWay.labelPoints(); this.galacticLabels = this.milkyWay.labelPoints();
@@ -391,8 +552,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT); this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
} }
this.labelOverlay = new StarLabelOverlay(scene); // A neighbour's label offers to fly there, and goes through the store like every other way
// of choosing a star — so a label click, a search hit and an in-scene click are one path.
this.labelOverlay = new StarLabelOverlay(scene, (starId) => this.navigationStore.selectStar(starId));
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement); this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
this.applyDisplay(this.display());
const { width, height } = canvas.getBoundingClientRect(); const { width, height } = canvas.getBoundingClientRect();
this.labelOverlay.setSize(width, height); this.labelOverlay.setSize(width, height);
@@ -401,15 +565,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
canvas.addEventListener('pointermove', this.handlePointerMove); canvas.addEventListener('pointermove', this.handlePointerMove);
this.observeResize(canvas); this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds)); // Asked for per frame rather than captured: the projection can be swapped underneath, and a
// frame computed against one camera and drawn through the other puts every label off its star.
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(this.engine.getCamera(), deltaSeconds));
this.engine.start(); this.engine.start();
this.ready = true; this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId()); this.reconcileSelection(this.navigationStore.selectedStarId());
} }
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void { private tick(camera: SceneCamera, deltaSeconds: number): void {
this.rig?.update(deltaSeconds); this.rig?.update(deltaSeconds);
this.frameProjection(camera);
this.controls?.update(); this.controls?.update();
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the // Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
@@ -436,6 +603,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (this.systemGroup.visible) { if (this.systemGroup.visible) {
this.systemRenderer?.update(dateToJulianDate()); this.systemRenderer?.update(dateToJulianDate());
} }
this.updateSelectionMark(camera);
this.updateNeighbourRing(camera);
this.labelOverlay?.render(camera); this.labelOverlay?.render(camera);
} }
@@ -445,24 +614,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* has pulled back from the Sun, so the scale ladder reports where the view already is instead * has pulled back from the Sun, so the scale ladder reports where the view already is instead
* of switching it. * of switching it.
*/ */
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void { private updateGalacticCrossfade(camera: SceneCamera): void {
if (!this.milkyWay) { if (!this.milkyWay) {
return; return;
} }
const distancePc = camera.position.length(); // How much of the Galaxy is in frame, expressed as the distance a perspective camera would
// have to be at to show that much. Under a plan view the camera's own distance says nothing
// about the extent — the frustum does — so reading `position.length()` there would report a
// fixed scale however far the view was zoomed.
const distancePc = this.effectiveDistance(camera);
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc); this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
this.galacticGrid?.setStrength(this.galacticStrength); // Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
this.localGrid?.setStrength(1 - this.galacticStrength); // `setStrength` rewrites visibility every frame from the strength it is given, so a hidden
this.tethers?.setStrength(1 - this.galacticStrength); // layer has to be told a strength of zero every frame too.
const display = this.display();
this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0);
this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
this.hostRings?.setStrength(display.systems ? 1 - this.galacticStrength : 0);
this.jumpLinks?.setStrength(display.jumpLinks ? 1 - this.galacticStrength : 0);
// The backdrop shell is the sky as seen from here; from outside it, it is a wall. // The backdrop shell is the sky as seen from here; from outside it, it is a wall.
this.deepSky?.setStrength(1 - this.galacticStrength); this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0);
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken // Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside. // from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength; this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
this.applyGalaxyDepthRange(camera, distancePc); this.applyGalaxyDepthRange(distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy'; const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) { if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
this.navigationStore.setViewLevel(level); this.navigationStore.setViewLevel(level);
@@ -475,26 +654,47 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection * and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
* spanning both has no precision left to separate one spiral arm from the next. * spanning both has no precision left to separate one spiral arm from the next.
*/ */
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void { /**
* What "how far back is the camera" means, in either projection. Under perspective it is the
* camera's own distance from the origin; under an orthographic one it is the distance a
* perspective camera would need to frame the same extent, so everything keyed on it — the
* crossfade, the depth range, the scale ladder — goes on meaning what it meant.
*/
private effectiveDistance(camera: SceneCamera): number {
if (this.engine.currentProjection === 'perspective') {
return camera.position.length();
}
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET));
return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
}
private applyGalaxyDepthRange(distancePc: number): void {
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC); const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC); const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
// Written to the perspective camera whichever one is live, because it is the one this range
// is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan
// view left the far plane wherever it was when the projection changed, so flying out to the
// Galaxy from there clipped away most of it.
const perspective = this.engine.getPerspectiveCamera();
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection // Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
// matrix on every frame of it. // matrix on every frame of it.
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) { if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
camera.near = near; perspective.near = near;
camera.far = far; perspective.far = far;
camera.updateProjectionMatrix(); perspective.updateProjectionMatrix();
// The plan view's own range is symmetric about the camera and derived from this one; see
// `frameOrthographic`. It is re-derived every frame, so there is nothing to do here.
} }
} }
private updateLabels(camera: THREE.PerspectiveCamera): void { private updateLabels(camera: SceneCamera): void {
const selectedId = this.navigationStore.selectedStarId(); const selectedId = this.navigationStore.selectedStarId();
// Measured from what the camera is looking at, not from where it is. Those differ by the // Measured from what the camera is looking at, not from where it is. Those differ by the
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the // orbit distance, so a camera-relative rule names the stars closest to the near edge of the
// view — a ring of labels around the outside of the thing the user is actually looking at. // view — a ring of labels around the outside of the thing the user is actually looking at.
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET; const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const { x: cx, y: cy, z: cz } = target; const { x: cx, y: cy, z: cz } = target;
const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE; const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC); const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
const maxDistanceSq = labelRadius * labelRadius; const maxDistanceSq = labelRadius * labelRadius;
@@ -551,7 +751,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and * `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
* its label going missing mid-flight reads as the target having been lost. * its label going missing mid-flight reads as the target having been lost.
*/ */
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] { /** The frame's shape, from the canvas rather than the camera: only one of the two has it. */
private viewportAspect(): number {
const canvas = this.canvasRef().nativeElement;
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
}
private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
const placed: THREE.Vector2[] = []; const placed: THREE.Vector2[] = [];
const chosen: LabeledPoint[] = []; const chosen: LabeledPoint[] = [];
const projected = new THREE.Vector3(); const projected = new THREE.Vector3();
@@ -568,18 +774,210 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
continue; continue;
} }
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y); const point = new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) { if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
continue; continue;
} }
// Text hangs on the right unless it would run off the view there, or into the space a
// label already placed to the right is using; then it hangs on the left, unless *that*
// is off the view. A crowded centre still gets right-hand labels — the separation test
// above already keeps them apart.
const crowdedRight = placed.some(
(other) => other.x > point.x && other.x - point.x < LABEL_REACH_NDC && Math.abs(other.y - point.y) < LABEL_MIN_SEPARATION_NDC
);
// The body the card is about hangs its label on the left regardless: the leader line to
// the card leaves its right, and would otherwise run straight through the text.
const side: LabelSide =
candidate.id === this.cardBodyId || ((projected.x > LABEL_EDGE_NDC || crowdedRight) && projected.x > -LABEL_EDGE_NDC) ? 'left' : 'right';
placed.push(point); placed.push(point);
chosen.push(candidate); chosen.push({ ...candidate, side });
} }
return chosen; return chosen;
} }
/**
* Brackets the body the card is about with the selection arcs, and draws the leader from
* their rim to the card's near edge. Screen-space work done here, once per frame, because the
* body moves every frame and the card's height depends on its content.
*/
private updateSelectionMark(camera: SceneCamera): void {
const leader = this.leaderRef().nativeElement;
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
if (!member) {
this.labelOverlay?.setSelection(null);
leader.setAttribute('visibility', 'hidden');
return;
}
const world = member.marker.getWorldPosition(new THREE.Vector3());
this.labelOverlay?.setSelection(world);
const card = this.objectCardElement();
const canvas = this.canvasRef().nativeElement;
const projected = world.clone().project(camera);
if (!card || projected.z > 1 || projected.z < -1) {
leader.setAttribute('visibility', 'hidden');
return;
}
const canvasRect = canvas.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const fromX = ((projected.x + 1) / 2) * canvas.clientWidth;
const fromY = ((1 - projected.y) / 2) * canvas.clientHeight;
// The card is top-right: meet its left edge, at the body's height where the edge allows.
const toX = cardRect.left - canvasRect.left;
const toY = Math.min(Math.max(fromY, cardRect.top - canvasRect.top + 12), cardRect.bottom - canvasRect.top - 12);
const dx = toX - fromX;
const dy = toY - fromY;
const length = Math.hypot(dx, dy);
if (length <= SELECTION_RADIUS_PX) {
leader.setAttribute('visibility', 'hidden');
return;
}
// Start on the arcs' rim, not the body's centre.
leader.setAttribute('x1', String(fromX + (dx / length) * SELECTION_RADIUS_PX));
leader.setAttribute('y1', String(fromY + (dy / length) * SELECTION_RADIUS_PX));
leader.setAttribute('x2', String(toX));
leader.setAttribute('y2', String(toY));
leader.setAttribute('visibility', 'visible');
}
/** Resolves the current system's neighbours once, on arrival. Cleared outside a system. */
private resolveNeighbours(): void {
const origin = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
if (!origin || !this.neighbourhood) {
this.neighbours = [];
return;
}
this.neighbours = this.neighbourhood
// Asked wide and cut back, because a catalogue holds binary companions as two rows at one
// position: a neighbour whose separation rounds to what no separation prints as is not a
// place to go, it is the same place. Compared through the formatter rather than against a
// hand-picked epsilon, so the rule stays "would print as zero" whatever the formatter does.
//
// Named stars first, survey designations only where fewer than that are in reach: a ring
// that exists to say where you are is not helped by "Gaia DR3 5853498713190525696" —
// least of all when that is the same star as the Proxima Centauri printed beside it.
.nearestPreferring(origin.id, NEIGHBOUR_COUNT * 2, (point) => {
const star = this.starsById.get(point.id);
return star !== undefined && !isDesignation(star);
})
.filter((neighbour) => formatParsecs(neighbour.distancePc) !== formatParsecs(0))
.slice(0, NEIGHBOUR_COUNT)
.flatMap((neighbour) => {
const star = this.starsById.get(neighbour.id);
return star
? [
{
star,
distancePc: neighbour.distancePc,
// A unit vector in the catalogue's parsec frame, which is the same direction in
// the system's AU frame: only the scale between the two differs.
direction: new THREE.Vector3(star.x - origin.x, star.y - origin.y, star.z - origin.z).normalize()
}
]
: [];
});
}
/** Re-reads the HUD surfaces the ring has to print around, as boxes relative to the canvas. */
private refreshReservedBoxes(): void {
const canvas = this.canvasRef().nativeElement.getBoundingClientRect();
const panels = [
this.dockRef()?.nativeElement.querySelector('[role="tabpanel"]'),
this.dockRef()?.nativeElement.querySelector('[role="tablist"]')?.parentElement,
this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]')
];
this.reserved = panels.flatMap((panel) => {
if (!panel) {
return [];
}
const box = panel.getBoundingClientRect();
return [{ left: box.left - canvas.left, top: box.top - canvas.top, right: box.right - canvas.left, bottom: box.bottom - canvas.top }];
});
}
/**
* Where a neighbour's name sits: on the ring, at the bearing its own direction lands on —
* moved along the ring where a HUD panel already holds that place. `null` where the whole
* neighbourhood of that bearing is covered.
*/
private neighbourRingPosition(camera: SceneCamera, direction: THREE.Vector3): THREE.Vector3 | null {
const bearing = this.ringBearing.copy(direction).applyQuaternion(this.ringInverse.copy(camera.quaternion).invert());
// A neighbour behind the camera keeps the side it is on, which is still the way to turn to
// bring it round.
const angle = Math.atan2(bearing.y, bearing.x);
const canvas = this.canvasRef().nativeElement;
const placed = ringPlacement(angle, NEIGHBOUR_RING_NDC, { width: canvas.clientWidth, height: canvas.clientHeight }, this.reserved);
if (!placed) {
return null;
}
if (this.engine.currentProjection === 'orthographic') {
// A parallel projection has no vanishing point to walk towards: every ray through the
// frame is the view direction, so treating the unprojected offset as one and stepping
// along it throws the sideways part away and pulls the whole ring into the middle. The
// unprojected point is already where the name goes.
return this.ringPoint.set(placed.x, placed.y, 0).unproject(camera);
}
const along = this.ringPoint.set(placed.x, placed.y, 0.5).unproject(camera).sub(camera.position).normalize();
return along.multiplyScalar(NEIGHBOUR_DEPTH_AU).add(camera.position);
}
/**
* Names the stars nearest the one the camera is inside, each on the side of the view its own
* lies on. It is the one thing a system view cannot otherwise say: which way its neighbours
* are, and how far. Each is a button that flies there, so a chain of neighbours can be walked
* without pulling back out to the field between hops.
*
* These are bearings, not sky positions, and are drawn as such: a ring of names at a fixed
* radius from the centre of the frame, which reads as instrument rather than as scene. The
* true position cannot be drawn — the nearest star to the Sun is 268 000 AU away, thirteen
* times the far plane — and a true *direction* is worse than useless here: at this field of
* view three neighbours in four fall outside the frame, so the view would name whichever
* happened to be in front and stay silent about the rest. What survives is the half of the
* direction a viewer can act on: which way to turn to face it.
*/
private neighbourLabels(camera: SceneCamera): LabeledPoint[] {
this.refreshReservedBoxes();
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
const position = this.neighbourRingPosition(camera, direction);
if (!position) {
return [];
}
return [{
// Namespaced, so a star's ghost and the same star's own label in the galaxy view are
// never the one DOM node being asked to be two different things.
id: `neighbour:${star.id}`,
name: star.name,
kind: formatParsecs(distancePc),
tone: 'ghost' as const,
selectStarId: star.id,
x: position.x,
y: position.y,
z: position.z
}];
});
}
/**
* Holds the ring still. The names are placed relative to the camera, so between label passes
* — five a second — any camera movement would drag them off the ring and snap them back. This
* runs every frame and costs four vector operations.
*/
private updateNeighbourRing(camera: SceneCamera): void {
if (!this.systemGroup.visible || this.neighbours.length === 0) {
return;
}
for (const { star, direction } of this.neighbours) {
const position = this.neighbourRingPosition(camera, direction);
if (position) {
this.labelOverlay?.moveLabel(`neighbour:${star.id}`, position.x, position.y, position.z);
}
}
}
/** /**
* Names the bodies of the system the view is inside. * Names the bodies of the system the view is inside.
* *
@@ -591,7 +989,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* Moons are left out entirely: they sit within a marker's width of their planet at system * Moons are left out entirely: they sit within a marker's width of their planet at system
* framing, so their labels could only ever print on top of it. * framing, so their labels could only ever print on top of it.
*/ */
private updateSystemLabels(camera: THREE.PerspectiveCamera): void { private updateSystemLabels(camera: SceneCamera): void {
const renderer = this.systemRenderer; const renderer = this.systemRenderer;
if (!renderer) { if (!renderer) {
this.labelOverlay?.update([]); this.labelOverlay?.update([]);
@@ -629,11 +1027,122 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu); points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
this.labelOverlay?.update(this.spreadLabels(points, camera, null)); // Bodies first, so a neighbour's name never takes the space one of this system's own would
// have had: `spreadLabels` keeps whichever candidate it reaches first.
this.labelOverlay?.update(this.spreadLabels([...points, ...this.neighbourLabels(camera)], camera, null));
} }
/** Refreshes the readout panel for whichever scale the view is currently at. */ /** Refreshes the readout panel for whichever scale the view is currently at. */
private updateHud(camera: THREE.PerspectiveCamera): void { /** A zoom carried from one unit space into the other would frame nothing recognisable. */
private resetZoom(): void {
const camera = this.engine.getCamera();
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
(camera as THREE.OrthographicCamera).zoom = 1;
}
}
/**
* Keeps the plan view's frame, its zoom limits and its star sizes in step with the camera.
*
* All three are functions of how far the camera is orbiting from its target, which is the one
* thing the flights already animate — so a system entered, left or flown between reframes
* itself under this projection with the easing the perspective flights have, and no camera
* rig knows anything about it.
*/
private frameProjection(camera: SceneCamera): void {
if (!this.controls) {
return;
}
if (this.engine.currentProjection !== 'orthographic') {
this.starField?.setProjection(null);
this.hostRings?.setProjection(null);
return;
}
const distance = camera.position.distanceTo(this.controls.target);
this.engine.frameOrthographic(distance);
// Zoom is what a wheel moves under this projection, so the orbit clamps have to be restated
// as the zoom levels that frame the same extents.
// A plain multiplier on the frame the distance already sets, bounded by a factor rather than
// by the orbit limits: those are in whichever unit space the view is in, and reading them on
// the frame the scene swaps from parsecs to astronomical units pins the zoom at the ratio
// between the two — which is how leaving a system used to land the view three kiloparsecs out.
this.controls.minZoom = 1 / PLAN_ZOOM_SPAN;
this.controls.maxZoom = PLAN_ZOOM_SPAN;
const halfHeight = this.engine.visibleHalfHeight(distance);
this.starField?.setProjection(halfHeight);
this.hostRings?.setProjection(halfHeight);
}
/**
* Switches between the perspective view and the plan: an orthographic projection looking down
* the plane the current scale is read against — the galactic plane out here, this system's own
* orbital plane inside one.
*
* Both halves matter and neither alone is "2D". The projection is what makes a circle a circle
* wherever it sits in the frame instead of an ellipse that leans away from the centre; the
* swing to face the plane is what makes that worth looking at. Orbiting still works afterwards,
* so the plan is where a plan view starts, not a cage.
*/
private applyProjection(plan: boolean): void {
if (!this.controls || !this.rig || this.engine.currentProjection === (plan ? 'orthographic' : 'perspective')) {
return;
}
const camera = this.engine.getCamera();
const target = this.controls.target.clone();
const distance = camera.position.distanceTo(target);
this.engine.setProjection(plan ? 'orthographic' : 'perspective', distance);
const next = this.engine.getCamera();
// OrbitControls holds one camera for the lifetime of the gesture state it keeps; handing it
// the other one keeps the target, the damping and the pointer bindings it already has.
this.controls.object = next;
this.rig = new CameraRigController(next, this.controls);
next.position.copy(camera.position);
next.up.copy(camera.up);
if (plan) {
// Straight down the plane's normal, from where the camera already was.
// Down the normal of the plane this scale is actually read against. Inside a system that
// is the system's own orbital plane; outside it, the galactic plane — whose normal is the
// north galactic pole, not the celestial one. Defaulting to the scene's own z would have
// looked down the Earth's rotation axis and called it the plane of the Galaxy.
const galactic = GALACTIC_BASIS_EQUATORIAL;
const inSystem = this.systemGroup.visible && this.systemRenderer;
const normal = inSystem
? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame)
: new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z);
next.position.copy(target).add(normal.multiplyScalar(distance));
if (inSystem) {
next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame);
} else {
// Towards the galactic centre, so the plan is oriented the way the model is described.
next.up.set(galactic.x.x, galactic.x.y, galactic.x.z);
}
}
next.lookAt(target);
this.controls.update();
this.applyDisplay(this.display());
}
/**
* Shows or hides the layers that hold still between frames: the label layer and the system
* view's orbits and grid. The galaxy grids and deep-sky shell are crossfaded every frame
* instead, so their toggles live in `updateGalacticCrossfade`. The skybox is both: the
* crossfade rewrites its intensity while the galaxy is up, but the crossfade is parked in
* system view, where the sky is still on screen — so it is also set here, once, on toggle.
*/
private applyDisplay(display: HudDisplay): void {
if (this.labelOverlay) {
this.labelOverlay.domElement.style.display = display.labels ? '' : 'none';
}
this.systemRenderer?.setLayerVisibility({ orbits: display.orbits, grid: display.grid });
// The effect that calls this fires once at construction, before the engine has a scene.
if (this.engine.isInitialized) {
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
}
}
private updateHud(camera: SceneCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId); const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
if (this.systemGroup.visible && star) { if (this.systemGroup.visible && star) {
@@ -653,11 +1162,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : []) ...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
]); ]);
this.hudNote.set('Orbits propagated from published elements to the current date.'); this.hudNote.set('Orbits propagated from published elements to the current date.');
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET))); this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360)));
return; return;
} }
this.hudRange.set(formatParsecs(camera.position.length())); this.hudRange.set(formatParsecs(this.effectiveDistance(camera)));
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) { if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
this.hudEyebrow.set('Galactic Scale'); this.hudEyebrow.set('Galactic Scale');
@@ -720,13 +1229,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
} }
}; };
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void { private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: SceneCamera): void {
if (!this.starField) { if (!this.starField) {
return; return;
} }
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so // Screen-space rather than a raycast: the star field billboards in the vertex shader, so
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`. // its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
const starId = this.starField.pickAt(pointerNdc, camera); const starId = this.starField.pickAt(pointerNdc, camera, this.viewportAspect());
if (starId !== undefined) { if (starId !== undefined) {
this.navigationStore.selectStar(starId); this.navigationStore.selectStar(starId);
} }
@@ -780,9 +1289,72 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.refreshObjectCard(); this.refreshObjectCard();
}; };
/** Offered as the departure without typing, since it is where the view already is. */
readonly currentStarOption = computed<RouteStarOption | null>(() => {
const starId = this.navigationStore.selectedStarId();
const star = starId === null ? undefined : this.starsById.get(starId);
return star ? { id: star.id, name: star.name, subtitle: star.spectralType } : null;
});
onRouteQuery(query: string): void {
this.routeQuery.set(query);
}
/**
* Walks the graph, and where it cannot, says what range would. The search is lazy — it asks
* the index for a star's neighbours as it reaches that star — so plotting one route never
* costs a pass over the catalogue.
*/
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
if (!this.neighbourhood) {
return;
}
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc);
if (route) {
this.routeResult.set({
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
totalPc: route.totalPc,
neededRangePc: null
});
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id));
return;
}
this.routeResult.set({
stars: [],
totalPc: 0,
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC)
});
this.jumpLinks?.setRoute([], () => undefined);
}
/**
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once. Only
* when the layer is on and the range has actually moved — the control emits per pixel dragged.
*/
private refreshJumpLinks(): void {
if (!this.jumpLinks || !this.neighbourhood) {
return;
}
const rangePc = this.jumpRangePc();
if (!this.display().jumpLinks) {
if (this.drawnJumpRangePc !== null) {
this.jumpLinks.setLinks([], () => undefined);
this.drawnJumpRangePc = null;
}
return;
}
if (this.drawnJumpRangePc === rangePc) {
return;
}
this.drawnJumpRangePc = rangePc;
const links = collectJumpLinks(this.neighbourhood, rangePc);
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
}
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */ /** A pinned body wins over a hovered one, so the card does not change under the pointer. */
private refreshObjectCard(): void { private refreshObjectCard(): void {
const id = this.pinnedBodyId ?? this.hoveredBodyId; const id = this.pinnedBodyId ?? this.hoveredBodyId;
this.cardBodyId = id;
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars })); this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
} }
@@ -790,6 +1362,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private clearObjectCard(): void { private clearObjectCard(): void {
this.pinnedBodyId = null; this.pinnedBodyId = null;
this.hoveredBodyId = null; this.hoveredBodyId = null;
this.cardBodyId = null;
this.objectCard.set(undefined); this.objectCard.set(undefined);
this.canvasRef().nativeElement.style.cursor = ''; this.canvasRef().nativeElement.style.cursor = '';
} }
@@ -798,6 +1371,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.clearObjectCard(); this.clearObjectCard();
} }
/** A kept place, revisited: a star is a system to fly into, a body is a page to open. */
goToBookmark(bookmark: Bookmark): void {
if (bookmark.kind === 'star') {
this.navigationStore.selectStar(Number(bookmark.id));
} else {
this.openObjectDetail(String(bookmark.id));
}
}
/** The deliberate step out to the dedicated route, from the card's own control. */ /** The deliberate step out to the dedicated route, from the card's own control. */
openObjectDetail(id: string): void { openObjectDetail(id: string): void {
this.navigationStore.selectBody(id); this.navigationStore.selectBody(id);
@@ -878,12 +1460,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const hostLuminosity = luminosityOf(star); const hostLuminosity = luminosityOf(star);
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity); this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
this.systemGroup.add(this.systemRenderer.object); this.systemGroup.add(this.systemRenderer.object);
this.applyDisplay(this.display());
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always // Framed against the grid's outer ring rather than the outermost orbit — the ring is always
// the wider of the two — and against the camera this scene actually has, so the margin holds // the wider of the two — and against the camera this scene actually has, so the margin holds
// whatever the window shape. Computed before the star, because how far away the star will be // whatever the window shape. Computed before the star, because how far away the star will be
// seen from is what decides how big its halo has to be to stay visible. // seen from is what decides how big its halo has to be to stay visible.
const viewport = { fovDegrees: camera.fov, aspect: camera.aspect }; // Framed against the perspective camera whichever is active: the framing distance is what
// the orthographic frustum is then sized from, so both projections show the same extent.
const framingCamera = this.engine.getPerspectiveCamera();
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport); const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport); const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
@@ -914,12 +1500,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// clumped over the system's star. // clumped over the system's star.
this.labelOverlay?.update([]); this.labelOverlay?.update([]);
camera.near = SYSTEM_NEAR_AU; // The perspective camera whichever one is live: it is where the depth range is reasoned,
camera.far = SYSTEM_FAR_AU; // and the plan view re-derives its own from it every frame. Writing to the active camera
camera.updateProjectionMatrix(); // put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = SYSTEM_NEAR_AU;
depthCamera.far = SYSTEM_FAR_AU;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU; this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU; this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.resetZoom();
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) }); this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
// Arrives along whichever direction the approach came from, then swings round to look down // Arrives along whichever direction the approach came from, then swings round to look down
@@ -929,6 +1520,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => { this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
this.currentStarId = star.id; this.currentStarId = star.id;
this.resolveNeighbours();
this.navigationStore.setViewLevel('system'); this.navigationStore.setViewLevel('system');
onComplete(); onComplete();
}); });
@@ -963,22 +1555,29 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// into the next system entered. // into the next system entered.
this.clearObjectCard(); this.clearObjectCard();
camera.near = GALAXY_NEAR_PC; // The perspective camera whichever one is live: it is where the depth range is reasoned,
camera.far = GALAXY_FAR_PC; // and the plan view re-derives its own from it every frame. Writing to the active camera
camera.updateProjectionMatrix(); // put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC; this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC; this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.resetZoom();
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc }); this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) { if (isSwitchingSystems) {
this.currentStarId = null; this.currentStarId = null;
this.resolveNeighbours();
onComplete(); onComplete();
return; return;
} }
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => { this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null; this.currentStarId = null;
this.resolveNeighbours();
this.navigationStore.setViewLevel('galaxy'); this.navigationStore.setViewLevel('galaxy');
onComplete(); onComplete();
}); });
@@ -0,0 +1,86 @@
import * as THREE from 'three/webgpu';
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
const RING_SIZE_PX = 12;
const RING_PEAK_OPACITY = 0.35;
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
const RING_RADIUS_UV = 0.42;
const RING_STROKE_UV = 0.06;
/** A unit quad centred on the origin — the billboard every ring instance is drawn on. */
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
const geometry = new THREE.InstancedBufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
geometry.instanceCount = instanceCount;
return geometry;
}
/**
* A faint accent ring around every star known to host planets: the one binary fact about a
* point of light worth reading at a glance from the neighbourhood view, since it is the one
* thing that says "there is somewhere to go here".
*
* Drawn the way the star field draws its stars — instanced unattenuated sprites — so the rings
* sit exactly on the field's own points at any zoom and window size. The ring itself is a band
* of the quad's uv distance from centre, not a texture, so it stays a hairline at any scale.
*/
export class HostStarRings {
readonly object: THREE.Mesh;
readonly count: number;
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
private readonly opacity = uniform(RING_PEAK_OPACITY);
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
constructor(hosts: readonly StarRecord[], accent: number) {
const positions = new Float32Array(hosts.length * 3);
hosts.forEach((star, i) => positions.set([star.x, star.y, star.z], i * 3));
this.geometry = createQuadGeometry(hosts.length);
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
// As in the star field: the angular-to-world conversion is done here rather than by
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
this.material.positionNode = position;
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = color(accent);
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
this.material.opacityNode = smoothstep(RING_STROKE_UV, 0.0, distanceFromRing).mul(this.opacity);
this.object = new THREE.Mesh(this.geometry, this.material);
this.object.name = 'host-star-rings';
// As for the star field: the quad's bounds say nothing about where the instances are.
this.object.frustumCulled = false;
this.count = hosts.length;
}
/** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
setStrength(strength: number): void {
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
this.opacity.value = RING_PEAK_OPACITY * clamped;
this.object.visible = clamped > 0;
}
dispose(): void {
this.geometry.dispose();
this.material.dispose();
}
}
@@ -0,0 +1,110 @@
import * as THREE from 'three/webgpu';
import { JumpLink } from '../../shared/astro/jump-links';
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
const LINK_OPACITY = 0.16;
/** The one route is the figure; the graph it is drawn on is the ground. */
const ROUTE_OPACITY = 0.9;
/**
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
* bright cloud; stepping the ground down is what makes the figure a figure.
*/
const GROUND_WHILE_ROUTED = 0.4;
export interface LinkPoint {
readonly x: number;
readonly y: number;
readonly z: number;
}
/**
* The jump-link graph, and one route through it.
*
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
* so a chain of hops reads as one continuous thing.
*/
export class JumpLinkRenderer {
readonly object = new THREE.Group();
private readonly linkMaterial: THREE.LineBasicMaterial;
private readonly routeMaterial: THREE.LineBasicMaterial;
private readonly links: THREE.LineSegments;
private readonly route: THREE.Line;
private strength = 1;
private routed = false;
constructor(accent: THREE.ColorRepresentation) {
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
this.links.frustumCulled = false;
this.route.frustumCulled = false;
this.object.add(this.links, this.route);
this.setLinks([], () => undefined);
this.setRoute([], () => undefined);
}
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
const vertices = new Float32Array(links.length * 6);
let at = 0;
for (const link of links) {
const from = positionOf(link.from);
const to = positionOf(link.to);
if (!from || !to) {
continue;
}
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
at += 6;
}
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
}
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
this.routed = points.length >= 2;
this.applyOpacity();
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
points.forEach((point, i) => {
if (vertices.length > 0) {
vertices.set([point.x, point.y, point.z], i * 3);
}
});
this.replaceGeometry(this.route, vertices);
}
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
setStrength(strength: number): void {
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
this.applyOpacity();
this.object.visible = this.strength > 0;
}
private applyOpacity(): void {
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
}
dispose(): void {
this.links.geometry.dispose();
this.route.geometry.dispose();
this.linkMaterial.dispose();
this.routeMaterial.dispose();
}
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
const previous = target.geometry;
target.geometry = geometry;
previous.dispose();
}
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { ReservedBox, ringPlacement } from './label-ring';
const VIEWPORT = { width: 1440, height: 900 };
/** Where a placement lands on screen, which is what the rule is really about. */
function screen(placement: { x: number; y: number }): { x: number; y: number } {
return { x: Math.round(((placement.x + 1) / 2) * VIEWPORT.width), y: Math.round(((1 - placement.y) / 2) * VIEWPORT.height) };
}
describe('ringPlacement', () => {
it('puts a name where its bearing points, on a ring that is round on screen', () => {
const right = ringPlacement(0, 0.74, VIEWPORT);
const up = ringPlacement(Math.PI / 2, 0.74, VIEWPORT);
// Same distance from the centre in pixels, despite the frame being wider than it is tall.
const centre = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };
const radius = (p: { x: number; y: number }) => Math.hypot(screen(p).x - centre.x, screen(p).y - centre.y);
expect(radius(right!)).toBeCloseTo(radius(up!), 0);
expect(screen(right!).y).toBe(450);
expect(screen(up!).x).toBe(720);
});
it('leaves the bearing alone when nothing is in the way', () => {
expect(ringPlacement(1.1, 0.74, VIEWPORT)?.angle).toBeCloseTo(1.1);
});
it('slides a name along the ring rather than printing it behind a panel', () => {
// The readout panel, bottom left, where the ring passes.
const readout: ReservedBox = { left: 24, top: 640, right: 536, bottom: 830 };
const behindIt = (5 * Math.PI) / 4;
const placed = ringPlacement(behindIt, 0.74, VIEWPORT, [readout]);
expect(placed).not.toBeNull();
expect(placed!.angle).not.toBeCloseTo(behindIt);
const { x, y } = screen(placed!);
expect(x > readout.right || x < readout.left || y < readout.top || y > readout.bottom).toBe(true);
});
it('moves it the smallest distance that clears, and to the nearer side', () => {
const box: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 200 };
const straightUp = Math.PI / 2;
const placed = ringPlacement(straightUp, 0.74, VIEWPORT, [box]);
expect(placed).not.toBeNull();
expect(Math.abs(placed!.angle - straightUp)).toBeLessThanOrEqual(Math.PI / 3);
});
it('gives up rather than half-hide a name, when everything near its bearing is covered', () => {
const wall: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 900 };
expect(ringPlacement(0, 0.74, VIEWPORT, [wall])).toBeNull();
});
it('counts the width of the text, not just the point it hangs from', () => {
// A panel the anchor clears by 40px but the text does not.
const justRight: ReservedBox = { left: 1150, top: 400, right: 1440, bottom: 500 };
const placed = ringPlacement(0, 0.74, VIEWPORT, [justRight]);
expect(placed!.angle).not.toBeCloseTo(0);
});
it('fits a frame held upright, where sizing against the height alone would miss it entirely', () => {
const phone = { width: 390, height: 844 };
const right = ringPlacement(0, 0.74, phone);
const up = ringPlacement(Math.PI / 2, 0.74, phone);
expect(Math.abs(right!.x)).toBeLessThanOrEqual(1);
expect(Math.abs(up!.y)).toBeLessThanOrEqual(1);
// Still a circle: the same number of pixels out, whichever way it is measured.
expect(Math.abs(right!.x) * (phone.width / 2)).toBeCloseTo(Math.abs(up!.y) * (phone.height / 2), 0);
});
it('survives a viewport with no height rather than dividing by it', () => {
expect(ringPlacement(0, 0.74, { width: 0, height: 0 })).not.toBeNull();
});
});
@@ -0,0 +1,86 @@
/**
* Where a name goes on the ring around the view, given the direction it stands for and the
* panels already occupying the frame.
*
* Pure and in screen space, so the rule can be read and tested without a scene: the caller turns
* a direction into an angle, this decides where on the ring that angle can actually be printed,
* and the caller turns the answer back into a point the renderer can project.
*/
/** A box the ring must not print into, in pixels from the top-left of the viewport. */
export interface ReservedBox {
readonly left: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
}
export interface RingViewport {
readonly width: number;
readonly height: number;
}
/** A place on the ring, in normalised device coordinates (-1..1, y up). */
export interface RingPlacement {
readonly x: number;
readonly y: number;
/** The angle actually used, which is the requested one unless a panel was in the way. */
readonly angle: number;
}
/**
* How far the bearing may be bent to get out from behind a panel, and in what steps. Bending is
* a lie about the direction, so it is kept small and always tried in the smallest amount that
* works, alternating sides so the name ends up on whichever side of the panel is nearer.
*/
const MAX_NUDGE_RADIANS = Math.PI / 3;
const NUDGE_STEP_RADIANS = Math.PI / 24;
/**
* The label's text runs this far from its anchor, as a fraction of the viewport width, and this
* tall. A name clears a panel only if the whole line does, not just the point it hangs from.
*/
const LABEL_REACH_FRACTION = 0.13;
const LABEL_HEIGHT_PX = 30;
function overlaps(x: number, y: number, viewport: RingViewport, reserved: readonly ReservedBox[]): boolean {
const px = ((x + 1) / 2) * viewport.width;
const py = ((1 - y) / 2) * viewport.height;
const reach = viewport.width * LABEL_REACH_FRACTION;
// Either side, because which side the text hangs on is decided later, by the label pass.
const left = px - reach;
const right = px + reach;
const top = py - LABEL_HEIGHT_PX / 2;
const bottom = py + LABEL_HEIGHT_PX / 2;
return reserved.some((box) => left < box.right && right > box.left && top < box.bottom && bottom > box.top);
}
/**
* Places one name on the ring at `angle`, moved along the ring if a panel is in the way, or
* `null` if the whole neighbourhood of that angle is covered — better absent than half hidden
* behind a readout.
*
* `radius` is a fraction of the frame's shorter side, so the ring is a circle on screen — and
* fits whichever way up the frame is. Sizing it against the height alone puts the ring a
* viewport and a half wide on a phone held upright, which is to say off both edges.
*/
export function ringPlacement(
angle: number,
radius: number,
viewport: RingViewport,
reserved: readonly ReservedBox[] = []
): RingPlacement | null {
const shorterSide = Math.min(viewport.width, viewport.height);
const scaleX = viewport.width === 0 ? radius : (radius * shorterSide) / viewport.width;
const scaleY = viewport.height === 0 ? radius : (radius * shorterSide) / viewport.height;
for (let nudge = 0; nudge <= MAX_NUDGE_RADIANS; nudge += NUDGE_STEP_RADIANS) {
for (const candidate of nudge === 0 ? [angle] : [angle + nudge, angle - nudge]) {
const x = Math.cos(candidate) * scaleX;
const y = Math.sin(candidate) * scaleY;
if (!overlaps(x, y, viewport, reserved)) {
return { x, y, angle: candidate };
}
}
}
return null;
}
@@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => {
it('finds the star under the pointer', () => { it('finds the star under the pointer', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked)); const renderer = new StarFieldRenderer(picked, packPositions(picked));
// Both Near and Far project to the screen centre; either is a correct hit. // Both Near and Far project to the screen centre; either is a correct hit.
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera)); expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect));
renderer.dispose(); renderer.dispose();
}); });
it('returns undefined when the pointer is on empty sky', () => { it('returns undefined when the pointer is on empty sky', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked)); const renderer = new StarFieldRenderer(picked, packPositions(picked));
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined();
renderer.dispose(); renderer.dispose();
}); });
@@ -168,7 +168,7 @@ describe('StarFieldRenderer', () => {
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })]; const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
const renderer = new StarFieldRenderer(behind, packPositions(behind)); const renderer = new StarFieldRenderer(behind, packPositions(behind));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.dispose(); renderer.dispose();
}); });
@@ -182,7 +182,7 @@ describe('StarFieldRenderer', () => {
// Aim at where star 2 projects, and confirm we get it rather than its neighbours. // Aim at where star 2 projects, and confirm we get it rather than its neighbours.
const target = new THREE.Vector3(0, 2, -10).project(camera); const target = new THREE.Vector3(0, 2, -10).project(camera);
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2); expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2);
renderer.dispose(); renderer.dispose();
}); });
@@ -195,7 +195,7 @@ describe('StarFieldRenderer', () => {
// Walk outward from the centre until each stops being pickable. // Walk outward from the centre until each stops being pickable.
const reach = (renderer: StarFieldRenderer): number => { const reach = (renderer: StarFieldRenderer): number => {
let offset = 0; let offset = 0;
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) { while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) {
offset += 0.001; offset += 0.001;
} }
return offset; return offset;
@@ -212,13 +212,13 @@ describe('StarFieldRenderer', () => {
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })]; const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
const renderer = new StarFieldRenderer(faint, packPositions(faint)); const renderer = new StarFieldRenderer(faint, packPositions(faint));
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5); expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5);
renderer.dispose(); renderer.dispose();
}); });
it('finds nothing in an empty field', () => { it('finds nothing in an empty field', () => {
const renderer = new StarFieldRenderer([], new Float32Array(0)); const renderer = new StarFieldRenderer([], new Float32Array(0));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined(); expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.dispose(); renderer.dispose();
}); });
}); });
@@ -1,23 +1,15 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl'; import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { spectralTypeToColorIndex } from '../../shared/astro/spectral'; import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */ /** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5; const MIN_POINT_SIZE = 1.5;
const MAX_POINT_SIZE = 6; const MAX_POINT_SIZE = 6;
/**
* Star size is expressed in pixels for readability, but the material works in angular size, so
* the two are related through the scene's vertical field of view and a reference viewport.
* Because the size is angular, a star keeps the same share of the screen at any window size —
* these pixel figures are exact only at this reference height.
*/
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
const REFERENCE_FOV_DEGREES = 55;
const PIXELS_TO_ANGULAR_SIZE =
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
/** /**
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the * Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
@@ -169,6 +161,10 @@ export class StarFieldRenderer {
/** How many of the catalogue's stars this field actually draws. */ /** How many of the catalogue's stars this field actually draws. */
readonly drawnCount: number; readonly drawnCount: number;
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
private readonly geometry: THREE.InstancedBufferGeometry; private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial; private readonly material: THREE.SpriteNodeMaterial;
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */ /** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
@@ -209,10 +205,22 @@ export class StarFieldRenderer {
depthWrite: false, depthWrite: false,
blending: THREE.AdditiveBlending blending: THREE.AdditiveBlending
}); });
this.material.sizeAttenuation = false; // The compensation that turns an angular size into a world size is done here rather than by
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3'); // `sizeAttenuation: false`, which three.js applies only when it is compiling against a
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float'); // perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3'); // === false`). Under an orthographic one it is silently skipped and every star collapses to
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
// a uniform, lets one material serve both cameras without being recompiled between them.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
this.material.positionNode = position;
// Perspective: a star's world size is its angular size times how far away it is, which is
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
// the frustum does, so the same angular size is scaled by the frustum instead.
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
// Soft radial falloff so each star is a small bright core inside a halo, rather than a // Soft radial falloff so each star is a small bright core inside a halo, rather than a
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre. // hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
const radius = uv().sub(vec2(0.5)).length(); const radius = uv().sub(vec2(0.5)).length();
@@ -224,6 +232,19 @@ export class StarFieldRenderer {
this.object.frustumCulled = false; this.object.frustumCulled = false;
} }
/**
* Tells the field which projection it is being drawn under.
*
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
* perspective camera, where a star's distance sets its apparent size on its own.
*/
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
// The world size that subtends the same share of the viewport an angular size would under
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** Looks up the HYG star id for a given instance index. */ /** Looks up the HYG star id for a given instance index. */
starIdAt(instanceIndex: number): number | undefined { starIdAt(instanceIndex: number): number | undefined {
return this.stars[instanceIndex]?.id; return this.stars[instanceIndex]?.id;
@@ -239,8 +260,14 @@ export class StarFieldRenderer {
* what the user sees at every zoom level instead of being over-permissive up close and * what the user sees at every zoom level instead of being over-permissive up close and
* sub-pixel at the far end of the camera's range. * sub-pixel at the far end of the camera's range.
*/ */
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined { pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360); // What a unit of angular size is worth on screen. Under perspective the field of view sets
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
// by that same half-height leaves the reference field of view and nothing else. Both cases
// are therefore one formula over a different angle.
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
const projected = new THREE.Vector3(); const projected = new THREE.Vector3();
let bestIndex: number | undefined; let bestIndex: number | undefined;
@@ -258,7 +285,7 @@ export class StarFieldRenderer {
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y, // A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular. // divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP; const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
const dx = (projected.x - pointerNdc.x) * camera.aspect; const dx = (projected.x - pointerNdc.x) * aspect;
const dy = projected.y - pointerNdc.y; const dy = projected.y - pointerNdc.y;
const score = Math.hypot(dx, dy) / ndcRadius; const score = Math.hypot(dx, dy) / ndcRadius;
@@ -1,6 +1,8 @@
import * as THREE from 'three/webgpu'; import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import { StarLabelOverlay } from './star-label-overlay'; import { StarLabelOverlay } from './star-label-overlay';
describe('StarLabelOverlay', () => { describe('StarLabelOverlay', () => {
@@ -77,6 +79,66 @@ describe('StarLabelOverlay', () => {
expect(labels()).toHaveLength(0); expect(labels()).toHaveLength(0);
}); });
it('hangs a label on the side it is told to, and can move it across', () => {
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'left' }]);
const object = scene.children[0] as CSS2DObject;
expect(labels()[0].classList.contains('map-label--left')).toBe(true);
expect(object.center.x).toBe(1);
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'right' }]);
expect(labels()[0].classList.contains('map-label--left')).toBe(false);
expect(object.center.x).toBe(0);
});
it('brackets one selected point with the mark, moves it, and clears it', () => {
overlay.setSelection({ x: 1, y: 2, z: 3 });
overlay.setSelection({ x: 4, y: 5, z: 6 });
overlay.render(camera);
const marks = overlay.domElement.querySelectorAll('.map-select');
expect(marks).toHaveLength(1);
expect((scene.children[0] as THREE.Object3D).position.toArray()).toEqual([4, 5, 6]);
overlay.setSelection(null);
overlay.render(camera);
expect(overlay.domElement.querySelectorAll('.map-select')).toHaveLength(0);
expect(scene.children).toHaveLength(0);
});
it('says a neighbour more quietly than a body of this system', () => {
overlay.update([
{ id: 'earth', name: 'Earth', kind: 'Planet', x: 1, y: 0, z: 0 },
{ id: 'neighbour:1', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 1, x: 0, y: 1, z: 0 }
]);
const [body, ghost] = labels();
expect(body.classList.contains('map-label--ghost')).toBe(false);
expect(ghost.classList.contains('map-label--ghost')).toBe(true);
expect(ghost.querySelector('.map-label-kind')?.textContent).toBe('2.64 pc');
});
it('makes a label that offers a star a button, and hands back the star it names', () => {
const chosen: number[] = [];
overlay = new StarLabelOverlay(scene, (starId) => chosen.push(starId));
overlay.setSize(800, 600);
overlay.update([{ id: 'neighbour:42', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 42, x: 1, y: 0, z: 0 }]);
const ghost = labels()[0];
expect(ghost.tagName).toBe('BUTTON');
// Its two lines are adjacent spans, so without this it is announced as "Sirius2.64 pc".
expect(ghost.getAttribute('aria-label')).toBe('Go to Sirius, 2.64 pc away');
ghost.click();
expect(chosen).toEqual([42]);
});
it('leaves a label that offers nothing untouchable, so the scene behind it stays clickable', () => {
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
const label = labels()[0];
expect(label.tagName).toBe('DIV');
expect(label.classList.contains('map-label--select')).toBe(false);
});
it('leaves nothing behind when disposed', () => { it('leaves nothing behind when disposed', () => {
overlay.update([ overlay.update([
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }, { id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
@@ -15,11 +15,49 @@ export interface LabeledPoint {
* is called. * is called.
*/ */
kind?: string; kind?: string;
/**
* Which side of the point the text hangs on. Right is the default; left is for a point close
* to the right edge of the view, or one whose right-hand text would run into a neighbour's.
*/
side?: LabelSide;
/**
* `ghost` is the quieter voice: a star outside the system the camera is in, named so its
* direction can be read without leaving. Dimmer, and it can be selected.
*/
tone?: LabelTone;
/**
* The star this label offers to fly to. Present makes the label a real button — focusable,
* clickable, and the only labels the pointer can reach at all. Whether a given id is
* selectable never changes between updates, so the element it needs is settled at creation.
*/
selectStarId?: number;
x: number; x: number;
y: number; y: number;
z: number; z: number;
} }
export type LabelSide = 'left' | 'right';
export type LabelTone = 'normal' | 'ghost';
/** Where the selection mark sits, in the same scene units as the labels. */
export interface SelectionPoint {
x: number;
y: number;
z: number;
}
function classesFor(point: Pick<LabeledPoint, 'side' | 'tone' | 'selectStarId'>): string {
return [
'map-label',
point.side === 'left' ? 'map-label--left' : '',
point.tone === 'ghost' ? 'map-label--ghost' : '',
point.selectStarId === undefined ? '' : 'map-label--select',
'whitespace-nowrap font-body'
]
.filter(Boolean)
.join(' ');
}
/** /**
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as * Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and * children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
@@ -31,8 +69,13 @@ export class StarLabelOverlay {
private readonly cssRenderer = new CSS2DRenderer(); private readonly cssRenderer = new CSS2DRenderer();
private readonly labelObjects = new Map<number | string, CSS2DObject>(); private readonly labelObjects = new Map<number | string, CSS2DObject>();
private selection?: CSS2DObject;
constructor(private readonly scene: THREE.Scene) { constructor(
private readonly scene: THREE.Scene,
/** Called with the star a selectable label names, when it is clicked or keyed. */
private readonly onSelectStar?: (starId: number) => void
) {
this.cssRenderer.domElement.classList.add('star-label-layer'); this.cssRenderer.domElement.classList.add('star-label-layer');
this.domElement = this.cssRenderer.domElement; this.domElement = this.cssRenderer.domElement;
} }
@@ -61,12 +104,48 @@ export class StarLabelOverlay {
const existing = this.labelObjects.get(point.id); const existing = this.labelObjects.get(point.id);
if (existing) { if (existing) {
existing.position.set(point.x, point.y, point.z); existing.position.set(point.x, point.y, point.z);
this.applyPresentation(existing, point);
} else { } else {
this.addLabel(point); this.addLabel(point);
} }
} }
} }
/**
* Moves one label that is already up, without going through `update`. For labels whose place
* is fixed relative to the camera rather than to anything in the scene: they have to be
* recomputed every frame, and rebuilding the whole label set at that rate would throw away
* the diffing that keeps the DOM still.
*/
moveLabel(id: number | string, x: number, y: number, z: number): void {
this.labelObjects.get(id)?.position.set(x, y, z);
}
/**
* Marks the selected object in the scene: two thin arcs bracketing the point, the one thing
* borrowed from the ARK's control disc. `null` clears it. Kept out of `update` because it is
* a different rhythm — labels change on their own cadence, the mark follows a moving body
* every frame.
*/
setSelection(point: SelectionPoint | null): void {
if (!point) {
if (this.selection) {
this.scene.remove(this.selection);
this.selection.element.remove();
this.selection = undefined;
}
return;
}
if (!this.selection) {
const element = document.createElement('div');
element.className = 'map-select';
element.setAttribute('aria-hidden', 'true');
this.selection = new CSS2DObject(element);
this.scene.add(this.selection);
}
this.selection.position.set(point.x, point.y, point.z);
}
render(camera: THREE.Camera): void { render(camera: THREE.Camera): void {
this.cssRenderer.render(this.scene, camera); this.cssRenderer.render(this.scene, camera);
} }
@@ -75,15 +154,30 @@ export class StarLabelOverlay {
for (const [id, object] of this.labelObjects) { for (const [id, object] of this.labelObjects) {
this.removeLabel(id, object); this.removeLabel(id, object);
} }
this.setSelection(null);
} }
private addLabel(point: LabeledPoint): void { private addLabel(point: LabeledPoint): void {
const element = document.createElement('div'); // A selectable label is a real button, so it is reachable by keyboard and announced as an
// action rather than as text that happens to respond to a click.
const element = document.createElement(point.selectStarId === undefined ? 'div' : 'button');
if (point.selectStarId !== undefined) {
const starId = point.selectStarId;
(element as HTMLButtonElement).type = 'button';
// Read out as a sentence rather than as the two lines run together — the name and the
// distance are adjacent spans, so the default accessible name is "Sirius2.64 pc" — and
// said as the action it is, since nothing else on screen says these labels are doors.
element.setAttribute('aria-label', `Go to ${point.name}${point.kind ? `, ${point.kind} away` : ''}`);
element.addEventListener('click', (event) => {
event.stopPropagation();
this.onSelectStar?.(starId);
});
}
// Classes assigned directly since this element lives outside Angular's view encapsulation // Classes assigned directly since this element lives outside Angular's view encapsulation
// (see the class comment above). The offset and leader line live in `.map-label` itself: // (see the class comment above). The offset and leader line live in `.map-label` itself:
// CSS2DRenderer rewrites this element's inline transform every frame, so a translate here // CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
// would be overwritten — the margin is the offset it cannot touch. // would be overwritten — the margin is the offset it cannot touch.
element.className = 'map-label whitespace-nowrap font-body'; element.className = classesFor(point);
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'map-label-name'; name.className = 'map-label-name';
@@ -98,16 +192,25 @@ export class StarLabelOverlay {
} }
const object = new CSS2DObject(element); const object = new CSS2DObject(element);
// Anchor the label's left edge at the point, vertically centred. The default center of // Anchor the label's near edge at the point, vertically centred. The default center of
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the // (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and // star — under which `.map-label`'s margin offset only nudges the centred box sideways and
// the leader line points at empty space half the label's width from the star. // the leader line points at empty space half the label's width from the star.
object.center.set(0, 0.5); this.applyPresentation(object, point);
object.position.set(point.x, point.y, point.z); object.position.set(point.x, point.y, point.z);
this.scene.add(object); this.scene.add(object);
this.labelObjects.set(point.id, object); this.labelObjects.set(point.id, object);
} }
/** Right-hand text hangs its left edge on the point; left-hand text hangs its right edge. */
private applyPresentation(object: CSS2DObject, point: LabeledPoint): void {
object.center.set(point.side === 'left' ? 1 : 0, 0.5);
const wanted = classesFor(point);
if (object.element.className !== wanted) {
object.element.className = wanted;
}
}
private removeLabel(id: number | string, object: CSS2DObject): void { private removeLabel(id: number | string, object: CSS2DObject): void {
this.scene.remove(object); this.scene.remove(object);
object.element.remove(); object.element.remove();
@@ -67,28 +67,6 @@ describe('StarmapHudComponent', () => {
expect(emitted).toEqual(['galactic', 'galaxy']); expect(emitted).toEqual(['galactic', 'galaxy']);
}); });
it('renders the readout panel from its inputs', () => {
fixture.componentRef.setInput('level', 'galactic');
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
fixture.detectChanges();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
expect(text).toContain(expected);
}
});
it('leaves out the optional lines it was given nothing for', () => {
const host = render('galaxy');
expect(host.querySelector('dl')).toBeNull();
expect(host.textContent).not.toContain('undefined');
});
it('names what the view is holding on the banner across the top', () => { it('names what the view is holding on the banner across the top', () => {
fixture.componentRef.setInput('level', 'system'); fixture.componentRef.setInput('level', 'system');
fixture.componentRef.setInput('title', 'Sol'); fixture.componentRef.setInput('title', 'Sol');
@@ -3,17 +3,6 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
import { ViewLevel } from '../../shared/state/navigation.store'; import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
export interface HudReadout {
readonly label: string;
readonly value: string;
/**
* True when the figure was computed from other measurements rather than catalogued directly.
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
* an observed one.
*/
readonly derived?: boolean;
}
interface LadderStep { interface LadderStep {
readonly level: ViewLevel; readonly level: ViewLevel;
readonly label: string; readonly label: string;
@@ -31,9 +20,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
]; ];
/** /**
* The map's heads-up display: the scale ladder down the left, the readout panel across the * The top of the map's heads-up display: the scale ladder on the left, the nameplate across
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around * the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live
* the whole viewport. * in the dock along the bottom (`HudDockComponent`).
* *
* Purely presentational — every value arrives as an input and the only thing it emits is a * Purely presentational — every value arrives as an input and the only thing it emits is a
* request to move to another scale. The scene owns the camera and decides what that means. * request to move to another scale. The scene owns the camera and decides what that means.
@@ -49,8 +38,6 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
imports: [ReticleIconComponent], imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' }, host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: ` template: `
<div class="hud-vignette absolute inset-0"></div>
@if (showReticle()) { @if (showReticle()) {
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark <!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
for the whole instrument, whether it is holding a query or a body. --> for the whole instrument, whether it is holding a query or a body. -->
@@ -58,8 +45,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
} }
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line <!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
across the top of the display, clear of the search field above them. --> across the top of the display; the search lives in the dock below, so nothing sits above. -->
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40"> <nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-6 left-6 flex items-stretch divide-x divide-border/40">
@for (step of ladder(); track step.level) { @for (step of ladder(); track step.level) {
@if (step.reachable) { @if (step.reachable) {
<button <button
@@ -84,58 +71,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@if (title()) { @if (title()) {
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a <!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
long star name runs into the scale rail on its left and under the object card on its long star name runs into the scale rail on its left and under the object card on its
right — all three share the top-16 line. --> right — all three share the top-6 line. -->
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block"> <div class="absolute top-6 left-1/2 hidden -translate-x-1/2 lg:block">
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5"> <div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" /> <app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span> <span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div> </div>
</div> </div>
} }
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
<p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
}
@if (readouts().length) {
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
</div>
}
</dl>
}
@if (note() || hasDerived()) {
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
}
</div>
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
<p class="type-label text-muted">Range</p>
<p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
</div>
</div>
` `
}) })
export class StarmapHudComponent { export class StarmapHudComponent {
readonly level = input.required<ViewLevel>(); readonly level = input.required<ViewLevel>();
/** Headline for the readout panel — the selected star, or the name of the current scale. */ /** What the view is holding, for the nameplate — the selected star, or nothing. */
readonly title = input(''); readonly title = input('');
readonly subtitle = input('');
readonly eyebrow = input('');
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Whether any readout needs the derived-value footnote. */
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
readonly range = input('');
readonly showReticle = input(true); readonly showReticle = input(true);
readonly levelSelected = output<ViewLevel>(); readonly levelSelected = output<ViewLevel>();
@@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { bodyReadouts } from '../body-detail/body-readouts'; import { bodyReadouts } from '../body-detail/body-readouts';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component'; import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@Component({ @Component({
selector: 'app-system-object-card', selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
// the scene rather than against whatever box the inline default would have left it in — which // the scene rather than against whatever box the inline default would have left it in — which
// put the card off the bottom-left corner of the viewport entirely. // put the card off the bottom-left corner of the viewport entirely.
@@ -29,13 +31,23 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
never fights the absolute placement. Same organism as the detail page's info panel: never fights the absolute placement. Same organism as the detail page's info panel:
header, shared readout sections, and a route rail — there at the top, here at the header, shared readout sections, and a route rail — there at the top, here at the
bottom, because here the route is the next step rather than the way back. --> bottom, because here the route is the next step rather than the way back. -->
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]"> <div class="pointer-events-auto absolute top-6 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text"> <div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3"> <div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<header class="min-w-0"> <header class="min-w-0">
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p> <p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header> </header>
<button
type="button"
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
[attr.aria-pressed]="bookmarks.has('body', body().id)"
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
</button>
<button <button
type="button" type="button"
(click)="dismissed.emit()" (click)="dismissed.emit()"
@@ -69,5 +81,7 @@ export class SystemObjectCardComponent {
/** Request for the full `/body/:id` route. */ /** Request for the full `/body/:id` route. */
readonly openRequested = output<void>(); readonly openRequested = output<void>();
readonly bookmarks = inject(BookmarksStore);
readonly readouts = computed(() => bodyReadouts(this.body())); readonly readouts = computed(() => bodyReadouts(this.body()));
} }
@@ -88,6 +88,9 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
} }
} }
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
const ORBIT_LINE_NAME = 'orbit-line';
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line { function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
const points = orbitEllipsePoints(elements); const points = orbitEllipsePoints(elements);
const positions = new Float32Array(points.length * 3); const positions = new Float32Array(points.length * 3);
@@ -110,7 +113,9 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind] opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
}); });
return new THREE.Line(geometry, material); const line = new THREE.Line(geometry, material);
line.name = ORBIT_LINE_NAME;
return line;
} }
/** /**
@@ -329,6 +334,21 @@ export class SystemOrbitsRenderer {
return this.members.map((member) => member.marker); return this.members.map((member) => member.marker);
} }
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
this.object.traverse((child) => {
if (child.name === ORBIT_LINE_NAME) {
child.visible = layers.orbits;
}
});
if (this.grid) {
this.grid.object.visible = layers.grid;
}
if (this.tethers) {
this.tethers.object.visible = layers.grid;
}
}
dispose(): void { dispose(): void {
this.grid?.dispose(); this.grid?.dispose();
this.tethers?.dispose(); this.tethers?.dispose();
@@ -0,0 +1,255 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
class EmptyDataLoaderService {
loadStars() {
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
}
loadBodies() {
return Promise.resolve([]);
}
loadExoplanets() {
return Promise.resolve([]);
}
}
describe('HudDockComponent', () => {
let fixture: ComponentFixture<HudDockComponent>;
function host(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}
function tabNames(): string[] {
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
}
function tab(name: string): HTMLButtonElement {
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
if (!found) {
throw new Error(`no "${name}" tab`);
}
return found;
}
function setReadout(): void {
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
}
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [HudDockComponent],
providers: [
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).compileComponents();
fixture = TestBed.createComponent(HudDockComponent);
});
it('offers the search and what has been kept, when it has nothing else', () => {
// Bookmarks are always offered: it is the only place that says the map can keep anything.
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Bookmarks']);
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
});
it('grows a tab per thing it has been given', () => {
setReadout();
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
fixture.componentRef.setInput('routing', true);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
});
it('opens the default tab on mount and renders the readout from its inputs', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
const text = host().textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
expect(text).toContain(expected);
}
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
});
it('leaves out the optional lines it was given nothing for', () => {
fixture.componentRef.setInput('title', 'Local Stars');
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('dl')).toBeNull();
expect(host().textContent).not.toContain('undefined');
});
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
expect(host().textContent).toContain('21.5 kpc');
fixture.componentRef.setInput('range', '');
fixture.detectChanges();
expect(host().textContent).not.toContain('Range');
});
it('toggles a tab closed when it is clicked while open', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
tab('Search').click();
fixture.detectChanges();
expect(host().querySelector('input[type="text"]')).not.toBeNull();
});
it('emits the flipped layer and nothing else', () => {
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const emitted: HudDisplay[] = [];
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
orbits?.click();
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
});
it('reflects the layer state it is given as pressed buttons', () => {
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']);
});
it('says how to keep a place, rather than showing an empty list', () => {
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
expect(host().querySelector('#dock-panel-bookmarks ul')).toBeNull();
expect(host().textContent).toContain('Nothing kept yet');
});
it('lists what has been kept, newest first, and says which kind each is', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const rows = [...host().querySelectorAll('#dock-panel-bookmarks li')].map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
expect(rows[0]).toContain('Earth');
expect(rows[0]).toContain('Body');
expect(rows[1]).toContain('Sirius');
expect(rows[1]).toContain('System');
});
it('hands back the place that was chosen, whole', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const chosen: unknown[] = [];
fixture.componentInstance.bookmarkChosen.subscribe((bookmark) => chosen.push(bookmark));
host().querySelector<HTMLButtonElement>('#dock-panel-bookmarks li button')?.click();
expect(chosen).toEqual([{ kind: 'star', id: 7, name: 'Sirius' }]);
});
it('forgets one without disturbing the rest', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Forget Earth"]')?.click();
fixture.detectChanges();
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Sirius']);
expect(host().textContent).not.toContain('Earth');
});
it('keeps the star the readout is about, and says so on the control', () => {
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sirius');
fixture.componentRef.setInput('keepableStarId', 7);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
const keep = host().querySelector<HTMLButtonElement>('[aria-label="Keep Sirius"]');
expect(keep?.getAttribute('aria-pressed')).toBe('false');
keep?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 7)).toBe(true);
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
});
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sol');
fixture.componentRef.setInput('keepableStarId', 0);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 0)).toBe(true);
});
it('offers nothing to keep where the readout is a scale rather than a place', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('[aria-label^="Keep"]')).toBeNull();
});
it('opens the search on "/" from anywhere but a text field', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
fixture.detectChanges();
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
});
it('hands the panel back to the readout once a result is picked', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
fixture.componentInstance.onPicked();
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
});
});
+343
View File
@@ -0,0 +1,343 @@
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
import { SearchComponent } from '../search/search.component';
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
export interface HudReadout {
readonly label: string;
readonly value: string;
/**
* True when the figure was computed from other measurements rather than catalogued directly.
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
* an observed one.
*/
readonly derived?: boolean;
}
/** Which layers the scene draws. Every one is a real object in the scene, toggled by visibility. */
export interface HudDisplay {
readonly labels: boolean;
readonly orbits: boolean;
readonly grid: boolean;
readonly deepSky: boolean;
readonly sky: boolean;
/** The rings on stars known to host planets. */
readonly systems: boolean;
/** The graph of crossings within the range the Routes panel is set to. */
readonly jumpLinks: boolean;
/** Drawn flat: an orthographic projection, looking down on the plane. */
readonly plan: boolean;
}
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'labels', label: 'Labels' },
{ key: 'orbits', label: 'Orbits' },
{ key: 'grid', label: 'Grid' },
{ key: 'deepSky', label: 'Deep sky' },
{ key: 'sky', label: 'Sky' },
{ key: 'systems', label: 'Systems' },
{ key: 'jumpLinks', label: 'Jump links' },
{ key: 'plan', label: 'Plan view' }
];
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
const WIDE_VIEWPORT = '(min-width: 640px)';
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
function isWideViewport(): boolean {
return wideViewportQuery?.matches ?? true;
}
/**
* The instrument's dock: one rail across the bottom of the viewport carrying every tool and
* readout, so the top of the screen keeps only the scale ladder and the nameplate.
*
* The tab strip is pinned to the bottom edge and never moves; whichever panel is open grows
* upward from it. That is why the search field sits at the *bottom* of its panel with the
* results above — the thing being typed into stays put while the list grows.
*
* `null` for the active tab is a real state, not an error: the strip alone. It is the default
* below `sm`, where the panel would cover most of the scene, and wherever there is no readout
* to show by default. Purely presentational: readouts and layer state arrive as inputs, the
* only things it emits are layer toggles.
*/
@Component({
selector: 'app-hud-dock',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
template: `
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
wide as the strip and as tall as the open panel, so a solid one would swallow every
click in the empty band beside the panel — where the scene, and its labels, are. -->
<div class="flex flex-col items-start">
@if (activeTab(); as tab) {
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
locking on, once per switch, never per keystroke. -->
@switch (tab) {
@case ('search') {
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
<app-search (picked)="onPicked()" />
</section>
}
@case ('readout') {
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">{{ eyebrow() }}</p>
<div class="mt-1 flex items-start gap-2">
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
truthiness test is what would quietly make the Solar System the one
system nobody could keep. -->
@if (keepableStarId() !== null) {
<button
type="button"
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
</button>
}
</div>
@if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
}
@if (readouts().length) {
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) {
<div>
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
</div>
}
</dl>
}
@if (note() || hasDerived()) {
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
}
</section>
}
@case ('routes') {
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
<app-routes-panel
[result]="routeResult()"
[options]="routeOptions()"
[currentStar]="currentStar()"
(queryChange)="routeQuery.emit($event)"
(routeRequested)="routeRequested.emit($event)"
(starSelected)="onRouteStarSelected($event)"
(rangeChange)="jumpRangeChange.emit($event)"
/>
</section>
}
@case ('bookmarks') {
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
@if (bookmarks.bookmarks().length) {
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
<li class="flex items-stretch">
<button
type="button"
(click)="onBookmarkChosen(bookmark)"
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
</button>
<button
type="button"
[attr.aria-label]="'Forget ' + bookmark.name"
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
</button>
</li>
}
</ul>
} @else {
<p class="px-3 py-3 text-sm text-muted">
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
</p>
}
</section>
}
@case ('display') {
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">Layers</p>
<div class="mt-2 flex flex-wrap gap-2">
@for (layer of layers; track layer.key) {
<button
type="button"
[attr.aria-pressed]="isOn(layer.key)"
(click)="toggleLayer(layer.key)"
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
>
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
{{ layer.label }}
</button>
}
</div>
</section>
}
}
}
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
@for (tab of tabs(); track tab) {
<button
type="button"
role="tab"
[id]="'dock-tab-' + tab"
[attr.aria-selected]="activeTab() === tab"
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
(click)="toggleTab(tab)"
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
>
{{ tabLabel(tab) }}
</button>
}
</div>
@if (range()) {
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
<span class="type-label text-muted">Range</span>
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
</p>
}
</div>
</div>
`
})
export class HudDockComponent implements OnInit {
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
readonly eyebrow = input('');
readonly title = input('');
readonly subtitle = input('');
readonly readouts = input<readonly HudReadout[]>([]);
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
readonly note = input('');
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
readonly range = input('');
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
readonly display = input<HudDisplay | null>(null);
/** Which panel is open on a wide viewport when the dock mounts. */
readonly defaultTab = input<DockTab | null>(null);
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
readonly routeResult = input<RouteResult | null>(null);
readonly routeOptions = input<readonly RouteStarOption[]>([]);
readonly currentStar = input<RouteStarOption | null>(null);
/** The star the readout is about, where there is one to keep — a scale is not a place. */
readonly keepableStarId = input<number | null>(null);
/** Present makes the Routes tab available; absent means this surface cannot route. */
readonly routing = input(false);
readonly displayChange = output<HudDisplay>();
readonly routeQuery = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly routeStarSelected = output<number>();
readonly jumpRangeChange = output<number>();
readonly bookmarkChosen = output<Bookmark>();
readonly layers = DISPLAY_LAYERS;
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
readonly tabs = computed<readonly DockTab[]>(() => [
'search',
...(this.title() ? (['readout'] as const) : []),
...(this.routing() ? (['routes'] as const) : []),
// Always offered, even with nothing in it: it is the only place that says the map can keep
// anything at all, and a tab that appears once you already know is a tab that never taught.
'bookmarks',
...(this.display() ? (['display'] as const) : [])
]);
readonly activeTab = signal<DockTab | null>(null);
readonly bookmarks = inject(BookmarksStore);
private readonly search = viewChild(SearchComponent);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
ngOnInit(): void {
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
}
tabLabel(tab: DockTab): string {
return TAB_LABELS[tab];
}
isOn(key: keyof HudDisplay): boolean {
return this.display()?.[key] ?? false;
}
toggleTab(tab: DockTab): void {
this.activeTab.set(this.activeTab() === tab ? null : tab);
}
toggleLayer(key: keyof HudDisplay): void {
const current = this.display();
if (current) {
this.displayChange.emit({ ...current, [key]: !current[key] });
}
}
/** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */
onRouteStarSelected(starId: number): void {
this.routeStarSelected.emit(starId);
}
/** Choosing a kept place is the same move as choosing a search result: the panel has done
* its job and the thing to look at is now the scene. */
onBookmarkChosen(bookmark: Bookmark): void {
this.bookmarkChosen.emit(bookmark);
this.onPicked();
}
onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
// readout where there is one, and fold the sheet away where there is not. On a narrow
// viewport it always folds away — there the panel is a sheet over most of the scene, and
// reopening it onto whatever was just flown to is the opposite of what was asked for.
this.activeTab.set(isWideViewport() && this.title() ? 'readout' : null);
}
/** `/` opens the search from anywhere, unless something else is already taking text. */
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
event.preventDefault();
this.activeTab.set('search');
// The field only exists after the panel renders; defer the focus to after that pass.
setTimeout(() => this.search()?.focus());
}
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
@HostListener('document:pointerdown', ['$event'])
onDocumentPointerDown(event: PointerEvent): void {
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
this.activeTab.set(null);
}
}
}
@@ -0,0 +1,227 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { formatParsecs } from '../../shared/format/quantity';
/** A star offered for one of the two fields, as the panel needs to show it. */
export interface RouteStarOption {
readonly id: number;
readonly name: string;
readonly subtitle: string;
}
/** What the scene worked out, once it has been asked. */
export interface RouteResult {
/** The chain, departure first. Empty when there is no route at the range asked for. */
readonly stars: readonly { id: number; name: string }[];
readonly totalPc: number;
/** The shortest range that would open a route, where none was found at the one asked for. */
readonly neededRangePc: number | null;
}
export interface RouteRequest {
readonly fromId: number;
readonly toId: number;
readonly rangePc: number;
}
/** Which end of the journey a query is for. */
type Field = 'from' | 'to';
/**
* Departure, destination, range, and the chain between them.
*
* The one genuinely two-sided tool in the instrument, and the reason the range lives here
* rather than beside the layer toggle that draws the graph: the number that decides which
* crossings are possible is the same number in both places, and a control is easier to trust
* where its consequence is printed.
*
* Presentational. It knows how to ask; the scene knows the catalogue and does the walking.
*/
@Component({
selector: 'app-routes-panel',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col gap-3">
<div class="grid gap-2 sm:grid-cols-2">
@for (field of fields; track field) {
<div class="relative">
<label [for]="'route-' + field" class="type-label text-muted">{{ field === 'from' ? 'Departure' : 'Destination' }}</label>
<input
[id]="'route-' + field"
type="text"
autocomplete="off"
[value]="text(field)"
[placeholder]="field === 'from' ? 'From this system' : 'Search a star'"
(input)="onInput(field, $event)"
(keydown.escape)="closeOptions()"
class="hud-surface mt-1 w-full px-2.5 py-1.5 text-sm text-text caret-accent placeholder:text-muted focus:border-accent focus:outline-none"
/>
@if (open() === field && options().length) {
<ul class="hud-surface absolute bottom-full left-0 z-10 mb-1 max-h-48 w-full overflow-y-auto divide-y divide-border/25">
@for (option of options(); track option.id) {
<li>
<button
type="button"
(click)="choose(field, option)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ option.name }}</span>
<span class="type-label shrink-0 truncate text-muted">{{ option.subtitle }}</span>
</button>
</li>
}
</ul>
}
</div>
}
</div>
<div class="flex items-center gap-3">
<label for="route-range" class="type-label shrink-0 text-muted">Jump range</label>
<input
id="route-range"
type="range"
[min]="minRangePc"
[max]="maxRangePc"
step="0.1"
[value]="rangePc()"
(input)="onRange($event)"
class="h-1 min-w-0 flex-1 appearance-none rounded-none bg-border accent-accent"
/>
<output for="route-range" class="w-20 shrink-0 text-right text-sm text-accent tabular-nums">{{ rangeLabel() }}</output>
</div>
<div class="flex items-center gap-3">
<button
type="button"
[disabled]="!canPlot()"
(click)="plot()"
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
Plot route
</button>
@if (result(); as plotted) {
@if (plotted.stars.length) {
<p data-testid="route-summary" class="text-sm text-text tabular-nums">
{{ plotted.stars.length - 1 }} {{ plotted.stars.length === 2 ? 'jump' : 'jumps' }} <span class="text-muted">·</span> {{ format(plotted.totalPc) }}
</p>
} @else {
<p data-testid="route-summary" class="text-sm text-muted">
No route at this range.
@if (plotted.neededRangePc !== null) {
<button
type="button"
(click)="raiseTo(plotted.neededRangePc)"
class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>
{{ format(plotted.neededRangePc) }} would reach.
</button>
} @else {
Nothing in the catalogue bridges the gap.
}
</p>
}
}
</div>
@if (result()?.stars?.length) {
<ol data-testid="route-steps" class="hud-surface divide-y divide-border/25">
@for (step of result()!.stars; track step.id; let i = $index) {
<li>
<button
type="button"
(click)="starSelected.emit(step.id)"
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="type-label w-6 shrink-0 text-muted tabular-nums">{{ i + 1 }}</span>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ step.name }}</span>
</button>
</li>
}
</ol>
}
</div>
`
})
export class RoutesPanelComponent {
/** Whatever the scene found for the last query it was given. */
readonly result = input<RouteResult | null>(null);
/** Matches for the field currently being typed into, ranked by the scene. */
readonly options = input<readonly RouteStarOption[]>([]);
/** The star the view is currently inside, offered as the departure without typing. */
readonly currentStar = input<RouteStarOption | null>(null);
readonly queryChange = output<string>();
readonly routeRequested = output<RouteRequest>();
readonly starSelected = output<number>();
/** The graph is drawn at whatever range this panel is set to, so the scene follows it. */
readonly rangeChange = output<number>();
readonly fields: readonly Field[] = ['from', 'to'];
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
readonly minRangePc = 0.5;
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
readonly maxRangePc = 8;
readonly rangePc = signal(3);
readonly open = signal<Field | null>(null);
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
/** Departure falls back to wherever the view already is, so one field is usually enough. */
private readonly departure = computed(() => this.chosen().from ?? this.currentStar());
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
text(field: Field): string {
return this.chosen()[field]?.name ?? this.typed()[field];
}
format(distancePc: number): string {
return formatParsecs(distancePc);
}
onInput(field: Field, event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.typed.update((current) => ({ ...current, [field]: value }));
// Typing over a chosen star un-chooses it: the field says what it will be searched for.
this.chosen.update((current) => ({ ...current, [field]: null }));
this.open.set(field);
this.queryChange.emit(value);
}
choose(field: Field, option: RouteStarOption): void {
this.chosen.update((current) => ({ ...current, [field]: option }));
this.closeOptions();
}
closeOptions(): void {
this.open.set(null);
this.queryChange.emit('');
}
onRange(event: Event): void {
const value = Number((event.target as HTMLInputElement).value);
this.rangePc.set(value);
this.rangeChange.emit(value);
}
raiseTo(rangePc: number): void {
// Rounded up to the control's own step, so the number shown is one it can actually hold —
// and up rather than down, since down would land just short of the crossing it names.
const stepped = Math.min(this.maxRangePc, Math.ceil(rangePc * 10) / 10);
this.rangePc.set(stepped);
this.rangeChange.emit(stepped);
this.plot();
}
plot(): void {
const from = this.departure();
const to = this.chosen().to;
if (from && to) {
this.routeRequested.emit({ fromId: from.id, toId: to.id, rangePc: this.rangePc() });
}
}
}
+19 -6
View File
@@ -1,4 +1,4 @@
import { Component, computed, signal } from '@angular/core'; import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
@@ -25,13 +25,17 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
selector: 'app-search', selector: 'app-search',
imports: [ReticleIconComponent], imports: [ReticleIconComponent],
template: ` template: `
<div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body"> <!-- Sits on the dock's tab strip: the field is pinned to the bottom of the block and the
results grow upward above it, so typing never moves the thing being typed into. DOM
order stays field-then-results (flex-col-reverse), which is also the focus order. -->
<div class="flex flex-col-reverse font-body">
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur. <!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
the star field. --> the star field. -->
<div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50"> <div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
<app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" /> <app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
<input <input
#field
type="text" type="text"
placeholder="Search stars, planets, exoplanets…" placeholder="Search stars, planets, exoplanets…"
[value]="query()" [value]="query()"
@@ -42,11 +46,10 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
</div> </div>
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only <!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
the rows instead of remounting the panel and replaying its acquire wipe per the rows instead of remounting the panel. Gated on the index being ready: "no matches"
keystroke. Gated on the index being ready: "no matches" may only ever describe a may only ever describe a search that actually ran against the loaded catalogues. -->
search that actually ran against the loaded catalogues. -->
@if (indexReady() && hasQuery()) { @if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2"> <div class="hud-surface mb-2">
@if (results().length) { @if (results().length) {
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted"> <p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span> Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
@@ -78,6 +81,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
}) })
export class SearchComponent { export class SearchComponent {
readonly query = signal(''); readonly query = signal('');
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
* the view back to the readout, since the thing to look at is now the scene, not the box. */
readonly picked = output<void>();
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]); private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false /** False until the catalogues have loaded — and forever if they fail, which beats a false
@@ -117,6 +125,10 @@ export class SearchComponent {
this.query.set(''); this.query.set('');
} }
focus(): void {
this.field().nativeElement.focus();
}
kindLabel(kind: SearchResultKind): string { kindLabel(kind: SearchResultKind): string {
return KIND_LABELS[kind]; return KIND_LABELS[kind];
} }
@@ -129,6 +141,7 @@ export class SearchComponent {
} else if (result.bodyId) { } else if (result.bodyId) {
void this.router.navigate(['/body', result.bodyId]); void this.router.navigate(['/body', result.bodyId]);
} }
this.picked.emit();
} }
private async buildIndex(): Promise<void> { private async buildIndex(): Promise<void> {
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
function chain(count: number): StarNeighbourhood {
return new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })));
}
function index(points: StarPoint[]): StarNeighbourhood {
return new StarNeighbourhood(points);
}
describe('routeBetween', () => {
it('walks the chain a hop at a time when that is all the range allows', () => {
const route = routeBetween(chain(5), 0, 4, 1.5);
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
expect(route?.totalPc).toBeCloseTo(4);
expect(route?.longestHopPc).toBeCloseTo(1);
});
it('goes straight there when the range reaches, however many stars lie between', () => {
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
// range that covers it makes it the answer, and the stars in between are just scenery.
const route = routeBetween(chain(5), 0, 4, 5);
expect(route?.stars).toEqual([0, 4]);
expect(route?.totalPc).toBeCloseTo(4);
});
it('picks the shorter of two ways round when neither is a straight line', () => {
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
// off the line, or through 2, well off it. Shorter is what "the way there" means.
const route = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 5, y: 0.5, z: 0 },
{ id: 2, x: 5, y: 3, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]),
0,
3,
6
);
expect(route?.stars).toEqual([0, 1, 3]);
expect(route?.totalPc).toBeCloseTo(10.05, 1);
});
it('finds nothing across a gap wider than the range', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 20, y: 0, z: 0 }
]);
expect(routeBetween(split, 0, 2, 5)).toBeNull();
});
it('answers nothing for a star that is not there, or for going nowhere', () => {
const line = chain(3);
expect(routeBetween(line, 0, 0, 2)).toBeNull();
expect(routeBetween(line, 0, 99, 2)).toBeNull();
expect(routeBetween(line, 0, 2, 0)).toBeNull();
});
it('reports the longest hop, which is what the range has to cover', () => {
const route = routeBetween(
index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]),
0,
2,
4
);
expect(route?.longestHopPc).toBeCloseTo(4);
});
});
describe('minimumRangeBetween', () => {
it('names the shortest range that opens a way through', () => {
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
const stepped = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 1, y: 0, z: 0 },
{ id: 2, x: 5, y: 0, z: 0 }
]);
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
expect(routeBetween(stepped, 0, 2, 3.99)).toBeNull();
});
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
// Direct: one hop of 10. Round: three hops of at most 4. The range only has to cover 4.
const both = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 0, y: 4, z: 0 },
{ id: 2, x: 6, y: 7, z: 0 },
{ id: 3, x: 10, y: 0, z: 0 }
]);
const needed = minimumRangeBetween(both, 0, 3, 50);
expect(needed).toBeLessThan(10);
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
});
it('finds nothing when even the ceiling does not reach', () => {
const split = index([
{ id: 0, x: 0, y: 0, z: 0 },
{ id: 1, x: 100, y: 0, z: 0 }
]);
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
});
});
describe('collectJumpLinks', () => {
it('reports each pair once, not once from either end', () => {
const links = collectJumpLinks(chain(4), 1.5);
expect(links.map((link) => [link.from, link.to])).toEqual([
[0, 1],
[1, 2],
[2, 3]
]);
});
it('measures every link it reports', () => {
const links = collectJumpLinks(chain(3), 2.5);
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2);
});
it('draws nothing at no range', () => {
expect(collectJumpLinks(chain(4), 0)).toEqual([]);
});
it('agrees with every route it makes possible', () => {
// The graph drawn and the graph walked have to be the same graph, or the map shows a way
// the route cannot take.
let seed = 11;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
const points: StarPoint[] = Array.from({ length: 120 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const cloud = index(points);
// 9 rather than 6: at 6 this cloud falls into pieces and 0 never reaches 119, which an
// earlier version of this test hid by only checking the route it happened to find.
const range = 9;
const links = collectJumpLinks(cloud, range);
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
const route = routeBetween(cloud, 0, 119, range);
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
expect(route).not.toBeNull();
expect(route!.stars.length).toBeGreaterThan(2);
for (let i = 1; i < route!.stars.length; i++) {
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
expect(drawn.has(`${a}-${b}`)).toBe(true);
}
expect(links.length).toBeGreaterThan(0);
});
});
+206
View File
@@ -0,0 +1,206 @@
/**
* Which stars are within reach of which, and how to get from one to another through them.
*
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
* chosen range. It is not a feature of space — there are no corridors out there — it is a
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
* can be strung together, and what is the shortest chain from here to there.
*
* Two facts about the catalogue shape everything here, and both are worth stating because the
* answers look like defects otherwise. It is magnitude-limited, so it is dense around the Sun
* and thins with distance: within 50 pc a 3 pc range links 99% of it into one piece, while over
* the whole 250 pc reach the same range leaves most stars alone. And a gap in it is a gap in
* what has been catalogued, not in what is there. So a route that cannot be found is a
* statement about the map, and `minimumRangeBetween` exists to say which.
*/
import { StarNeighbourhood } from './star-neighbourhood';
/** A chain of stars from one to another, each hop within the range that was asked for. */
export interface Route {
/** Star ids, departure first and destination last. One hop is two ids. */
readonly stars: readonly number[];
/** The sum of the hops, in parsecs. */
readonly totalPc: number;
/**
* The longest single hop. The range has to cover this and nothing wider, so it is what a
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
*/
readonly longestHopPc: number;
}
/** An unordered pair of stars within range of each other. */
export interface JumpLink {
readonly from: number;
readonly to: number;
readonly distancePc: number;
}
/**
* A cap on how much of the catalogue one search may walk. Reached only where a route does not
* exist and the range is wide enough to make most of the catalogue one component; a search that
* hits it has already visited more stars than any real chain passes through.
*/
const MAX_VISITED = 20000;
/** Pops the smallest-cost entry. A linear scan: the frontier is small next to the work per node. */
function takeCheapest<T>(frontier: Map<number, T>, costOf: (value: T) => number): [number, T] | undefined {
let bestId: number | undefined;
let bestValue: T | undefined;
let bestCost = Number.POSITIVE_INFINITY;
for (const [id, value] of frontier) {
const cost = costOf(value);
if (cost < bestCost) {
bestCost = cost;
bestId = id;
bestValue = value;
}
}
if (bestId === undefined || bestValue === undefined) {
return undefined;
}
frontier.delete(bestId);
return [bestId, bestValue];
}
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
const stars = [toId];
let at = toId;
while (at !== fromId) {
const previous = cameFrom.get(at);
if (previous === undefined) {
return [];
}
stars.push(previous);
at = previous;
}
return stars.reverse();
}
/**
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
* `null` where the catalogue holds no such chain.
*
* Shortest by total distance travelled rather than by number of hops: two chains of the same
* length are not equally good, and the one that covers less ground is the one a reader means by
* "the way there". Neighbours are asked for as the search reaches each star rather than built
* into a graph first, so finding one route never costs a pass over the whole catalogue.
*/
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
return null;
}
const best = new Map<number, number>([[fromId, 0]]);
const cameFrom = new Map<number, number>();
const settled = new Set<number>();
const frontier = new Map<number, number>([[fromId, 0]]);
while (frontier.size > 0 && settled.size < MAX_VISITED) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
const [starId, costHere] = cheapest;
if (settled.has(starId)) {
continue;
}
settled.add(starId);
if (starId === toId) {
const stars = rebuild(cameFrom, fromId, toId);
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
}
for (const neighbour of index.within(starId, rangePc)) {
if (settled.has(neighbour.id)) {
continue;
}
const cost = costHere + neighbour.distancePc;
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
best.set(neighbour.id, cost);
cameFrom.set(neighbour.id, starId);
frontier.set(neighbour.id, cost);
}
}
}
return null;
}
function longestHop(index: StarNeighbourhood, stars: readonly number[]): number {
let longest = 0;
for (let i = 1; i < stars.length; i++) {
const a = index.point(stars[i - 1]);
const b = index.point(stars[i]);
if (a && b) {
longest = Math.max(longest, Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
}
}
return longest;
}
/**
* The shortest range at which any chain at all exists between two stars, or `null` if none does
* within `ceilingPc`.
*
* This is what turns "no route" from a dead end into an answer: the range control can be told
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
* short as possible — found by the same search as above, with the cost of reaching a star being
* the longest hop taken to get there rather than the sum of them.
*/
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
return null;
}
const best = new Map<number, number>([[fromId, 0]]);
const settled = new Set<number>();
const frontier = new Map<number, number>([[fromId, 0]]);
while (frontier.size > 0 && settled.size < MAX_VISITED) {
const cheapest = takeCheapest(frontier, (cost) => cost);
if (!cheapest) {
break;
}
const [starId, worstHopHere] = cheapest;
if (settled.has(starId)) {
continue;
}
settled.add(starId);
if (starId === toId) {
return worstHopHere;
}
for (const neighbour of index.within(starId, ceilingPc)) {
if (settled.has(neighbour.id)) {
continue;
}
// What this chain would need: the longest hop on it, not the distance covered by it.
const needed = Math.max(worstHopHere, neighbour.distancePc);
if (needed < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
best.set(neighbour.id, needed);
frontier.set(neighbour.id, needed);
}
}
}
return null;
}
/**
* Every link within `rangePc` in the whole catalogue, each pair once.
*
* For drawing the graph, which is the only thing that wants all of it: routing asks for a
* star's neighbours as it reaches that star and never builds this.
*/
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
const links: JumpLink[] = [];
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
});
return links;
}
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** A line of stars one parsec apart along x, so every expected distance is an integer. */
function line(count: number): StarPoint[] {
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
}
function ids(found: { id: number }[]): number[] {
return found.map((neighbour) => neighbour.id);
}
describe('StarNeighbourhood', () => {
it('names the nearest stars in order, and never the star itself', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 3))).toEqual([3, 5, 2]);
});
it('measures the separation it found each star by', () => {
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 3, y: 4, z: 0 }
]);
expect(index.nearest(1, 1)[0].distancePc).toBeCloseTo(5);
});
it('reaches past its own cell for a star sitting alone in one', () => {
// 5 pc cells: these three are in three different cells, and the nearest is 12 pc out.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 12, y: 0, z: 0 },
{ id: 3, x: 40, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2))).toEqual([2, 3]);
});
it('does not stop at the first ring that fills the list, where the next holds something closer', () => {
// The diagonal neighbour is in the ring-1 shell but 8.7 pc away; the one straight along x is
// in the ring-2 shell and only 6 pc away. Stopping at the first full ring would miss it.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 5, y: 5, z: 5 },
{ id: 3, x: 6, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 1))).toEqual([3]);
});
it('agrees with a brute-force scan over a pseudo-random cloud', () => {
// The property that matters: the grid is an optimisation, never a different answer.
let seed = 7;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 100 - 50;
const cloud: StarPoint[] = Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
const index = new StarNeighbourhood(cloud);
for (const origin of [cloud[0], cloud[199], cloud[399]]) {
const brute = cloud
.filter((point) => point.id !== origin.id)
.map((point) => ({ id: point.id, distancePc: Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) }))
.sort((a, b) => a.distancePc - b.distancePc);
expect(ids(index.nearest(origin.id, 5))).toEqual(ids(brute.slice(0, 5)));
expect(ids(index.within(origin.id, 20))).toEqual(ids(brute.filter((neighbour) => neighbour.distancePc <= 20)));
}
});
it('takes only the stars a filter accepts', () => {
const index = new StarNeighbourhood(line(10));
expect(ids(index.nearest(4, 2, (point) => point.id % 2 === 0))).toEqual([2, 6]);
});
it('puts the stars it is told to prefer first, and fills with the rest only when short', () => {
const index = new StarNeighbourhood(line(10));
const even = (point: StarPoint) => point.id % 2 === 0;
// Enough even stars: the odd ones next door, though nearer, do not get a look in.
expect(ids(index.nearestPreferring(4, 2, even))).toEqual([2, 6]);
// Not enough: every even star in reach, then the nearest of the others.
expect(ids(index.nearestPreferring(4, 6, even))).toEqual([2, 6, 0, 8, 3, 5]);
});
it('answers nothing for a star it has never heard of', () => {
const index = new StarNeighbourhood(line(3));
expect(index.nearest(99, 3)).toEqual([]);
expect(index.within(99, 10)).toEqual([]);
expect(index.point(99)).toBeUndefined();
});
it('asks for nothing and gets nothing', () => {
const index = new StarNeighbourhood(line(5));
expect(index.nearest(0, 0)).toEqual([]);
expect(index.within(0, 0)).toEqual([]);
});
it('finds every star inside a radius and none on the far side of it', () => {
const index = new StarNeighbourhood(line(20));
expect(ids(index.within(10, 2.5))).toEqual([9, 11, 8, 12]);
});
it('holds stars that share a position without losing either', () => {
// Real catalogue rows do this: Gl 65 A and B are one binary, two entries, one position.
const index = new StarNeighbourhood([
{ id: 1, x: 0, y: 0, z: 0 },
{ id: 2, x: 2.63, y: 0, z: 0 },
{ id: 3, x: 2.63, y: 0, z: 0 }
]);
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* Which stars are near which, over the whole catalogue.
*
* Two questions are asked of the same catalogue and answered here once: "what are the k nearest
* stars to this one" (the neighbour labels shown from inside a system) and "which pairs lie
* within n parsecs of each other" (the jump-link graph). A linear scan answers the first
* acceptably — 68 000 distance tests, once, on entering a system — and the second not at all: a
* graph over a few thousand nodes is a few thousand scans, and the quadratic shows.
*
* So both run on a uniform grid keyed by cell coordinates. The catalogue is a dense blob around
* the Sun thinning out to 250 pc, which is exactly the distribution a uniform grid handles
* badly in the dense middle and well everywhere else — but the queries are all small radii in
* that same dense middle, where a cell holds a handful of stars, so the cost lands where the
* answers are. A KD-tree would be tighter and is not yet worth its code.
*/
/** A catalogued star reduced to what proximity needs: an id and a position in parsecs. */
export interface StarPoint {
readonly id: number;
readonly x: number;
readonly y: number;
readonly z: number;
}
/** A star found near another, with the separation that found it. */
export interface Neighbour {
readonly id: number;
readonly distancePc: number;
}
/**
* Cell edge in parsecs. Sized so a cell in the crowded inner catalogue holds a few dozen stars:
* small enough that a 5 pc query touches a handful of cells, large enough that a 250 pc
* catalogue does not allocate a map with a million keys.
*/
const DEFAULT_CELL_SIZE_PC = 5;
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
const MAX_RING = 12;
function cellKey(ix: number, iy: number, iz: number): string {
return `${ix},${iy},${iz}`;
}
export class StarNeighbourhood {
private readonly cells = new Map<string, number[]>();
private readonly points: readonly StarPoint[];
private readonly indexById = new Map<number, number>();
private readonly cellSizePc: number;
constructor(points: readonly StarPoint[], cellSizePc: number = DEFAULT_CELL_SIZE_PC) {
this.points = points;
this.cellSizePc = cellSizePc > 0 ? cellSizePc : DEFAULT_CELL_SIZE_PC;
points.forEach((point, index) => {
this.indexById.set(point.id, index);
const key = this.keyFor(point.x, point.y, point.z);
const cell = this.cells.get(key);
if (cell) {
cell.push(index);
} else {
this.cells.set(key, [index]);
}
});
}
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
point(id: number): StarPoint | undefined {
const index = this.indexById.get(id);
return index === undefined ? undefined : this.points[index];
}
/**
* Like `nearest`, but the stars `prefer` accepts come first, and the rest only fill what is
* left. The preferred pass exhausts the search before the fill runs, so a preferred star is
* never outranked by an ordinary one that happens to be closer — that is the point of asking.
*/
nearestPreferring(id: number, count: number, prefer: (point: StarPoint) => boolean): Neighbour[] {
const preferred = this.nearest(id, count, prefer);
if (preferred.length >= count) {
return preferred;
}
const taken = new Set(preferred.map((neighbour) => neighbour.id));
return preferred.concat(this.nearest(id, count - preferred.length, (point) => !taken.has(point.id)));
}
/**
* The `count` stars nearest to `id`, nearest first, excluding the star itself.
*
* Searches outward a shell of cells at a time and stops only once the shell it just finished
* lies further away than the furthest result held — the ring that contains the kth star can
* still be beaten by a closer star in the next ring out, since a cell's near corner is nearer
* than its centre.
*/
nearest(id: number, count: number, filter?: (point: StarPoint) => boolean): Neighbour[] {
const origin = this.point(id);
if (!origin || count <= 0) {
return [];
}
const found: Neighbour[] = [];
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
for (let ring = 0; ring <= MAX_RING; ring++) {
// Everything in this ring is at least this far away, so once the results already held are
// all closer than that, no further ring can improve them.
if (found.length >= count && (ring - 1) * this.cellSizePc > found[found.length - 1].distancePc) {
break;
}
for (const index of this.ringIndices(ox, oy, oz, ring)) {
const candidate = this.points[index];
if (candidate.id === id || (filter && !filter(candidate))) {
continue;
}
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
if (found.length >= count && distancePc >= found[found.length - 1].distancePc) {
continue;
}
// Insertion sort into a list that is never longer than `count`: cheaper than sorting
// every candidate the rings turn up, of which there are far more than are kept.
const at = found.findIndex((other) => distancePc < other.distancePc);
found.splice(at === -1 ? found.length : at, 0, { id: candidate.id, distancePc });
if (found.length > count) {
found.pop();
}
}
}
return found;
}
/**
* Every star within `radiusPc` of `id`, nearest first, excluding the star itself. This is what
* a jump-link graph is built from: one call per node gives that node's edges.
*/
within(id: number, radiusPc: number): Neighbour[] {
const origin = this.point(id);
if (!origin || radiusPc <= 0) {
return [];
}
const found: Neighbour[] = [];
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
const reach = Math.ceil(radiusPc / this.cellSizePc);
for (let ix = ox - reach; ix <= ox + reach; ix++) {
for (let iy = oy - reach; iy <= oy + reach; iy++) {
for (let iz = oz - reach; iz <= oz + reach; iz++) {
for (const index of this.cells.get(cellKey(ix, iy, iz)) ?? []) {
const candidate = this.points[index];
if (candidate.id === id) {
continue;
}
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
if (distancePc <= radiusPc) {
found.push({ id: candidate.id, distancePc });
}
}
}
}
}
found.sort((a, b) => a.distancePc - b.distancePc);
return found;
}
/**
* Visits every pair of stars within `radiusPc` of each other, once per pair.
*
* The same question `within` answers, asked of the whole catalogue at once — and a different
* shape of answer, because asking it star by star is asking it twice per pair and paying for a
* sorted list of each star's neighbours that the caller then throws away. Sixty-eight thousand
* of those took eight seconds; walking the grid once takes a fraction of it.
*
* Each cell is paired with itself and with the half of its surrounding cells that lie after it
* in the scan, which is what makes each pair come up exactly once.
*/
forEachPairWithin(radiusPc: number, visit: (a: StarPoint, b: StarPoint, distancePc: number) => void): void {
if (radiusPc <= 0) {
return;
}
const reach = Math.ceil(radiusPc / this.cellSizePc);
const radiusSq = radiusPc * radiusPc;
for (const [key, cell] of this.cells) {
const [ix, iy, iz] = key.split(',').map(Number);
for (let dx = 0; dx <= reach; dx++) {
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
const other = dx === 0 && dy === 0 && dz === 0 ? cell : this.cells.get(cellKey(ix + dx, iy + dy, iz + dz));
if (!other) {
continue;
}
const sameCell = other === cell;
for (let i = 0; i < cell.length; i++) {
const a = this.points[cell[i]];
// Within one cell, only the pairs after this one; across two, all of them — the
// other cell is only ever visited from this side.
for (let j = sameCell ? i + 1 : 0; j < other.length; j++) {
const b = this.points[other[j]];
const dxp = b.x - a.x;
const dyp = b.y - a.y;
const dzp = b.z - a.z;
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
if (distanceSq <= radiusSq) {
visit(a, b, Math.sqrt(distanceSq));
}
}
}
}
}
}
}
}
private keyFor(x: number, y: number, z: number): string {
const [ix, iy, iz] = this.cellFor(x, y, z);
return cellKey(ix, iy, iz);
}
private cellFor(x: number, y: number, z: number): [number, number, number] {
return [Math.floor(x / this.cellSizePc), Math.floor(y / this.cellSizePc), Math.floor(z / this.cellSizePc)];
}
/** Indices in the hollow shell of cells exactly `ring` cells out from the centre one. */
private *ringIndices(ox: number, oy: number, oz: number, ring: number): Generator<number> {
for (let ix = ox - ring; ix <= ox + ring; ix++) {
for (let iy = oy - ring; iy <= oy + ring; iy++) {
for (let iz = oz - ring; iz <= oz + ring; iz++) {
// Only the shell: everything inside it was searched by a previous, smaller ring.
const onShell = Math.abs(ix - ox) === ring || Math.abs(iy - oy) === ring || Math.abs(iz - oz) === ring;
if (!onShell) {
continue;
}
yield* this.cells.get(cellKey(ix, iy, iz)) ?? [];
}
}
}
}
}
+15 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from './star-catalog'; import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
import { StarRecord } from './star.model'; import { StarRecord } from './star.model';
const STARS: StarRecord[] = [ const STARS: StarRecord[] = [
@@ -131,4 +131,18 @@ describe('star catalogue provenance and derived names', () => {
expect(named.index.names).toEqual(['Some Proper Name']); expect(named.index.names).toEqual(['Some Proper Name']);
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name'); expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
}); });
it('tells a name somebody gave from the designation a survey generates', () => {
expect(isDesignation(decoded[0])).toBe(false);
expect(isDesignation(decoded[1])).toBe(true);
// The real catalogue names Gaia stars by the survey's nineteen-digit source id, which no
// 32-bit row id can equal — so it is the prefix that decides, not a round trip through the id.
expect(isDesignation({ ...MIXED[1], name: 'Gaia DR3 5853498713190525696' })).toBe(true);
expect(isDesignation({ ...MIXED[1], name: 'Proxima Centauri' })).toBe(false);
// HYG's own last resort, once every designation it knows has come up empty. Upper case,
// whatever case the source id is in.
expect(isDesignation({ ...MIXED[0], name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], source: undefined, name: 'HYG 5' })).toBe(true);
expect(isDesignation({ ...MIXED[0], name: 'HD 48915' })).toBe(false);
});
}); });
+18 -3
View File
@@ -66,11 +66,14 @@ export interface StarCatalogIndex {
} }
/** /**
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; HYG's own * How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; `HYG <id>`
* fallbacks already produce real designations, so it never needs this. * for HYG, whose ETL reaches for that only after a proper name, Bayer, Flamsteed, HD, Gliese and
* HIP have all come up empty (`tools/etl/fetchStars.ts`) — none in the current catalogue, but
* the path is there, and a name made that way is no more a name than Gaia's.
*/ */
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = { const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
gaia: 'Gaia DR3' gaia: 'Gaia DR3',
hyg: 'HYG'
}; };
interface StarMetaColumns { interface StarMetaColumns {
@@ -161,6 +164,18 @@ function designationFor(prefix: string | undefined, id: number): string | undefi
return prefix === undefined ? undefined : `${prefix} ${id}`; return prefix === undefined ? undefined : `${prefix} ${id}`;
} }
/**
* Whether a star's name is only the designation its source generates, rather than anything
* somebody called it. Judged by the prefix alone: the number after it is the survey's own id —
* nineteen digits for Gaia — which the 32-bit row id `designationFor` prints cannot hold, so a
* round-trip through the id would call every one of those stars named.
*/
export function isDesignation(star: StarRecord): boolean {
// No source at all is a single-catalogue build, whose fallback is HYG's — see `decodeStarCatalog`.
const prefix = star.source === undefined ? DESIGNATION_PREFIXES['hyg'] : (DESIGNATION_PREFIXES[star.source] ?? star.source);
return star.name.startsWith(`${prefix} `);
}
/** Rebuilds the star records the app works with from the three loaded assets. */ /** Rebuilds the star records the app works with from the three loaded assets. */
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] { export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
const columns = metaColumns(meta, index.count); const columns = metaColumns(meta, index.count);
@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Bookmark, BookmarksStore } from './bookmarks.store';
const KEY = 'star-map.bookmarks';
const SIRIUS: Bookmark = { kind: 'star', id: 32349, name: 'Sirius' };
const EARTH: Bookmark = { kind: 'body', id: 'earth', name: 'Earth' };
function store(): BookmarksStore {
// Constructed per test, because the list is read once on construction — which is the
// behaviour being tested for anything that seeds storage first.
TestBed.resetTestingModule();
return TestBed.inject(BookmarksStore);
}
describe('BookmarksStore', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('starts empty, and keeps what it is given', () => {
const bookmarks = store();
expect(bookmarks.bookmarks()).toEqual([]);
expect(bookmarks.toggle(SIRIUS)).toBe(true);
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
expect(bookmarks.has('star', 32349)).toBe(true);
});
it('drops what it already had, when told the same thing twice', () => {
const bookmarks = store();
bookmarks.toggle(SIRIUS);
expect(bookmarks.toggle(SIRIUS)).toBe(false);
expect(bookmarks.bookmarks()).toEqual([]);
expect(bookmarks.has('star', 32349)).toBe(false);
});
it('tells a star from a body that happen to share an id', () => {
const bookmarks = store();
bookmarks.toggle({ kind: 'star', id: 1, name: 'A star' });
bookmarks.toggle({ kind: 'body', id: 1, name: 'A body' });
expect(bookmarks.bookmarks()).toHaveLength(2);
bookmarks.remove('star', 1);
expect(bookmarks.bookmarks()).toEqual([{ kind: 'body', id: 1, name: 'A body' }]);
});
it('puts the newest first, since that is the one being come back to', () => {
const bookmarks = store();
bookmarks.toggle(SIRIUS);
bookmarks.toggle(EARTH);
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Earth', 'Sirius']);
});
it('survives the visit it was kept in', () => {
store().toggle(SIRIUS);
expect(store().bookmarks()).toEqual([SIRIUS]);
});
it('reads past whatever else is in there, rather than losing the lot', () => {
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { kind: 'moon', id: 1, name: 'No such kind' }, { id: 'no-kind' }, null, 42, EARTH]));
expect(store().bookmarks()).toEqual([SIRIUS, EARTH]);
});
it('keeps one entry per place, however many the stored list holds', () => {
// Two entries for one place would each toggle the other's control on and off.
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { ...SIRIUS, name: 'Sirius (again)' }]));
expect(store().bookmarks()).toEqual([SIRIUS]);
});
it('treats a store that is not a list, or not JSON at all, as no bookmarks', () => {
localStorage.setItem(KEY, '{"not":"a list"}');
expect(store().bookmarks()).toEqual([]);
localStorage.setItem(KEY, 'nonsense{');
expect(store().bookmarks()).toEqual([]);
});
it('goes on working where the browser will not store anything at all', () => {
// Private mode, a full quota, storage disabled by policy: reading throws, writing throws.
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('denied');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('denied');
});
const bookmarks = store();
expect(bookmarks.bookmarks()).toEqual([]);
expect(() => bookmarks.toggle(SIRIUS)).not.toThrow();
// Kept for this visit, even though nothing will outlive it.
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
});
it('bounds what a hand-edited store can make it hold', () => {
const many = Array.from({ length: 500 }, (_, i) => ({ kind: 'star' as const, id: i, name: `Star ${i}` }));
localStorage.setItem(KEY, JSON.stringify(many));
expect(store().bookmarks()).toHaveLength(200);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { Injectable, signal } from '@angular/core';
/**
* A place someone chose to keep. Either a star, which is a system to fly into, or a body, which
* is a page to open — the two things the map lets you arrive at.
*/
export interface Bookmark {
readonly kind: 'star' | 'body';
/** A HYG star id, or a `bodies.json`/`exoplanets.json` id. */
readonly id: number | string;
/**
* The name as it read when it was kept. Stored rather than looked up, so the list can be
* shown before the catalogues have loaded — and so a bookmark to something a later catalogue
* no longer holds still says what it was rather than becoming a bare id.
*/
readonly name: string;
}
const STORAGE_KEY = 'star-map.bookmarks';
/**
* How many are kept. Not a limit anyone will reach by hand — it is a bound on what a corrupted
* or hand-edited store can make the app render, and on what is written back.
*/
const MAX_BOOKMARKS = 200;
/** `${kind}:${id}`, since a star id and a body id are different kinds of thing. */
function keyOf(kind: Bookmark['kind'], id: number | string): string {
return `${kind}:${id}`;
}
function isBookmark(value: unknown): value is Bookmark {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Partial<Bookmark>;
return (
(candidate.kind === 'star' || candidate.kind === 'body') &&
(typeof candidate.id === 'number' || typeof candidate.id === 'string') &&
typeof candidate.name === 'string'
);
}
/**
* The places kept between visits, in this browser and nowhere else.
*
* Local storage rather than an account: the map asks nobody to sign in, and a list of stars
* somebody liked is not worth a server. Every read of it is defensive — the store is a string
* a user can edit, another tab can write, and a browser can refuse to give at all — and a
* failure to read or write one is never allowed to take the map down with it.
*/
@Injectable({ providedIn: 'root' })
export class BookmarksStore {
private readonly kept = signal<readonly Bookmark[]>(this.read());
/** Most recently kept first, which is the order they are useful in. */
readonly bookmarks = this.kept.asReadonly();
has(kind: Bookmark['kind'], id: number | string): boolean {
return this.kept().some((bookmark) => bookmark.kind === kind && bookmark.id === id);
}
/** Keeps a place, or drops it if it was already kept. Returns whether it is kept now. */
toggle(bookmark: Bookmark): boolean {
const kept = this.has(bookmark.kind, bookmark.id);
this.write(kept ? this.kept().filter((other) => !(other.kind === bookmark.kind && other.id === bookmark.id)) : [bookmark, ...this.kept()].slice(0, MAX_BOOKMARKS));
return !kept;
}
remove(kind: Bookmark['kind'], id: number | string): void {
this.write(this.kept().filter((bookmark) => !(bookmark.kind === kind && bookmark.id === id)));
}
private write(bookmarks: readonly Bookmark[]): void {
this.kept.set(bookmarks);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
} catch {
// Full, disabled, or private-mode storage. The list still works for this visit; it just
// will not outlive it, which is a smaller loss than the alternative of failing here.
}
}
private read(): Bookmark[] {
let raw: string | null = null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return [];
}
if (!raw) {
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
// Filtered rather than rejected wholesale: one bad entry should not lose the others, and
// deduplicated because two entries for one place would each toggle the other's control.
const seen = new Set<string>();
return parsed
.filter(isBookmark)
.filter((bookmark) => !seen.has(keyOf(bookmark.kind, bookmark.id)) && seen.add(keyOf(bookmark.kind, bookmark.id)))
.slice(0, MAX_BOOKMARKS)
.map(({ kind, id, name }) => ({ kind, id, name }));
} catch {
return [];
}
}
}
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The mark on a place worth coming back to. Hollow when it is not kept, filled when it is —
* the same two states as the Display panel's layer ticks, which is where the eye has already
* learned what a filled mark means here.
*
* Size and colour come from the classes on the host; the svg fills it.
*/
@Component({
selector: 'app-bookmark-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" [attr.fill]="kept() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M7 4h10v16l-5-4-5 4z" />
</svg>
`
})
export class BookmarkIconComponent {
readonly kept = input(false);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+78 -1
View File
@@ -87,8 +87,13 @@ body {
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an /* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
* exponential ease-out the way a readout fills once an instrument locks onto a target. */ * exponential ease-out the way a readout fills once an instrument locks onto a target. */
/* `backwards`, not `both`: `both` leaves the final keyframe applied for good, and that keyframe
* carries a clip-path — which goes on clipping the panel's own box forever after, so anything
* that has to escape it (a dropdown opening upward, a tooltip) is cut away and cannot even be
* clicked. `backwards` holds the opening frame before the wipe and lets the element return to
* its own styles after it. */
@utility hud-acquire { @utility hud-acquire {
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
} }
@keyframes hud-acquire { @keyframes hud-acquire {
@@ -154,6 +159,64 @@ body {
background: color-mix(in oklab, var(--color-accent) 60%, transparent); background: color-mix(in oklab, var(--color-accent) 60%, transparent);
} }
/* The quieter voice: a star outside the system the camera is in, named so its direction can be
* read without leaving. Dimmed rather than shrunk — at the same size it still reads as a name,
* where a smaller one would read as a footnote to the system rather than a place beyond it. */
.map-label--ghost .map-label-name {
/* 65% of the accent over the void is 5.4:1 — a ghost is interactive, so it holds the same
* contrast floor as body text rather than the lower one large or decorative text could take. */
color: color-mix(in oklab, var(--color-accent) 65%, transparent);
}
.map-label--ghost .map-label-kind {
/* Quieter than the name it belongs to, and still 4.7:1. */
color: color-mix(in oklab, var(--color-accent) 60%, transparent);
}
.map-label--ghost::before {
background: color-mix(in oklab, var(--color-accent) 30%, transparent);
}
/* Selectable labels are buttons: the only ones the pointer can reach, since the layer they
* live in is transparent to it. They brighten to full strength on approach, which is the whole
* hover affordance — a ghost lighting up is the map saying this one is somewhere to go. */
.map-label--select {
pointer-events: auto;
cursor: pointer;
background: none;
border: 0;
padding: 0;
font: inherit;
}
.map-label--select:hover .map-label-name,
.map-label--select:focus-visible .map-label-name {
color: var(--color-accent);
}
.map-label--select:hover::before,
.map-label--select:focus-visible::before {
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
}
.map-label--select:focus-visible {
outline: 1px solid var(--color-accent);
outline-offset: 3px;
}
/* The mirror: text hangs off the point's left, right-aligned, leader line on its right. */
.map-label--left {
margin-left: 0;
margin-right: 0.875rem;
align-items: flex-end;
text-align: right;
}
.map-label--left::before {
left: auto;
right: -0.75rem;
}
.map-label-name { .map-label-name {
font-size: 11px; font-size: 11px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -167,3 +230,17 @@ body {
text-transform: uppercase; text-transform: uppercase;
color: color-mix(in oklab, var(--color-accent) 70%, transparent); color: color-mix(in oklab, var(--color-accent) 70%, transparent);
} }
/* The selection mark on a body in the scene: two hairline arcs bracketing the point — the one
* mark borrowed from the ARK's control disc, at hairline weight. Sized in pixels because it sits
* on the body's projected position and should read the same however far the camera is. Centred
* on the point by CSS2DRenderer's default (0.5, 0.5) anchor. */
.map-select {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid transparent;
border-left-color: var(--color-accent);
border-right-color: var(--color-accent);
pointer-events: none;
}