Merge pull request #11 from avalon-vanguard/feat/hud-2d

Draw it flat: an orthographic plan view
This commit is contained in:
Senrokai
2026-08-27 19:23:18 +02:00
committed by GitHub
14 changed files with 594 additions and 78 deletions
+109
View File
@@ -0,0 +1,109 @@
import { NgZone } from '@angular/core';
import * as THREE from 'three/webgpu';
import { beforeEach, describe, expect, it } from 'vitest';
import { EngineService } from './engine.service';
/**
* The projection half of the engine, which is the half that can be tested without a GPU: no
* renderer is created, the two cameras are placed by hand, and what is asserted is the
* arithmetic that keeps them showing the same thing.
*/
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
// The two cameras are private, because nothing outside should choose between them by hand.
Object.assign(engine as unknown as Record<string, unknown>, { perspective, orthographic });
return { engine, perspective, orthographic };
}
/** Half the height of what a perspective camera frames at `distance`, in world units. */
function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number {
return distance * Math.tan((camera.fov * Math.PI) / 360);
}
describe('EngineService projection', () => {
let engine: EngineService;
let perspective: THREE.PerspectiveCamera;
let orthographic: THREE.OrthographicCamera;
beforeEach(() => {
({ engine, perspective, orthographic } = engineWithCameras());
});
it('draws through the perspective camera until told otherwise', () => {
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
});
it('frames the same extent through either camera, which is the point of the swap', () => {
perspective.position.set(0, 0, 200);
engine.setProjection('orthographic', 200);
expect(engine.getCamera()).toBe(orthographic);
expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6);
// And as wide as the frame is, not as wide as it is tall.
expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6);
});
it('carries the pose across, so the swap changes the projection and not the view', () => {
perspective.position.set(3, 4, 12);
perspective.lookAt(0, 0, 0);
engine.setProjection('orthographic', 13);
expect(orthographic.position.toArray()).toEqual(perspective.position.toArray());
expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray());
});
it('sees behind itself, because a parallel camera does not back away from what it frames', () => {
engine.setProjection('orthographic', 100);
// A perspective camera pulls back as its frame grows and leaves the scene in front of it. An
// orthographic one does not move at all, so half the Galaxy ends up behind its own plane —
// and a near plane in front would clip it away. Parallel depth is linear, so the precision
// that a perspective near plane is guarding for does not apply.
expect(orthographic.near).toBe(-perspective.far);
expect(orthographic.far).toBe(perspective.far);
});
it('goes back, and hands out the perspective camera again', () => {
engine.setProjection('orthographic', 100);
engine.setProjection('perspective', 100);
expect(engine.currentProjection).toBe('perspective');
expect(engine.getCamera()).toBe(perspective);
expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6);
});
it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => {
engine.setProjection('orthographic', 100);
const framed = engine.visibleHalfHeight(100);
orthographic.zoom = 2;
// Zoomed in twice: half as much in frame. Distance says nothing about it, which is why
// nothing downstream may read the camera's distance under this projection.
expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6);
expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6);
});
it('never divides by a camera sitting on its own target', () => {
expect(() => engine.setProjection('orthographic', 0)).not.toThrow();
expect(Number.isFinite(orthographic.top)).toBe(true);
});
it('widens rather than magnifies when the window gets wider', () => {
engine.setProjection('orthographic', 100);
const height = orthographic.top - orthographic.bottom;
// No renderer, so resize returns early — the frustum is re-fitted by hand the same way.
orthographic.left = (-height / 2) * (21 / 9);
orthographic.right = (height / 2) * (21 / 9);
expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6);
expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6);
});
});
+96 -10
View File
@@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu';
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
/** Which projection the scene is drawn through. */
export type Projection = 'perspective' | 'orthographic';
/** Either camera, as everything downstream of the projection sees it. */
export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
/**
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
@@ -20,7 +26,14 @@ export class EngineService {
private canvas?: HTMLCanvasElement;
private renderer?: THREE.WebGPURenderer;
private scene?: THREE.Scene;
private camera?: THREE.PerspectiveCamera;
private perspective?: THREE.PerspectiveCamera;
/**
* Built alongside the perspective one and kept in step with it, rather than made on demand:
* the two share a position, an orientation and a depth range, and a camera that only exists
* while it is being looked through is a camera whose state is always one swap out of date.
*/
private orthographic?: THREE.OrthographicCamera;
private projection: Projection = 'perspective';
private running = false;
constructor(private readonly ngZone: NgZone) {}
@@ -33,8 +46,71 @@ export class EngineService {
return this.requireInitialized(this.scene);
}
getCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.camera);
/** The camera the scene is currently drawn through. */
getCamera(): SceneCamera {
return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective);
}
/**
* The perspective camera, whichever is active. For the handful of places that need a field of
* view to reason with — framing a system, sizing a star — and that go on meaning the same
* thing in either projection because the sizes were tuned against this one.
*/
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.requireInitialized(this.perspective);
}
get currentProjection(): Projection {
return this.projection;
}
/**
* Switches projection, carrying the pose across. The orthographic frustum is sized to show
* the same extent at `distanceToTarget` that the perspective camera showed from there, so the
* swap changes how the scene is projected and not how much of it is in frame.
*/
setProjection(projection: Projection, distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
this.projection = projection;
orthographic.zoom = 1;
orthographic.position.copy(perspective.position);
orthographic.quaternion.copy(perspective.quaternion);
this.frameOrthographic(distanceToTarget);
}
/**
* Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera
* would show from there. Called every frame while that projection is active, which is what
* makes the camera flights work through it: they move the camera, and the frame follows.
*
* The depth range is symmetric about the camera rather than starting in front of it. An
* orthographic camera does not pull back as its frame grows, so at galactic framing the
* backdrop shell and half the Milky Way lie behind its own plane and would be clipped away.
* A parallel projection has linear depth, so the precision argument that makes a perspective
* near plane worth guarding does not apply here.
*/
frameOrthographic(distanceToTarget: number): void {
const perspective = this.requireInitialized(this.perspective);
const orthographic = this.requireInitialized(this.orthographic);
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360);
orthographic.top = halfHeight;
orthographic.bottom = -halfHeight;
orthographic.left = -halfHeight * perspective.aspect;
orthographic.right = halfHeight * perspective.aspect;
orthographic.far = perspective.far;
orthographic.near = -perspective.far;
orthographic.updateProjectionMatrix();
}
/** Half the height of what is in frame at the target, in world units, under either camera. */
visibleHalfHeight(distanceToTarget: number): number {
if (this.projection === 'orthographic') {
const orthographic = this.requireInitialized(this.orthographic);
return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
}
return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360);
}
getRenderer(): THREE.WebGPURenderer {
@@ -52,8 +128,10 @@ export class EngineService {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.camera.position.set(0, 0, 5);
this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
this.perspective.position.set(0, 0, 5);
this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
this.orthographic.position.copy(this.perspective.position);
const { width, height } = this.canvasSize();
this.resize(width, height);
@@ -99,11 +177,18 @@ export class EngineService {
* Updates the camera aspect ratio and renderer drawing buffer size.
*/
resize(width: number, height: number): void {
if (!this.renderer || !this.camera || width <= 0 || height <= 0) {
if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) {
return;
}
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
const aspect = width / height;
this.perspective.aspect = aspect;
this.perspective.updateProjectionMatrix();
// The orthographic frustum keeps its height and re-fits its width, so a window getting wider
// shows more to the sides rather than magnifying what was already there.
const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2;
this.orthographic.left = -halfHeight * aspect;
this.orthographic.right = halfHeight * aspect;
this.orthographic.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
}
@@ -116,7 +201,8 @@ export class EngineService {
this.renderer?.dispose();
this.renderer = undefined;
this.scene = undefined;
this.camera = undefined;
this.perspective = undefined;
this.orthographic = undefined;
this.canvas = undefined;
}
@@ -128,7 +214,7 @@ export class EngineService {
callback(deltaSeconds, elapsedSeconds);
}
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera));
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera());
}
private canvasSize(): { width: number; height: number } {
@@ -1,4 +1,6 @@
import * as THREE from 'three/webgpu';
import { SceneCamera } from '../../core/engine/engine.service';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
export interface CameraPose {
@@ -27,7 +29,7 @@ export class CameraRigController {
};
constructor(
private readonly camera: THREE.PerspectiveCamera,
private readonly camera: SceneCamera,
private readonly controls: OrbitControls
) {}
@@ -64,7 +64,9 @@ const EARTH: BodyRecord = {
class FakeEngineService {
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
private readonly tickCallbacks = new Set<EngineTickCallback>();
projection: 'perspective' | 'orthographic' = 'perspective';
get isInitialized(): boolean {
return true;
@@ -78,10 +80,37 @@ class FakeEngineService {
return this.scene;
}
getCamera(): THREE.PerspectiveCamera {
getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
return this.projection === 'orthographic' ? this.orthographic : this.camera;
}
getPerspectiveCamera(): THREE.PerspectiveCamera {
return this.camera;
}
get currentProjection(): 'perspective' | 'orthographic' {
return this.projection;
}
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
this.projection = projection;
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
this.orthographic.top = halfHeight;
this.orthographic.bottom = -halfHeight;
this.orthographic.left = -halfHeight * this.camera.aspect;
this.orthographic.right = halfHeight * this.camera.aspect;
this.orthographic.zoom = 1;
this.orthographic.position.copy(this.camera.position);
this.orthographic.quaternion.copy(this.camera.quaternion);
this.orthographic.updateProjectionMatrix();
}
visibleHalfHeight(distanceToTarget: number): number {
return this.projection === 'orthographic'
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
}
onTick(callback: EngineTickCallback): () => void {
this.tickCallbacks.add(callback);
return () => this.tickCallbacks.delete(callback);
@@ -4,9 +4,15 @@ import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { dateToJulianDate } from '../../shared/astro/constants';
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
import {
GALACTIC_BASIS_EQUATORIAL,
MILKY_WAY_ARMS,
SUN_GALACTOCENTRIC_RADIUS_PC,
galacticCentrePositionPc,
galacticToEquatorial
} from '../../shared/astro/galaxy';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { EngineService } from '../../core/engine/engine.service';
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
import { BodyRecord } from '../../shared/models/body.model';
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
@@ -64,6 +70,13 @@ const LABEL_REACH_NDC = 0.3;
/** How long the range control has to be still before the graph is rebuilt at its value. */
const JUMP_LINK_REBUILD_DELAY_MS = 250;
/**
* How far in or out the plan view may be zoomed from the extent its distance frames. Under a
* parallel projection the wheel changes the frame rather than the distance, so the orbit limits
* stop applying and this is what stands in for them.
*/
const PLAN_ZOOM_SPAN = 64;
/** How many matches each routing field offers, and how little may be typed to get any. */
const ROUTE_OPTION_COUNT = 6;
const MIN_ROUTE_QUERY_LENGTH = 2;
@@ -368,6 +381,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
});
effect(() => this.applyDisplay(this.display()));
effect(() => this.applyProjection(this.display().plan));
// Reads both signals, so flipping the layer on and dragging the range each land here. The
// rebuild is a quarter-second of walking the catalogue, and the range control emits per
// pixel dragged, so it waits for the hand to settle rather than running once per pixel.
@@ -451,9 +465,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const scene = this.engine.getScene();
const camera = this.engine.getCamera();
camera.position.copy(GALAXY_OVERVIEW_POSITION);
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls = new OrbitControls(camera, canvas);
this.controls.enableDamping = true;
@@ -546,15 +564,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
canvas.addEventListener('pointermove', this.handlePointerMove);
this.observeResize(canvas);
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
// Asked for per frame rather than captured: the projection can be swapped underneath, and a
// frame computed against one camera and drawn through the other puts every label off its star.
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(this.engine.getCamera(), deltaSeconds));
this.engine.start();
this.ready = true;
this.reconcileSelection(this.navigationStore.selectedStarId());
}
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void {
private tick(camera: SceneCamera, deltaSeconds: number): void {
this.rig?.update(deltaSeconds);
this.frameProjection(camera);
this.controls?.update();
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
@@ -592,12 +613,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
* of switching it.
*/
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void {
private updateGalacticCrossfade(camera: SceneCamera): void {
if (!this.milkyWay) {
return;
}
const distancePc = camera.position.length();
// How much of the Galaxy is in frame, expressed as the distance a perspective camera would
// have to be at to show that much. Under a plan view the camera's own distance says nothing
// about the extent — the frustum does — so reading `position.length()` there would report a
// fixed scale however far the view was zoomed.
const distancePc = this.effectiveDistance(camera);
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
// Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
@@ -615,7 +640,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
this.applyGalaxyDepthRange(camera, distancePc);
this.applyGalaxyDepthRange(distancePc);
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
this.navigationStore.setViewLevel(level);
@@ -628,26 +653,47 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
* spanning both has no precision left to separate one spiral arm from the next.
*/
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void {
/**
* What "how far back is the camera" means, in either projection. Under perspective it is the
* camera's own distance from the origin; under an orthographic one it is the distance a
* perspective camera would need to frame the same extent, so everything keyed on it — the
* crossfade, the depth range, the scale ladder — goes on meaning what it meant.
*/
private effectiveDistance(camera: SceneCamera): number {
if (this.engine.currentProjection === 'perspective') {
return camera.position.length();
}
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET));
return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
}
private applyGalaxyDepthRange(distancePc: number): void {
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
// Written to the perspective camera whichever one is live, because it is the one this range
// is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan
// view left the far plane wherever it was when the projection changed, so flying out to the
// Galaxy from there clipped away most of it.
const perspective = this.engine.getPerspectiveCamera();
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
// matrix on every frame of it.
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
camera.near = near;
camera.far = far;
camera.updateProjectionMatrix();
if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
perspective.near = near;
perspective.far = far;
perspective.updateProjectionMatrix();
// The plan view's own range is symmetric about the camera and derived from this one; see
// `frameOrthographic`. It is re-derived every frame, so there is nothing to do here.
}
}
private updateLabels(camera: THREE.PerspectiveCamera): void {
private updateLabels(camera: SceneCamera): void {
const selectedId = this.navigationStore.selectedStarId();
// Measured from what the camera is looking at, not from where it is. Those differ by the
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
// view — a ring of labels around the outside of the thing the user is actually looking at.
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
const { x: cx, y: cy, z: cz } = target;
const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
const maxDistanceSq = labelRadius * labelRadius;
@@ -704,7 +750,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
* its label going missing mid-flight reads as the target having been lost.
*/
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] {
/** The frame's shape, from the canvas rather than the camera: only one of the two has it. */
private viewportAspect(): number {
const canvas = this.canvasRef().nativeElement;
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
}
private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
const placed: THREE.Vector2[] = [];
const chosen: LabeledPoint[] = [];
const projected = new THREE.Vector3();
@@ -721,7 +773,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
continue;
}
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
const point = new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
continue;
}
@@ -750,7 +802,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* 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 {
private updateSelectionMark(camera: SceneCamera): 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) {
@@ -765,7 +817,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
const card = this.objectCardElement();
const canvas = this.canvasRef().nativeElement;
const projected = world.clone().project(camera);
if (!card || projected.z > 1) {
if (!card || projected.z > 1 || projected.z < -1) {
leader.setAttribute('visibility', 'hidden');
return;
}
@@ -844,7 +896,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* 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 {
private neighbourRingPosition(camera: SceneCamera, 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.
@@ -854,6 +906,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
if (!placed) {
return null;
}
if (this.engine.currentProjection === 'orthographic') {
// A parallel projection has no vanishing point to walk towards: every ray through the
// frame is the view direction, so treating the unprojected offset as one and stepping
// along it throws the sideways part away and pulls the whole ring into the middle. The
// unprojected point is already where the name goes.
return this.ringPoint.set(placed.x, placed.y, 0).unproject(camera);
}
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);
}
@@ -872,7 +931,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* 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[] {
private neighbourLabels(camera: SceneCamera): LabeledPoint[] {
this.refreshReservedBoxes();
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
const position = this.neighbourRingPosition(camera, direction);
@@ -899,7 +958,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
* — 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 {
private updateNeighbourRing(camera: SceneCamera): void {
if (!this.systemGroup.visible || this.neighbours.length === 0) {
return;
}
@@ -922,7 +981,7 @@ 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.
*/
private updateSystemLabels(camera: THREE.PerspectiveCamera): void {
private updateSystemLabels(camera: SceneCamera): void {
const renderer = this.systemRenderer;
if (!renderer) {
this.labelOverlay?.update([]);
@@ -966,6 +1025,97 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
/** Refreshes the readout panel for whichever scale the view is currently at. */
/** A zoom carried from one unit space into the other would frame nothing recognisable. */
private resetZoom(): void {
const camera = this.engine.getCamera();
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
(camera as THREE.OrthographicCamera).zoom = 1;
}
}
/**
* Keeps the plan view's frame, its zoom limits and its star sizes in step with the camera.
*
* All three are functions of how far the camera is orbiting from its target, which is the one
* thing the flights already animate — so a system entered, left or flown between reframes
* itself under this projection with the easing the perspective flights have, and no camera
* rig knows anything about it.
*/
private frameProjection(camera: SceneCamera): void {
if (!this.controls) {
return;
}
if (this.engine.currentProjection !== 'orthographic') {
this.starField?.setProjection(null);
this.hostRings?.setProjection(null);
return;
}
const distance = camera.position.distanceTo(this.controls.target);
this.engine.frameOrthographic(distance);
// Zoom is what a wheel moves under this projection, so the orbit clamps have to be restated
// as the zoom levels that frame the same extents.
// A plain multiplier on the frame the distance already sets, bounded by a factor rather than
// by the orbit limits: those are in whichever unit space the view is in, and reading them on
// the frame the scene swaps from parsecs to astronomical units pins the zoom at the ratio
// between the two — which is how leaving a system used to land the view three kiloparsecs out.
this.controls.minZoom = 1 / PLAN_ZOOM_SPAN;
this.controls.maxZoom = PLAN_ZOOM_SPAN;
const halfHeight = this.engine.visibleHalfHeight(distance);
this.starField?.setProjection(halfHeight);
this.hostRings?.setProjection(halfHeight);
}
/**
* Switches between the perspective view and the plan: an orthographic projection looking down
* the plane the current scale is read against — the galactic plane out here, this system's own
* orbital plane inside one.
*
* Both halves matter and neither alone is "2D". The projection is what makes a circle a circle
* wherever it sits in the frame instead of an ellipse that leans away from the centre; the
* swing to face the plane is what makes that worth looking at. Orbiting still works afterwards,
* so the plan is where a plan view starts, not a cage.
*/
private applyProjection(plan: boolean): void {
if (!this.controls || !this.rig || this.engine.currentProjection === (plan ? 'orthographic' : 'perspective')) {
return;
}
const camera = this.engine.getCamera();
const target = this.controls.target.clone();
const distance = camera.position.distanceTo(target);
this.engine.setProjection(plan ? 'orthographic' : 'perspective', distance);
const next = this.engine.getCamera();
// OrbitControls holds one camera for the lifetime of the gesture state it keeps; handing it
// the other one keeps the target, the damping and the pointer bindings it already has.
this.controls.object = next;
this.rig = new CameraRigController(next, this.controls);
next.position.copy(camera.position);
next.up.copy(camera.up);
if (plan) {
// Straight down the plane's normal, from where the camera already was.
// Down the normal of the plane this scale is actually read against. Inside a system that
// is the system's own orbital plane; outside it, the galactic plane — whose normal is the
// north galactic pole, not the celestial one. Defaulting to the scene's own z would have
// looked down the Earth's rotation axis and called it the plane of the Galaxy.
const galactic = GALACTIC_BASIS_EQUATORIAL;
const inSystem = this.systemGroup.visible && this.systemRenderer;
const normal = inSystem
? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame)
: new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z);
next.position.copy(target).add(normal.multiplyScalar(distance));
if (inSystem) {
next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame);
} else {
// Towards the galactic centre, so the plan is oriented the way the model is described.
next.up.set(galactic.x.x, galactic.x.y, galactic.x.z);
}
}
next.lookAt(target);
this.controls.update();
this.applyDisplay(this.display());
}
/**
* Shows or hides the layers that hold still between frames: the label layer and the system
* view's orbits and grid. The galaxy grids and deep-sky shell are crossfaded every frame
@@ -984,7 +1134,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
}
private updateHud(camera: THREE.PerspectiveCamera): void {
private updateHud(camera: SceneCamera): void {
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
if (this.systemGroup.visible && star) {
@@ -1004,11 +1154,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
]);
this.hudNote.set('Orbits propagated from published elements to the current date.');
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360)));
return;
}
this.hudRange.set(formatParsecs(camera.position.length()));
this.hudRange.set(formatParsecs(this.effectiveDistance(camera)));
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
this.hudEyebrow.set('Galactic Scale');
@@ -1071,13 +1221,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
}
};
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void {
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: SceneCamera): void {
if (!this.starField) {
return;
}
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
const starId = this.starField.pickAt(pointerNdc, camera);
const starId = this.starField.pickAt(pointerNdc, camera, this.viewportAspect());
if (starId !== undefined) {
this.navigationStore.selectStar(starId);
}
@@ -1308,7 +1458,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// the wider of the two — and against the camera this scene actually has, so the margin holds
// whatever the window shape. Computed before the star, because how far away the star will be
// seen from is what decides how big its halo has to be to stay visible.
const viewport = { fovDegrees: camera.fov, aspect: camera.aspect };
// Framed against the perspective camera whichever is active: the framing distance is what
// the orthographic frustum is then sized from, so both projections show the same extent.
const framingCamera = this.engine.getPerspectiveCamera();
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
@@ -1339,12 +1492,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// clumped over the system's star.
this.labelOverlay?.update([]);
camera.near = SYSTEM_NEAR_AU;
camera.far = SYSTEM_FAR_AU;
camera.updateProjectionMatrix();
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = SYSTEM_NEAR_AU;
depthCamera.far = SYSTEM_FAR_AU;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
this.resetZoom();
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
// Arrives along whichever direction the approach came from, then swings round to look down
@@ -1389,12 +1547,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
// into the next system entered.
this.clearObjectCard();
camera.near = GALAXY_NEAR_PC;
camera.far = GALAXY_FAR_PC;
camera.updateProjectionMatrix();
// The perspective camera whichever one is live: it is where the depth range is reasoned,
// and the plan view re-derives its own from it every frame. Writing to the active camera
// put the astronomical-unit range on one that overwrites it, and the system clipped.
const depthCamera = this.engine.getPerspectiveCamera();
depthCamera.near = GALAXY_NEAR_PC;
depthCamera.far = GALAXY_FAR_PC;
depthCamera.updateProjectionMatrix();
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
this.resetZoom();
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
if (isSwitchingSystems) {
@@ -1,8 +1,8 @@
import * as THREE from 'three/webgpu';
import { color, float, instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE } from './angular-size';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
const RING_SIZE_PX = 12;
@@ -37,6 +37,9 @@ export class HostStarRings {
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
private readonly opacity = uniform(RING_PEAK_OPACITY);
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
constructor(hosts: readonly StarRecord[], accent: number) {
const positions = new Float32Array(hosts.length * 3);
@@ -44,9 +47,13 @@ export class HostStarRings {
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);
// As in the star field: the angular-to-world conversion is done here rather than by
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
this.material.positionNode = position;
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective));
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();
@@ -59,6 +66,12 @@ export class HostStarRings {
this.count = hosts.length;
}
/** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** 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);
@@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => {
it('finds the star under the pointer', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked));
// Both Near and Far project to the screen centre; either is a correct hit.
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect));
renderer.dispose();
});
it('returns undefined when the pointer is on empty sky', () => {
const renderer = new StarFieldRenderer(picked, packPositions(picked));
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined();
renderer.dispose();
});
@@ -168,7 +168,7 @@ describe('StarFieldRenderer', () => {
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
const renderer = new StarFieldRenderer(behind, packPositions(behind));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.dispose();
});
@@ -182,7 +182,7 @@ describe('StarFieldRenderer', () => {
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
const target = new THREE.Vector3(0, 2, -10).project(camera);
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2);
renderer.dispose();
});
@@ -195,7 +195,7 @@ describe('StarFieldRenderer', () => {
// Walk outward from the centre until each stops being pickable.
const reach = (renderer: StarFieldRenderer): number => {
let offset = 0;
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) {
offset += 0.001;
}
return offset;
@@ -212,13 +212,13 @@ describe('StarFieldRenderer', () => {
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
const renderer = new StarFieldRenderer(faint, packPositions(faint));
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5);
renderer.dispose();
});
it('finds nothing in an empty field', () => {
const renderer = new StarFieldRenderer([], new Float32Array(0));
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
renderer.dispose();
});
});
@@ -1,9 +1,10 @@
import * as THREE from 'three/webgpu';
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
import { SceneCamera } from '../../core/engine/engine.service';
import { StarRecord } from '../../shared/models/star.model';
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
const MIN_POINT_SIZE = 1.5;
@@ -160,6 +161,10 @@ export class StarFieldRenderer {
/** How many of the catalogue's stars this field actually draws. */
readonly drawnCount: number;
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
private readonly perspective = uniform(1);
private readonly orthographicScale = uniform(float(0));
private readonly geometry: THREE.InstancedBufferGeometry;
private readonly material: THREE.SpriteNodeMaterial;
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
@@ -200,10 +205,22 @@ export class StarFieldRenderer {
depthWrite: false,
blending: THREE.AdditiveBlending
});
this.material.sizeAttenuation = false;
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
// The compensation that turns an angular size into a world size is done here rather than by
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
// === false`). Under an orthographic one it is silently skipped and every star collapses to
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
// a uniform, lets one material serve both cameras without being recompiled between them.
this.material.sizeAttenuation = true;
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
this.material.positionNode = position;
// Perspective: a star's world size is its angular size times how far away it is, which is
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
// the frustum does, so the same angular size is scaled by the frustum instead.
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
const radius = uv().sub(vec2(0.5)).length();
@@ -215,6 +232,19 @@ export class StarFieldRenderer {
this.object.frustumCulled = false;
}
/**
* Tells the field which projection it is being drawn under.
*
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
* perspective camera, where a star's distance sets its apparent size on its own.
*/
setProjection(halfHeightWorld: number | null): void {
this.perspective.value = halfHeightWorld === null ? 1 : 0;
// The world size that subtends the same share of the viewport an angular size would under
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
}
/** Looks up the HYG star id for a given instance index. */
starIdAt(instanceIndex: number): number | undefined {
return this.stars[instanceIndex]?.id;
@@ -230,8 +260,14 @@ export class StarFieldRenderer {
* what the user sees at every zoom level instead of being over-permissive up close and
* sub-pixel at the far end of the camera's range.
*/
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
// What a unit of angular size is worth on screen. Under perspective the field of view sets
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
// by that same half-height leaves the reference field of view and nothing else. Both cases
// are therefore one formula over a different angle.
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
const projected = new THREE.Vector3();
let bestIndex: number | undefined;
@@ -249,7 +285,7 @@ export class StarFieldRenderer {
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
const dx = (projected.x - pointerNdc.x) * camera.aspect;
const dx = (projected.x - pointerNdc.x) * aspect;
const dy = projected.y - pointerNdc.y;
const score = Math.hypot(dx, dy) / ndcRadius;
@@ -141,7 +141,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', 'Systems=true', 'Jump links=false']);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']);
});
it('says how to keep a place, rather than showing an empty list', () => {
+5 -2
View File
@@ -27,9 +27,11 @@ export interface HudDisplay {
readonly systems: boolean;
/** The graph of crossings within the range the Routes panel is set to. */
readonly jumpLinks: boolean;
/** Drawn flat: an orthographic projection, looking down on the plane. */
readonly plan: boolean;
}
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false };
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'labels', label: 'Labels' },
@@ -38,7 +40,8 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'deepSky', label: 'Deep sky' },
{ key: 'sky', label: 'Sky' },
{ key: 'systems', label: 'Systems' },
{ key: 'jumpLinks', label: 'Jump links' }
{ key: 'jumpLinks', label: 'Jump links' },
{ key: 'plan', label: 'Plan view' }
];
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';