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:
Senrokai
2026-08-27 19:22:12 +02:00
committed by GitHub
9 changed files with 301 additions and 19 deletions
@@ -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', () => {
+5 -2
View File
@@ -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';
+27
View File
@@ -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;
}