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
6 changes: 6 additions & 0 deletions docs/universe-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ does not automatically need a caption on screen.
ranges. Background stars and the distant galaxy point field remain anonymous.
- Catalogue-only galaxies remain visible as dots. Without a prepared scene,
they do not mount a marker or caption in the spatial view.
- A body inside a nebula, such as its central star, is named over the nebula's
picture on that nebula's page. There its caption is white at full strength
over a dark grey copy of the same text, one pixel down and right, so it reads
on the bright and on the dark parts of the picture. The copy is the name
painted a second time; no stroke, shadow or blend is used. Its circle is
unchanged, and on every other page the caption is drawn like any other.

## Priority and crowding

Expand Down
25 changes: 22 additions & 3 deletions packages/renderer/src/styles/world-context.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@
pointer-events: none;
}
.prepared-world-context [data-context-body]::before,
.prepared-world-context [data-context-body] > .context-caption::after {
.prepared-world-context [data-context-body] > .context-caption::after,
.prepared-world-context [data-context-label-backdrop="picture"] > .context-caption::before {
visibility: hidden;
}
/* The caption is a retained element whose inline transform places the name beside its body (world-context-marker-paint.ts):
Expand All @@ -116,7 +117,8 @@
height: 0;
transform: translate(12px, -9px);
}
.prepared-world-context [data-context-body] > .context-caption::after {
.prepared-world-context [data-context-body] > .context-caption::after,
.prepared-world-context [data-context-label-backdrop="picture"] > .context-caption::before {
position: absolute;
inset: 0 auto auto 0;
pointer-events: none;
Expand Down Expand Up @@ -162,6 +164,7 @@
/* Ordinary captions share typography; a body's color and caption case come from its prepared world data, set on its
marker (prepared-world-context.ts). Geometry owners only place and fade text. */
.prepared-world-context [data-context-body] > .context-caption::after,
.prepared-world-context [data-context-label-backdrop="picture"] > .context-caption::before,
.prepared-context-label {
width: max-content;
height: 18px;
Expand All @@ -173,6 +176,21 @@
content: attr(data-context-name);
opacity: .65;
}
/* A body inside a nebula (its central star) is named over the nebula's picture, where the body's own color at 65% is lost
on the bright parts and black on the dark ones (HD 59088 in NGC 2392: 1% of the name's box reached 3:1, 2026-10-05).
Its name is white at full strength over a dark grey copy of itself one pixel down and right: the same text painted a
second time under the name, no stroke, shadow or blend. The world marks the body while its nebula is the selected
object (prepared-world-context.ts): on any other page the picture is not behind the name, and a white name would stand
out among names at 65%. */
.prepared-world-context [data-context-label-backdrop="picture"] > .context-caption::before {
content: attr(data-context-name);
color: #404040;
transform: translate(1px, 1px);
}
.prepared-world-context [data-context-label-backdrop="picture"] > .context-caption::after {
color: #fff;
opacity: 1;
}
.prepared-world-context .context-flight-caption {
position: absolute;
inset: 0 auto auto 0;
Expand All @@ -194,7 +212,8 @@
.prepared-world-context [data-context-annotations-animate="true"] > .context-caption::after {
transition: opacity 200ms linear;
}
.prepared-world-context [data-context-label-visible="true"] > .context-caption::after {
.prepared-world-context [data-context-label-visible="true"] > .context-caption::after,
.prepared-world-context [data-context-label-backdrop="picture"][data-context-label-visible="true"] > .context-caption::before {
visibility: inherit;
}
.prepared-world-context [data-context-indicator-hovered="true"][data-context-indicator-visible="true"]::before,
Expand Down
14 changes: 14 additions & 0 deletions packages/renderer/src/universe/prepared-world-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ export function mountPreparedWorldContext({ host, presentationHost = host, befor
orbitTemplate.className = orbitRenderer === 'bars' ? 'context-orbit context-orbit-bars' : 'context-orbit';
// Sprites by body id; a system added later brings its own (addBodies).
const spriteTable: Record<string, SpriteWithUrl> = { ...sprites };
// The selected nebula, if the selection is one: on its page the bodies inside it (its central star) are named over its
// picture and wear the name that reads there (world-context.css). On any other page they are named like every body.
let picturedHolder: string | null = null;
const createEntry = (body: PreparedWorldContext['focus'] | PreparedContextBody, index: number) => {
// A body drawn from its astronomy record has no package, so no prepared sprite and no page: it keeps its ring,
// name and orbit and is never a navigation target.
Expand All @@ -210,6 +213,7 @@ export function mountPreparedWorldContext({ host, presentationHost = host, befor
const color = body.contextColor ?? (unpackaged ? body.color : undefined);
if (color) marker.style.color = color;
if (body.labelCase === 'upper') data.contextLabelCase = 'upper';
if (picturedHolder !== null && 'inside' in body && body.inside === picturedHolder) data.contextLabelBackdrop = 'picture';
// A galaxy's ring is a diamond (world-context.css): its marker is another galaxy, not a body of this one.
if (body.classification === 'galaxy') data.contextShape = 'diamond';
if (approximate) {
Expand Down Expand Up @@ -564,6 +568,16 @@ export function mountPreparedWorldContext({ host, presentationHost = host, befor
settleHover();
invalidatePolicy();
selectedEntry = entry;
// The bodies inside a selected nebula take the name that reads over its picture; those of the nebula left give it up.
const holder = entry.body.classification === 'nebula' ? id : null;
if (holder !== picturedHolder) {
for (const { body, marker } of bodies) {
const inside = 'inside' in body ? body.inside : undefined;
if (inside === undefined) continue;
if (inside === holder) marker.dataset.contextLabelBackdrop = 'picture'; else if (inside === picturedHolder) delete marker.dataset.contextLabelBackdrop;
}
picturedHolder = holder;
}
refreshDepthBodies();
},
publish(world: WorldCameraPose, viewport: WorldCameraViewport, preparedFrame: WorldContextFrame) {
Expand Down
36 changes: 36 additions & 0 deletions site/test/prepared-world-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2401,6 +2401,42 @@ test('a galaxy wears a diamond, and the flight circle takes its destination\'s s
layer.destroy();
});

test('on a nebula\'s page the bodies inside it take the name that reads over its picture, and give it up on any other', async () => {
// Venus stands for a nebula with Mercury inside it; Earth, inside it too, arrives while its page is open.
const nested = (base: ReturnType<typeof plan>) => ({ ...base, bodies: base.bodies.map(body => body.id === 'venus' ? { ...body, classification: 'nebula' }
: body.id === 'mercury' || body.id === 'earth' ? { ...body, inside: 'venus' } : body) });
const document = new FakeDocument(), host = document.createElement('section'), before = document.createElement('i');
host.clientWidth = 800; host.clientHeight = 600; host.append(before);
const layer = mountTestContext({ host: host as unknown as HTMLElement, before: before as unknown as Element, plan: nested(plan(1)),
sprites: { sun: sprite, mercury: sprite, venus: sprite, earth: sprite } });
const root = layer.root as unknown as FakeElement, backdrop = (id: string) => find(root, 'contextGroup', id).dataset.contextLabelBackdrop;
// The mark is on the marker whose caption reads it, and only while the nebula that holds the body is selected.
assert.deepEqual(['sun', 'mercury', 'venus'].map(backdrop), [undefined, undefined, undefined]);
layer.selectObject('venus');
assert.deepEqual(['sun', 'mercury', 'venus'].map(backdrop), [undefined, 'picture', undefined]);
layer.addBodies(nested(plan(1, [], true)));
assert.equal(backdrop('earth'), 'picture');
layer.selectObject('sun');
assert.deepEqual(['sun', 'mercury', 'venus', 'earth'].map(backdrop), [undefined, undefined, undefined, undefined]);
// Selecting the body itself is not its nebula's page either.
layer.selectObject('venus'); layer.selectObject('mercury');
assert.deepEqual(['mercury', 'earth'].map(backdrop), [undefined, undefined]);
// The name and its copy are the caption's pseudo-elements, which the fake cascade leaves out: read from the stylesheet.
const css = await readFile(new URL('../../packages/renderer/src/styles/world-context.css', import.meta.url), 'utf8');
const rule = (selector: string) => css.replace(/\/\*[\s\S]*?\*\//gu, '').split('}').filter(block => block.split('{')[0]!.split(',').some(part => part.trim() === selector))
.map(block => block.split('{')[1]).join('');
const marked = '.prepared-world-context [data-context-label-backdrop="picture"]';
const name = rule(`${marked} > .context-caption::after`), copy = rule(`${marked} > .context-caption::before`);
assert.match(name, /color: #fff;/u);
assert.match(name, /opacity: 1;/u);
// The copy is the same text in the name's own box, hidden with it, one pixel down and right in dark grey.
for (const declaration of [/content: attr\(data-context-name\);/u, /color: #404040;/u, /transform: translate\(1px, 1px\);/u, /position: absolute;/u, /width: max-content;/u, /visibility: hidden;/u]) assert.match(copy, declaration);
assert.match(rule(`${marked}[data-context-label-visible="true"] > .context-caption::before`), /visibility: inherit;/u);
// No stroke, shadow, filter or blend draws it.
assert.doesNotMatch(name + copy, /shadow|stroke|blend|filter/u);
layer.destroy();
});

for (const destination of [null, 'venus']) test(`flights to ${destination} retain system annotations and orbit cutouts without enabling picking`, () => {
const root = mount(1), layer = mounted.get(root)!;
const nodes = all(root);
Expand Down
Loading