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; +}