Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/universe-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ their existing importance. Available scene-body labels are admitted before the
additional disabled moon captions; those captions cannot displace a clickable
label. The full sidebar list remains available regardless of scene crowding.

Beyond the Local Group scale, with the camera more than 300 kpc from a body,
a galaxy stands for what is inside it. A star, a black hole or a galaxy it
holds in the object tree is not a caption candidate while its marker falls
within the galaxy's circle, so the galaxy is named: M31 rather than M31-V1 or
M110, the Milky Way rather than the Large Magellanic Cloud. A hovered,
highlighted or selected body keeps its caption. A selected galaxy is named by
its own caption and stands for nothing.

Clickable deep-space labels try below, above, right and left when crowded.
Prepared cloud bounds keep those alternatives outside a nebula's image.
Disabled moon captions have no circle or navigation target.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,30 @@ test('past the Local Group scale the stars give their names to the galaxies', as
{ const values = labelled(1e6).map(body => body.id); assert.ok(['lmc', 'smc'].every(item => values.includes(item)), 'from 1 Mpc the galaxies are'); }
});

test('beyond the Local Group scale a galaxy is named, not a star or a galaxy inside it on the same pixels', () => {
const points = [plan.focus, ...plan.bodies];
// The app's tiers: a featured star (M31-V1 in M31, VHK 45 in M33) is admitted before every galaxy.
const calculate = createWorldContextPlanner(plan, Object.fromEntries(plan.bodies.map(body =>
[body.id, labelImportance(body.classification ?? 'star', false, 0, body.discovery?.featured === true, body.discovery?.imagery === true)])));
const input = view();
const named = (id: string, parsecs: number) => {
// Looking down -z at the body, from `parsecs` above it.
const at = plan.bodies.find(body => body.id === id)!;
input.world.pose.positionM = [at.positionM[0], at.positionM[1], at.positionM[2] + parsecs * 3.085677581491367e16];
return new Set(calculate(input).projectedBodies.filter(body => body.labelShown).map(body => points[body.index]!.id));
};
const andromeda = named('m31', 4e6);
for (const galaxy of ['m31', 'm33']) assert.equal(andromeda.has(galaxy), true, `from 4 Mpc ${galaxy} is named`);
for (const inside of ['m31-v1', 'vhk-45', 'm110', 'm32']) assert.equal(andromeda.has(inside), false, `${inside}, within its galaxy's circle, is not`);
const home = named('milky-way', 4e6);
assert.equal(home.has('milky-way'), true, 'from 4 Mpc the Milky Way is named');
assert.equal(home.has('lmc'), false, 'the Large Cloud, within its circle, is not');
const near = named('m31-v1', 100e3);
for (const body of ['m31-v1', 'm31', 'm110', 'm32']) assert.equal(near.has(body), true, `from 100 kpc, inside the Local Group scale and clear of each other, ${body} is named`);
input.bodies[points.findIndex(body => body.id === 'm31-v1')]!.highlighted = true;
assert.equal(named('m31', 4e6).has('m31-v1'), true, 'a star the reader highlights keeps its name');
});

test('past the Solar System only the featured stars and the references keep a dot; past the Local Group no body does', () => {
const featured = 'betelgeuse';
assert.equal(plan.bodies.some(body => body.id === featured && !body.orbit), true);
Expand All @@ -788,6 +812,8 @@ test('past the Solar System only the featured stars and the references keep a do

test('a body beyond the Local Group keeps its dot at the scale of its cluster, and loses it from the Milky Way', () => {
const far = plan.bodies.find(body => body.id === 'm87-star')!, calculate = createWorldContextPlanner(plan), input = view();
// M87 is left unnamed here: named, it stands for the black hole at its centre (the test above), and its dot covers the black hole's.
input.bodies[[plan.focus, ...plan.bodies].findIndex(body => body.id === 'm87')]!.labelHidden = true;
const dotted = (parsecs: number) => {
// Looking down -z at M87*, from `parsecs` above it.
input.world.pose.positionM = [far.positionM[0], far.positionM[1], far.positionM[2] + parsecs * 3.085677581491367e16];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createWorldFrameProjection } from '../world-frame-projection.js';
import { admitStableLabels, type StableLabelCandidate } from '../../labels/stable-label-layout.js';
import { createMarkerDeclutter } from './marker-declutter.js';
import type { LabelScreenRect } from '../../labels/screen-label-layout.js';
import { coveredTopRects, createLabelBudget, FEATURED_STAR_TIER, labelExtentOpacity, labelLimit, inGalaxyField, markerScale, UNIVERSE_LABEL_POLICY } from '../../labels/universe-label-policy.js';
import { coveredTopRects, createLabelBudget, FEATURED_STAR_TIER, labelExtentOpacity, labelLimit, inGalaxyField, LOCAL_GROUP_SCALE, markerScale, UNIVERSE_LABEL_POLICY } from '../../labels/universe-label-policy.js';
const ORBIT_LOD_PIXELS = 0.1;
// Keep the existing exit thresholds. A hidden annotation must clear a small
// entry margin before returning, so a boundary cannot reverse its fade each
Expand Down Expand Up @@ -116,7 +116,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW
const satellite = parent !== null && !systemFade.isSystemStar(parent.id), planet = orbit !== null && systemFade.isSystemStar(orbit.centerBodyId);
const prominent = planet && (tier ?? 0) >= 3;
return { body, orbit, levels, parent, satellite, planet, prominent, hosted: prominent && orbit!.centerBodyId !== plan.focus.id, minor: (tier ?? 2) < 2, scale: markerScale(distanceFromSunM, placedStar ? plan.volume : undefined),
galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), kind: 'classification' in body ? body.classification : undefined, extended: 'classification' in body && isExtendedClassification(body.classification),
galaxyField: !placedStar || inGalaxyField(distanceFromSunM, plan.volume), kind: 'classification' in body ? body.classification : undefined, inside: 'inside' in body ? body.inside : undefined, extended: 'classification' in body && isExtendedClassification(body.classification),
closedOrbit: orbit?.fullTrail === true, drawnRadiusM: body.radiusM * ('billboard' in body && body.billboard ? Math.max(1, billboardImageScale(body.billboard, body.radiusM)) : 1),
orbitProjection: createRetainedRingProjection(orbit ? orbit.vertexCount * 2 : 0),
// A hidden body is the same retired stub every frame: no projection, no allocation, no packet.
Expand Down Expand Up @@ -371,9 +371,23 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW
// inside the viewport below, while partially clipped circles are left to
// normal browser clipping. The viewport width still owns density only.
const candidates: (StableLabelCandidate & { projected: ProjectedBody<Entry> })[] = [];
// Beyond the Local Group scale a galaxy stands for what is inside it: a star, or a galaxy it holds (M110 in M31, the Large
// Cloud in the Milky Way), whose marker falls within the galaxy's circle is not named, so the galaxy is. By tier a
// featured star is admitted before a galaxy and held M31's name across the Local Group; by id M110 took it. The holder
// is found each frame (its row can arrive after the body's, `extend`); one named elsewhere or hidden stands for nothing.
const withinHolder = (entry: Entry, x: number, y: number) => {
for (let holder = bodies[indexById.get(entry.inside ?? '') ?? -1]; holder; holder = bodies[indexById.get(holder.inside ?? '') ?? -1]) {
if (holder.kind !== 'galaxy' || holder.labelSuppressed || holder.labelHidden || holder.bodyHidden) continue;
const [holderX, holderY] = project(frame.eye(holder.body));
if (Math.hypot(holderX! - x, holderY! - y) < BODY_INDICATOR_DIAMETER + UNIVERSE_LABEL_POLICY.spacingPixels) return true;
}
return false;
};
for (const projected of projectedBodies) {
if (projected.entry.retired) continue; // retired with its faded system: never named
const { entry, x, y, diameter, annotationVisible, hovered, priority, emphasised, targeted } = projected;
const fromCamera = Math.hypot(...frame.eye(entry.body));
if (!targeted && fromCamera > LOCAL_GROUP_SCALE.enterDistanceM && withinHolder(entry, x, y)) { projected.nameable = false; continue; }
let { circle } = projected;
const { body, labelSize: size } = entry;
const prominentOrbiter = entry.prominent;
Expand Down Expand Up @@ -420,7 +434,7 @@ export function createWorldContextPlanner(plan: PreparedWorldContext | PreparedW
// read as neighbourhood: an inner planet is not less part of its system because the camera frames the outer one.
// Past the Local Group scale the galaxies are named, not the stars inside them: a name fades with the body's distance
// from the camera over the band where the overview becomes the Local Group (the cluster scale beyond it).
const galactic = 1 - (extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(Math.hypot(...frame.eye(body)), entry.scale.returnDistanceM, entry.scale.enterDistanceM));
const galactic = 1 - (extendedRetirement(entry.kind, fromFocus) ?? logarithmicFade(fromCamera, entry.scale.returnDistanceM, entry.scale.enterDistanceM));
const alpha = flightDestination ? 1 : referenceAnnotationOnly ? galactic : targeted ? markerOpacity : Math.min(markerOpacity, galactic, resolvedDisc || hostedPlanet ? 1 : labelExtentOpacity(localExtent));
// Naming policy, decided before any slot is contested: suppressed, unresolved, too faint
// or out of context here, and the body is not one this camera names at all.
Expand Down
Loading