Merge pull request #7 from avalon-vanguard/feat/hud-neighbours

Name the neighbours, from inside the system
This commit is contained in:
Senrokai
2026-08-27 19:22:28 +02:00
committed by GitHub
13 changed files with 818 additions and 24 deletions
+6 -1
View File
@@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner
{ "name": "Earth", "kind": "Planet" },
{ "name": "Mercury", "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
(`.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)
+9 -3
View File
@@ -211,21 +211,27 @@ async function probe(page, view) {
await page.waitForTimeout(2000);
// 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
.filter((n) => n.offsetParent !== null)
.map((n) => ({
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(
JSON.stringify(
{
title: (await title(page).textContent())?.trim(),
level: (await level(page).textContent())?.trim(),
labelCount: labels.length,
labels
labels,
...(neighbours.length ? { neighbours } : {})
},
null,
2
+32
View File
@@ -0,0 +1,32 @@
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 rather than the Alpha Centauri trio, whose three members share one
// bearing and so 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 });
});
});
@@ -26,7 +26,9 @@ import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '.
import { StarmapHudComponent } from './starmap-hud.component';
import { SystemObjectCardComponent } from './system-object-card.component';
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { HostStarRings } from './host-star-rings';
import { ReservedBox, ringPlacement } from './label-ring';
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
import { SystemOrbitsRenderer } from './system-orbits-renderer';
@@ -54,6 +56,21 @@ const LABEL_MIN_SEPARATION_NDC = 0.12;
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 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;
@@ -164,6 +181,10 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
template: `
<div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
<!-- Between the canvas and the labels, not up in the HUD where it used to live: everything
in this stack paints in tree order, so from there a decorative gradient was laid over
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>
@@ -195,6 +216,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
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
@@ -234,6 +256,19 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private rig?: CameraRigController;
private starField?: StarFieldRenderer;
private hostRings?: HostStarRings;
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
private neighbourhood?: StarNeighbourhood;
/** 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 deepSkyLabels: readonly LabeledPoint[] = [];
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
@@ -379,6 +414,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
]);
this.stars = stars;
this.starsById = new Map(stars.map((star) => [star.id, star]));
this.neighbourhood = new StarNeighbourhood(stars);
this.bodies = bodies;
this.exoplanets = exoplanets;
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
@@ -426,7 +462,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
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.applyDisplay(this.display());
const { width, height } = canvas.getBoundingClientRect();
@@ -473,6 +511,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
this.systemRenderer?.update(dateToJulianDate());
}
this.updateSelectionMark(camera);
this.updateNeighbourRing(camera);
this.labelOverlay?.render(camera);
}
@@ -680,6 +719,126 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
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.
.nearest(origin.id, NEIGHBOUR_COUNT * 2)
.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: THREE.PerspectiveCamera, 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;
}
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: THREE.PerspectiveCamera): 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: THREE.PerspectiveCamera): 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.
*
@@ -729,7 +888,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
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. */
@@ -1050,6 +1211,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.currentStarId = star.id;
this.resolveNeighbours();
this.navigationStore.setViewLevel('system');
onComplete();
});
@@ -1094,12 +1256,14 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (isSwitchingSystems) {
this.currentStarId = null;
this.resolveNeighbours();
onComplete();
return;
}
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
this.currentStarId = null;
this.resolveNeighbours();
this.navigationStore.setViewLevel('galaxy');
onComplete();
});
@@ -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;
}
@@ -104,6 +104,41 @@ describe('StarLabelOverlay', () => {
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', () => {
overlay.update([
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
@@ -20,12 +20,24 @@ export interface LabeledPoint {
* 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;
y: 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 {
@@ -34,7 +46,17 @@ export interface SelectionPoint {
z: number;
}
const SIDE_CLASS: Record<LabelSide, string> = { right: 'map-label', left: 'map-label map-label--left' };
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
@@ -49,7 +71,11 @@ export class StarLabelOverlay {
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.domElement = this.cssRenderer.domElement;
}
@@ -78,13 +104,23 @@ export class StarLabelOverlay {
const existing = this.labelObjects.get(point.id);
if (existing) {
existing.position.set(point.x, point.y, point.z);
this.applySide(existing, point.side ?? 'right');
this.applyPresentation(existing, point);
} else {
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
@@ -122,12 +158,26 @@ export class StarLabelOverlay {
}
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
// (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
// would be overwritten — the margin is the offset it cannot touch.
element.className = `${SIDE_CLASS[point.side ?? 'right']} whitespace-nowrap font-body`;
element.className = classesFor(point);
const name = document.createElement('span');
name.className = 'map-label-name';
@@ -146,16 +196,16 @@ export class StarLabelOverlay {
// (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
// the leader line points at empty space half the label's width from the star.
this.applySide(object, point.side ?? 'right');
this.applyPresentation(object, point);
object.position.set(point.x, point.y, point.z);
this.scene.add(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 applySide(object: CSS2DObject, side: LabelSide): void {
object.center.set(side === 'left' ? 1 : 0, 0.5);
const wanted = `${SIDE_CLASS[side]} whitespace-nowrap font-body`;
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;
}
@@ -38,8 +38,6 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: `
<div class="hud-vignette absolute inset-0"></div>
@if (showReticle()) {
<!-- 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. -->
+12 -7
View File
@@ -67,18 +67,21 @@ function isWideViewport(): boolean {
imports: [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: `
<div class="pointer-events-auto flex flex-col items-start">
<!-- 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 mb-2 w-full max-w-xl">
<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 mb-2 w-full max-w-lg px-4 py-3">
<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>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) {
@@ -100,7 +103,7 @@ function isWideViewport(): boolean {
</section>
}
@case ('display') {
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface mb-2 w-full max-w-lg px-4 py-3">
<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) {
@@ -122,7 +125,7 @@ function isWideViewport(): boolean {
}
}
<div class="hud-brackets hud-surface flex w-full items-stretch">
<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
@@ -204,8 +207,10 @@ export class HudDockComponent implements OnInit {
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.
this.activeTab.set(this.title() ? 'readout' : null);
// 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. */
@@ -0,0 +1,108 @@
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('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]);
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* 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];
}
/**
* 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;
}
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)) ?? [];
}
}
}
}
}
+45
View File
@@ -154,6 +154,51 @@ body {
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;