diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
index 4004f33..0f4b1df 100644
--- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts
+++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts
@@ -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
+
+
@if (objectCard(); as card) {
@@ -180,6 +193,8 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
private readonly canvasRef = viewChild.required>('canvas');
private readonly labelHostRef = viewChild.required>('labelHost');
+ private readonly leaderRef = viewChild.required>('leader');
+ private readonly objectCardRef = viewChild>(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 = '';
}
diff --git a/src/app/features/galaxy-system/host-star-rings.ts b/src/app/features/galaxy-system/host-star-rings.ts
new file mode 100644
index 0000000..2b348ec
--- /dev/null
+++ b/src/app/features/galaxy-system/host-star-rings.ts
@@ -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();
+ }
+}
diff --git a/src/app/features/galaxy-system/star-label-overlay.spec.ts b/src/app/features/galaxy-system/star-label-overlay.spec.ts
index 69ce07f..084fa54 100644
--- a/src/app/features/galaxy-system/star-label-overlay.spec.ts
+++ b/src/app/features/galaxy-system/star-label-overlay.spec.ts
@@ -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 },
diff --git a/src/app/features/galaxy-system/star-label-overlay.ts b/src/app/features/galaxy-system/star-label-overlay.ts
index dad2d3f..e0457ff 100644
--- a/src/app/features/galaxy-system/star-label-overlay.ts
+++ b/src/app/features/galaxy-system/star-label-overlay.ts
@@ -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 = { 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();
+ 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();
diff --git a/src/app/features/hud/hud-dock.component.spec.ts b/src/app/features/hud/hud-dock.component.spec.ts
index caa1cb5..1eb9a55 100644
--- a/src/app/features/hud/hud-dock.component.spec.ts
+++ b/src/app/features/hud/hud-dock.component.spec.ts
@@ -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', () => {
diff --git a/src/app/features/hud/hud-dock.component.ts b/src/app/features/hud/hud-dock.component.ts
index ad27185..76a2eb3 100644
--- a/src/app/features/hud/hud-dock.component.ts
+++ b/src/app/features/hud/hud-dock.component.ts
@@ -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';
diff --git a/src/styles.css b/src/styles.css
index 12ff02a..2312bfe 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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;
+}