From 44c6a1f15ef2ef795edc8768851eb0d0a70ca735 Mon Sep 17 00:00:00 2001 From: Senrokai Date: Thu, 20 Aug 2026 17:06:25 +0200 Subject: [PATCH 1/2] Name the neighbours, from inside the system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- .claude/skills/run-star-map/SKILL.md | 7 +- .claude/skills/run-star-map/driver.mjs | 12 +- e2e/neighbour-jump.spec.ts | 32 ++++ .../galaxy-system-scene.component.ts | 172 ++++++++++++++++- .../features/galaxy-system/label-ring.spec.ts | 82 ++++++++ src/app/features/galaxy-system/label-ring.ts | 86 +++++++++ .../galaxy-system/star-label-overlay.spec.ts | 35 ++++ .../galaxy-system/star-label-overlay.ts | 68 ++++++- src/app/features/hud/hud-dock.component.ts | 19 +- .../shared/astro/star-neighbourhood.spec.ts | 108 +++++++++++ src/app/shared/astro/star-neighbourhood.ts | 178 ++++++++++++++++++ src/styles.css | 45 +++++ 12 files changed, 822 insertions(+), 22 deletions(-) create mode 100644 e2e/neighbour-jump.spec.ts create mode 100644 src/app/features/galaxy-system/label-ring.spec.ts create mode 100644 src/app/features/galaxy-system/label-ring.ts create mode 100644 src/app/shared/astro/star-neighbourhood.spec.ts create mode 100644 src/app/shared/astro/star-neighbourhood.ts 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 0f4b1df..d0b58da 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; @@ -195,6 +212,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 }); private readonly raycaster = new THREE.Raycaster(); private readonly galaxyGroup = new THREE.Group(); @@ -228,6 +246,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. */ @@ -373,6 +404,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. @@ -420,7 +452,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(); @@ -467,6 +501,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { this.systemRenderer?.update(dateToJulianDate()); } this.updateSelectionMark(camera); + this.updateNeighbourRing(camera); this.labelOverlay?.render(camera); } @@ -685,6 +720,134 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { * 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. */ + /** + * 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 entirely, so the view would name + * whichever happened to be in front and stay silent about the rest. What survives the ring is + * the half of the direction that a viewer can act on: which way to turn to face it. + */ + /** 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 + .nearest(origin.id, 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); + } + } + } + private updateSystemLabels(camera: THREE.PerspectiveCamera): void { const renderer = this.systemRenderer; if (!renderer) { @@ -723,7 +886,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. */ @@ -1044,6 +1209,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(); }); @@ -1088,12 +1254,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/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') { -