A system view could say everything about the star it was inside and nothing about where that star was. The four nearest catalogue stars are now named around the edge of it, each with its distance, each a button that flies there — so a chain of neighbours can be walked without pulling back out to the field between hops. These are bearings, not sky positions, and that is the one deliberate compromise here. A true direction was tried first and does not work: at this field of view the visible cone is about 30 degrees, so on average one neighbour in fifteen falls inside the frame — measured, not guessed, at one label of four in Sol and none at all after a small orbit. What survives the ring is the half of the direction a viewer can act on, which way to turn to face it, and the ring reads as instrument rather than as scene because it sits at a fixed radius. Real distance was never an option: Proxima is 268 000 AU from Sol, thirteen far planes out, so the distance goes on the type line. Proximity is answered by a new pure module rather than by a scan. A uniform grid over the catalogue answers both "the k nearest to this star" and "every star within n parsecs", the second being what the jump-link graph in the next PR is built from — one scan per node, and the quadratic would show. Its spec pins the grid against a brute-force sweep of a pseudo-random cloud, because a spatial index is an optimisation and never a different answer. Where the ring meets the HUD, the HUD wins: placement is given the boxes the readout, the strip and the object card occupy, and slides a name along the ring until it clears them, or drops it rather than print it half hidden. That rule is a pure function with its own spec. Four defects found while verifying this, three of them older than it: The dock's flex column was pointer-events-auto and as wide as its strip, so an invisible band above the strip swallowed every click in it — including, but not only, a neighbour's. The column is transparent now and each surface opts back in. The ring was sized against the frame's height alone, which on a phone held upright put it a viewport and a half wide: no neighbour was reachable on any portrait screen. It is sized against the shorter side. Picking a search result reopened the readout, which on a narrow viewport is a sheet over most of the scene — reopening it onto whatever was just flown to. Below sm it now folds away. A selectable label's two lines are adjacent spans, so it announced as "Sirius2.64 pc"; it carries an explicit label saying what it does. Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that flies Sol to Barnard's Star by its label, design detector clean, screenshots at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path walked: both names are in the tab order, focusable, with the accent ring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
153 lines
6.0 KiB
TypeScript
153 lines
6.0 KiB
TypeScript
import * as THREE from 'three/webgpu';
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
|
|
|
import { StarLabelOverlay } from './star-label-overlay';
|
|
|
|
describe('StarLabelOverlay', () => {
|
|
let scene: THREE.Scene;
|
|
let overlay: StarLabelOverlay;
|
|
let camera: THREE.PerspectiveCamera;
|
|
|
|
/**
|
|
* Every label element currently in the layer, in DOM order.
|
|
*
|
|
* `CSS2DRenderer` only attaches an element to its layer when it projects it, so the labels do
|
|
* not exist in the DOM until something has been rendered — which is why this draws a frame
|
|
* rather than reading straight off `domElement`.
|
|
*/
|
|
function labels(): HTMLElement[] {
|
|
overlay.render(camera);
|
|
return [...overlay.domElement.querySelectorAll<HTMLElement>('.map-label')];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
scene = new THREE.Scene();
|
|
overlay = new StarLabelOverlay(scene);
|
|
overlay.setSize(800, 600);
|
|
camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 1000);
|
|
camera.position.set(0, 0, 20);
|
|
camera.updateMatrixWorld();
|
|
});
|
|
|
|
it('prints what a thing is under what it is called', () => {
|
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'System', x: 1, y: 2, z: 3 }]);
|
|
|
|
const label = labels()[0];
|
|
expect(label.querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
|
expect(label.querySelector('.map-label-kind')?.textContent).toBe('System');
|
|
});
|
|
|
|
it('prints the name alone when there is no type to give', () => {
|
|
// The second line is optional, and an empty one would still cost its line height — which
|
|
// over a screen of labels shifts every name off the point it is anchored to.
|
|
overlay.update([{ id: 1, name: 'Sirius', x: 1, y: 2, z: 3 }]);
|
|
|
|
expect(labels()[0].querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
|
expect(labels()[0].querySelector('.map-label-kind')).toBeNull();
|
|
});
|
|
|
|
it('places the label at the point it belongs to', () => {
|
|
overlay.update([{ id: 7, name: 'Vega', kind: 'Star', x: 4, y: -5, z: 6 }]);
|
|
|
|
const object = scene.children.find((child) => child.type === 'Object3D');
|
|
expect(object?.position.toArray()).toEqual([4, -5, 6]);
|
|
});
|
|
|
|
it('touches the DOM only for labels that actually changed', () => {
|
|
overlay.update([
|
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
|
{ id: 2, name: 'Vega', kind: 'Star', x: 0, y: 1, z: 0 }
|
|
]);
|
|
const sirius = labels()[0];
|
|
|
|
overlay.update([
|
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
|
{ id: 3, name: 'Altair', kind: 'Star', x: 0, y: 0, z: 1 }
|
|
]);
|
|
|
|
// Same element instance: the label that stayed was not torn down and rebuilt.
|
|
expect(labels()[0]).toBe(sirius);
|
|
expect(labels().map((label) => label.querySelector('.map-label-name')?.textContent)).toEqual(['Sirius', 'Altair']);
|
|
});
|
|
|
|
it('drops every label when given none', () => {
|
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
|
overlay.update([]);
|
|
|
|
expect(labels()).toHaveLength(0);
|
|
});
|
|
|
|
it('hangs a label on the side it is told to, and can move it across', () => {
|
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'left' }]);
|
|
const object = scene.children[0] as CSS2DObject;
|
|
expect(labels()[0].classList.contains('map-label--left')).toBe(true);
|
|
expect(object.center.x).toBe(1);
|
|
|
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'right' }]);
|
|
expect(labels()[0].classList.contains('map-label--left')).toBe(false);
|
|
expect(object.center.x).toBe(0);
|
|
});
|
|
|
|
it('brackets one selected point with the mark, moves it, and clears it', () => {
|
|
overlay.setSelection({ x: 1, y: 2, z: 3 });
|
|
overlay.setSelection({ x: 4, y: 5, z: 6 });
|
|
overlay.render(camera);
|
|
const marks = overlay.domElement.querySelectorAll('.map-select');
|
|
expect(marks).toHaveLength(1);
|
|
expect((scene.children[0] as THREE.Object3D).position.toArray()).toEqual([4, 5, 6]);
|
|
|
|
overlay.setSelection(null);
|
|
overlay.render(camera);
|
|
expect(overlay.domElement.querySelectorAll('.map-select')).toHaveLength(0);
|
|
expect(scene.children).toHaveLength(0);
|
|
});
|
|
|
|
it('says a neighbour more quietly than a body of this system', () => {
|
|
overlay.update([
|
|
{ id: 'earth', name: 'Earth', kind: 'Planet', x: 1, y: 0, z: 0 },
|
|
{ id: 'neighbour:1', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 1, x: 0, y: 1, z: 0 }
|
|
]);
|
|
|
|
const [body, ghost] = labels();
|
|
expect(body.classList.contains('map-label--ghost')).toBe(false);
|
|
expect(ghost.classList.contains('map-label--ghost')).toBe(true);
|
|
expect(ghost.querySelector('.map-label-kind')?.textContent).toBe('2.64 pc');
|
|
});
|
|
|
|
it('makes a label that offers a star a button, and hands back the star it names', () => {
|
|
const chosen: number[] = [];
|
|
overlay = new StarLabelOverlay(scene, (starId) => chosen.push(starId));
|
|
overlay.setSize(800, 600);
|
|
overlay.update([{ id: 'neighbour:42', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 42, x: 1, y: 0, z: 0 }]);
|
|
|
|
const ghost = labels()[0];
|
|
expect(ghost.tagName).toBe('BUTTON');
|
|
// Its two lines are adjacent spans, so without this it is announced as "Sirius2.64 pc".
|
|
expect(ghost.getAttribute('aria-label')).toBe('Go to Sirius, 2.64 pc away');
|
|
ghost.click();
|
|
|
|
expect(chosen).toEqual([42]);
|
|
});
|
|
|
|
it('leaves a label that offers nothing untouchable, so the scene behind it stays clickable', () => {
|
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
|
|
|
const label = labels()[0];
|
|
expect(label.tagName).toBe('DIV');
|
|
expect(label.classList.contains('map-label--select')).toBe(false);
|
|
});
|
|
|
|
it('leaves nothing behind when disposed', () => {
|
|
overlay.update([
|
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
|
{ id: 'ngc:224', name: 'Andromeda', kind: 'GALAXY', x: 0, y: 1, z: 0 }
|
|
]);
|
|
overlay.dispose();
|
|
|
|
expect(labels()).toHaveLength(0);
|
|
expect(scene.children).toHaveLength(0);
|
|
});
|
|
});
|