Merge pull request #7 from avalon-vanguard/feat/hud-neighbours
Name the neighbours, from inside the system
This commit is contained in:
@@ -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. -->
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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)) ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user