diff --git a/.claude/skills/run-star-map/SKILL.md b/.claude/skills/run-star-map/SKILL.md index dd00110..e6c3200 100644 --- a/.claude/skills/run-star-map/SKILL.md +++ b/.claude/skills/run-star-map/SKILL.md @@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner { "name": "Earth", "kind": "Planet" }, { "name": "Mercury", "kind": "Planet" }, { "name": "Venus", "kind": "Planet" } + ], + "neighbours": [ + { "name": "Proxima Centauri", "distance": "1.30 pc" }, + { "name": "Barnard's Star", "distance": "1.82 pc" } ] } ``` `probe` accepts the same four view names. Labels are read from the CSS2D layer -(`.map-label` > `.map-label-name` + `.map-label-kind`). +(`.map-label` > `.map-label-name` + `.map-label-kind`). `neighbours` is the ring of nearby +stars named from inside a system (`.map-label--ghost`); it is absent where there are none. ## Run (human path) diff --git a/.claude/skills/run-star-map/driver.mjs b/.claude/skills/run-star-map/driver.mjs index 21b6263..22f736f 100644 --- a/.claude/skills/run-star-map/driver.mjs +++ b/.claude/skills/run-star-map/driver.mjs @@ -211,21 +211,27 @@ async function probe(page, view) { await page.waitForTimeout(2000); // Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind. - const labels = await page.locator('.map-label').evaluateAll((nodes) => + // Neighbouring stars named from inside a system carry .map-label--ghost; they are reported + // apart from the system's own bodies, since they are not in the system being probed. + const all = await page.locator('.map-label').evaluateAll((nodes) => nodes .filter((n) => n.offsetParent !== null) .map((n) => ({ name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '', - kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null + kind: n.querySelector('.map-label-kind')?.textContent?.trim() ?? null, + ghost: n.classList.contains('map-label--ghost') })) ); + const labels = all.filter((label) => !label.ghost).map(({ name, kind }) => ({ name, kind })); + const neighbours = all.filter((label) => label.ghost).map(({ name, kind }) => ({ name, distance: kind })); console.log( JSON.stringify( { title: (await title(page).textContent())?.trim(), level: (await level(page).textContent())?.trim(), labelCount: labels.length, - labels + labels, + ...(neighbours.length ? { neighbours } : {}) }, null, 2 diff --git a/e2e/neighbour-jump.spec.ts b/e2e/neighbour-jump.spec.ts new file mode 100644 index 0000000..81bb8fe --- /dev/null +++ b/e2e/neighbour-jump.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +import { openSearch } from './support/open-search'; + +test.describe('Neighbour jump', () => { + test('a neighbour named from inside one system flies into that one', async ({ page }) => { + // Two full camera flights on a software rasterizer shared with the rest of the suite: into + // Sol, then out and into the star its label names. See the same note on camera-flight. + test.setTimeout(120_000); + await page.goto('/?stars=4000'); + + const searchInput = await openSearch(page); + await searchInput.fill('Sol'); + await page.getByRole('button', { name: /^Sol\b/ }).first().click(); + + const readout = page.getByTestId('hud-title'); + await expect(readout).toHaveText('Sol', { timeout: 30_000 }); + + // Its nearest neighbours are named around the edge of the view; each is a button that flies + // there. Barnard's Star rather than the Alpha Centauri trio, whose three members share one + // bearing and so are decluttered down to whichever the label pass reaches first. + const neighbour = page.getByRole('button', { name: /Barnard's Star/ }); + await expect(neighbour).toBeVisible({ timeout: 30_000 }); + await neighbour.click(); + + await expect(readout).toHaveText("Barnard's Star", { timeout: 45_000 }); + // And from there the walk goes on: the new system names its own neighbours. Which ones is + // not asserted — several of Barnard's nearest share a bearing, so which of them survives + // the declutter is a property of the view, not a fact about the catalogue. + await expect(page.locator('.map-label--ghost')).not.toHaveCount(0, { timeout: 30_000 }); + }); +}); diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 7a33269..9a23f43 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -26,7 +26,9 @@ import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '. import { StarmapHudComponent } from './starmap-hud.component'; import { SystemObjectCardComponent } from './system-object-card.component'; import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer'; +import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood'; import { HostStarRings } from './host-star-rings'; +import { ReservedBox, ringPlacement } from './label-ring'; import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay'; import { SystemOrbitsRenderer } from './system-orbits-renderer'; @@ -54,6 +56,21 @@ const LABEL_MIN_SEPARATION_NDC = 0.12; const LABEL_EDGE_NDC = 0.7; /** How far right of its point a label's text reaches, in aspect-scaled NDC (~135px at 1440). */ const LABEL_REACH_NDC = 0.3; +/** How many neighbouring stars are named from inside a system. */ +const NEIGHBOUR_COUNT = 4; +/** + * How far out from the centre of the view a neighbour's name sits, as a fraction of the frame's + * half-height. Clear of the scale rail at the top and the dock at the bottom. + */ +const NEIGHBOUR_RING_NDC = 0.74; +/** + * How far in front of the camera a neighbour's name is planted, in AU. Any depth projects to + * the same place on the ring, but not to the same stability: unprojecting at the middle of the + * depth buffer lands ~0.008 AU from the eye, where a hundredth of a degree of camera drift + * swings the label across the screen. Out here the same drift moves it by a pixel. + */ +const NEIGHBOUR_DEPTH_AU = 500; + /** Radius of the selection arcs, in pixels — the leader line starts at their rim. */ const SELECTION_RADIUS_PX = 14; const HUD_ACCENT = 0x4dd7ff; @@ -164,6 +181,10 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector template: `
{{ eyebrow() }}
{{ title() }}
@if (subtitle()) { @@ -100,7 +103,7 @@ function isWideViewport(): boolean {Layers