diff --git a/docs/universe-labels.md b/docs/universe-labels.md index 85ef582df5f..5d65f8aa325 100644 --- a/docs/universe-labels.md +++ b/docs/universe-labels.md @@ -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 diff --git a/packages/renderer/src/styles/world-context.css b/packages/renderer/src/styles/world-context.css index 3b174dac37f..6218bba164c 100644 --- a/packages/renderer/src/styles/world-context.css +++ b/packages/renderer/src/styles/world-context.css @@ -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): @@ -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; @@ -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; @@ -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; @@ -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, diff --git a/packages/renderer/src/universe/prepared-world-context.ts b/packages/renderer/src/universe/prepared-world-context.ts index 250588f6917..be5443293ba 100644 --- a/packages/renderer/src/universe/prepared-world-context.ts +++ b/packages/renderer/src/universe/prepared-world-context.ts @@ -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 = { ...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. @@ -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) { @@ -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) { diff --git a/site/test/prepared-world-context.test.ts b/site/test/prepared-world-context.test.ts index c48c74c0b37..9c7ce4bac5f 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -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) => ({ ...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);