Point at what is selected: arcs, a leader to the card, rings on the systems
Four things the scene did not yet say, all about where to look. Selection. Hovering or pinning a body raised its card in the corner, but nothing in the scene said which point the card was about. Two hairline arcs now bracket the body — the one mark borrowed from the ARK's control disc — and a leader runs from their rim to the card's near edge, in screen space, once per frame, because the body moves and the card's height depends on its content. The selected body's own label swaps to the left of its point, since the leader leaves the right and would otherwise cross the text. Labels choose a side. Right by default; left when the text would run off the right of the view, or into the reach of a label already placed to the right, and never left when that would run off the left. The overlay hangs the label's near edge on the point either way, so the hairline still meets the star. Rings on the systems. A faint accent ring on every star known to host planets — the one binary fact about a point of light worth reading at a glance from the neighbourhood, since it is the one thing that says "there is somewhere to go here". Drawn the way the star field draws stars, as unattenuated instanced sprites with the ring a band of the quad's own uv, so they sit on the field's points at any zoom; a first cut as three.js Points rendered nothing at all under the WebGPU renderer. 634 of them are a lot at the overview, so they are faint, small, fade with the local layer, and have their own toggle — Systems — in the dock's Display tab. Verified: build clean, 537/537 unit, 6/6 end-to-end, design detector clean, screenshots at the overview, at 90 pc, and in Sol and Proxima with a body hovered and pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -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,8 @@ 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 });
|
||||
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly galaxyGroup = new THREE.Group();
|
||||
@@ -206,10 +221,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 +286,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 +385,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 +466,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
if (this.systemGroup.visible) {
|
||||
this.systemRenderer?.update(dateToJulianDate());
|
||||
}
|
||||
this.updateSelectionMark(camera);
|
||||
this.labelOverlay?.render(camera);
|
||||
}
|
||||
|
||||
@@ -469,6 +491,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 +609,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.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]');
|
||||
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 +895,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 +903,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,76 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { color, float, instancedBufferAttribute, smoothstep, uniform, uv, vec2 } from 'three/tsl';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
|
||||
/** 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;
|
||||
/** Same reference as `StarFieldRenderer`, so a ring and its star agree on what a pixel is. */
|
||||
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;
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
@@ -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