diff --git a/.claude/skills/run-star-map/SKILL.md b/.claude/skills/run-star-map/SKILL.md index dd00110..e6c3200 100644 --- a/.claude/skills/run-star-map/SKILL.md +++ b/.claude/skills/run-star-map/SKILL.md @@ -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) diff --git a/.claude/skills/run-star-map/driver.mjs b/.claude/skills/run-star-map/driver.mjs index 21b6263..22f736f 100644 --- a/.claude/skills/run-star-map/driver.mjs +++ b/.claude/skills/run-star-map/driver.mjs @@ -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 diff --git a/e2e/neighbour-jump.spec.ts b/e2e/neighbour-jump.spec.ts new file mode 100644 index 0000000..81bb8fe --- /dev/null +++ b/e2e/neighbour-jump.spec.ts @@ -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 }); + }); +}); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 7a33269..9a23f43 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -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: `
+ +
@@ -195,6 +216,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { private readonly labelHostRef = viewChild.required>('labelHost'); private readonly leaderRef = viewChild.required>('leader'); private readonly objectCardRef = viewChild>(SystemObjectCardComponent, { read: ElementRef }); + private readonly dockRef = viewChild>(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(); }); diff --git a/src/app/features/galaxy-system/label-ring.spec.ts b/src/app/features/galaxy-system/label-ring.spec.ts new file mode 100644 index 0000000..47887ca --- /dev/null +++ b/src/app/features/galaxy-system/label-ring.spec.ts @@ -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(); + }); +}); diff --git a/src/app/features/galaxy-system/label-ring.ts b/src/app/features/galaxy-system/label-ring.ts new file mode 100644 index 0000000..8c43819 --- /dev/null +++ b/src/app/features/galaxy-system/label-ring.ts @@ -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; +} diff --git a/src/app/features/galaxy-system/star-label-overlay.spec.ts b/src/app/features/galaxy-system/star-label-overlay.spec.ts index 084fa54..4cf9287 100644 --- a/src/app/features/galaxy-system/star-label-overlay.spec.ts +++ b/src/app/features/galaxy-system/star-label-overlay.spec.ts @@ -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 }, diff --git a/src/app/features/galaxy-system/star-label-overlay.ts b/src/app/features/galaxy-system/star-label-overlay.ts index e0457ff..2377c05 100644 --- a/src/app/features/galaxy-system/star-label-overlay.ts +++ b/src/app/features/galaxy-system/star-label-overlay.ts @@ -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 = { right: 'map-label', left: 'map-label map-label--left' }; +function classesFor(point: Pick): 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(); 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; } diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 9a0f2c7..5334719 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -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: ` -
- @if (showReticle()) { diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts index 76a2eb3..7106938 100644 --- a/src/app/features/hud/hud-dock.component.ts +++ b/src/app/features/hud/hud-dock.component.ts @@ -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: ` -
+ +
@if (activeTab(); as tab) { @switch (tab) { @case ('search') { -