From 72a3ebef0367620e98f37e8dcf5944f9f634e537 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 5 Oct 2026 18:00:25 -0300 Subject: [PATCH 1/2] fix(universe): a body inside a nebula is named in white over a dark grey copy of its name, so it reads on the picture --- docs/universe-labels.md | 5 ++++ .../renderer/src/styles/world-context.css | 24 +++++++++++++-- .../src/universe/prepared-world-context.ts | 11 +++++++ site/test/prepared-world-context.test.ts | 29 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/universe-labels.md b/docs/universe-labels.md index 85ef582df5f..94e60dd31d8 100644 --- a/docs/universe-labels.md +++ b/docs/universe-labels.md @@ -27,6 +27,11 @@ 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. 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. ## Priority and crowding diff --git a/packages/renderer/src/styles/world-context.css b/packages/renderer/src/styles/world-context.css index 3b174dac37f..90d2cb1ea37 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,20 @@ 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 from its prepared holder + (prepared-world-context.ts). */ +.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 +211,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..76a44931356 100644 --- a/packages/renderer/src/universe/prepared-world-context.ts +++ b/packages/renderer/src/universe/prepared-world-context.ts @@ -192,6 +192,10 @@ 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 }; + // Each body's classification by id (the first plan's, then each added system's): a body inside a nebula, its central + // star, is named over the nebula's picture and wears the name that reads there (world-context.css). + const classifications = new Map([plan.focus, ...plan.bodies].map(body => [body.id, body.classification])); + const namedOnPicture = (body: PreparedWorldContext['focus'] | PreparedContextBody) => 'inside' in body && body.inside !== undefined && classifications.get(body.inside) === 'nebula'; 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 +214,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 (namedOnPicture(body)) 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) { @@ -534,6 +539,12 @@ export function mountPreparedWorldContext({ host, presentationHost = host, befor const added = next.bodies.filter(body => !entriesById.has(body.id)); if (!added.length) return; Object.assign(spriteTable, nextSprites); + // A nebula can arrive after the body inside it: that body, already mounted, takes its name over the picture now. + for (const body of added) classifications.set(body.id, body.classification); + for (const entry of bodies) { + const holder = 'inside' in entry.body ? entry.body.inside : undefined; + if (holder !== undefined && added.some(body => body.id === holder) && namedOnPicture(entry.body)) entry.marker.dataset.contextLabelBackdrop = 'picture'; + } const entries = added.map((body, offset) => createEntry(body, bodies.length + offset)); bodies.push(...entries); bodyColumns = createWorldBodyColumns(bodies.length); diff --git a/site/test/prepared-world-context.test.ts b/site/test/prepared-world-context.test.ts index c48c74c0b37..5ac919727ed 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -2401,6 +2401,35 @@ test('a galaxy wears a diamond, and the flight circle takes its destination\'s s layer.destroy(); }); +test('a body inside a nebula takes the name that reads over its picture, also when the nebula arrives after it', async () => { + // Venus stands for a nebula with Mercury inside it; Earth for a nebula that arrives later, with Venus inside it. + const nested = (base: ReturnType) => ({ ...base, bodies: base.bodies.map(body => body.id === 'venus' ? { ...body, classification: 'nebula', inside: 'earth' } + : body.id === 'mercury' ? { ...body, inside: 'venus' } : body.id === 'earth' ? { ...body, classification: 'nebula' } : 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 a body whose holder is a nebula carries one. + assert.deepEqual(['sun', 'mercury', 'venus'].map(backdrop), [undefined, 'picture', undefined]); + layer.addBodies(nested(plan(1, [], true))); + assert.deepEqual(['sun', 'mercury', 'venus', 'earth'].map(backdrop), [undefined, 'picture', 'picture', 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); From b8bbc4d18e074c6d70575a5d685691447b42b854 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 5 Oct 2026 18:12:15 -0300 Subject: [PATCH 2/2] fix(universe): a body inside a nebula takes its white name on that nebula's page only --- docs/universe-labels.md | 9 ++++--- .../renderer/src/styles/world-context.css | 5 ++-- .../src/universe/prepared-world-context.ts | 25 +++++++++++-------- site/test/prepared-world-context.test.ts | 19 +++++++++----- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/universe-labels.md b/docs/universe-labels.md index 94e60dd31d8..5d65f8aa325 100644 --- a/docs/universe-labels.md +++ b/docs/universe-labels.md @@ -28,10 +28,11 @@ does not automatically need a caption on screen. - 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. 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. + 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 90d2cb1ea37..6218bba164c 100644 --- a/packages/renderer/src/styles/world-context.css +++ b/packages/renderer/src/styles/world-context.css @@ -179,8 +179,9 @@ /* 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 from its prepared holder - (prepared-world-context.ts). */ + 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; diff --git a/packages/renderer/src/universe/prepared-world-context.ts b/packages/renderer/src/universe/prepared-world-context.ts index 76a44931356..be5443293ba 100644 --- a/packages/renderer/src/universe/prepared-world-context.ts +++ b/packages/renderer/src/universe/prepared-world-context.ts @@ -192,10 +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 }; - // Each body's classification by id (the first plan's, then each added system's): a body inside a nebula, its central - // star, is named over the nebula's picture and wears the name that reads there (world-context.css). - const classifications = new Map([plan.focus, ...plan.bodies].map(body => [body.id, body.classification])); - const namedOnPicture = (body: PreparedWorldContext['focus'] | PreparedContextBody) => 'inside' in body && body.inside !== undefined && classifications.get(body.inside) === 'nebula'; + // 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. @@ -214,7 +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 (namedOnPicture(body)) data.contextLabelBackdrop = 'picture'; + 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) { @@ -539,12 +538,6 @@ export function mountPreparedWorldContext({ host, presentationHost = host, befor const added = next.bodies.filter(body => !entriesById.has(body.id)); if (!added.length) return; Object.assign(spriteTable, nextSprites); - // A nebula can arrive after the body inside it: that body, already mounted, takes its name over the picture now. - for (const body of added) classifications.set(body.id, body.classification); - for (const entry of bodies) { - const holder = 'inside' in entry.body ? entry.body.inside : undefined; - if (holder !== undefined && added.some(body => body.id === holder) && namedOnPicture(entry.body)) entry.marker.dataset.contextLabelBackdrop = 'picture'; - } const entries = added.map((body, offset) => createEntry(body, bodies.length + offset)); bodies.push(...entries); bodyColumns = createWorldBodyColumns(bodies.length); @@ -575,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 5ac919727ed..9c7ce4bac5f 100644 --- a/site/test/prepared-world-context.test.ts +++ b/site/test/prepared-world-context.test.ts @@ -2401,19 +2401,26 @@ test('a galaxy wears a diamond, and the flight circle takes its destination\'s s layer.destroy(); }); -test('a body inside a nebula takes the name that reads over its picture, also when the nebula arrives after it', async () => { - // Venus stands for a nebula with Mercury inside it; Earth for a nebula that arrives later, with Venus inside it. - const nested = (base: ReturnType) => ({ ...base, bodies: base.bodies.map(body => body.id === 'venus' ? { ...body, classification: 'nebula', inside: 'earth' } - : body.id === 'mercury' ? { ...body, inside: 'venus' } : body.id === 'earth' ? { ...body, classification: 'nebula' } : body) }); +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 a body whose holder is a nebula carries one. + // 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.deepEqual(['sun', 'mercury', 'venus', 'earth'].map(backdrop), [undefined, 'picture', 'picture', undefined]); + 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))