Rebuild the map chrome against the Star Citizen starmap
Until now this was built from memory — the reference site is blocked by this environment's egress policy, so the resemblance was asserted rather than checked. Five screenshots of the real thing arrived, and this is what comparing against them changed. Labels say what a thing is, not just what it is called. Every label is now two lines: the name, then its type in smaller, wider-tracked, dimmer capitals. This is the single most characteristic element of the reference and it appears in every frame of it. It also settles a real ambiguity — in a map that mixes scales, "Orion" is an arm, a nebula and a constellation, and nothing about a bare name said which one a label pointed at. For stars the type line distinguishes "System" from "Star", which is the one thing it can say that the map could not otherwise show: which points are somewhere you can actually go. The system view had no body labels at all, where the reference labels every planet. It does now, which turned out to need two supporting changes. The overlay had only ever added and removed labels, never moved them, because stars do not move; planets do, so an existing label is now repositioned rather than left where the body used to be. And the inner four planets printed on top of each other in exactly the clump the star labels were already spread to avoid — so that logic is now shared rather than duplicated, with system bodies ordered outermost-first. Closing in reverses it by itself: the outer orbits leave the frame, their labels drop, and the inner planets take the space. The chrome follows the reference's layout. The scale ladder is a row of chamfered tabs at the top left rather than a vertical list of diamonds at the middle left, and a nameplate across the top centre says what the view is holding. The centre reticle is a hexagon, which is how the reference locks onto a body, and stays distinct from the rectangular panel chrome. Not copied: the ARK/RSI logos, wordmarks, and the bottom-right tool tabs. The first two are someone else's brand, and the third would be four tabs opening features this app does not have. Two e2e assertions moved off bare text matches onto the readout panel's own title. The nameplate names the same thing the panel does, so "is 'Local Stars' on screen" became ambiguous — the assertion, not the design, was what had to give. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -12,7 +12,9 @@ test.describe('Galaxy view', () => {
|
|||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
||||||
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
||||||
await expect(backButtonLocator(page)).toHaveCount(0);
|
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||||
await expect(page.getByText('Local Stars')).toBeVisible();
|
// The readout panel's own title, not just the text anywhere on screen: the selected-object
|
||||||
|
// banner across the top names the same thing, so a bare text match is ambiguous.
|
||||||
|
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the scale ladder flies out to the whole Galaxy and back to the solar neighbourhood', async ({ page }) => {
|
test('the scale ladder flies out to the whole Galaxy and back to the solar neighbourhood', async ({ page }) => {
|
||||||
@@ -32,6 +34,6 @@ test.describe('Galaxy view', () => {
|
|||||||
await expect(page.getByRole('button', { name: 'Milky Way' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Milky Way' })).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||||
await expect(page.getByText('Local Stars')).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId('hud-title')).toHaveText('Local Stars', { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function deepSkyLabelPoints(
|
|||||||
): LabeledPoint[] {
|
): LabeledPoint[] {
|
||||||
return records.slice(0, limit).map((record) => {
|
return records.slice(0, limit).map((record) => {
|
||||||
const position = backdropPosition(record, radiusPc);
|
const position = backdropPosition(record, radiusPc);
|
||||||
return { id: record.id, name: record.name, x: position.x, y: position.y, z: position.z };
|
return { id: record.id, name: record.name, kind: record.kind.toUpperCase(), x: position.x, y: position.y, z: position.z };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private starField?: StarFieldRenderer;
|
private starField?: StarFieldRenderer;
|
||||||
private deepSky?: DeepSkyRenderer;
|
private deepSky?: DeepSkyRenderer;
|
||||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||||
|
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||||
|
private starIdsWithBodies = new Set<number>();
|
||||||
private milkyWay?: MilkyWayRenderer;
|
private milkyWay?: MilkyWayRenderer;
|
||||||
private galacticLabels: readonly LabeledPoint[] = [];
|
private galacticLabels: readonly LabeledPoint[] = [];
|
||||||
private galacticGrid?: PolarGridPlane;
|
private galacticGrid?: PolarGridPlane;
|
||||||
@@ -339,6 +341,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
this.bodies = bodies;
|
this.bodies = bodies;
|
||||||
this.exoplanets = exoplanets;
|
this.exoplanets = exoplanets;
|
||||||
|
// Built once rather than per label refresh: it is a scan of every body and exoplanet, and the
|
||||||
|
// labels are recomputed whenever the camera moves.
|
||||||
|
this.starIdsWithBodies = new Set([...bodies.map((body) => body.systemStarId), ...exoplanets.map((exoplanet) => exoplanet.hostStarId)].filter(
|
||||||
|
(id): id is number => id !== null && id !== undefined
|
||||||
|
));
|
||||||
|
|
||||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
||||||
this.galaxyGroup.add(this.starField.object);
|
this.galaxyGroup.add(this.starField.object);
|
||||||
@@ -410,6 +417,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.labelUpdateAccumulator = 0;
|
this.labelUpdateAccumulator = 0;
|
||||||
if (this.galaxyGroup.visible) {
|
if (this.galaxyGroup.visible) {
|
||||||
this.updateLabels(camera);
|
this.updateLabels(camera);
|
||||||
|
} else if (this.systemGroup.visible) {
|
||||||
|
this.updateSystemLabels(camera);
|
||||||
}
|
}
|
||||||
this.updateHud(camera);
|
this.updateHud(camera);
|
||||||
}
|
}
|
||||||
@@ -498,50 +507,121 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
// Individual star names mean nothing once the whole Galaxy is in frame — at that range the
|
||||||
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
// entire catalogue is inside one pixel — so the labels hand over to the structural ones.
|
||||||
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
const isGalactic = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD;
|
||||||
const starLabels = isGalactic ? [] : this.spreadLabels(candidates, camera, selectedId);
|
// "System" rather than "Star" for anything with catalogued bodies: it is the one distinction
|
||||||
|
// the second line can draw that the map cannot otherwise show, since it says which of these
|
||||||
|
// points is somewhere you can actually go.
|
||||||
|
const starLabels: LabeledPoint[] = isGalactic
|
||||||
|
? []
|
||||||
|
: this.spreadLabels(
|
||||||
|
candidates.map(({ star }) => ({
|
||||||
|
id: star.id,
|
||||||
|
name: star.name,
|
||||||
|
kind: this.starIdsWithBodies.has(star.id) ? 'System' : 'Star',
|
||||||
|
x: star.x,
|
||||||
|
y: star.y,
|
||||||
|
z: star.z
|
||||||
|
})),
|
||||||
|
camera,
|
||||||
|
selectedId
|
||||||
|
);
|
||||||
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
const backdropLabels = isGalactic ? this.galacticLabels : this.deepSkyLabels;
|
||||||
this.labelOverlay?.update([...starLabels, ...backdropLabels]);
|
this.labelOverlay?.update([...starLabels, ...backdropLabels]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes the nearest stars in order and keeps only those that land clear of the labels already
|
* Takes candidate labels in priority order and keeps only those that land clear of the labels
|
||||||
* placed, dropping the rest.
|
* already placed, dropping the rest.
|
||||||
*
|
*
|
||||||
* Nearest-first alone is not enough: the Sun's fifteen nearest neighbours are all inside four
|
* Priority alone is not enough at either scale. The Sun's fifteen nearest neighbours are all
|
||||||
* parsecs, so from anything but point-blank range their names print on top of each other in a
|
* inside four parsecs, so from anything but point-blank range their names print on top of each
|
||||||
* single unreadable clump. Rejecting on screen separation instead of on distance means the set
|
* other in a single unreadable clump; the inner four planets do exactly the same thing when a
|
||||||
* naturally opens up as the camera closes in, and stays legible when it pulls back.
|
* system is framed out to Pluto. Rejecting on screen separation rather than on distance means
|
||||||
|
* the set naturally opens up as the camera closes in, and stays legible when it pulls back.
|
||||||
|
*
|
||||||
|
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
|
||||||
|
* its label going missing mid-flight reads as the target having been lost.
|
||||||
*/
|
*/
|
||||||
private spreadLabels(candidates: readonly { star: StarRecord }[], camera: THREE.PerspectiveCamera, selectedId: number | null): StarRecord[] {
|
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] {
|
||||||
const placed: THREE.Vector2[] = [];
|
const placed: THREE.Vector2[] = [];
|
||||||
const chosen: StarRecord[] = [];
|
const chosen: LabeledPoint[] = [];
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
for (const { star } of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (chosen.length >= LABEL_MAX_COUNT) {
|
if (chosen.length >= LABEL_MAX_COUNT) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
projected.set(star.x, star.y, star.z).project(camera);
|
projected.set(candidate.x, candidate.y, candidate.z).project(camera);
|
||||||
const isSelected = star.id === selectedId;
|
const isKept = candidate.id === keepId;
|
||||||
// Offscreen or behind the camera. The selection is exempt: it is about to be flown to, and
|
// Offscreen or behind the camera.
|
||||||
// its label going missing mid-flight reads as the target having been lost.
|
if (!isKept && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) {
|
||||||
if (!isSelected && (projected.z < -1 || projected.z > 1 || Math.abs(projected.x) > 1 || Math.abs(projected.y) > 1)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
|
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
|
||||||
if (!isSelected && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
placed.push(point);
|
placed.push(point);
|
||||||
chosen.push(star);
|
chosen.push(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return chosen;
|
return chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the bodies of the system the view is inside.
|
||||||
|
*
|
||||||
|
* Outermost first, because that is the order that survives the separation test usefully: with
|
||||||
|
* the whole system in frame the outer planets are the ones far enough apart to label, and the
|
||||||
|
* inner four are a single clump around the star. Closing in reverses it on its own — the outer
|
||||||
|
* orbits leave the frame and their labels drop out, freeing the space for the inner planets.
|
||||||
|
*
|
||||||
|
* Moons are left out entirely: they sit within a marker's width of their planet at system
|
||||||
|
* framing, so their labels could only ever print on top of it.
|
||||||
|
*/
|
||||||
|
private updateSystemLabels(camera: THREE.PerspectiveCamera): void {
|
||||||
|
const renderer = this.systemRenderer;
|
||||||
|
if (!renderer) {
|
||||||
|
this.labelOverlay?.update([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = new Map<string, { name: string; semiMajorAxisAu: number }>([
|
||||||
|
...this.bodies.map((body): [string, { name: string; semiMajorAxisAu: number }] => [
|
||||||
|
body.id,
|
||||||
|
{ name: body.name, semiMajorAxisAu: body.orbit.semiMajorAxisAu }
|
||||||
|
]),
|
||||||
|
...this.exoplanets.map((exoplanet): [string, { name: string; semiMajorAxisAu: number }] => [
|
||||||
|
exoplanet.id,
|
||||||
|
{ name: exoplanet.name, semiMajorAxisAu: exoplanet.orbit?.semiMajorAxisAu ?? 0 }
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
|
||||||
|
const points: Array<LabeledPoint & { semiMajorAxisAu: number }> = [];
|
||||||
|
for (const member of renderer.members) {
|
||||||
|
if (member.kind === 'moon') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = records.get(member.id);
|
||||||
|
member.marker.getWorldPosition(position);
|
||||||
|
points.push({
|
||||||
|
id: member.id,
|
||||||
|
name: record?.name ?? member.id,
|
||||||
|
kind: member.kind === 'exoplanet' ? 'Exoplanet' : member.kind === 'dwarf' ? 'Dwarf Planet' : 'Planet',
|
||||||
|
semiMajorAxisAu: record?.semiMajorAxisAu ?? 0,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
z: position.z
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
|
||||||
|
this.labelOverlay?.update(this.spreadLabels(points, camera, null));
|
||||||
|
}
|
||||||
|
|
||||||
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
||||||
private updateHud(camera: THREE.PerspectiveCamera): void {
|
private updateHud(camera: THREE.PerspectiveCamera): void {
|
||||||
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export class MilkyWayRenderer {
|
|||||||
labelPoints(): readonly LabeledPoint[] {
|
labelPoints(): readonly LabeledPoint[] {
|
||||||
return GALACTIC_LANDMARKS.map((landmark) => {
|
return GALACTIC_LANDMARKS.map((landmark) => {
|
||||||
const position = landmarkPositionPc(landmark);
|
const position = landmarkPositionPc(landmark);
|
||||||
return { id: `galactic:${landmark.id}`, name: landmark.name, x: position.x, y: position.y, z: position.z };
|
return { id: `galactic:${landmark.id}`, name: landmark.name, kind: landmark.kind, x: position.x, y: position.y, z: position.z };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { StarLabelOverlay } from './star-label-overlay';
|
||||||
|
|
||||||
|
describe('StarLabelOverlay', () => {
|
||||||
|
let scene: THREE.Scene;
|
||||||
|
let overlay: StarLabelOverlay;
|
||||||
|
let camera: THREE.PerspectiveCamera;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every label element currently in the layer, in DOM order.
|
||||||
|
*
|
||||||
|
* `CSS2DRenderer` only attaches an element to its layer when it projects it, so the labels do
|
||||||
|
* not exist in the DOM until something has been rendered — which is why this draws a frame
|
||||||
|
* rather than reading straight off `domElement`.
|
||||||
|
*/
|
||||||
|
function labels(): HTMLElement[] {
|
||||||
|
overlay.render(camera);
|
||||||
|
return [...overlay.domElement.querySelectorAll<HTMLElement>('.map-label')];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
overlay = new StarLabelOverlay(scene);
|
||||||
|
overlay.setSize(800, 600);
|
||||||
|
camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 1000);
|
||||||
|
camera.position.set(0, 0, 20);
|
||||||
|
camera.updateMatrixWorld();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints what a thing is under what it is called', () => {
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'System', x: 1, y: 2, z: 3 }]);
|
||||||
|
|
||||||
|
const label = labels()[0];
|
||||||
|
expect(label.querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||||
|
expect(label.querySelector('.map-label-kind')?.textContent).toBe('System');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the name alone when there is no type to give', () => {
|
||||||
|
// The second line is optional, and an empty one would still cost its line height — which
|
||||||
|
// over a screen of labels shifts every name off the point it is anchored to.
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', x: 1, y: 2, z: 3 }]);
|
||||||
|
|
||||||
|
expect(labels()[0].querySelector('.map-label-name')?.textContent).toBe('Sirius');
|
||||||
|
expect(labels()[0].querySelector('.map-label-kind')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the label at the point it belongs to', () => {
|
||||||
|
overlay.update([{ id: 7, name: 'Vega', kind: 'Star', x: 4, y: -5, z: 6 }]);
|
||||||
|
|
||||||
|
const object = scene.children.find((child) => child.type === 'Object3D');
|
||||||
|
expect(object?.position.toArray()).toEqual([4, -5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('touches the DOM only for labels that actually changed', () => {
|
||||||
|
overlay.update([
|
||||||
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 2, name: 'Vega', kind: 'Star', x: 0, y: 1, z: 0 }
|
||||||
|
]);
|
||||||
|
const sirius = labels()[0];
|
||||||
|
|
||||||
|
overlay.update([
|
||||||
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 3, name: 'Altair', kind: 'Star', x: 0, y: 0, z: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Same element instance: the label that stayed was not torn down and rebuilt.
|
||||||
|
expect(labels()[0]).toBe(sirius);
|
||||||
|
expect(labels().map((label) => label.querySelector('.map-label-name')?.textContent)).toEqual(['Sirius', 'Altair']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops every label when given none', () => {
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
||||||
|
overlay.update([]);
|
||||||
|
|
||||||
|
expect(labels()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves nothing behind when disposed', () => {
|
||||||
|
overlay.update([
|
||||||
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 'ngc:224', name: 'Andromeda', kind: 'GALAXY', x: 0, y: 1, z: 0 }
|
||||||
|
]);
|
||||||
|
overlay.dispose();
|
||||||
|
|
||||||
|
expect(labels()).toHaveLength(0);
|
||||||
|
expect(scene.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,16 @@ export interface LabeledPoint {
|
|||||||
/** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */
|
/** Numeric for HYG stars, string for catalog designations such as deep-sky objects. */
|
||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* What sort of thing this is — `STAR`, `PLANET`, `NEBULA`, `ARM`. Printed under the name in
|
||||||
|
* smaller, dimmer, wider-tracked capitals.
|
||||||
|
*
|
||||||
|
* A name on its own is ambiguous in a map that mixes scales: "Orion" is an arm, a nebula and a
|
||||||
|
* constellation, and at a glance nothing distinguishes the label on one from the label on
|
||||||
|
* another. The second line is what makes a label say what it is pointing at, not just what it
|
||||||
|
* is called.
|
||||||
|
*/
|
||||||
|
kind?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
@@ -31,7 +41,13 @@ export class StarLabelOverlay {
|
|||||||
this.cssRenderer.setSize(width, height);
|
this.cssRenderer.setSize(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows exactly these labels, adding/removing DOM elements only for a changed set. */
|
/**
|
||||||
|
* Shows exactly these labels, adding/removing DOM elements only for a changed set.
|
||||||
|
*
|
||||||
|
* A label that is already up is repositioned rather than left where it was: stars never move,
|
||||||
|
* but planets do, and a system's labels would otherwise stay pinned to wherever each body
|
||||||
|
* happened to be when its label first appeared.
|
||||||
|
*/
|
||||||
update(points: readonly LabeledPoint[]): void {
|
update(points: readonly LabeledPoint[]): void {
|
||||||
const idsToShow = new Set(points.map((point) => point.id));
|
const idsToShow = new Set(points.map((point) => point.id));
|
||||||
|
|
||||||
@@ -42,7 +58,10 @@ export class StarLabelOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const point of points) {
|
for (const point of points) {
|
||||||
if (!this.labelObjects.has(point.id)) {
|
const existing = this.labelObjects.get(point.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.position.set(point.x, point.y, point.z);
|
||||||
|
} else {
|
||||||
this.addLabel(point);
|
this.addLabel(point);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,8 +81,19 @@ export class StarLabelOverlay {
|
|||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
// Tailwind utility classes assigned directly since this element lives outside Angular's
|
// Tailwind utility classes assigned directly since this element lives outside Angular's
|
||||||
// view encapsulation (see the class comment above) rather than through a component template.
|
// view encapsulation (see the class comment above) rather than through a component template.
|
||||||
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
|
element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body';
|
||||||
element.textContent = point.name;
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'map-label-name';
|
||||||
|
name.textContent = point.name;
|
||||||
|
element.appendChild(name);
|
||||||
|
|
||||||
|
if (point.kind) {
|
||||||
|
const kind = document.createElement('span');
|
||||||
|
kind.className = 'map-label-kind';
|
||||||
|
kind.textContent = point.kind;
|
||||||
|
element.appendChild(kind);
|
||||||
|
}
|
||||||
|
|
||||||
const object = new CSS2DObject(element);
|
const object = new CSS2DObject(element);
|
||||||
object.position.set(point.x, point.y, point.z);
|
object.position.set(point.x, point.y, point.z);
|
||||||
|
|||||||
@@ -88,4 +88,17 @@ describe('StarmapHudComponent', () => {
|
|||||||
expect(host.querySelector('dl')).toBeNull();
|
expect(host.querySelector('dl')).toBeNull();
|
||||||
expect(host.textContent).not.toContain('undefined');
|
expect(host.textContent).not.toContain('undefined');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names what the view is holding on the banner across the top', () => {
|
||||||
|
fixture.componentRef.setInput('level', 'system');
|
||||||
|
fixture.componentRef.setInput('title', 'Sol');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no banner when the view is holding nothing', () => {
|
||||||
|
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
||||||
|
expect(render('galaxy').querySelector('.hud-banner')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,48 +45,54 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
<div class="hud-vignette absolute inset-0"></div>
|
<div class="hud-vignette absolute inset-0"></div>
|
||||||
|
|
||||||
@if (showReticle()) {
|
@if (showReticle()) {
|
||||||
<div class="absolute top-1/2 left-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2">
|
<!-- A hexagon rather than a square bracket: the shape reads as a sensor lock on a body,
|
||||||
<span class="absolute top-0 left-0 h-3 w-3 border-t border-l border-accent/70"></span>
|
and stays distinct from the rectangular panel chrome everywhere else on screen. -->
|
||||||
<span class="absolute top-0 right-0 h-3 w-3 border-t border-r border-accent/70"></span>
|
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
||||||
<span class="absolute bottom-0 left-0 h-3 w-3 border-b border-l border-accent/70"></span>
|
<polygon points="28,4 48,16 48,40 28,52 8,40 8,16" stroke="currentColor" stroke-width="1" />
|
||||||
<span class="absolute right-0 bottom-0 h-3 w-3 border-r border-b border-accent/70"></span>
|
<path d="M28 22v-6M28 40v-6M22 28h-6M40 28h-6" stroke="currentColor" stroke-width="1" opacity="0.8" />
|
||||||
<span class="absolute top-1/2 left-1/2 h-px w-2 -translate-x-1/2 -translate-y-1/2 bg-accent/60"></span>
|
</svg>
|
||||||
<span class="absolute top-1/2 left-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-accent/60"></span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<nav aria-label="Map scale" class="pointer-events-auto absolute top-1/2 left-6 flex -translate-y-1/2 flex-col gap-5">
|
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
||||||
|
across the top of the display, clear of the search field above them. -->
|
||||||
|
<nav aria-label="Map scale" class="pointer-events-auto absolute top-16 left-6 flex items-stretch gap-px">
|
||||||
@for (step of ladder(); track step.level) {
|
@for (step of ladder(); track step.level) {
|
||||||
<div class="flex items-center gap-3">
|
@if (step.reachable) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="levelSelected.emit(step.level)"
|
||||||
|
class="hud-tab border-y border-border/70 bg-panel/70 px-4 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase backdrop-blur-sm transition-colors hover:bg-accent/15 hover:text-accent focus:bg-accent/15 focus:text-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{{ step.label }}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
<span
|
<span
|
||||||
class="block h-2 w-2 rotate-45 border"
|
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||||
[class]="step.state === 'current' ? 'border-accent bg-accent shadow-[0_0_10px_var(--color-accent)]' : step.state === 'above' ? 'border-accent/60' : 'border-border/60'"
|
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||||
></span>
|
class="hud-tab border-y px-4 py-1.5 font-display text-[10px] tracking-[0.22em] uppercase backdrop-blur-sm"
|
||||||
@if (step.reachable) {
|
[class]="step.state === 'current' ? 'border-accent/70 bg-accent/20 text-accent' : 'border-border/40 bg-panel/40 text-border'"
|
||||||
<button
|
>{{ step.label }}</span
|
||||||
type="button"
|
>
|
||||||
(click)="levelSelected.emit(step.level)"
|
}
|
||||||
class="font-display text-[10px] tracking-[0.22em] text-muted uppercase transition-colors hover:text-accent focus:text-accent focus:outline-none"
|
|
||||||
>
|
|
||||||
{{ step.label }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<span
|
|
||||||
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
|
||||||
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
|
||||||
class="font-display text-[10px] tracking-[0.22em] uppercase"
|
|
||||||
[class]="step.state === 'current' ? 'text-accent' : 'text-border'"
|
|
||||||
>{{ step.label }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@if (title()) {
|
||||||
|
<div class="absolute top-16 left-1/2 -translate-x-1/2">
|
||||||
|
<div class="hud-banner flex items-center gap-2.5 border-b border-accent/60 bg-panel/75 px-6 py-1.5 backdrop-blur-sm">
|
||||||
|
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<polygon points="6,1 10.5,3.5 10.5,8.5 6,11 1.5,8.5 1.5,3.5" stroke="currentColor" stroke-width="1" />
|
||||||
|
<circle cx="6" cy="6" r="1.4" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-display text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div class="hud-panel max-w-lg px-4 py-3">
|
<div class="hud-panel max-w-lg px-4 py-3">
|
||||||
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p>
|
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p>
|
||||||
<p class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p>
|
<p data-testid="hud-title" class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p>
|
||||||
@if (subtitle()) {
|
@if (subtitle()) {
|
||||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,21 +183,30 @@ export function galacticCentrePositionPc(): CartesianCoordinates {
|
|||||||
export interface GalacticLandmark {
|
export interface GalacticLandmark {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
/** What the landmark is, printed under its name on the map. */
|
||||||
|
readonly kind: string;
|
||||||
/** Galactocentric radius/azimuth, matching {@link galactocentricToHeliocentricGalactic}. */
|
/** Galactocentric radius/azimuth, matching {@link galactocentricToHeliocentricGalactic}. */
|
||||||
readonly radiusPc: number;
|
readonly radiusPc: number;
|
||||||
readonly azimuthDeg: number;
|
readonly azimuthDeg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GALACTIC_LANDMARKS: readonly GalacticLandmark[] = [
|
export const GALACTIC_LANDMARKS: readonly GalacticLandmark[] = [
|
||||||
{ id: 'sgr-a', name: 'Sagittarius A*', radiusPc: 0, azimuthDeg: 0 },
|
{ id: 'sgr-a', name: 'Sagittarius A*', kind: 'Galactic Centre', radiusPc: 0, azimuthDeg: 0 },
|
||||||
{ id: 'sol', name: 'Sol', radiusPc: SUN_GALACTOCENTRIC_RADIUS_PC, azimuthDeg: 0 },
|
{ id: 'sol', name: 'Sol', kind: 'Star', radiusPc: SUN_GALACTOCENTRIC_RADIUS_PC, azimuthDeg: 0 },
|
||||||
...MILKY_WAY_ARMS.map((arm) => ({
|
...MILKY_WAY_ARMS.map((arm) => ({
|
||||||
id: `arm-${arm.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
id: `arm-${arm.name.toLowerCase().replace(/[^a-z]+/g, '-')}`,
|
||||||
name: `${arm.name} Arm`,
|
name: `${arm.name} Arm`,
|
||||||
|
kind: 'Spiral Arm',
|
||||||
radiusPc: armRadiusPc(arm, arm.labelAzimuthDeg),
|
radiusPc: armRadiusPc(arm, arm.labelAzimuthDeg),
|
||||||
azimuthDeg: arm.labelAzimuthDeg
|
azimuthDeg: arm.labelAzimuthDeg
|
||||||
})),
|
})),
|
||||||
{ id: 'arm-orion-spur', name: 'Orion Spur', radiusPc: armRadiusPc(ORION_SPUR, ORION_SPUR.labelAzimuthDeg), azimuthDeg: ORION_SPUR.labelAzimuthDeg }
|
{
|
||||||
|
id: 'arm-orion-spur',
|
||||||
|
name: 'Orion Spur',
|
||||||
|
kind: 'Spur',
|
||||||
|
radiusPc: armRadiusPc(ORION_SPUR, ORION_SPUR.labelAzimuthDeg),
|
||||||
|
azimuthDeg: ORION_SPUR.labelAzimuthDeg
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/** A landmark's position in the scene's equatorial frame, in parsecs from the Sun. */
|
/** A landmark's position in the scene's equatorial frame, in parsecs from the Sun. */
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ body {
|
|||||||
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A chamfered tab: the angled-corner shape the scale ladder and the selection banner share.
|
||||||
|
* The cut is on the leading and trailing edges rather than the panel's diagonal corners, which
|
||||||
|
* is what makes a row of them read as tabs on a rail instead of a row of cards. */
|
||||||
|
.hud-tab {
|
||||||
|
clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The selected-object banner: symmetric, flaring outward toward its lit bottom edge, so it
|
||||||
|
* reads as a nameplate hanging off the top of the display rather than another tab. */
|
||||||
|
.hud-banner {
|
||||||
|
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
||||||
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
||||||
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
||||||
@@ -84,3 +97,26 @@ body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately
|
||||||
|
* much quieter than the name — it should be readable when looked at and invisible when not,
|
||||||
|
* because on a crowded view it is printed as many times as there are labels. */
|
||||||
|
.map-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label-name {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label-kind {
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user