Merge pull request #6 from avalon-vanguard/feat/hud-scene
Point at what is selected: arcs, a leader to the card, rings on the systems
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* How a size in pixels becomes a size the scene can draw.
|
||||
*
|
||||
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
|
||||
* share of the screen however far away it is and whatever the window is doing. Pixels are what
|
||||
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
|
||||
* is a statement someone can check by looking — so the two are related through a reference
|
||||
* viewport and field of view, and the pixel figures are exact only at that height.
|
||||
*
|
||||
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
|
||||
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
|
||||
* others.
|
||||
*/
|
||||
|
||||
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
export const REFERENCE_FOV_DEGREES = 55;
|
||||
|
||||
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
|
||||
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
@@ -26,7 +26,8 @@ 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 { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
import { HostStarRings } from './host-star-rings';
|
||||
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
|
||||
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
||||
@@ -49,6 +50,13 @@ const LABEL_MAX_COUNT = 15;
|
||||
* Nearer stars win the space; see `spreadLabels`.
|
||||
*/
|
||||
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
|
||||
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;
|
||||
/** Radius of the selection arcs, in pixels — the leader line starts at their rim. */
|
||||
const SELECTION_RADIUS_PX = 14;
|
||||
const HUD_ACCENT = 0x4dd7ff;
|
||||
/**
|
||||
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
||||
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
||||
@@ -159,6 +167,11 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
||||
<!-- 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>
|
||||
<!-- Leader from the selected body to its card, drawn in screen space and repositioned in
|
||||
the render loop; visibility is toggled there too, so no change detection per frame. -->
|
||||
<svg aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full text-accent/60">
|
||||
<line #leader x1="0" y1="0" x2="0" y2="0" stroke="currentColor" stroke-width="1" visibility="hidden" />
|
||||
</svg>
|
||||
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" (levelSelected)="goToLevel($event)" />
|
||||
@if (objectCard(); as card) {
|
||||
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
|
||||
@@ -180,6 +193,14 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
||||
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||
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 });
|
||||
/**
|
||||
* 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
|
||||
* moves, and it is only replaced when a different body is selected.
|
||||
*/
|
||||
private readonly objectCardElement = computed(() => this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]') ?? null);
|
||||
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly galaxyGroup = new THREE.Group();
|
||||
@@ -206,10 +227,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private enterableSystems = 0;
|
||||
private pinnedBodyId: string | null = null;
|
||||
private hoveredBodyId: string | null = null;
|
||||
/** The body the card is about — what the selection mark brackets and the leader line leaves. */
|
||||
private cardBodyId: string | null = null;
|
||||
|
||||
private controls?: OrbitControls;
|
||||
private rig?: CameraRigController;
|
||||
private starField?: StarFieldRenderer;
|
||||
private hostRings?: HostStarRings;
|
||||
private deepSky?: DeepSkyRenderer;
|
||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||
@@ -268,6 +292,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
|
||||
this.controls?.dispose();
|
||||
this.starField?.dispose();
|
||||
this.hostRings?.dispose();
|
||||
this.deepSky?.dispose();
|
||||
this.milkyWay?.dispose();
|
||||
this.galacticGrid?.dispose();
|
||||
@@ -366,6 +391,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
||||
this.galaxyGroup.add(this.starField.object);
|
||||
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
||||
this.galaxyGroup.add(this.hostRings.object);
|
||||
|
||||
this.milkyWay = new MilkyWayRenderer();
|
||||
this.galacticLabels = this.milkyWay.labelPoints();
|
||||
@@ -445,6 +472,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
if (this.systemGroup.visible) {
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
}
|
||||
this.updateSelectionMark(camera);
|
||||
this.labelOverlay?.render(camera);
|
||||
}
|
||||
|
||||
@@ -469,6 +497,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0);
|
||||
this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
|
||||
this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
|
||||
this.hostRings?.setStrength(display.systems ? 1 - this.galacticStrength : 0);
|
||||
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
||||
this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0);
|
||||
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
|
||||
@@ -586,13 +615,71 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text hangs on the right unless it would run off the view there, or into the space a
|
||||
// label already placed to the right is using; then it hangs on the left, unless *that*
|
||||
// is off the view. A crowded centre still gets right-hand labels — the separation test
|
||||
// above already keeps them apart.
|
||||
const crowdedRight = placed.some(
|
||||
(other) => other.x > point.x && other.x - point.x < LABEL_REACH_NDC && Math.abs(other.y - point.y) < LABEL_MIN_SEPARATION_NDC
|
||||
);
|
||||
// The body the card is about hangs its label on the left regardless: the leader line to
|
||||
// the card leaves its right, and would otherwise run straight through the text.
|
||||
const side: LabelSide =
|
||||
candidate.id === this.cardBodyId || ((projected.x > LABEL_EDGE_NDC || crowdedRight) && projected.x > -LABEL_EDGE_NDC) ? 'left' : 'right';
|
||||
|
||||
placed.push(point);
|
||||
chosen.push(candidate);
|
||||
chosen.push({ ...candidate, side });
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brackets the body the card is about with the selection arcs, and draws the leader from
|
||||
* their rim to the card's near edge. Screen-space work done here, once per frame, because the
|
||||
* body moves every frame and the card's height depends on its content.
|
||||
*/
|
||||
private updateSelectionMark(camera: THREE.PerspectiveCamera): void {
|
||||
const leader = this.leaderRef().nativeElement;
|
||||
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
|
||||
if (!member) {
|
||||
this.labelOverlay?.setSelection(null);
|
||||
leader.setAttribute('visibility', 'hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const world = member.marker.getWorldPosition(new THREE.Vector3());
|
||||
this.labelOverlay?.setSelection(world);
|
||||
|
||||
const card = this.objectCardElement();
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const projected = world.clone().project(camera);
|
||||
if (!card || projected.z > 1) {
|
||||
leader.setAttribute('visibility', 'hidden');
|
||||
return;
|
||||
}
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const fromX = ((projected.x + 1) / 2) * canvas.clientWidth;
|
||||
const fromY = ((1 - projected.y) / 2) * canvas.clientHeight;
|
||||
// The card is top-right: meet its left edge, at the body's height where the edge allows.
|
||||
const toX = cardRect.left - canvasRect.left;
|
||||
const toY = Math.min(Math.max(fromY, cardRect.top - canvasRect.top + 12), cardRect.bottom - canvasRect.top - 12);
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length <= SELECTION_RADIUS_PX) {
|
||||
leader.setAttribute('visibility', 'hidden');
|
||||
return;
|
||||
}
|
||||
// Start on the arcs' rim, not the body's centre.
|
||||
leader.setAttribute('x1', String(fromX + (dx / length) * SELECTION_RADIUS_PX));
|
||||
leader.setAttribute('y1', String(fromY + (dy / length) * SELECTION_RADIUS_PX));
|
||||
leader.setAttribute('x2', String(toX));
|
||||
leader.setAttribute('y2', String(toY));
|
||||
leader.setAttribute('visibility', 'visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the bodies of the system the view is inside.
|
||||
*
|
||||
@@ -814,6 +901,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
|
||||
private refreshObjectCard(): void {
|
||||
const id = this.pinnedBodyId ?? this.hoveredBodyId;
|
||||
this.cardBodyId = id;
|
||||
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
|
||||
}
|
||||
|
||||
@@ -821,6 +909,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private clearObjectCard(): void {
|
||||
this.pinnedBodyId = null;
|
||||
this.hoveredBodyId = null;
|
||||
this.cardBodyId = null;
|
||||
this.objectCard.set(undefined);
|
||||
this.canvasRef().nativeElement.style.cursor = '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { color, float, instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { PIXELS_TO_ANGULAR_SIZE } from './angular-size';
|
||||
|
||||
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
|
||||
const RING_SIZE_PX = 12;
|
||||
const RING_PEAK_OPACITY = 0.35;
|
||||
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
|
||||
const RING_RADIUS_UV = 0.42;
|
||||
const RING_STROKE_UV = 0.06;
|
||||
|
||||
/** A unit quad centred on the origin — the billboard every ring instance is drawn on. */
|
||||
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
geometry.instanceCount = instanceCount;
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/**
|
||||
* A faint accent ring around every star known to host planets: the one binary fact about a
|
||||
* point of light worth reading at a glance from the neighbourhood view, since it is the one
|
||||
* thing that says "there is somewhere to go here".
|
||||
*
|
||||
* Drawn the way the star field draws its stars — instanced unattenuated sprites — so the rings
|
||||
* sit exactly on the field's own points at any zoom and window size. The ring itself is a band
|
||||
* of the quad's uv distance from centre, not a texture, so it stays a hairline at any scale.
|
||||
*/
|
||||
export class HostStarRings {
|
||||
readonly object: THREE.Mesh;
|
||||
readonly count: number;
|
||||
|
||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||
private readonly material: THREE.SpriteNodeMaterial;
|
||||
private readonly opacity = uniform(RING_PEAK_OPACITY);
|
||||
|
||||
constructor(hosts: readonly StarRecord[], accent: number) {
|
||||
const positions = new Float32Array(hosts.length * 3);
|
||||
hosts.forEach((star, i) => positions.set([star.x, star.y, star.z], i * 3));
|
||||
this.geometry = createQuadGeometry(hosts.length);
|
||||
|
||||
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
|
||||
this.material.sizeAttenuation = false;
|
||||
this.material.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
|
||||
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE);
|
||||
this.material.colorNode = color(accent);
|
||||
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
|
||||
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
|
||||
this.material.opacityNode = smoothstep(RING_STROKE_UV, 0.0, distanceFromRing).mul(this.opacity);
|
||||
|
||||
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||
this.object.name = 'host-star-rings';
|
||||
// As for the star field: the quad's bounds say nothing about where the instances are.
|
||||
this.object.frustumCulled = false;
|
||||
this.count = hosts.length;
|
||||
}
|
||||
|
||||
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
|
||||
setStrength(strength: number): void {
|
||||
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||
this.opacity.value = RING_PEAK_OPACITY * clamped;
|
||||
this.object.visible = clamped > 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.geometry.dispose();
|
||||
this.material.dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,13 @@ import * as THREE from 'three/webgpu';
|
||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||
const MIN_POINT_SIZE = 1.5;
|
||||
const MAX_POINT_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Star size is expressed in pixels for readability, but the material works in angular size, so
|
||||
* the two are related through the scene's vertical field of view and a reference viewport.
|
||||
* Because the size is angular, a star keeps the same share of the screen at any window size —
|
||||
* these pixel figures are exact only at this reference height.
|
||||
*/
|
||||
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||
const REFERENCE_FOV_DEGREES = 55;
|
||||
const PIXELS_TO_ANGULAR_SIZE =
|
||||
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||
|
||||
/**
|
||||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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', () => {
|
||||
@@ -77,6 +79,31 @@ describe('StarLabelOverlay', () => {
|
||||
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('leaves nothing behind when disposed', () => {
|
||||
overlay.update([
|
||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||
|
||||
@@ -15,11 +15,27 @@ export interface LabeledPoint {
|
||||
* is called.
|
||||
*/
|
||||
kind?: string;
|
||||
/**
|
||||
* Which side of the point the text hangs on. Right is the default; left is for a point close
|
||||
* to the right edge of the view, or one whose right-hand text would run into a neighbour's.
|
||||
*/
|
||||
side?: LabelSide;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export type LabelSide = 'left' | 'right';
|
||||
|
||||
/** Where the selection mark sits, in the same scene units as the labels. */
|
||||
export interface SelectionPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const SIDE_CLASS: Record<LabelSide, string> = { right: 'map-label', left: 'map-label map-label--left' };
|
||||
|
||||
/**
|
||||
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
|
||||
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
|
||||
@@ -31,6 +47,7 @@ export class StarLabelOverlay {
|
||||
|
||||
private readonly cssRenderer = new CSS2DRenderer();
|
||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||
private selection?: CSS2DObject;
|
||||
|
||||
constructor(private readonly scene: THREE.Scene) {
|
||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||
@@ -61,12 +78,38 @@ 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');
|
||||
} else {
|
||||
this.addLabel(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* a different rhythm — labels change on their own cadence, the mark follows a moving body
|
||||
* every frame.
|
||||
*/
|
||||
setSelection(point: SelectionPoint | null): void {
|
||||
if (!point) {
|
||||
if (this.selection) {
|
||||
this.scene.remove(this.selection);
|
||||
this.selection.element.remove();
|
||||
this.selection = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.selection) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'map-select';
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
this.selection = new CSS2DObject(element);
|
||||
this.scene.add(this.selection);
|
||||
}
|
||||
this.selection.position.set(point.x, point.y, point.z);
|
||||
}
|
||||
|
||||
render(camera: THREE.Camera): void {
|
||||
this.cssRenderer.render(this.scene, camera);
|
||||
}
|
||||
@@ -75,6 +118,7 @@ export class StarLabelOverlay {
|
||||
for (const [id, object] of this.labelObjects) {
|
||||
this.removeLabel(id, object);
|
||||
}
|
||||
this.setSelection(null);
|
||||
}
|
||||
|
||||
private addLabel(point: LabeledPoint): void {
|
||||
@@ -83,7 +127,7 @@ export class StarLabelOverlay {
|
||||
// (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 = 'map-label whitespace-nowrap font-body';
|
||||
element.className = `${SIDE_CLASS[point.side ?? 'right']} whitespace-nowrap font-body`;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'map-label-name';
|
||||
@@ -98,16 +142,25 @@ export class StarLabelOverlay {
|
||||
}
|
||||
|
||||
const object = new CSS2DObject(element);
|
||||
// Anchor the label's left edge at the point, vertically centred. The default center of
|
||||
// Anchor the label's near edge at the point, vertically centred. The default center of
|
||||
// (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.
|
||||
object.center.set(0, 0.5);
|
||||
this.applySide(object, point.side ?? 'right');
|
||||
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`;
|
||||
if (object.element.className !== wanted) {
|
||||
object.element.className = wanted;
|
||||
}
|
||||
}
|
||||
|
||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||
this.scene.remove(object);
|
||||
object.element.remove();
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('HudDockComponent', () => {
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
||||
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true']);
|
||||
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true']);
|
||||
});
|
||||
|
||||
it('opens the search on "/" from anywhere but a text field', () => {
|
||||
|
||||
@@ -20,16 +20,19 @@ export interface HudDisplay {
|
||||
readonly grid: boolean;
|
||||
readonly deepSky: boolean;
|
||||
readonly sky: boolean;
|
||||
/** The rings on stars known to host planets. */
|
||||
readonly systems: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true };
|
||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true };
|
||||
|
||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||
{ key: 'labels', label: 'Labels' },
|
||||
{ key: 'orbits', label: 'Orbits' },
|
||||
{ key: 'grid', label: 'Grid' },
|
||||
{ key: 'deepSky', label: 'Deep sky' },
|
||||
{ key: 'sky', label: 'Sky' }
|
||||
{ key: 'sky', label: 'Sky' },
|
||||
{ key: 'systems', label: 'Systems' }
|
||||
];
|
||||
|
||||
export type DockTab = 'search' | 'readout' | 'display';
|
||||
|
||||
@@ -154,6 +154,19 @@ body {
|
||||
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
/* The mirror: text hangs off the point's left, right-aligned, leader line on its right. */
|
||||
.map-label--left {
|
||||
margin-left: 0;
|
||||
margin-right: 0.875rem;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.map-label--left::before {
|
||||
left: auto;
|
||||
right: -0.75rem;
|
||||
}
|
||||
|
||||
.map-label-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -167,3 +180,17 @@ body {
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in oklab, var(--color-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
/* The selection mark on a body in the scene: two hairline arcs bracketing the point — the one
|
||||
* mark borrowed from the ARK's control disc, at hairline weight. Sized in pixels because it sits
|
||||
* on the body's projected position and should read the same however far the camera is. Centred
|
||||
* on the point by CSS2DRenderer's default (0.5, 0.5) anchor. */
|
||||
.map-select {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid transparent;
|
||||
border-left-color: var(--color-accent);
|
||||
border-right-color: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user