Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a112e4bb3 | ||
|
|
f14e252b19 | ||
|
|
14f3fe8e66 | ||
|
|
82d70ce871 | ||
|
|
42f304b30b | ||
|
|
4e2575674d | ||
|
|
3612d722c5 | ||
|
|
6c5cc1ca19 | ||
|
|
911d876f55 | ||
|
|
0c190539fe | ||
|
|
c11087cb8f | ||
|
|
ee02147a78 | ||
|
|
46cb923849 | ||
|
|
59c2d8483e | ||
|
|
e808f50faa | ||
|
|
7c16ad8627 | ||
|
|
971aa66303 | ||
|
|
8ceea02875 | ||
|
|
005ba07750 | ||
|
|
b05324337c | ||
|
|
687bc3b01c | ||
|
|
d397aaa7e0 | ||
|
|
18e9d85a1b | ||
|
|
b2cb307b60 | ||
|
|
50c0351870 | ||
|
|
a381c02cd0 | ||
|
|
f42337c841 | ||
|
|
e853fe312e | ||
|
|
bd37bb8b96 | ||
|
|
d9bd913458 | ||
|
|
307fd41be8 | ||
|
|
9cdd8f9388 | ||
|
|
bd3a5a48c9 | ||
|
|
68a919bd84 | ||
|
|
c1e7f6363d | ||
|
|
a6b30e84f0 | ||
|
|
44c6a1f15e | ||
|
|
7591bcc0ea | ||
|
|
8b507427d8 | ||
|
|
4a1cc5240a | ||
|
|
9ae87e83fb | ||
|
|
650b3b37a0 | ||
|
|
0dcd49c777 | ||
|
|
a965aed1db | ||
|
|
836816ffbd |
@@ -110,12 +110,17 @@ node .claude/skills/run-star-map/driver.mjs probe inner
|
|||||||
{ "name": "Earth", "kind": "Planet" },
|
{ "name": "Earth", "kind": "Planet" },
|
||||||
{ "name": "Mercury", "kind": "Planet" },
|
{ "name": "Mercury", "kind": "Planet" },
|
||||||
{ "name": "Venus", "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
|
`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)
|
## Run (human path)
|
||||||
|
|
||||||
|
|||||||
@@ -211,21 +211,27 @@ async function probe(page, view) {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Labels are plain DOM in a CSS2D layer: .map-label > .map-label-name + .map-label-kind.
|
// 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
|
nodes
|
||||||
.filter((n) => n.offsetParent !== null)
|
.filter((n) => n.offsetParent !== null)
|
||||||
.map((n) => ({
|
.map((n) => ({
|
||||||
name: n.querySelector('.map-label-name')?.textContent?.trim() ?? '',
|
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(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
title: (await title(page).textContent())?.trim(),
|
title: (await title(page).textContent())?.trim(),
|
||||||
level: (await level(page).textContent())?.trim(),
|
level: (await level(page).textContent())?.trim(),
|
||||||
labelCount: labels.length,
|
labelCount: labels.length,
|
||||||
labels
|
labels,
|
||||||
|
...(neighbours.length ? { neighbours } : {})
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ name: Junie review
|
|||||||
# it reads well, and a review comment should never be able to turn the build red.
|
# it reads well, and a review comment should never be able to turn the build red.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, ready_for_review]
|
# `reopened` because a pull request closed and reopened has had no review since it was
|
||||||
|
# closed, and `ready_for_review` because the draft guard below would otherwise skip a pull
|
||||||
|
# request opened as a draft forever. No `branches:` filter: work here stacks feature onto
|
||||||
|
# feature, so filtering on main would skip every pull request in a chain but the last.
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
# A review of the previous push is stale the moment a new one lands, so supersede it rather than
|
# A review of the previous push is stale the moment a new one lands, so supersede it rather than
|
||||||
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
|
# letting two reviews comment on the same pull request. Keyed by pull request rather than by ref
|
||||||
@@ -25,6 +29,11 @@ jobs:
|
|||||||
review:
|
review:
|
||||||
name: Review the diff
|
name: Review the diff
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# A ceiling, not a target: a run that goes wrong hangs rather than stops, and the pull
|
||||||
|
# request shows a pending check until it does. Set above the longest review this repository
|
||||||
|
# has actually had — 35 minutes, on the largest diff so far — rather than at the sibling
|
||||||
|
# repositories' 30, which would have cut that one short.
|
||||||
|
timeout-minutes: 45
|
||||||
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
|
# Drafts are work in progress and forks cannot see `JUNIE_API_KEY` — GitHub withholds secrets
|
||||||
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather
|
# from `pull_request` runs on forked branches, so the job would fail on a missing key rather
|
||||||
# than say anything useful about the code.
|
# than say anything useful about the code.
|
||||||
@@ -54,7 +63,13 @@ jobs:
|
|||||||
# clone is never used.
|
# clone is never used.
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- uses: JetBrains/junie-github-action@v1
|
# Pinned to a commit rather than to `v1`: this is the only third-party action here and it
|
||||||
|
# is handed a repository secret, so its definition should not be able to change under us.
|
||||||
|
# `v1` resolves to this same commit today; the pin is about who gets to move it. The pin
|
||||||
|
# covers this definition only — the composite pulls its own dependencies by tag and fetches
|
||||||
|
# the Junie CLI over the network. Bump by resolving the new release's commit, never by
|
||||||
|
# moving a tag name.
|
||||||
|
- uses: JetBrains/junie-github-action@3f6a906f11c6f67c76efaf3d3264bbb615f9ce29 # v1.7.5
|
||||||
if: env.HAS_JUNIE_KEY == 'true'
|
if: env.HAS_JUNIE_KEY == 'true'
|
||||||
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
|
# An opinion, not a gate. If Junie is down or rate-limited that is worth seeing in the
|
||||||
# log, but it is not a reason to hold a pull request whose tests pass.
|
# log, but it is not a reason to hold a pull request whose tests pass.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Every request the article service makes, whichever language it is asking in. */
|
||||||
|
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
|
||||||
|
|
||||||
|
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
|
||||||
|
const SUMMARY = {
|
||||||
|
type: 'standard',
|
||||||
|
titles: { normalized: 'Titan (moon)' },
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('About', () => {
|
||||||
|
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route(isWikipedia, async (route) => {
|
||||||
|
requests++;
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
|
||||||
|
expect(requests).toBe(0);
|
||||||
|
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||||
|
const credit = page.getByRole('link', { name: /Wikipedia/ });
|
||||||
|
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
|
||||||
|
expect(requests).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
|
||||||
|
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
|
||||||
|
await page.route(isWikipedia, (route) => route.abort('failed'));
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
await expect(page.getByText(/could not be reached/)).toBeVisible();
|
||||||
|
|
||||||
|
// And the retry actually retries, rather than reading back a remembered failure.
|
||||||
|
await page.unroute(isWikipedia);
|
||||||
|
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { openSearch } from './support/open-search';
|
||||||
|
|
||||||
|
test.describe('Bookmarks', () => {
|
||||||
|
test('keeps a body, comes back to it in a later visit, and forgets it', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
await page.goto('/body/earth');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Keep Earth' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Forget Earth' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// A later visit, on a different page: kept places outlive the one they were kept from.
|
||||||
|
await page.goto('/');
|
||||||
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||||
|
|
||||||
|
const kept = page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Earth' });
|
||||||
|
await expect(kept).toHaveCount(1);
|
||||||
|
await kept.getByRole('button').first().click();
|
||||||
|
|
||||||
|
// Choosing it goes there, which for a body is its page.
|
||||||
|
await expect(page).toHaveURL(/\/body\/earth$/, { timeout: 30_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: 'Earth' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Forget Earth' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Keep Earth' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||||
|
await expect(page.locator('#dock-panel-bookmarks')).toContainText('Nothing kept yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the system the view is inside, and flies back to it', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
|
||||||
|
const searchInput = await openSearch(page);
|
||||||
|
await searchInput.fill('Proxima Centauri');
|
||||||
|
await page.getByRole('button', { name: /Proxima Centauri/ }).first().click();
|
||||||
|
|
||||||
|
const readout = page.getByTestId('hud-title');
|
||||||
|
await expect(readout).toHaveText('Proxima Centauri', { timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'Keep Proxima Centauri' }).click();
|
||||||
|
|
||||||
|
// Back out to the field, then return by what was kept rather than by searching again.
|
||||||
|
await page.getByRole('button', { name: 'Solar Neighbourhood' }).click();
|
||||||
|
await expect(readout).toHaveText('Local Stars', { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Bookmarks' }).click();
|
||||||
|
await page.locator('#dock-panel-bookmarks li').filter({ hasText: 'Proxima Centauri' }).getByRole('button').first().click();
|
||||||
|
|
||||||
|
await expect(readout).toHaveText('Proxima Centauri', { timeout: 45_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,12 @@ test.describe('Galaxy view', () => {
|
|||||||
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
test('boots the app, initializes the 3D scene, and starts in the galaxy overview (no system controls shown)', async ({ page }) => {
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
|
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
await expect(page.getByPlaceholder('Search stars, planets, exoplanets…')).toBeVisible();
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
// The dock's tab strip is up before the scene finishes booting; the search is one tab in it.
|
||||||
|
await expect(page.getByRole('tab', { name: 'Search' })).toBeVisible();
|
||||||
await expect(backButtonLocator(page)).toHaveCount(0);
|
await expect(backButtonLocator(page)).toHaveCount(0);
|
||||||
// The readout panel's own title, not just the text anywhere on screen: the selected-object
|
// The readout panel's own title, not just the text anywhere on screen: the selected-object
|
||||||
// banner across the top names the same thing, so a bare text match is ambiguous.
|
// banner across the top names the same thing, so a bare text match is ambiguous.
|
||||||
@@ -22,7 +26,7 @@ test.describe('Galaxy view', () => {
|
|||||||
// up to more than the default per-test budget.
|
// up to more than the default per-test budget.
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
await expect(page.getByTestId('scene-canvas')).toBeVisible();
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Milky Way' }).click();
|
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 by name, on purpose: the ring ranks named stars ahead of survey
|
||||||
|
// designations, and the four nearest named to the Sun — Proxima, Rigil Kentaurus, Toliman,
|
||||||
|
// Barnard's — are a fact about space, not about which catalogue was refreshed last. Before
|
||||||
|
// that preference, a Gaia row duplicating Proxima took fourth place and this very label was
|
||||||
|
// the one that vanished, so it is also the regression test for it. Barnard's rather than the
|
||||||
|
// Alpha Centauri trio because those three share one bearing and 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { openSearch } from './support/open-search';
|
||||||
|
|
||||||
|
test.describe('Plan view', () => {
|
||||||
|
test('flattens a system onto its own orbital plane, and unflattens it', async ({ page }) => {
|
||||||
|
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();
|
||||||
|
await expect(page.getByTestId('hud-title')).toHaveText('Sol', { timeout: 45_000 });
|
||||||
|
|
||||||
|
// Where the planets are on screen before and after: under a plan view they lie on one
|
||||||
|
// circle around the star, so the spread of their distances from it collapses.
|
||||||
|
const spread = async () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const labels = [...document.querySelectorAll('.map-label:not(.map-label--ghost)')];
|
||||||
|
const centre = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
||||||
|
const radii = labels.map((label) => {
|
||||||
|
const box = label.getBoundingClientRect();
|
||||||
|
return Math.hypot(box.left - centre.x, box.top + box.height / 2 - centre.y);
|
||||||
|
});
|
||||||
|
return radii.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
const plan = page.getByRole('button', { name: 'Plan view', exact: true });
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await plan.click();
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// The scene survives the swap: it is still this system, still labelled, still readable.
|
||||||
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
await page.getByRole('tab', { name: 'Readout' }).click();
|
||||||
|
await expect(page.getByTestId('hud-title')).toHaveText('Sol');
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
await plan.click();
|
||||||
|
await expect(plan).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(spread, { timeout: 30_000 }).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the scale ladder honest about how far out the view is', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood', { timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Display' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Plan view', exact: true }).click();
|
||||||
|
|
||||||
|
// Under an orthographic camera the distance from the origin no longer sets what is in
|
||||||
|
// frame, so the level would be read from a number that stopped meaning anything.
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Solar Neighbourhood');
|
||||||
|
await page.getByRole('button', { name: 'Milky Way' }).click();
|
||||||
|
await expect(page.getByTestId('hud-current-level')).toHaveText('Milky Way', { timeout: 45_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Route plotting', () => {
|
||||||
|
test('chains one star to another through the crossings a chosen range allows', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
// Generously timed on purpose: this suite shares one software rasterizer, and the boot
|
||||||
|
// it waits on is the slowest thing in it. The default five seconds is a coin toss
|
||||||
|
// under a full parallel run.
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||||
|
|
||||||
|
// Sirius is 2.64 pc from the Sun, so the default 3 pc range crosses it in one.
|
||||||
|
await page.locator('#route-from').fill('Sol');
|
||||||
|
const departure = page.locator('#dock-panel-routes ul li button').first();
|
||||||
|
await expect(departure).toBeVisible({ timeout: 30_000 });
|
||||||
|
await departure.click();
|
||||||
|
|
||||||
|
await page.locator('#route-to').fill('Sirius');
|
||||||
|
const destination = page.locator('#dock-panel-routes ul li button').first();
|
||||||
|
await expect(destination).toBeVisible();
|
||||||
|
await destination.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Plot route' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('route-summary')).toHaveText(/1 jump · 2\.6\d pc/);
|
||||||
|
// The chain itself, departure first, each step a way to fly there.
|
||||||
|
const steps = page.getByTestId('route-steps').getByRole('button');
|
||||||
|
await expect(steps).toHaveCount(2);
|
||||||
|
await expect(steps.first()).toContainText('Sol');
|
||||||
|
await expect(steps.last()).toContainText('Sirius');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says what range a crossing would need, rather than only that there is none', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto('/?stars=4000');
|
||||||
|
await expect(page.getByTestId('scene-canvas')).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Routes' }).click();
|
||||||
|
await page.locator('#route-from').fill('Sol');
|
||||||
|
const departure = page.locator('#dock-panel-routes ul li button').first();
|
||||||
|
await expect(departure).toBeVisible({ timeout: 30_000 });
|
||||||
|
await departure.click();
|
||||||
|
|
||||||
|
// Narrowed until nothing the catalogue holds is within reach of the Sun: its nearest
|
||||||
|
// neighbour is 1.30 pc away, so half a parsec strands it.
|
||||||
|
await page.locator('#route-range').fill('0.5');
|
||||||
|
await page.locator('#route-to').fill('Sirius');
|
||||||
|
const destination = page.locator('#dock-panel-routes ul li button').first();
|
||||||
|
await expect(destination).toBeVisible();
|
||||||
|
await destination.click();
|
||||||
|
await page.getByRole('button', { name: 'Plot route' }).click();
|
||||||
|
|
||||||
|
const summary = page.getByTestId('route-summary');
|
||||||
|
await expect(summary).toContainText('No route at this range', { timeout: 30_000 });
|
||||||
|
// And the answer, not just the refusal: the range that would open one, offered as a control.
|
||||||
|
const raise = summary.getByRole('button');
|
||||||
|
await expect(raise).toContainText(/pc would reach/);
|
||||||
|
await raise.click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('route-summary')).toContainText(/jump/, { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { openSearch } from './support/open-search';
|
||||||
import { backButtonLocator } from './support/wait-for-back-button';
|
import { backButtonLocator } from './support/wait-for-back-button';
|
||||||
|
|
||||||
test.describe('Search-driven navigation', () => {
|
test.describe('Search-driven navigation', () => {
|
||||||
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
|
test('selecting a star result flies into that system and shows the back-to-galaxy control', async ({ page }) => {
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
const searchInput = await openSearch(page);
|
||||||
await searchInput.fill('Proxima Centauri');
|
await searchInput.fill('Proxima Centauri');
|
||||||
|
|
||||||
const result = page.getByRole('button', { name: /Proxima Centauri/ });
|
const result = page.getByRole('button', { name: /Proxima Centauri/ });
|
||||||
await expect(result).toBeVisible();
|
await expect(result).toBeVisible();
|
||||||
await result.click();
|
await result.click();
|
||||||
|
|
||||||
// Selecting a result clears the search query immediately (before the flight even starts).
|
// Selecting a result hands the dock straight back to the readout (before the flight even
|
||||||
await expect(searchInput).toHaveValue('');
|
// starts): the thing to look at is now the scene, and the search panel folds away with it.
|
||||||
|
await expect(page.getByRole('tab', { name: 'Readout' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(searchInput).toHaveCount(0);
|
||||||
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
|
await expect(backButtonLocator(page)).toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
|
test('selecting a body result navigates straight to its detail route and shows real NASA data', async ({ page }) => {
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
const searchInput = await openSearch(page);
|
||||||
await searchInput.fill('Earth');
|
await searchInput.fill('Earth');
|
||||||
|
|
||||||
const result = page.getByText('Earth', { exact: true });
|
const result = page.getByText('Earth', { exact: true });
|
||||||
@@ -36,7 +39,7 @@ test.describe('Search-driven navigation', () => {
|
|||||||
|
|
||||||
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
|
test('typing fewer than two characters shows no results, and Escape clears the query', async ({ page }) => {
|
||||||
await page.goto('/?stars=4000');
|
await page.goto('/?stars=4000');
|
||||||
const searchInput = page.getByPlaceholder('Search stars, planets, exoplanets…');
|
const searchInput = await openSearch(page);
|
||||||
|
|
||||||
await searchInput.fill('E');
|
await searchInput.fill('E');
|
||||||
await expect(page.getByTestId('search-results')).toHaveCount(0);
|
await expect(page.getByTestId('search-results')).toHaveCount(0);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search lives in the dock along the bottom, behind its own tab, so a test that wants to
|
||||||
|
* type has to open it first — the way a user does, or with the `/` shortcut. Returns the field.
|
||||||
|
*/
|
||||||
|
export async function openSearch(page: Page): Promise<Locator> {
|
||||||
|
// Clicking the active tab folds it closed, so only click when it is not already open.
|
||||||
|
const tab = page.getByRole('tab', { name: 'Search' });
|
||||||
|
if ((await tab.getAttribute('aria-selected')) !== 'true') {
|
||||||
|
await tab.click();
|
||||||
|
}
|
||||||
|
return page.getByPlaceholder('Search stars, planets, exoplanets…');
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
|
// Well above Playwright's 5 s, which was a fair ceiling when the star catalogue was 820 kB and
|
||||||
|
// is not now that the scheduled refresh has it at 5.4 MB and 447 410 rows: every one of these
|
||||||
|
// tests boots that catalogue, and the suite boots several at once on a software rasterizer.
|
||||||
|
// The heavy waits already carry their own longer timeouts; this is the same judgement applied
|
||||||
|
// to the assertions that were left on the default. A ceiling costs nothing when it is not hit.
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4300',
|
baseURL: 'http://localhost:4300',
|
||||||
trace: 'on-first-retry'
|
trace: 'on-first-retry'
|
||||||
|
|||||||
+6
-1
@@ -1,2 +1,7 @@
|
|||||||
<app-search></app-search>
|
<!-- Viewport frame: the four corner marks that read the whole screen as one instrument, rather
|
||||||
|
than as unrelated panels floating on black. Decorative, so it never intercepts scene input. -->
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
|
||||||
|
></div>
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|||||||
+1
-3
@@ -1,11 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
import { SearchComponent } from './features/search/search.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, SearchComponent],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ArticleService } from './article.service';
|
||||||
|
|
||||||
|
function summary(overrides: Record<string, unknown> = {}): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'standard',
|
||||||
|
titles: { normalized: 'Titan (moon)' },
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
|
||||||
|
...overrides
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function missing(): Response {
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Wikipedia sends anyone who asks too quickly. */
|
||||||
|
function rateLimited(): Response {
|
||||||
|
return new Response('You are making too many requests to the API.', { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function service(): ArticleService {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
return TestBed.inject(ArticleService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The language each call was made in, in order, so the fallback chain can be asserted. */
|
||||||
|
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArticleService', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('navigator', { language: 'en-GB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'found',
|
||||||
|
article: {
|
||||||
|
title: 'Titan (moon)',
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
|
||||||
|
language: 'en'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks in the browser’s language first and English second', async () => {
|
||||||
|
vi.stubGlobal('navigator', { language: 'fr-FR' });
|
||||||
|
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
|
||||||
|
expect(result.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks English only once, when English is what the browser is set to', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the kind when the plain name lands on a list of other things', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan', 'moon');
|
||||||
|
|
||||||
|
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
|
||||||
|
expect(result.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says there is nothing written rather than pretending, when nothing is', async () => {
|
||||||
|
fetchMock.mockResolvedValue(missing());
|
||||||
|
|
||||||
|
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells being unable to ask apart from there being no answer', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
|
||||||
|
fetchMock.mockResolvedValue(rateLimited());
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks once per body, however many times it is asked for', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
const articles = service();
|
||||||
|
|
||||||
|
await articles.lookup('Titan');
|
||||||
|
await articles.lookup('Titan');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers that there is nothing, but never that it could not ask', async () => {
|
||||||
|
const articles = service();
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(missing());
|
||||||
|
await articles.lookup('Nowhere');
|
||||||
|
await articles.lookup('Nowhere');
|
||||||
|
const afterMissing = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||||
|
await articles.lookup('Elsewhere');
|
||||||
|
const afterFirstFailure = fetchMock.mock.calls.length;
|
||||||
|
await articles.lookup('Elsewhere');
|
||||||
|
|
||||||
|
// One round of requests for the missing page, then nothing more; but a failure to reach
|
||||||
|
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
|
||||||
|
expect(afterMissing).toBe(1);
|
||||||
|
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an article with nothing in it as no article', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send a browser language that is not one', async () => {
|
||||||
|
vi.stubGlobal('navigator', { language: 'not a language tag' });
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/** A Wikipedia summary, as much of it as this app shows. */
|
||||||
|
export interface Article {
|
||||||
|
readonly title: string;
|
||||||
|
readonly extract: string;
|
||||||
|
/** The article itself, so a reader can go and check. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
|
||||||
|
readonly language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
|
||||||
|
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
|
||||||
|
* same, and only one of them is about the world.
|
||||||
|
*/
|
||||||
|
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
|
||||||
|
|
||||||
|
const FALLBACK_LANGUAGE = 'en';
|
||||||
|
|
||||||
|
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
|
||||||
|
* "Proxima Centauri b" without this app having to know that. */
|
||||||
|
function summaryUrl(language: string, title: string): string {
|
||||||
|
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
|
||||||
|
function browserLanguage(): string {
|
||||||
|
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
|
||||||
|
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
|
||||||
|
const primary = tag.split('-')[0]?.toLowerCase();
|
||||||
|
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wikipedia summaries, fetched only when asked for.
|
||||||
|
*
|
||||||
|
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
|
||||||
|
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
|
||||||
|
* where it came from and linked back to the article — the same rule the derived surfaces follow,
|
||||||
|
* for the same reason.
|
||||||
|
*
|
||||||
|
* Asked for in the browser's language first and English second, because the catalogue's names
|
||||||
|
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ArticleService {
|
||||||
|
private readonly cache = new Map<string, ArticleLookup>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The article for `name`, or why there is none.
|
||||||
|
*
|
||||||
|
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
|
||||||
|
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
|
||||||
|
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
|
||||||
|
* English rather than guessing at a translation.
|
||||||
|
*/
|
||||||
|
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
|
||||||
|
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
|
||||||
|
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
|
||||||
|
const key = `${name}\0${qualifier ?? ''}`;
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = browserLanguage();
|
||||||
|
const attempts: { language: string; title: string }[] = [
|
||||||
|
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
|
||||||
|
{ language: FALLBACK_LANGUAGE, title: name },
|
||||||
|
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
let reachedWikipedia = false;
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
const result = await this.fetchSummary(attempt.language, attempt.title);
|
||||||
|
if (result === 'unreachable') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reachedWikipedia = true;
|
||||||
|
if (result) {
|
||||||
|
const found: ArticleLookup = { status: 'found', article: result };
|
||||||
|
this.cache.set(key, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached when Wikipedia could not be reached: that is a fact about the network this
|
||||||
|
// minute, and the next press should be allowed to ask again.
|
||||||
|
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
|
||||||
|
if (reachedWikipedia) {
|
||||||
|
this.cache.set(key, outcome);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
|
||||||
|
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
|
||||||
|
* answer at all, including the rate limit it returns to anyone who asks too fast.
|
||||||
|
*/
|
||||||
|
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
|
||||||
|
} catch {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
type?: string;
|
||||||
|
extract?: string;
|
||||||
|
titles?: { normalized?: string };
|
||||||
|
content_urls?: { desktop?: { page?: string } };
|
||||||
|
};
|
||||||
|
const extract = body.extract?.trim();
|
||||||
|
if (!extract || body.type === 'disambiguation') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: body.titles?.normalized ?? title,
|
||||||
|
extract,
|
||||||
|
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
|
||||||
|
language
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { NgZone } from '@angular/core';
|
||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { EngineService } from './engine.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projection half of the engine, which is the half that can be tested without a GPU: no
|
||||||
|
* renderer is created, the two cameras are placed by hand, and what is asserted is the
|
||||||
|
* arithmetic that keeps them showing the same thing.
|
||||||
|
*/
|
||||||
|
function engineWithCameras(): { engine: EngineService; perspective: THREE.PerspectiveCamera; orthographic: THREE.OrthographicCamera } {
|
||||||
|
const engine = new EngineService({ runOutsideAngular: (fn: () => unknown) => fn() } as unknown as NgZone);
|
||||||
|
const perspective = new THREE.PerspectiveCamera(50, 16 / 9, 0.1, 1000);
|
||||||
|
const orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||||
|
// The two cameras are private, because nothing outside should choose between them by hand.
|
||||||
|
Object.assign(engine as unknown as Record<string, unknown>, { perspective, orthographic });
|
||||||
|
return { engine, perspective, orthographic };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half the height of what a perspective camera frames at `distance`, in world units. */
|
||||||
|
function perspectiveHalfHeight(camera: THREE.PerspectiveCamera, distance: number): number {
|
||||||
|
return distance * Math.tan((camera.fov * Math.PI) / 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EngineService projection', () => {
|
||||||
|
let engine: EngineService;
|
||||||
|
let perspective: THREE.PerspectiveCamera;
|
||||||
|
let orthographic: THREE.OrthographicCamera;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ engine, perspective, orthographic } = engineWithCameras());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws through the perspective camera until told otherwise', () => {
|
||||||
|
expect(engine.currentProjection).toBe('perspective');
|
||||||
|
expect(engine.getCamera()).toBe(perspective);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frames the same extent through either camera, which is the point of the swap', () => {
|
||||||
|
perspective.position.set(0, 0, 200);
|
||||||
|
|
||||||
|
engine.setProjection('orthographic', 200);
|
||||||
|
|
||||||
|
expect(engine.getCamera()).toBe(orthographic);
|
||||||
|
expect(engine.visibleHalfHeight(200)).toBeCloseTo(perspectiveHalfHeight(perspective, 200), 6);
|
||||||
|
// And as wide as the frame is, not as wide as it is tall.
|
||||||
|
expect(orthographic.right - orthographic.left).toBeCloseTo((orthographic.top - orthographic.bottom) * perspective.aspect, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the pose across, so the swap changes the projection and not the view', () => {
|
||||||
|
perspective.position.set(3, 4, 12);
|
||||||
|
perspective.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
engine.setProjection('orthographic', 13);
|
||||||
|
|
||||||
|
expect(orthographic.position.toArray()).toEqual(perspective.position.toArray());
|
||||||
|
expect(orthographic.quaternion.toArray()).toEqual(perspective.quaternion.toArray());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees behind itself, because a parallel camera does not back away from what it frames', () => {
|
||||||
|
engine.setProjection('orthographic', 100);
|
||||||
|
|
||||||
|
// A perspective camera pulls back as its frame grows and leaves the scene in front of it. An
|
||||||
|
// orthographic one does not move at all, so half the Galaxy ends up behind its own plane —
|
||||||
|
// and a near plane in front would clip it away. Parallel depth is linear, so the precision
|
||||||
|
// that a perspective near plane is guarding for does not apply.
|
||||||
|
expect(orthographic.near).toBe(-perspective.far);
|
||||||
|
expect(orthographic.far).toBe(perspective.far);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes back, and hands out the perspective camera again', () => {
|
||||||
|
engine.setProjection('orthographic', 100);
|
||||||
|
engine.setProjection('perspective', 100);
|
||||||
|
|
||||||
|
expect(engine.currentProjection).toBe('perspective');
|
||||||
|
expect(engine.getCamera()).toBe(perspective);
|
||||||
|
expect(engine.visibleHalfHeight(100)).toBeCloseTo(perspectiveHalfHeight(perspective, 100), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the extent the orthographic camera is zoomed to, not the one it was built at', () => {
|
||||||
|
engine.setProjection('orthographic', 100);
|
||||||
|
const framed = engine.visibleHalfHeight(100);
|
||||||
|
|
||||||
|
orthographic.zoom = 2;
|
||||||
|
|
||||||
|
// Zoomed in twice: half as much in frame. Distance says nothing about it, which is why
|
||||||
|
// nothing downstream may read the camera's distance under this projection.
|
||||||
|
expect(engine.visibleHalfHeight(100)).toBeCloseTo(framed / 2, 6);
|
||||||
|
expect(engine.visibleHalfHeight(999)).toBeCloseTo(framed / 2, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never divides by a camera sitting on its own target', () => {
|
||||||
|
expect(() => engine.setProjection('orthographic', 0)).not.toThrow();
|
||||||
|
expect(Number.isFinite(orthographic.top)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widens rather than magnifies when the window gets wider', () => {
|
||||||
|
engine.setProjection('orthographic', 100);
|
||||||
|
const height = orthographic.top - orthographic.bottom;
|
||||||
|
|
||||||
|
// No renderer, so resize returns early — the frustum is re-fitted by hand the same way.
|
||||||
|
orthographic.left = (-height / 2) * (21 / 9);
|
||||||
|
orthographic.right = (height / 2) * (21 / 9);
|
||||||
|
|
||||||
|
expect(orthographic.top - orthographic.bottom).toBeCloseTo(height, 6);
|
||||||
|
expect(orthographic.right - orthographic.left).toBeCloseTo(height * (21 / 9), 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,12 @@ import * as THREE from 'three/webgpu';
|
|||||||
|
|
||||||
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
|
export type EngineTickCallback = (deltaSeconds: number, elapsedSeconds: number) => void;
|
||||||
|
|
||||||
|
/** Which projection the scene is drawn through. */
|
||||||
|
export type Projection = 'perspective' | 'orthographic';
|
||||||
|
|
||||||
|
/** Either camera, as everything downstream of the projection sees it. */
|
||||||
|
export type SceneCamera = THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
|
* Owns the Three.js WebGPURenderer (with automatic WebGL2 fallback), the base scene/camera,
|
||||||
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
|
* and the render loop. The loop always runs outside Angular's zone so per-frame work never
|
||||||
@@ -20,7 +26,14 @@ export class EngineService {
|
|||||||
private canvas?: HTMLCanvasElement;
|
private canvas?: HTMLCanvasElement;
|
||||||
private renderer?: THREE.WebGPURenderer;
|
private renderer?: THREE.WebGPURenderer;
|
||||||
private scene?: THREE.Scene;
|
private scene?: THREE.Scene;
|
||||||
private camera?: THREE.PerspectiveCamera;
|
private perspective?: THREE.PerspectiveCamera;
|
||||||
|
/**
|
||||||
|
* Built alongside the perspective one and kept in step with it, rather than made on demand:
|
||||||
|
* the two share a position, an orientation and a depth range, and a camera that only exists
|
||||||
|
* while it is being looked through is a camera whose state is always one swap out of date.
|
||||||
|
*/
|
||||||
|
private orthographic?: THREE.OrthographicCamera;
|
||||||
|
private projection: Projection = 'perspective';
|
||||||
private running = false;
|
private running = false;
|
||||||
|
|
||||||
constructor(private readonly ngZone: NgZone) {}
|
constructor(private readonly ngZone: NgZone) {}
|
||||||
@@ -33,8 +46,71 @@ export class EngineService {
|
|||||||
return this.requireInitialized(this.scene);
|
return this.requireInitialized(this.scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCamera(): THREE.PerspectiveCamera {
|
/** The camera the scene is currently drawn through. */
|
||||||
return this.requireInitialized(this.camera);
|
getCamera(): SceneCamera {
|
||||||
|
return this.projection === 'orthographic' ? this.requireInitialized(this.orthographic) : this.requireInitialized(this.perspective);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The perspective camera, whichever is active. For the handful of places that need a field of
|
||||||
|
* view to reason with — framing a system, sizing a star — and that go on meaning the same
|
||||||
|
* thing in either projection because the sizes were tuned against this one.
|
||||||
|
*/
|
||||||
|
getPerspectiveCamera(): THREE.PerspectiveCamera {
|
||||||
|
return this.requireInitialized(this.perspective);
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentProjection(): Projection {
|
||||||
|
return this.projection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches projection, carrying the pose across. The orthographic frustum is sized to show
|
||||||
|
* the same extent at `distanceToTarget` that the perspective camera showed from there, so the
|
||||||
|
* swap changes how the scene is projected and not how much of it is in frame.
|
||||||
|
*/
|
||||||
|
setProjection(projection: Projection, distanceToTarget: number): void {
|
||||||
|
const perspective = this.requireInitialized(this.perspective);
|
||||||
|
const orthographic = this.requireInitialized(this.orthographic);
|
||||||
|
this.projection = projection;
|
||||||
|
|
||||||
|
orthographic.zoom = 1;
|
||||||
|
orthographic.position.copy(perspective.position);
|
||||||
|
orthographic.quaternion.copy(perspective.quaternion);
|
||||||
|
this.frameOrthographic(distanceToTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sizes the orthographic frustum to show, at `distanceToTarget`, what the perspective camera
|
||||||
|
* would show from there. Called every frame while that projection is active, which is what
|
||||||
|
* makes the camera flights work through it: they move the camera, and the frame follows.
|
||||||
|
*
|
||||||
|
* The depth range is symmetric about the camera rather than starting in front of it. An
|
||||||
|
* orthographic camera does not pull back as its frame grows, so at galactic framing the
|
||||||
|
* backdrop shell and half the Milky Way lie behind its own plane and would be clipped away.
|
||||||
|
* A parallel projection has linear depth, so the precision argument that makes a perspective
|
||||||
|
* near plane worth guarding does not apply here.
|
||||||
|
*/
|
||||||
|
frameOrthographic(distanceToTarget: number): void {
|
||||||
|
const perspective = this.requireInitialized(this.perspective);
|
||||||
|
const orthographic = this.requireInitialized(this.orthographic);
|
||||||
|
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((perspective.fov * Math.PI) / 360);
|
||||||
|
orthographic.top = halfHeight;
|
||||||
|
orthographic.bottom = -halfHeight;
|
||||||
|
orthographic.left = -halfHeight * perspective.aspect;
|
||||||
|
orthographic.right = halfHeight * perspective.aspect;
|
||||||
|
orthographic.far = perspective.far;
|
||||||
|
orthographic.near = -perspective.far;
|
||||||
|
orthographic.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half the height of what is in frame at the target, in world units, under either camera. */
|
||||||
|
visibleHalfHeight(distanceToTarget: number): number {
|
||||||
|
if (this.projection === 'orthographic') {
|
||||||
|
const orthographic = this.requireInitialized(this.orthographic);
|
||||||
|
return (orthographic.top - orthographic.bottom) / (2 * orthographic.zoom);
|
||||||
|
}
|
||||||
|
return distanceToTarget * Math.tan((this.requireInitialized(this.perspective).fov * Math.PI) / 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRenderer(): THREE.WebGPURenderer {
|
getRenderer(): THREE.WebGPURenderer {
|
||||||
@@ -52,8 +128,10 @@ export class EngineService {
|
|||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
this.perspective = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||||
this.camera.position.set(0, 0, 5);
|
this.perspective.position.set(0, 0, 5);
|
||||||
|
this.orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||||
|
this.orthographic.position.copy(this.perspective.position);
|
||||||
|
|
||||||
const { width, height } = this.canvasSize();
|
const { width, height } = this.canvasSize();
|
||||||
this.resize(width, height);
|
this.resize(width, height);
|
||||||
@@ -99,11 +177,18 @@ export class EngineService {
|
|||||||
* Updates the camera aspect ratio and renderer drawing buffer size.
|
* Updates the camera aspect ratio and renderer drawing buffer size.
|
||||||
*/
|
*/
|
||||||
resize(width: number, height: number): void {
|
resize(width: number, height: number): void {
|
||||||
if (!this.renderer || !this.camera || width <= 0 || height <= 0) {
|
if (!this.renderer || !this.perspective || !this.orthographic || width <= 0 || height <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.camera.aspect = width / height;
|
const aspect = width / height;
|
||||||
this.camera.updateProjectionMatrix();
|
this.perspective.aspect = aspect;
|
||||||
|
this.perspective.updateProjectionMatrix();
|
||||||
|
// The orthographic frustum keeps its height and re-fits its width, so a window getting wider
|
||||||
|
// shows more to the sides rather than magnifying what was already there.
|
||||||
|
const halfHeight = (this.orthographic.top - this.orthographic.bottom) / 2;
|
||||||
|
this.orthographic.left = -halfHeight * aspect;
|
||||||
|
this.orthographic.right = halfHeight * aspect;
|
||||||
|
this.orthographic.updateProjectionMatrix();
|
||||||
this.renderer.setSize(width, height, false);
|
this.renderer.setSize(width, height, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +201,8 @@ export class EngineService {
|
|||||||
this.renderer?.dispose();
|
this.renderer?.dispose();
|
||||||
this.renderer = undefined;
|
this.renderer = undefined;
|
||||||
this.scene = undefined;
|
this.scene = undefined;
|
||||||
this.camera = undefined;
|
this.perspective = undefined;
|
||||||
|
this.orthographic = undefined;
|
||||||
this.canvas = undefined;
|
this.canvas = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +214,7 @@ export class EngineService {
|
|||||||
callback(deltaSeconds, elapsedSeconds);
|
callback(deltaSeconds, elapsedSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.requireInitialized(this.camera));
|
this.requireInitialized(this.renderer).render(this.requireInitialized(this.scene), this.getCamera());
|
||||||
}
|
}
|
||||||
|
|
||||||
private canvasSize(): { width: number; height: number } {
|
private canvasSize(): { width: number; height: number } {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
@@ -12,7 +12,10 @@ import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBO
|
|||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
|
import { HudDockComponent } from '../hud/hud-dock.component';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
import { buildBodyViewModel } from './body-view-model';
|
import { buildBodyViewModel } from './body-view-model';
|
||||||
import { InfoPanelComponent } from './info-panel.component';
|
import { InfoPanelComponent } from './info-panel.component';
|
||||||
@@ -36,22 +39,42 @@ const GLOW_SCALE = 2.6;
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-body-detail-scene',
|
selector: 'app-body-detail-scene',
|
||||||
providers: [EngineService],
|
providers: [EngineService],
|
||||||
imports: [InfoPanelComponent, RouterLink],
|
imports: [ChevronIconComponent, HudDockComponent, InfoPanelComponent, RouterLink],
|
||||||
template: `
|
template: `
|
||||||
<div class="relative h-full w-full">
|
<div class="relative h-full w-full">
|
||||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||||
@if (viewModel()) {
|
@if (viewModel()) {
|
||||||
<app-info-panel [body]="viewModel()!" />
|
<app-info-panel [body]="viewModel()!" />
|
||||||
} @else if (notFound()) {
|
} @else if (notFound()) {
|
||||||
<div class="absolute top-4 right-4 rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
|
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text">
|
||||||
<p class="mb-2 text-sm">Couldn't find that body.</p>
|
<p class="type-eyebrow text-accent">No record</p>
|
||||||
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a>
|
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="type-label mt-4 inline-flex items-center gap-2 border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<app-chevron-icon class="h-3 w-3" direction="left" />
|
||||||
|
Back to the galaxy
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Search and what has been kept: there is no scene readout here, the info panel is
|
||||||
|
the reading, and the panel's own control is what keeps this body. -->
|
||||||
|
<app-hud-dock (bookmarkChosen)="goToBookmark($event)" />
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
||||||
|
/** A kept place, revisited from this page: a star means leaving it for the map. */
|
||||||
|
goToBookmark(bookmark: Bookmark): void {
|
||||||
|
if (bookmark.kind === 'star') {
|
||||||
|
this.navigationStore.selectStar(Number(bookmark.id));
|
||||||
|
void this.router.navigate(['/']);
|
||||||
|
} else {
|
||||||
|
void this.router.navigate(['/body', String(bookmark.id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||||
|
|
||||||
private controls?: OrbitControls;
|
private controls?: OrbitControls;
|
||||||
@@ -76,6 +99,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private readonly engine: EngineService,
|
private readonly engine: EngineService,
|
||||||
private readonly dataLoader: DataLoaderService,
|
private readonly dataLoader: DataLoaderService,
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
|
private readonly router: Router,
|
||||||
private readonly navigationStore: NavigationStore
|
private readonly navigationStore: NavigationStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +1,137 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { Article, ArticleService } from '../../core/data/article.service';
|
||||||
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
import { bodyReadouts } from './body-readouts';
|
import { bodyReadouts } from './body-readouts';
|
||||||
import { BodyDetailViewModel } from './body-detail.model';
|
import { BodyDetailViewModel } from './body-detail.model';
|
||||||
|
import { ReadoutSectionsComponent } from './readout-sections.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
|
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical
|
||||||
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
|
||||||
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
|
* `BodyDetailSceneComponent` supplies the view model and owns navigation state.
|
||||||
*
|
*
|
||||||
* The rows come from `bodyReadouts`, shared with the system view's object card so the same body
|
* The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the system
|
||||||
* cannot read differently in the two places it can be inspected.
|
* view's object card so the same body cannot read differently in the two places it can be
|
||||||
|
* inspected.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-info-panel',
|
selector: 'app-info-panel',
|
||||||
|
imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
|
<!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. -->
|
||||||
|
<div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="goBack()"
|
(click)="goBack()"
|
||||||
class="mb-3 flex items-center gap-1.5 rounded-md border border-border bg-panel/60 px-3 py-1.5 text-xs tracking-wide text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
|
class="type-label flex w-full items-center gap-2 border-b border-border/40 px-4 py-2 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<app-chevron-icon class="h-3 w-3" direction="left" />
|
||||||
<path d="M15 6l-6 6 6 6" />
|
|
||||||
</svg>
|
|
||||||
System
|
System
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
|
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
||||||
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
<div class="min-w-0 flex-1">
|
||||||
|
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
||||||
|
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
|
||||||
|
[attr.aria-pressed]="bookmarks.has('body', body().id)"
|
||||||
|
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
|
||||||
|
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||||
|
>
|
||||||
|
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
@if (readouts().measured.length) {
|
<app-readout-sections [readouts]="readouts()" />
|
||||||
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
|
||||||
@for (row of readouts().measured; track row.label) {
|
measurement or something derived from one; this is a person's paragraph on another
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
site, so it says whose and links back to it. -->
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
@if (article(); as found) {
|
||||||
|
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
|
||||||
|
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
|
||||||
|
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
|
||||||
|
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
|
||||||
|
<a
|
||||||
|
[href]="found.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||||
|
>Wikipedia · {{ found.language }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
} @else if (aboutState() !== 'idle') {
|
||||||
|
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
|
||||||
|
@switch (aboutState()) {
|
||||||
|
@case ('loading') {
|
||||||
|
Asking Wikipedia…
|
||||||
|
}
|
||||||
|
@case ('none') {
|
||||||
|
Wikipedia has no article on {{ body().name }}.
|
||||||
|
}
|
||||||
|
@case ('unavailable') {
|
||||||
|
Wikipedia could not be reached.
|
||||||
|
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</dl>
|
</p>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="loadArticle()"
|
||||||
|
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
|
|
||||||
@for (row of readouts().derived; track row.label) {
|
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class InfoPanelComponent {
|
export class InfoPanelComponent {
|
||||||
readonly body = input.required<BodyDetailViewModel>();
|
readonly body = input.required<BodyDetailViewModel>();
|
||||||
|
|
||||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
|
||||||
constructor(private readonly router: Router) {}
|
private readonly articles = inject(ArticleService);
|
||||||
|
|
||||||
|
readonly article = signal<Article | null>(null);
|
||||||
|
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
|
||||||
|
|
||||||
|
constructor(private readonly router: Router) {
|
||||||
|
// The panel is reused as the route's parameter changes, so what was asked about one body
|
||||||
|
// must not still be showing under the next one's name.
|
||||||
|
effect(() => {
|
||||||
|
this.body();
|
||||||
|
this.article.set(null);
|
||||||
|
this.aboutState.set('idle');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the article, on the press and not before. The kind goes with the name because
|
||||||
|
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
|
||||||
|
*/
|
||||||
|
async loadArticle(): Promise<void> {
|
||||||
|
this.aboutState.set('loading');
|
||||||
|
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
|
||||||
|
if (result.status === 'found') {
|
||||||
|
this.article.set(result.article);
|
||||||
|
this.aboutState.set('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.article.set(null);
|
||||||
|
this.aboutState.set(result.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
import { BodyReadouts } from './body-readouts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Measured/Derived rows and the provenance footnote, shared by the detail page's info panel
|
||||||
|
* and the system view's object card. `bodyReadouts` already guarantees the two surfaces agree
|
||||||
|
* on the numbers; this guarantees they agree on the pixels — the markup used to be pasted four
|
||||||
|
* times across the two templates, which is exactly the drift the shared data model exists to
|
||||||
|
* prevent.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-readout-sections',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
@if (readouts().measured.length) {
|
||||||
|
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Measured</p>
|
||||||
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().measured; track row.label) {
|
||||||
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
|
<dt class="type-label text-muted">{{ row.label }}</dt>
|
||||||
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Derived</p>
|
||||||
|
<dl class="divide-y divide-border/25">
|
||||||
|
@for (row of readouts().derived; track row.label) {
|
||||||
|
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
|
||||||
|
<dt class="type-label text-muted">{{ row.label }}</dt>
|
||||||
|
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ReadoutSectionsComponent {
|
||||||
|
readonly readouts = input.required<BodyReadouts>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* How a size in pixels becomes a size the scene can draw.
|
||||||
|
*
|
||||||
|
* Sprites in this view are sized angularly rather than in world units, so a star holds the same
|
||||||
|
* share of the screen however far away it is and whatever the window is doing. Pixels are what
|
||||||
|
* the figures are chosen in, though — "a star is between one and a half and six pixels across"
|
||||||
|
* is a statement someone can check by looking — so the two are related through a reference
|
||||||
|
* viewport and field of view, and the pixel figures are exact only at that height.
|
||||||
|
*
|
||||||
|
* Shared rather than restated per renderer: the star field and the rings drawn over it have to
|
||||||
|
* agree, or a ring sits a little wide of the star it belongs to at some window sizes and not at
|
||||||
|
* others.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
||||||
|
export const REFERENCE_FOV_DEGREES = 55;
|
||||||
|
|
||||||
|
/** Multiply a size in reference pixels by this to get the angular size the material wants. */
|
||||||
|
export const PIXELS_TO_ANGULAR_SIZE = (2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
export interface CameraPose {
|
export interface CameraPose {
|
||||||
@@ -27,7 +29,7 @@ export class CameraRigController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly camera: THREE.PerspectiveCamera,
|
private readonly camera: SceneCamera,
|
||||||
private readonly controls: OrbitControls
|
private readonly controls: OrbitControls
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ const EARTH: BodyRecord = {
|
|||||||
class FakeEngineService {
|
class FakeEngineService {
|
||||||
private readonly scene = new THREE.Scene();
|
private readonly scene = new THREE.Scene();
|
||||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
||||||
|
private readonly orthographic = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||||
private readonly tickCallbacks = new Set<EngineTickCallback>();
|
private readonly tickCallbacks = new Set<EngineTickCallback>();
|
||||||
|
projection: 'perspective' | 'orthographic' = 'perspective';
|
||||||
|
|
||||||
get isInitialized(): boolean {
|
get isInitialized(): boolean {
|
||||||
return true;
|
return true;
|
||||||
@@ -78,10 +80,37 @@ class FakeEngineService {
|
|||||||
return this.scene;
|
return this.scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCamera(): THREE.PerspectiveCamera {
|
getCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
|
||||||
|
return this.projection === 'orthographic' ? this.orthographic : this.camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPerspectiveCamera(): THREE.PerspectiveCamera {
|
||||||
return this.camera;
|
return this.camera;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get currentProjection(): 'perspective' | 'orthographic' {
|
||||||
|
return this.projection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjection(projection: 'perspective' | 'orthographic', distanceToTarget: number): void {
|
||||||
|
this.projection = projection;
|
||||||
|
const halfHeight = Math.max(distanceToTarget, 1e-6) * Math.tan((this.camera.fov * Math.PI) / 360);
|
||||||
|
this.orthographic.top = halfHeight;
|
||||||
|
this.orthographic.bottom = -halfHeight;
|
||||||
|
this.orthographic.left = -halfHeight * this.camera.aspect;
|
||||||
|
this.orthographic.right = halfHeight * this.camera.aspect;
|
||||||
|
this.orthographic.zoom = 1;
|
||||||
|
this.orthographic.position.copy(this.camera.position);
|
||||||
|
this.orthographic.quaternion.copy(this.camera.quaternion);
|
||||||
|
this.orthographic.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleHalfHeight(distanceToTarget: number): number {
|
||||||
|
return this.projection === 'orthographic'
|
||||||
|
? (this.orthographic.top - this.orthographic.bottom) / (2 * this.orthographic.zoom)
|
||||||
|
: distanceToTarget * Math.tan((this.camera.fov * Math.PI) / 360);
|
||||||
|
}
|
||||||
|
|
||||||
onTick(callback: EngineTickCallback): () => void {
|
onTick(callback: EngineTickCallback): () => void {
|
||||||
this.tickCallbacks.add(callback);
|
this.tickCallbacks.add(callback);
|
||||||
return () => this.tickCallbacks.delete(callback);
|
return () => this.tickCallbacks.delete(callback);
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { AfterViewInit, Component, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
import { AfterViewInit, Component, computed, effect, ElementRef, OnDestroy, signal, viewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
import { dateToJulianDate } from '../../shared/astro/constants';
|
import { dateToJulianDate } from '../../shared/astro/constants';
|
||||||
import { galacticCentrePositionPc, galacticToEquatorial, MILKY_WAY_ARMS, SUN_GALACTOCENTRIC_RADIUS_PC } from '../../shared/astro/galaxy';
|
import {
|
||||||
|
GALACTIC_BASIS_EQUATORIAL,
|
||||||
|
MILKY_WAY_ARMS,
|
||||||
|
SUN_GALACTOCENTRIC_RADIUS_PC,
|
||||||
|
galacticCentrePositionPc,
|
||||||
|
galacticToEquatorial
|
||||||
|
} from '../../shared/astro/galaxy';
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { EngineService } from '../../core/engine/engine.service';
|
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { BodyRecord } from '../../shared/models/body.model';
|
import { BodyRecord } from '../../shared/models/body.model';
|
||||||
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
import { DeepSkyRecord } from '../../shared/models/deepsky.model';
|
||||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||||
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox';
|
||||||
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||||
|
import { isDesignation } from '../../shared/models/star-catalog';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||||
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
||||||
import { CameraRigController } from './camera-rig-controller';
|
import { CameraRigController } from './camera-rig-controller';
|
||||||
import { DeepSkyRenderer } from './deep-sky-renderer';
|
import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||||
@@ -22,10 +30,18 @@ import { starGlowExtentAu, starMarkerRadiusAu, systemFrameRadiusAu, systemFramin
|
|||||||
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
|
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
|
||||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
|
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
|
||||||
import { HudReadout, StarmapHudComponent } from './starmap-hud.component';
|
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent, HudReadout } from '../hud/hud-dock.component';
|
||||||
|
import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.component';
|
||||||
|
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||||
|
import { StarmapHudComponent } from './starmap-hud.component';
|
||||||
import { SystemObjectCardComponent } from './system-object-card.component';
|
import { SystemObjectCardComponent } from './system-object-card.component';
|
||||||
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
import { colorIndexToRgb, StarFieldRenderer, starRenderBudgetFromUrl } from './star-field-renderer';
|
||||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
import { collectJumpLinks, minimumRangeBetween, routeBetween } from '../../shared/astro/jump-links';
|
||||||
|
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
|
||||||
|
import { HostStarRings } from './host-star-rings';
|
||||||
|
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||||
|
import { ReservedBox, ringPlacement } from './label-ring';
|
||||||
|
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
|
||||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||||
|
|
||||||
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
||||||
@@ -48,6 +64,47 @@ const LABEL_MAX_COUNT = 15;
|
|||||||
* Nearer stars win the space; see `spreadLabels`.
|
* Nearer stars win the space; see `spreadLabels`.
|
||||||
*/
|
*/
|
||||||
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
const LABEL_MIN_SEPARATION_NDC = 0.12;
|
||||||
|
/** Beyond this the text of a right-hand label would run off the view: hang it on the left. */
|
||||||
|
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 long the range control has to be still before the graph is rebuilt at its value. */
|
||||||
|
const JUMP_LINK_REBUILD_DELAY_MS = 250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far in or out the plan view may be zoomed from the extent its distance frames. Under a
|
||||||
|
* parallel projection the wheel changes the frame rather than the distance, so the orbit limits
|
||||||
|
* stop applying and this is what stands in for them.
|
||||||
|
*/
|
||||||
|
const PLAN_ZOOM_SPAN = 64;
|
||||||
|
|
||||||
|
/** How many matches each routing field offers, and how little may be typed to get any. */
|
||||||
|
const ROUTE_OPTION_COUNT = 6;
|
||||||
|
const MIN_ROUTE_QUERY_LENGTH = 2;
|
||||||
|
/**
|
||||||
|
* The widest crossing `minimumRangeBetween` will consider when saying what a route would need.
|
||||||
|
* Beyond this the catalogue is one component and the answer stops being informative.
|
||||||
|
*/
|
||||||
|
const ROUTE_RANGE_CEILING_PC = 30;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
/**
|
/**
|
||||||
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
* How many deep-sky objects get a permanent label. These sit on a fixed backdrop shell rather
|
||||||
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
* than near the camera, so proximity is meaningless for them — the brightest handful are simply
|
||||||
@@ -151,30 +208,62 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-galaxy-system-scene',
|
selector: 'app-galaxy-system-scene',
|
||||||
providers: [EngineService],
|
providers: [EngineService],
|
||||||
imports: [StarmapHudComponent, SystemObjectCardComponent],
|
imports: [HudDockComponent, StarmapHudComponent, SystemObjectCardComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="relative h-full w-full">
|
<div class="relative h-full w-full">
|
||||||
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
|
||||||
<div #labelHost class="absolute inset-0 overflow-hidden pointer-events-none"></div>
|
<!-- Between the canvas and the labels, not up in the HUD where it used to live: everything
|
||||||
<app-starmap-hud
|
in this stack paints in tree order, so from there a decorative gradient was laid over
|
||||||
[level]="navigationStore.viewLevel()"
|
the names near the edge of the frame — which is exactly where the neighbour ring is. -->
|
||||||
|
<div aria-hidden="true" class="hud-vignette pointer-events-none absolute inset-0"></div>
|
||||||
|
<!-- isolate: CSS2DRenderer gives every label its own z-index for depth ordering; without a
|
||||||
|
stacking context here those indices escape and the labels paint over the HUD. -->
|
||||||
|
<div #labelHost class="pointer-events-none absolute inset-0 isolate overflow-hidden"></div>
|
||||||
|
<!-- Leader from the selected body to its card, drawn in screen space and repositioned in
|
||||||
|
the render loop; visibility is toggled there too, so no change detection per frame. -->
|
||||||
|
<svg aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full text-accent/60">
|
||||||
|
<line #leader x1="0" y1="0" x2="0" y2="0" stroke="currentColor" stroke-width="1" visibility="hidden" />
|
||||||
|
</svg>
|
||||||
|
<app-starmap-hud [level]="navigationStore.viewLevel()" [title]="hudTitle()" (levelSelected)="goToLevel($event)" />
|
||||||
|
@if (objectCard(); as card) {
|
||||||
|
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
|
||||||
|
}
|
||||||
|
<app-hud-dock
|
||||||
[eyebrow]="hudEyebrow()"
|
[eyebrow]="hudEyebrow()"
|
||||||
[title]="hudTitle()"
|
[title]="hudTitle()"
|
||||||
[subtitle]="hudSubtitle()"
|
[subtitle]="hudSubtitle()"
|
||||||
[readouts]="hudReadouts()"
|
[readouts]="hudReadouts()"
|
||||||
[note]="hudNote()"
|
[note]="hudNote()"
|
||||||
[range]="hudRange()"
|
[range]="hudRange()"
|
||||||
(levelSelected)="goToLevel($event)"
|
[display]="display()"
|
||||||
|
[routing]="true"
|
||||||
|
[routeResult]="routeResult()"
|
||||||
|
[routeOptions]="routeOptions()"
|
||||||
|
[currentStar]="currentStarOption()"
|
||||||
|
[keepableStarId]="navigationStore.selectedStarId()"
|
||||||
|
defaultTab="readout"
|
||||||
|
(displayChange)="display.set($event)"
|
||||||
|
(routeQuery)="onRouteQuery($event)"
|
||||||
|
(routeRequested)="onRouteRequested($event)"
|
||||||
|
(routeStarSelected)="navigationStore.selectStar($event)"
|
||||||
|
(jumpRangeChange)="jumpRangePc.set($event)"
|
||||||
|
(bookmarkChosen)="goToBookmark($event)"
|
||||||
/>
|
/>
|
||||||
@if (objectCard(); as card) {
|
|
||||||
<app-system-object-card [body]="card" (dismissed)="dismissObjectCard()" (openRequested)="openObjectDetail(card.id)" />
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||||
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
private readonly canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
|
||||||
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
|
private readonly labelHostRef = viewChild.required<ElementRef<HTMLDivElement>>('labelHost');
|
||||||
|
private readonly leaderRef = viewChild.required<ElementRef<SVGLineElement>>('leader');
|
||||||
|
private readonly objectCardRef = viewChild<SystemObjectCardComponent, ElementRef<HTMLElement>>(SystemObjectCardComponent, { read: ElementRef });
|
||||||
|
private readonly dockRef = viewChild<HudDockComponent, ElementRef<HTMLElement>>(HudDockComponent, { read: ElementRef });
|
||||||
|
/**
|
||||||
|
* The card's own box, looked up when the card changes rather than in the render loop that
|
||||||
|
* draws the leader to it. The host element is a stable wrapper; the panel inside it is what
|
||||||
|
* moves, and it is only replaced when a different body is selected.
|
||||||
|
*/
|
||||||
|
private readonly objectCardElement = computed(() => this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]') ?? null);
|
||||||
|
|
||||||
private readonly raycaster = new THREE.Raycaster();
|
private readonly raycaster = new THREE.Raycaster();
|
||||||
private readonly galaxyGroup = new THREE.Group();
|
private readonly galaxyGroup = new THREE.Group();
|
||||||
@@ -190,6 +279,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
readonly hudReadouts = signal<readonly HudReadout[]>([]);
|
readonly hudReadouts = signal<readonly HudReadout[]>([]);
|
||||||
readonly hudNote = signal('');
|
readonly hudNote = signal('');
|
||||||
readonly hudRange = signal('');
|
readonly hudRange = signal('');
|
||||||
|
/** Which layers are drawn, as toggled from the dock. Applied by `applyDisplay`. */
|
||||||
|
readonly display = signal<HudDisplay>(DEFAULT_HUD_DISPLAY);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
|
* The body whose card is showing: whichever is pinned by a click, else whatever the pointer is
|
||||||
@@ -199,14 +290,58 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private enterableSystems = 0;
|
private enterableSystems = 0;
|
||||||
private pinnedBodyId: string | null = null;
|
private pinnedBodyId: string | null = null;
|
||||||
private hoveredBodyId: string | null = null;
|
private hoveredBodyId: string | null = null;
|
||||||
|
/** The body the card is about — what the selection mark brackets and the leader line leaves. */
|
||||||
|
private cardBodyId: string | null = null;
|
||||||
|
|
||||||
private controls?: OrbitControls;
|
private controls?: OrbitControls;
|
||||||
private rig?: CameraRigController;
|
private rig?: CameraRigController;
|
||||||
private starField?: StarFieldRenderer;
|
private starField?: StarFieldRenderer;
|
||||||
|
private hostRings?: HostStarRings;
|
||||||
|
/** Proximity over the whole catalogue, built once; the neighbour labels are one query on it. */
|
||||||
|
private neighbourhood?: StarNeighbourhood;
|
||||||
|
private jumpLinks?: JumpLinkRenderer;
|
||||||
|
/** How far a single crossing may be. Drives both the drawn graph and the route walked on it. */
|
||||||
|
readonly jumpRangePc = signal(3);
|
||||||
|
readonly routeResult = signal<RouteResult | null>(null);
|
||||||
|
/**
|
||||||
|
* Matches for whichever routing field is being typed into. Stars only: a route is a chain of
|
||||||
|
* stars, and offering a moon as a destination would be offering a place that leads nowhere.
|
||||||
|
*
|
||||||
|
* Derived rather than assigned, because the two things it needs arrive in either order — the
|
||||||
|
* catalogue is still loading when the dock is already up, and a query typed before it lands
|
||||||
|
* used to return nothing and stay nothing until the next keystroke.
|
||||||
|
*/
|
||||||
|
readonly routeOptions = computed<readonly RouteStarOption[]>(() => {
|
||||||
|
const query = this.routeQuery().trim();
|
||||||
|
const index = this.starSearchIndex();
|
||||||
|
if (query.length < MIN_ROUTE_QUERY_LENGTH || index.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return rankSearchResults(index, query, ROUTE_OPTION_COUNT).flatMap((entry) =>
|
||||||
|
entry.starId === undefined ? [] : [{ id: entry.starId, name: entry.name, subtitle: entry.subtitle }]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
private readonly routeQuery = signal('');
|
||||||
|
/** The range the drawn graph was last built at, so a redraw is skipped when nothing moved. */
|
||||||
|
private drawnJumpRangePc: number | null = null;
|
||||||
|
private jumpLinkRebuild?: ReturnType<typeof setTimeout>;
|
||||||
|
/** The current system's neighbours, resolved on arrival: id, name, distance and bearing. */
|
||||||
|
private neighbours: readonly { star: StarRecord; distancePc: number; direction: THREE.Vector3 }[] = [];
|
||||||
|
/**
|
||||||
|
* The HUD boxes the ring prints around, read on the label pass rather than per frame: each
|
||||||
|
* read is a forced layout, and the panels move when a tab is switched, not between frames.
|
||||||
|
*/
|
||||||
|
private reserved: readonly ReservedBox[] = [];
|
||||||
|
/** Scratch for the per-frame ring maths, so holding the ring still allocates nothing. */
|
||||||
|
private readonly ringBearing = new THREE.Vector3();
|
||||||
|
private readonly ringInverse = new THREE.Quaternion();
|
||||||
|
private readonly ringPoint = new THREE.Vector3();
|
||||||
private deepSky?: DeepSkyRenderer;
|
private deepSky?: DeepSkyRenderer;
|
||||||
private deepSkyLabels: readonly LabeledPoint[] = [];
|
private deepSkyLabels: readonly LabeledPoint[] = [];
|
||||||
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
/** Stars with at least one catalogued body, which are the ones the map can be flown into. */
|
||||||
private starIdsWithBodies = new Set<number>();
|
private starIdsWithBodies = new Set<number>();
|
||||||
|
/** Stars alone, normalised once, for the two routing fields. Empty until the catalogue lands. */
|
||||||
|
private readonly starSearchIndex = signal<IndexedSearchEntry[]>([]);
|
||||||
private milkyWay?: MilkyWayRenderer;
|
private milkyWay?: MilkyWayRenderer;
|
||||||
private galacticLabels: readonly LabeledPoint[] = [];
|
private galacticLabels: readonly LabeledPoint[] = [];
|
||||||
private galacticGrid?: PolarGridPlane;
|
private galacticGrid?: PolarGridPlane;
|
||||||
@@ -246,6 +381,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.reconcileSelection(selectedStarId);
|
this.reconcileSelection(selectedStarId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
effect(() => this.applyDisplay(this.display()));
|
||||||
|
effect(() => this.applyProjection(this.display().plan));
|
||||||
|
// Reads both signals, so flipping the layer on and dragging the range each land here. The
|
||||||
|
// rebuild is a quarter-second of walking the catalogue, and the range control emits per
|
||||||
|
// pixel dragged, so it waits for the hand to settle rather than running once per pixel.
|
||||||
|
effect(() => {
|
||||||
|
this.jumpRangePc();
|
||||||
|
this.display().jumpLinks;
|
||||||
|
clearTimeout(this.jumpLinkRebuild);
|
||||||
|
this.jumpLinkRebuild = setTimeout(() => this.refreshJumpLinks(), JUMP_LINK_REBUILD_DELAY_MS);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@@ -260,6 +406,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
|
this.canvasRef().nativeElement.removeEventListener('pointermove', this.handlePointerMove);
|
||||||
this.controls?.dispose();
|
this.controls?.dispose();
|
||||||
this.starField?.dispose();
|
this.starField?.dispose();
|
||||||
|
this.hostRings?.dispose();
|
||||||
|
this.jumpLinks?.dispose();
|
||||||
|
clearTimeout(this.jumpLinkRebuild);
|
||||||
this.deepSky?.dispose();
|
this.deepSky?.dispose();
|
||||||
this.milkyWay?.dispose();
|
this.milkyWay?.dispose();
|
||||||
this.galacticGrid?.dispose();
|
this.galacticGrid?.dispose();
|
||||||
@@ -317,9 +466,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
const scene = this.engine.getScene();
|
const scene = this.engine.getScene();
|
||||||
const camera = this.engine.getCamera();
|
const camera = this.engine.getCamera();
|
||||||
camera.position.copy(GALAXY_OVERVIEW_POSITION);
|
camera.position.copy(GALAXY_OVERVIEW_POSITION);
|
||||||
camera.near = GALAXY_NEAR_PC;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = GALAXY_FAR_PC;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// put the astronomical-unit range on one that overwrites it, and the system clipped.
|
||||||
|
const depthCamera = this.engine.getPerspectiveCamera();
|
||||||
|
depthCamera.near = GALAXY_NEAR_PC;
|
||||||
|
depthCamera.far = GALAXY_FAR_PC;
|
||||||
|
depthCamera.updateProjectionMatrix();
|
||||||
|
|
||||||
this.controls = new OrbitControls(camera, canvas);
|
this.controls = new OrbitControls(camera, canvas);
|
||||||
this.controls.enableDamping = true;
|
this.controls.enableDamping = true;
|
||||||
@@ -346,6 +499,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
]);
|
]);
|
||||||
this.stars = stars;
|
this.stars = stars;
|
||||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||||
|
this.neighbourhood = new StarNeighbourhood(stars);
|
||||||
|
this.starSearchIndex.set(
|
||||||
|
buildSearchIndex(stars.map((star) => ({ kind: 'star' as const, name: star.name, subtitle: star.spectralType, starId: star.id })))
|
||||||
|
);
|
||||||
this.bodies = bodies;
|
this.bodies = bodies;
|
||||||
this.exoplanets = exoplanets;
|
this.exoplanets = exoplanets;
|
||||||
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
|
// Which stars can be flown into: those with catalogued bodies of their own, plus the Sun.
|
||||||
@@ -358,6 +515,10 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
this.starField = new StarFieldRenderer(stars, positions, starRenderBudgetFromUrl(window.location.search));
|
||||||
this.galaxyGroup.add(this.starField.object);
|
this.galaxyGroup.add(this.starField.object);
|
||||||
|
this.hostRings = new HostStarRings(stars.filter((star) => this.starIdsWithBodies.has(star.id)), HUD_ACCENT);
|
||||||
|
this.galaxyGroup.add(this.hostRings.object);
|
||||||
|
this.jumpLinks = new JumpLinkRenderer(HUD_ACCENT);
|
||||||
|
this.galaxyGroup.add(this.jumpLinks.object);
|
||||||
|
|
||||||
this.milkyWay = new MilkyWayRenderer();
|
this.milkyWay = new MilkyWayRenderer();
|
||||||
this.galacticLabels = this.milkyWay.labelPoints();
|
this.galacticLabels = this.milkyWay.labelPoints();
|
||||||
@@ -391,8 +552,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
|
this.deepSkyLabels = this.deepSky.labelPoints(DEEP_SKY_LABEL_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.labelOverlay = new StarLabelOverlay(scene);
|
// A neighbour's label offers to fly there, and goes through the store like every other way
|
||||||
|
// of choosing a star — so a label click, a search hit and an in-scene click are one path.
|
||||||
|
this.labelOverlay = new StarLabelOverlay(scene, (starId) => this.navigationStore.selectStar(starId));
|
||||||
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
|
this.labelHostRef().nativeElement.appendChild(this.labelOverlay.domElement);
|
||||||
|
this.applyDisplay(this.display());
|
||||||
const { width, height } = canvas.getBoundingClientRect();
|
const { width, height } = canvas.getBoundingClientRect();
|
||||||
this.labelOverlay.setSize(width, height);
|
this.labelOverlay.setSize(width, height);
|
||||||
|
|
||||||
@@ -401,15 +565,18 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
canvas.addEventListener('pointermove', this.handlePointerMove);
|
canvas.addEventListener('pointermove', this.handlePointerMove);
|
||||||
this.observeResize(canvas);
|
this.observeResize(canvas);
|
||||||
|
|
||||||
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(camera, deltaSeconds));
|
// Asked for per frame rather than captured: the projection can be swapped underneath, and a
|
||||||
|
// frame computed against one camera and drawn through the other puts every label off its star.
|
||||||
|
this.unsubscribeTick = this.engine.onTick((deltaSeconds) => this.tick(this.engine.getCamera(), deltaSeconds));
|
||||||
this.engine.start();
|
this.engine.start();
|
||||||
|
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this.reconcileSelection(this.navigationStore.selectedStarId());
|
this.reconcileSelection(this.navigationStore.selectedStarId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private tick(camera: THREE.PerspectiveCamera, deltaSeconds: number): void {
|
private tick(camera: SceneCamera, deltaSeconds: number): void {
|
||||||
this.rig?.update(deltaSeconds);
|
this.rig?.update(deltaSeconds);
|
||||||
|
this.frameProjection(camera);
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
|
|
||||||
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
// Gated on the galaxy group rather than on `currentStarId`, which is only assigned once the
|
||||||
@@ -436,6 +603,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
if (this.systemGroup.visible) {
|
if (this.systemGroup.visible) {
|
||||||
this.systemRenderer?.update(dateToJulianDate());
|
this.systemRenderer?.update(dateToJulianDate());
|
||||||
}
|
}
|
||||||
|
this.updateSelectionMark(camera);
|
||||||
|
this.updateNeighbourRing(camera);
|
||||||
this.labelOverlay?.render(camera);
|
this.labelOverlay?.render(camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,24 +614,34 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
|
* has pulled back from the Sun, so the scale ladder reports where the view already is instead
|
||||||
* of switching it.
|
* of switching it.
|
||||||
*/
|
*/
|
||||||
private updateGalacticCrossfade(camera: THREE.PerspectiveCamera): void {
|
private updateGalacticCrossfade(camera: SceneCamera): void {
|
||||||
if (!this.milkyWay) {
|
if (!this.milkyWay) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distancePc = camera.position.length();
|
// How much of the Galaxy is in frame, expressed as the distance a perspective camera would
|
||||||
|
// have to be at to show that much. Under a plan view the camera's own distance says nothing
|
||||||
|
// about the extent — the frustum does — so reading `position.length()` there would report a
|
||||||
|
// fixed scale however far the view was zoomed.
|
||||||
|
const distancePc = this.effectiveDistance(camera);
|
||||||
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
this.galacticStrength = this.milkyWay.setViewerDistancePc(distancePc);
|
||||||
|
|
||||||
this.galacticGrid?.setStrength(this.galacticStrength);
|
// Layer toggles from the dock fold in here rather than as a one-off `visible = false`:
|
||||||
this.localGrid?.setStrength(1 - this.galacticStrength);
|
// `setStrength` rewrites visibility every frame from the strength it is given, so a hidden
|
||||||
this.tethers?.setStrength(1 - this.galacticStrength);
|
// layer has to be told a strength of zero every frame too.
|
||||||
|
const display = this.display();
|
||||||
|
this.galacticGrid?.setStrength(display.grid ? this.galacticStrength : 0);
|
||||||
|
this.localGrid?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
|
||||||
|
this.tethers?.setStrength(display.grid ? 1 - this.galacticStrength : 0);
|
||||||
|
this.hostRings?.setStrength(display.systems ? 1 - this.galacticStrength : 0);
|
||||||
|
this.jumpLinks?.setStrength(display.jumpLinks ? 1 - this.galacticStrength : 0);
|
||||||
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
// The backdrop shell is the sky as seen from here; from outside it, it is a wall.
|
||||||
this.deepSky?.setStrength(1 - this.galacticStrength);
|
this.deepSky?.setStrength(display.deepSky ? 1 - this.galacticStrength : 0);
|
||||||
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
|
// Same argument for the skybox, and more sharply: it is a photograph of the Milky Way taken
|
||||||
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
|
// from inside it, so it cannot also be the sky behind a view of the Galaxy from outside.
|
||||||
this.engine.getScene().backgroundIntensity = 1 - this.galacticStrength;
|
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
|
||||||
|
|
||||||
this.applyGalaxyDepthRange(camera, distancePc);
|
this.applyGalaxyDepthRange(distancePc);
|
||||||
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
|
const level: ViewLevel = this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD ? 'galactic' : 'galaxy';
|
||||||
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
if (this.navigationStore.viewLevel() !== level && !this.systemGroup.visible) {
|
||||||
this.navigationStore.setViewLevel(level);
|
this.navigationStore.setViewLevel(level);
|
||||||
@@ -475,26 +654,47 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
|
* and holding the Galaxy needs a far plane a hundred thousand parsecs out, and a projection
|
||||||
* spanning both has no precision left to separate one spiral arm from the next.
|
* spanning both has no precision left to separate one spiral arm from the next.
|
||||||
*/
|
*/
|
||||||
private applyGalaxyDepthRange(camera: THREE.PerspectiveCamera, distancePc: number): void {
|
/**
|
||||||
|
* What "how far back is the camera" means, in either projection. Under perspective it is the
|
||||||
|
* camera's own distance from the origin; under an orthographic one it is the distance a
|
||||||
|
* perspective camera would need to frame the same extent, so everything keyed on it — the
|
||||||
|
* crossfade, the depth range, the scale ladder — goes on meaning what it meant.
|
||||||
|
*/
|
||||||
|
private effectiveDistance(camera: SceneCamera): number {
|
||||||
|
if (this.engine.currentProjection === 'perspective') {
|
||||||
|
return camera.position.length();
|
||||||
|
}
|
||||||
|
const halfHeight = this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET));
|
||||||
|
return halfHeight / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyGalaxyDepthRange(distancePc: number): void {
|
||||||
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
|
const near = THREE.MathUtils.clamp(distancePc / 2000, GALAXY_NEAR_PC, GALACTIC_NEAR_PC);
|
||||||
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
|
const far = THREE.MathUtils.clamp(distancePc * 8, GALAXY_FAR_PC, GALACTIC_FAR_PC);
|
||||||
|
// Written to the perspective camera whichever one is live, because it is the one this range
|
||||||
|
// is reasoned in and the one `frameOrthographic` reads its own from. Skipping it under a plan
|
||||||
|
// view left the far plane wherever it was when the projection changed, so flying out to the
|
||||||
|
// Galaxy from there clipped away most of it.
|
||||||
|
const perspective = this.engine.getPerspectiveCamera();
|
||||||
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
// Only when it has drifted enough to matter, so a slow zoom isn't rebuilding the projection
|
||||||
// matrix on every frame of it.
|
// matrix on every frame of it.
|
||||||
if (Math.abs(near - camera.near) > camera.near * 0.05 || Math.abs(far - camera.far) > camera.far * 0.05) {
|
if (Math.abs(near - perspective.near) > perspective.near * 0.05 || Math.abs(far - perspective.far) > perspective.far * 0.05) {
|
||||||
camera.near = near;
|
perspective.near = near;
|
||||||
camera.far = far;
|
perspective.far = far;
|
||||||
camera.updateProjectionMatrix();
|
perspective.updateProjectionMatrix();
|
||||||
|
// The plan view's own range is symmetric about the camera and derived from this one; see
|
||||||
|
// `frameOrthographic`. It is re-derived every frame, so there is nothing to do here.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLabels(camera: THREE.PerspectiveCamera): void {
|
private updateLabels(camera: SceneCamera): void {
|
||||||
const selectedId = this.navigationStore.selectedStarId();
|
const selectedId = this.navigationStore.selectedStarId();
|
||||||
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
// Measured from what the camera is looking at, not from where it is. Those differ by the
|
||||||
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
// orbit distance, so a camera-relative rule names the stars closest to the near edge of the
|
||||||
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
// view — a ring of labels around the outside of the thing the user is actually looking at.
|
||||||
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
const target = this.controls?.target ?? GALAXY_OVERVIEW_TARGET;
|
||||||
const { x: cx, y: cy, z: cz } = target;
|
const { x: cx, y: cy, z: cz } = target;
|
||||||
const orbitDistance = (this.controls ? camera.position.distanceTo(target) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
const orbitDistance = (this.controls ? this.effectiveDistance(camera) : GALAXY_OVERVIEW_POSITION.length()) * LABEL_RADIUS_TO_ORBIT_DISTANCE;
|
||||||
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
const labelRadius = THREE.MathUtils.clamp(orbitDistance, MIN_LABEL_RADIUS_PC, MAX_LABEL_RADIUS_PC);
|
||||||
const maxDistanceSq = labelRadius * labelRadius;
|
const maxDistanceSq = labelRadius * labelRadius;
|
||||||
|
|
||||||
@@ -551,7 +751,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
|
* `keepId` is exempt from both tests — it is the selection, which is about to be flown to, and
|
||||||
* its label going missing mid-flight reads as the target having been lost.
|
* its label going missing mid-flight reads as the target having been lost.
|
||||||
*/
|
*/
|
||||||
private spreadLabels(candidates: readonly LabeledPoint[], camera: THREE.PerspectiveCamera, keepId: number | string | null): LabeledPoint[] {
|
/** The frame's shape, from the canvas rather than the camera: only one of the two has it. */
|
||||||
|
private viewportAspect(): number {
|
||||||
|
const canvas = this.canvasRef().nativeElement;
|
||||||
|
return canvas.clientHeight > 0 ? canvas.clientWidth / canvas.clientHeight : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spreadLabels(candidates: readonly LabeledPoint[], camera: SceneCamera, keepId: number | string | null): LabeledPoint[] {
|
||||||
const placed: THREE.Vector2[] = [];
|
const placed: THREE.Vector2[] = [];
|
||||||
const chosen: LabeledPoint[] = [];
|
const chosen: LabeledPoint[] = [];
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
@@ -568,18 +774,210 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = new THREE.Vector2(projected.x * camera.aspect, projected.y);
|
const point = new THREE.Vector2(projected.x * this.viewportAspect(), projected.y);
|
||||||
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
if (!isKept && placed.some((other) => other.distanceTo(point) < LABEL_MIN_SEPARATION_NDC)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text hangs on the right unless it would run off the view there, or into the space a
|
||||||
|
// label already placed to the right is using; then it hangs on the left, unless *that*
|
||||||
|
// is off the view. A crowded centre still gets right-hand labels — the separation test
|
||||||
|
// above already keeps them apart.
|
||||||
|
const crowdedRight = placed.some(
|
||||||
|
(other) => other.x > point.x && other.x - point.x < LABEL_REACH_NDC && Math.abs(other.y - point.y) < LABEL_MIN_SEPARATION_NDC
|
||||||
|
);
|
||||||
|
// The body the card is about hangs its label on the left regardless: the leader line to
|
||||||
|
// the card leaves its right, and would otherwise run straight through the text.
|
||||||
|
const side: LabelSide =
|
||||||
|
candidate.id === this.cardBodyId || ((projected.x > LABEL_EDGE_NDC || crowdedRight) && projected.x > -LABEL_EDGE_NDC) ? 'left' : 'right';
|
||||||
|
|
||||||
placed.push(point);
|
placed.push(point);
|
||||||
chosen.push(candidate);
|
chosen.push({ ...candidate, side });
|
||||||
}
|
}
|
||||||
|
|
||||||
return chosen;
|
return chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brackets the body the card is about with the selection arcs, and draws the leader from
|
||||||
|
* their rim to the card's near edge. Screen-space work done here, once per frame, because the
|
||||||
|
* body moves every frame and the card's height depends on its content.
|
||||||
|
*/
|
||||||
|
private updateSelectionMark(camera: SceneCamera): void {
|
||||||
|
const leader = this.leaderRef().nativeElement;
|
||||||
|
const member = this.systemGroup.visible && this.cardBodyId !== null ? this.systemRenderer?.members.find((candidate) => candidate.id === this.cardBodyId) : undefined;
|
||||||
|
if (!member) {
|
||||||
|
this.labelOverlay?.setSelection(null);
|
||||||
|
leader.setAttribute('visibility', 'hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const world = member.marker.getWorldPosition(new THREE.Vector3());
|
||||||
|
this.labelOverlay?.setSelection(world);
|
||||||
|
|
||||||
|
const card = this.objectCardElement();
|
||||||
|
const canvas = this.canvasRef().nativeElement;
|
||||||
|
const projected = world.clone().project(camera);
|
||||||
|
if (!card || projected.z > 1 || projected.z < -1) {
|
||||||
|
leader.setAttribute('visibility', 'hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const cardRect = card.getBoundingClientRect();
|
||||||
|
const fromX = ((projected.x + 1) / 2) * canvas.clientWidth;
|
||||||
|
const fromY = ((1 - projected.y) / 2) * canvas.clientHeight;
|
||||||
|
// The card is top-right: meet its left edge, at the body's height where the edge allows.
|
||||||
|
const toX = cardRect.left - canvasRect.left;
|
||||||
|
const toY = Math.min(Math.max(fromY, cardRect.top - canvasRect.top + 12), cardRect.bottom - canvasRect.top - 12);
|
||||||
|
const dx = toX - fromX;
|
||||||
|
const dy = toY - fromY;
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
if (length <= SELECTION_RADIUS_PX) {
|
||||||
|
leader.setAttribute('visibility', 'hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Start on the arcs' rim, not the body's centre.
|
||||||
|
leader.setAttribute('x1', String(fromX + (dx / length) * SELECTION_RADIUS_PX));
|
||||||
|
leader.setAttribute('y1', String(fromY + (dy / length) * SELECTION_RADIUS_PX));
|
||||||
|
leader.setAttribute('x2', String(toX));
|
||||||
|
leader.setAttribute('y2', String(toY));
|
||||||
|
leader.setAttribute('visibility', 'visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the current system's neighbours once, on arrival. Cleared outside a system. */
|
||||||
|
private resolveNeighbours(): void {
|
||||||
|
const origin = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||||
|
if (!origin || !this.neighbourhood) {
|
||||||
|
this.neighbours = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.neighbours = this.neighbourhood
|
||||||
|
// Asked wide and cut back, because a catalogue holds binary companions as two rows at one
|
||||||
|
// position: a neighbour whose separation rounds to what no separation prints as is not a
|
||||||
|
// place to go, it is the same place. Compared through the formatter rather than against a
|
||||||
|
// hand-picked epsilon, so the rule stays "would print as zero" whatever the formatter does.
|
||||||
|
//
|
||||||
|
// Named stars first, survey designations only where fewer than that are in reach: a ring
|
||||||
|
// that exists to say where you are is not helped by "Gaia DR3 5853498713190525696" —
|
||||||
|
// least of all when that is the same star as the Proxima Centauri printed beside it.
|
||||||
|
.nearestPreferring(origin.id, NEIGHBOUR_COUNT * 2, (point) => {
|
||||||
|
const star = this.starsById.get(point.id);
|
||||||
|
return star !== undefined && !isDesignation(star);
|
||||||
|
})
|
||||||
|
.filter((neighbour) => formatParsecs(neighbour.distancePc) !== formatParsecs(0))
|
||||||
|
.slice(0, NEIGHBOUR_COUNT)
|
||||||
|
.flatMap((neighbour) => {
|
||||||
|
const star = this.starsById.get(neighbour.id);
|
||||||
|
return star
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
star,
|
||||||
|
distancePc: neighbour.distancePc,
|
||||||
|
// A unit vector in the catalogue's parsec frame, which is the same direction in
|
||||||
|
// the system's AU frame: only the scale between the two differs.
|
||||||
|
direction: new THREE.Vector3(star.x - origin.x, star.y - origin.y, star.z - origin.z).normalize()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the HUD surfaces the ring has to print around, as boxes relative to the canvas. */
|
||||||
|
private refreshReservedBoxes(): void {
|
||||||
|
const canvas = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||||
|
const panels = [
|
||||||
|
this.dockRef()?.nativeElement.querySelector('[role="tabpanel"]'),
|
||||||
|
this.dockRef()?.nativeElement.querySelector('[role="tablist"]')?.parentElement,
|
||||||
|
this.objectCardRef()?.nativeElement.querySelector('[data-testid="object-card"]')
|
||||||
|
];
|
||||||
|
this.reserved = panels.flatMap((panel) => {
|
||||||
|
if (!panel) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const box = panel.getBoundingClientRect();
|
||||||
|
return [{ left: box.left - canvas.left, top: box.top - canvas.top, right: box.right - canvas.left, bottom: box.bottom - canvas.top }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a neighbour's name sits: on the ring, at the bearing its own direction lands on —
|
||||||
|
* moved along the ring where a HUD panel already holds that place. `null` where the whole
|
||||||
|
* neighbourhood of that bearing is covered.
|
||||||
|
*/
|
||||||
|
private neighbourRingPosition(camera: SceneCamera, direction: THREE.Vector3): THREE.Vector3 | null {
|
||||||
|
const bearing = this.ringBearing.copy(direction).applyQuaternion(this.ringInverse.copy(camera.quaternion).invert());
|
||||||
|
// A neighbour behind the camera keeps the side it is on, which is still the way to turn to
|
||||||
|
// bring it round.
|
||||||
|
const angle = Math.atan2(bearing.y, bearing.x);
|
||||||
|
const canvas = this.canvasRef().nativeElement;
|
||||||
|
const placed = ringPlacement(angle, NEIGHBOUR_RING_NDC, { width: canvas.clientWidth, height: canvas.clientHeight }, this.reserved);
|
||||||
|
if (!placed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (this.engine.currentProjection === 'orthographic') {
|
||||||
|
// A parallel projection has no vanishing point to walk towards: every ray through the
|
||||||
|
// frame is the view direction, so treating the unprojected offset as one and stepping
|
||||||
|
// along it throws the sideways part away and pulls the whole ring into the middle. The
|
||||||
|
// unprojected point is already where the name goes.
|
||||||
|
return this.ringPoint.set(placed.x, placed.y, 0).unproject(camera);
|
||||||
|
}
|
||||||
|
const along = this.ringPoint.set(placed.x, placed.y, 0.5).unproject(camera).sub(camera.position).normalize();
|
||||||
|
return along.multiplyScalar(NEIGHBOUR_DEPTH_AU).add(camera.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the stars nearest the one the camera is inside, each on the side of the view its own
|
||||||
|
* lies on. It is the one thing a system view cannot otherwise say: which way its neighbours
|
||||||
|
* are, and how far. Each is a button that flies there, so a chain of neighbours can be walked
|
||||||
|
* without pulling back out to the field between hops.
|
||||||
|
*
|
||||||
|
* These are bearings, not sky positions, and are drawn as such: a ring of names at a fixed
|
||||||
|
* radius from the centre of the frame, which reads as instrument rather than as scene. The
|
||||||
|
* true position cannot be drawn — the nearest star to the Sun is 268 000 AU away, thirteen
|
||||||
|
* times the far plane — and a true *direction* is worse than useless here: at this field of
|
||||||
|
* view three neighbours in four fall outside the frame, so the view would name whichever
|
||||||
|
* happened to be in front and stay silent about the rest. What survives is the half of the
|
||||||
|
* direction a viewer can act on: which way to turn to face it.
|
||||||
|
*/
|
||||||
|
private neighbourLabels(camera: SceneCamera): LabeledPoint[] {
|
||||||
|
this.refreshReservedBoxes();
|
||||||
|
return this.neighbours.flatMap(({ star, distancePc, direction }) => {
|
||||||
|
const position = this.neighbourRingPosition(camera, direction);
|
||||||
|
if (!position) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
// Namespaced, so a star's ghost and the same star's own label in the galaxy view are
|
||||||
|
// never the one DOM node being asked to be two different things.
|
||||||
|
id: `neighbour:${star.id}`,
|
||||||
|
name: star.name,
|
||||||
|
kind: formatParsecs(distancePc),
|
||||||
|
tone: 'ghost' as const,
|
||||||
|
selectStarId: star.id,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
z: position.z
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the ring still. The names are placed relative to the camera, so between label passes
|
||||||
|
* — five a second — any camera movement would drag them off the ring and snap them back. This
|
||||||
|
* runs every frame and costs four vector operations.
|
||||||
|
*/
|
||||||
|
private updateNeighbourRing(camera: SceneCamera): void {
|
||||||
|
if (!this.systemGroup.visible || this.neighbours.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { star, direction } of this.neighbours) {
|
||||||
|
const position = this.neighbourRingPosition(camera, direction);
|
||||||
|
if (position) {
|
||||||
|
this.labelOverlay?.moveLabel(`neighbour:${star.id}`, position.x, position.y, position.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names the bodies of the system the view is inside.
|
* Names the bodies of the system the view is inside.
|
||||||
*
|
*
|
||||||
@@ -591,7 +989,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
* Moons are left out entirely: they sit within a marker's width of their planet at system
|
* Moons are left out entirely: they sit within a marker's width of their planet at system
|
||||||
* framing, so their labels could only ever print on top of it.
|
* framing, so their labels could only ever print on top of it.
|
||||||
*/
|
*/
|
||||||
private updateSystemLabels(camera: THREE.PerspectiveCamera): void {
|
private updateSystemLabels(camera: SceneCamera): void {
|
||||||
const renderer = this.systemRenderer;
|
const renderer = this.systemRenderer;
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
this.labelOverlay?.update([]);
|
this.labelOverlay?.update([]);
|
||||||
@@ -629,11 +1027,122 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
|
points.sort((a, b) => b.semiMajorAxisAu - a.semiMajorAxisAu);
|
||||||
this.labelOverlay?.update(this.spreadLabels(points, camera, null));
|
// Bodies first, so a neighbour's name never takes the space one of this system's own would
|
||||||
|
// have had: `spreadLabels` keeps whichever candidate it reaches first.
|
||||||
|
this.labelOverlay?.update(this.spreadLabels([...points, ...this.neighbourLabels(camera)], camera, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
/** Refreshes the readout panel for whichever scale the view is currently at. */
|
||||||
private updateHud(camera: THREE.PerspectiveCamera): void {
|
/** A zoom carried from one unit space into the other would frame nothing recognisable. */
|
||||||
|
private resetZoom(): void {
|
||||||
|
const camera = this.engine.getCamera();
|
||||||
|
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
|
||||||
|
(camera as THREE.OrthographicCamera).zoom = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the plan view's frame, its zoom limits and its star sizes in step with the camera.
|
||||||
|
*
|
||||||
|
* All three are functions of how far the camera is orbiting from its target, which is the one
|
||||||
|
* thing the flights already animate — so a system entered, left or flown between reframes
|
||||||
|
* itself under this projection with the easing the perspective flights have, and no camera
|
||||||
|
* rig knows anything about it.
|
||||||
|
*/
|
||||||
|
private frameProjection(camera: SceneCamera): void {
|
||||||
|
if (!this.controls) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.engine.currentProjection !== 'orthographic') {
|
||||||
|
this.starField?.setProjection(null);
|
||||||
|
this.hostRings?.setProjection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const distance = camera.position.distanceTo(this.controls.target);
|
||||||
|
this.engine.frameOrthographic(distance);
|
||||||
|
// Zoom is what a wheel moves under this projection, so the orbit clamps have to be restated
|
||||||
|
// as the zoom levels that frame the same extents.
|
||||||
|
// A plain multiplier on the frame the distance already sets, bounded by a factor rather than
|
||||||
|
// by the orbit limits: those are in whichever unit space the view is in, and reading them on
|
||||||
|
// the frame the scene swaps from parsecs to astronomical units pins the zoom at the ratio
|
||||||
|
// between the two — which is how leaving a system used to land the view three kiloparsecs out.
|
||||||
|
this.controls.minZoom = 1 / PLAN_ZOOM_SPAN;
|
||||||
|
this.controls.maxZoom = PLAN_ZOOM_SPAN;
|
||||||
|
const halfHeight = this.engine.visibleHalfHeight(distance);
|
||||||
|
this.starField?.setProjection(halfHeight);
|
||||||
|
this.hostRings?.setProjection(halfHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches between the perspective view and the plan: an orthographic projection looking down
|
||||||
|
* the plane the current scale is read against — the galactic plane out here, this system's own
|
||||||
|
* orbital plane inside one.
|
||||||
|
*
|
||||||
|
* Both halves matter and neither alone is "2D". The projection is what makes a circle a circle
|
||||||
|
* wherever it sits in the frame instead of an ellipse that leans away from the centre; the
|
||||||
|
* swing to face the plane is what makes that worth looking at. Orbiting still works afterwards,
|
||||||
|
* so the plan is where a plan view starts, not a cage.
|
||||||
|
*/
|
||||||
|
private applyProjection(plan: boolean): void {
|
||||||
|
if (!this.controls || !this.rig || this.engine.currentProjection === (plan ? 'orthographic' : 'perspective')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const camera = this.engine.getCamera();
|
||||||
|
const target = this.controls.target.clone();
|
||||||
|
const distance = camera.position.distanceTo(target);
|
||||||
|
|
||||||
|
this.engine.setProjection(plan ? 'orthographic' : 'perspective', distance);
|
||||||
|
const next = this.engine.getCamera();
|
||||||
|
// OrbitControls holds one camera for the lifetime of the gesture state it keeps; handing it
|
||||||
|
// the other one keeps the target, the damping and the pointer bindings it already has.
|
||||||
|
this.controls.object = next;
|
||||||
|
this.rig = new CameraRigController(next, this.controls);
|
||||||
|
next.position.copy(camera.position);
|
||||||
|
next.up.copy(camera.up);
|
||||||
|
|
||||||
|
if (plan) {
|
||||||
|
// Straight down the plane's normal, from where the camera already was.
|
||||||
|
// Down the normal of the plane this scale is actually read against. Inside a system that
|
||||||
|
// is the system's own orbital plane; outside it, the galactic plane — whose normal is the
|
||||||
|
// north galactic pole, not the celestial one. Defaulting to the scene's own z would have
|
||||||
|
// looked down the Earth's rotation axis and called it the plane of the Galaxy.
|
||||||
|
const galactic = GALACTIC_BASIS_EQUATORIAL;
|
||||||
|
const inSystem = this.systemGroup.visible && this.systemRenderer;
|
||||||
|
const normal = inSystem
|
||||||
|
? new THREE.Vector3(0, 0, 1).applyQuaternion(this.systemRenderer!.referenceFrame)
|
||||||
|
: new THREE.Vector3(galactic.z.x, galactic.z.y, galactic.z.z);
|
||||||
|
next.position.copy(target).add(normal.multiplyScalar(distance));
|
||||||
|
if (inSystem) {
|
||||||
|
next.up.set(0, 1, 0).applyQuaternion(this.systemRenderer!.referenceFrame);
|
||||||
|
} else {
|
||||||
|
// Towards the galactic centre, so the plan is oriented the way the model is described.
|
||||||
|
next.up.set(galactic.x.x, galactic.x.y, galactic.x.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.lookAt(target);
|
||||||
|
this.controls.update();
|
||||||
|
this.applyDisplay(this.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the layers that hold still between frames: the label layer and the system
|
||||||
|
* view's orbits and grid. The galaxy grids and deep-sky shell are crossfaded every frame
|
||||||
|
* instead, so their toggles live in `updateGalacticCrossfade`. The skybox is both: the
|
||||||
|
* crossfade rewrites its intensity while the galaxy is up, but the crossfade is parked in
|
||||||
|
* system view, where the sky is still on screen — so it is also set here, once, on toggle.
|
||||||
|
*/
|
||||||
|
private applyDisplay(display: HudDisplay): void {
|
||||||
|
if (this.labelOverlay) {
|
||||||
|
this.labelOverlay.domElement.style.display = display.labels ? '' : 'none';
|
||||||
|
}
|
||||||
|
this.systemRenderer?.setLayerVisibility({ orbits: display.orbits, grid: display.grid });
|
||||||
|
// The effect that calls this fires once at construction, before the engine has a scene.
|
||||||
|
if (this.engine.isInitialized) {
|
||||||
|
this.engine.getScene().backgroundIntensity = display.sky ? 1 - this.galacticStrength : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHud(camera: SceneCamera): void {
|
||||||
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
const star = this.currentStarId === null ? undefined : this.starsById.get(this.currentStarId);
|
||||||
|
|
||||||
if (this.systemGroup.visible && star) {
|
if (this.systemGroup.visible && star) {
|
||||||
@@ -653,11 +1162,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
|
...(luminosity !== null ? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }] : [])
|
||||||
]);
|
]);
|
||||||
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
this.hudNote.set('Orbits propagated from published elements to the current date.');
|
||||||
this.hudRange.set(formatAu(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)));
|
this.hudRange.set(formatAu(this.engine.visibleHalfHeight(camera.position.distanceTo(this.controls?.target ?? GALAXY_OVERVIEW_TARGET)) / Math.tan((this.engine.getPerspectiveCamera().fov * Math.PI) / 360)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hudRange.set(formatParsecs(camera.position.length()));
|
this.hudRange.set(formatParsecs(this.effectiveDistance(camera)));
|
||||||
|
|
||||||
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
if (this.galacticStrength >= GALACTIC_LEVEL_THRESHOLD) {
|
||||||
this.hudEyebrow.set('Galactic Scale');
|
this.hudEyebrow.set('Galactic Scale');
|
||||||
@@ -720,13 +1229,13 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): void {
|
private handleGalaxyClick(pointerNdc: THREE.Vector2, camera: SceneCamera): void {
|
||||||
if (!this.starField) {
|
if (!this.starField) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
|
// Screen-space rather than a raycast: the star field billboards in the vertex shader, so
|
||||||
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
|
// its CPU-side geometry is a single quad at the origin. See `StarFieldRenderer.pickAt`.
|
||||||
const starId = this.starField.pickAt(pointerNdc, camera);
|
const starId = this.starField.pickAt(pointerNdc, camera, this.viewportAspect());
|
||||||
if (starId !== undefined) {
|
if (starId !== undefined) {
|
||||||
this.navigationStore.selectStar(starId);
|
this.navigationStore.selectStar(starId);
|
||||||
}
|
}
|
||||||
@@ -780,9 +1289,72 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.refreshObjectCard();
|
this.refreshObjectCard();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Offered as the departure without typing, since it is where the view already is. */
|
||||||
|
readonly currentStarOption = computed<RouteStarOption | null>(() => {
|
||||||
|
const starId = this.navigationStore.selectedStarId();
|
||||||
|
const star = starId === null ? undefined : this.starsById.get(starId);
|
||||||
|
return star ? { id: star.id, name: star.name, subtitle: star.spectralType } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
onRouteQuery(query: string): void {
|
||||||
|
this.routeQuery.set(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the graph, and where it cannot, says what range would. The search is lazy — it asks
|
||||||
|
* the index for a star's neighbours as it reaches that star — so plotting one route never
|
||||||
|
* costs a pass over the catalogue.
|
||||||
|
*/
|
||||||
|
onRouteRequested({ fromId, toId, rangePc }: RouteRequest): void {
|
||||||
|
if (!this.neighbourhood) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = routeBetween(this.neighbourhood, fromId, toId, rangePc);
|
||||||
|
if (route) {
|
||||||
|
this.routeResult.set({
|
||||||
|
stars: route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })),
|
||||||
|
totalPc: route.totalPc,
|
||||||
|
neededRangePc: null
|
||||||
|
});
|
||||||
|
this.jumpLinks?.setRoute(route.stars, (id) => this.starsById.get(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.routeResult.set({
|
||||||
|
stars: [],
|
||||||
|
totalPc: 0,
|
||||||
|
neededRangePc: minimumRangeBetween(this.neighbourhood, fromId, toId, ROUTE_RANGE_CEILING_PC)
|
||||||
|
});
|
||||||
|
this.jumpLinks?.setRoute([], () => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the drawn graph, which is the expensive half: every star's neighbours, once. Only
|
||||||
|
* when the layer is on and the range has actually moved — the control emits per pixel dragged.
|
||||||
|
*/
|
||||||
|
private refreshJumpLinks(): void {
|
||||||
|
if (!this.jumpLinks || !this.neighbourhood) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rangePc = this.jumpRangePc();
|
||||||
|
if (!this.display().jumpLinks) {
|
||||||
|
if (this.drawnJumpRangePc !== null) {
|
||||||
|
this.jumpLinks.setLinks([], () => undefined);
|
||||||
|
this.drawnJumpRangePc = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.drawnJumpRangePc === rangePc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.drawnJumpRangePc = rangePc;
|
||||||
|
const links = collectJumpLinks(this.neighbourhood, rangePc);
|
||||||
|
this.jumpLinks.setLinks(links, (id) => this.starsById.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
|
/** A pinned body wins over a hovered one, so the card does not change under the pointer. */
|
||||||
private refreshObjectCard(): void {
|
private refreshObjectCard(): void {
|
||||||
const id = this.pinnedBodyId ?? this.hoveredBodyId;
|
const id = this.pinnedBodyId ?? this.hoveredBodyId;
|
||||||
|
this.cardBodyId = id;
|
||||||
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
|
this.objectCard.set(id === null ? undefined : buildBodyViewModel(id, { bodies: this.bodies, exoplanets: this.exoplanets, stars: this.stars }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +1362,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
private clearObjectCard(): void {
|
private clearObjectCard(): void {
|
||||||
this.pinnedBodyId = null;
|
this.pinnedBodyId = null;
|
||||||
this.hoveredBodyId = null;
|
this.hoveredBodyId = null;
|
||||||
|
this.cardBodyId = null;
|
||||||
this.objectCard.set(undefined);
|
this.objectCard.set(undefined);
|
||||||
this.canvasRef().nativeElement.style.cursor = '';
|
this.canvasRef().nativeElement.style.cursor = '';
|
||||||
}
|
}
|
||||||
@@ -798,6 +1371,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.clearObjectCard();
|
this.clearObjectCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A kept place, revisited: a star is a system to fly into, a body is a page to open. */
|
||||||
|
goToBookmark(bookmark: Bookmark): void {
|
||||||
|
if (bookmark.kind === 'star') {
|
||||||
|
this.navigationStore.selectStar(Number(bookmark.id));
|
||||||
|
} else {
|
||||||
|
this.openObjectDetail(String(bookmark.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The deliberate step out to the dedicated route, from the card's own control. */
|
/** The deliberate step out to the dedicated route, from the card's own control. */
|
||||||
openObjectDetail(id: string): void {
|
openObjectDetail(id: string): void {
|
||||||
this.navigationStore.selectBody(id);
|
this.navigationStore.selectBody(id);
|
||||||
@@ -878,12 +1460,16 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
const hostLuminosity = luminosityOf(star);
|
const hostLuminosity = luminosityOf(star);
|
||||||
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
this.systemRenderer = new SystemOrbitsRenderer(systemBodies, systemExoplanets, { x: star.x, y: star.y, z: star.z }, hostLuminosity);
|
||||||
this.systemGroup.add(this.systemRenderer.object);
|
this.systemGroup.add(this.systemRenderer.object);
|
||||||
|
this.applyDisplay(this.display());
|
||||||
|
|
||||||
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
|
// Framed against the grid's outer ring rather than the outermost orbit — the ring is always
|
||||||
// the wider of the two — and against the camera this scene actually has, so the margin holds
|
// the wider of the two — and against the camera this scene actually has, so the margin holds
|
||||||
// whatever the window shape. Computed before the star, because how far away the star will be
|
// whatever the window shape. Computed before the star, because how far away the star will be
|
||||||
// seen from is what decides how big its halo has to be to stay visible.
|
// seen from is what decides how big its halo has to be to stay visible.
|
||||||
const viewport = { fovDegrees: camera.fov, aspect: camera.aspect };
|
// Framed against the perspective camera whichever is active: the framing distance is what
|
||||||
|
// the orthographic frustum is then sized from, so both projections show the same extent.
|
||||||
|
const framingCamera = this.engine.getPerspectiveCamera();
|
||||||
|
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
|
||||||
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
|
const framingDistance = systemFramingDistanceAu(this.systemRenderer.gridOuterRadiusAu, viewport);
|
||||||
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
const frameRadiusAu = systemFrameRadiusAu(framingDistance, viewport);
|
||||||
|
|
||||||
@@ -914,12 +1500,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// clumped over the system's star.
|
// clumped over the system's star.
|
||||||
this.labelOverlay?.update([]);
|
this.labelOverlay?.update([]);
|
||||||
|
|
||||||
camera.near = SYSTEM_NEAR_AU;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = SYSTEM_FAR_AU;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// put the astronomical-unit range on one that overwrites it, and the system clipped.
|
||||||
|
const depthCamera = this.engine.getPerspectiveCamera();
|
||||||
|
depthCamera.near = SYSTEM_NEAR_AU;
|
||||||
|
depthCamera.far = SYSTEM_FAR_AU;
|
||||||
|
depthCamera.updateProjectionMatrix();
|
||||||
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
|
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
|
||||||
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
|
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
|
||||||
|
|
||||||
|
this.resetZoom();
|
||||||
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
|
this.rig!.setImmediate({ position: direction.clone().multiplyScalar(SYSTEM_ENTRY_DISTANCE_AU), target: new THREE.Vector3(0, 0, 0) });
|
||||||
|
|
||||||
// Arrives along whichever direction the approach came from, then swings round to look down
|
// Arrives along whichever direction the approach came from, then swings round to look down
|
||||||
@@ -929,6 +1520,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
this.rig!.flyTo({ position: viewDirection.multiplyScalar(framingDistance), target: new THREE.Vector3(0, 0, 0) }, SETTLE_DURATION_SECONDS, () => {
|
||||||
this.currentStarId = star.id;
|
this.currentStarId = star.id;
|
||||||
|
this.resolveNeighbours();
|
||||||
this.navigationStore.setViewLevel('system');
|
this.navigationStore.setViewLevel('system');
|
||||||
onComplete();
|
onComplete();
|
||||||
});
|
});
|
||||||
@@ -963,22 +1555,29 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
|||||||
// into the next system entered.
|
// into the next system entered.
|
||||||
this.clearObjectCard();
|
this.clearObjectCard();
|
||||||
|
|
||||||
camera.near = GALAXY_NEAR_PC;
|
// The perspective camera whichever one is live: it is where the depth range is reasoned,
|
||||||
camera.far = GALAXY_FAR_PC;
|
// and the plan view re-derives its own from it every frame. Writing to the active camera
|
||||||
camera.updateProjectionMatrix();
|
// put the astronomical-unit range on one that overwrites it, and the system clipped.
|
||||||
|
const depthCamera = this.engine.getPerspectiveCamera();
|
||||||
|
depthCamera.near = GALAXY_NEAR_PC;
|
||||||
|
depthCamera.far = GALAXY_FAR_PC;
|
||||||
|
depthCamera.updateProjectionMatrix();
|
||||||
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
|
this.controls!.minDistance = GALAXY_MIN_DISTANCE_PC;
|
||||||
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
|
this.controls!.maxDistance = GALAXY_MAX_DISTANCE_PC;
|
||||||
|
|
||||||
|
this.resetZoom();
|
||||||
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
|
this.rig!.setImmediate({ position: starPc.clone().add(direction.clone().multiplyScalar(GALAXY_APPROACH_DISTANCE_PC)), target: starPc });
|
||||||
|
|
||||||
if (isSwitchingSystems) {
|
if (isSwitchingSystems) {
|
||||||
this.currentStarId = null;
|
this.currentStarId = null;
|
||||||
|
this.resolveNeighbours();
|
||||||
onComplete();
|
onComplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
|
this.rig!.flyTo({ position: GALAXY_OVERVIEW_POSITION.clone(), target: GALAXY_OVERVIEW_TARGET.clone() }, RETURN_DURATION_SECONDS, () => {
|
||||||
this.currentStarId = null;
|
this.currentStarId = null;
|
||||||
|
this.resolveNeighbours();
|
||||||
this.navigationStore.setViewLevel('galaxy');
|
this.navigationStore.setViewLevel('galaxy');
|
||||||
onComplete();
|
onComplete();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
import { color, float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||||
|
|
||||||
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES } from './angular-size';
|
||||||
|
|
||||||
|
/** Ring diameter in screen pixels at the reference viewport — angular, like the star points. */
|
||||||
|
const RING_SIZE_PX = 12;
|
||||||
|
const RING_PEAK_OPACITY = 0.35;
|
||||||
|
/** Ring radius and stroke half-width in quad-uv units (the quad runs 0..1, centre 0.5). */
|
||||||
|
const RING_RADIUS_UV = 0.42;
|
||||||
|
const RING_STROKE_UV = 0.06;
|
||||||
|
|
||||||
|
/** A unit quad centred on the origin — the billboard every ring instance is drawn on. */
|
||||||
|
function createQuadGeometry(instanceCount: number): THREE.InstancedBufferGeometry {
|
||||||
|
const geometry = new THREE.InstancedBufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]), 3));
|
||||||
|
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
|
||||||
|
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
geometry.instanceCount = instanceCount;
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A faint accent ring around every star known to host planets: the one binary fact about a
|
||||||
|
* point of light worth reading at a glance from the neighbourhood view, since it is the one
|
||||||
|
* thing that says "there is somewhere to go here".
|
||||||
|
*
|
||||||
|
* Drawn the way the star field draws its stars — instanced unattenuated sprites — so the rings
|
||||||
|
* sit exactly on the field's own points at any zoom and window size. The ring itself is a band
|
||||||
|
* of the quad's uv distance from centre, not a texture, so it stays a hairline at any scale.
|
||||||
|
*/
|
||||||
|
export class HostStarRings {
|
||||||
|
readonly object: THREE.Mesh;
|
||||||
|
readonly count: number;
|
||||||
|
|
||||||
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
|
private readonly opacity = uniform(RING_PEAK_OPACITY);
|
||||||
|
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||||
|
private readonly perspective = uniform(1);
|
||||||
|
private readonly orthographicScale = uniform(float(0));
|
||||||
|
|
||||||
|
constructor(hosts: readonly StarRecord[], accent: number) {
|
||||||
|
const positions = new Float32Array(hosts.length * 3);
|
||||||
|
hosts.forEach((star, i) => positions.set([star.x, star.y, star.z], i * 3));
|
||||||
|
this.geometry = createQuadGeometry(hosts.length);
|
||||||
|
|
||||||
|
this.material = new THREE.SpriteNodeMaterial({ transparent: true, depthWrite: false });
|
||||||
|
// As in the star field: the angular-to-world conversion is done here rather than by
|
||||||
|
// `sizeAttenuation: false`, which three.js applies only under a perspective camera.
|
||||||
|
this.material.sizeAttenuation = true;
|
||||||
|
const position = instancedBufferAttribute<'vec3'>(new THREE.InstancedBufferAttribute(positions, 3), 'vec3');
|
||||||
|
this.material.positionNode = position;
|
||||||
|
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||||
|
this.material.scaleNode = float(RING_SIZE_PX * PIXELS_TO_ANGULAR_SIZE).mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||||
|
this.material.colorNode = color(accent);
|
||||||
|
// Opaque on the ring's centreline, falling to nothing one stroke-width either side.
|
||||||
|
const distanceFromRing = uv().sub(vec2(0.5)).length().sub(RING_RADIUS_UV).abs();
|
||||||
|
this.material.opacityNode = smoothstep(RING_STROKE_UV, 0.0, distanceFromRing).mul(this.opacity);
|
||||||
|
|
||||||
|
this.object = new THREE.Mesh(this.geometry, this.material);
|
||||||
|
this.object.name = 'host-star-rings';
|
||||||
|
// As for the star field: the quad's bounds say nothing about where the instances are.
|
||||||
|
this.object.frustumCulled = false;
|
||||||
|
this.count = hosts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which projection the rings are drawn under; see `StarFieldRenderer.setProjection`. */
|
||||||
|
setProjection(halfHeightWorld: number | null): void {
|
||||||
|
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||||||
|
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crossfaded with the local grid: from outside the Galaxy the rings are noise. */
|
||||||
|
setStrength(strength: number): void {
|
||||||
|
const clamped = THREE.MathUtils.clamp(strength, 0, 1);
|
||||||
|
this.opacity.value = RING_PEAK_OPACITY * clamped;
|
||||||
|
this.object.visible = clamped > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.geometry.dispose();
|
||||||
|
this.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as THREE from 'three/webgpu';
|
||||||
|
|
||||||
|
import { JumpLink } from '../../shared/astro/jump-links';
|
||||||
|
|
||||||
|
/** Faint, because there are tens of thousands of them and none is worth reading on its own. */
|
||||||
|
const LINK_OPACITY = 0.16;
|
||||||
|
/** The one route is the figure; the graph it is drawn on is the ground. */
|
||||||
|
const ROUTE_OPACITY = 0.9;
|
||||||
|
/**
|
||||||
|
* How far the graph falls back while a route is up. Near the Sun the catalogue is dense enough
|
||||||
|
* that the links are a solid haze, and a chain drawn through it would be one bright thread in a
|
||||||
|
* bright cloud; stepping the ground down is what makes the figure a figure.
|
||||||
|
*/
|
||||||
|
const GROUND_WHILE_ROUTED = 0.4;
|
||||||
|
|
||||||
|
export interface LinkPoint {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The jump-link graph, and one route through it.
|
||||||
|
*
|
||||||
|
* Both are line segments in the galaxy's parsec frame: the graph as a single buffer, because a
|
||||||
|
* pair of vertices per link is the cheapest way to draw a hundred thousand of them, and the
|
||||||
|
* route as a second, brighter one over the top. The route is a strip rather than a set of pairs
|
||||||
|
* so a chain of hops reads as one continuous thing.
|
||||||
|
*/
|
||||||
|
export class JumpLinkRenderer {
|
||||||
|
readonly object = new THREE.Group();
|
||||||
|
|
||||||
|
private readonly linkMaterial: THREE.LineBasicMaterial;
|
||||||
|
private readonly routeMaterial: THREE.LineBasicMaterial;
|
||||||
|
private readonly links: THREE.LineSegments;
|
||||||
|
private readonly route: THREE.Line;
|
||||||
|
private strength = 1;
|
||||||
|
private routed = false;
|
||||||
|
|
||||||
|
constructor(accent: THREE.ColorRepresentation) {
|
||||||
|
this.linkMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: LINK_OPACITY, depthWrite: false });
|
||||||
|
this.routeMaterial = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: ROUTE_OPACITY, depthWrite: false });
|
||||||
|
|
||||||
|
this.links = new THREE.LineSegments(new THREE.BufferGeometry(), this.linkMaterial);
|
||||||
|
this.route = new THREE.Line(new THREE.BufferGeometry(), this.routeMaterial);
|
||||||
|
// Both are rebuilt from scratch whenever they change, so their bounds are only ever right
|
||||||
|
// by accident between rebuilds; culling on a stale sphere drops the graph mid-pan.
|
||||||
|
this.links.frustumCulled = false;
|
||||||
|
this.route.frustumCulled = false;
|
||||||
|
this.object.add(this.links, this.route);
|
||||||
|
this.setLinks([], () => undefined);
|
||||||
|
this.setRoute([], () => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLinks(links: readonly JumpLink[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
||||||
|
const vertices = new Float32Array(links.length * 6);
|
||||||
|
let at = 0;
|
||||||
|
for (const link of links) {
|
||||||
|
const from = positionOf(link.from);
|
||||||
|
const to = positionOf(link.to);
|
||||||
|
if (!from || !to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
vertices.set([from.x, from.y, from.z, to.x, to.y, to.z], at);
|
||||||
|
at += 6;
|
||||||
|
}
|
||||||
|
this.replaceGeometry(this.links, at === vertices.length ? vertices : vertices.subarray(0, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chain to draw over the graph, departure first. Fewer than two stars draws nothing. */
|
||||||
|
setRoute(starIds: readonly number[], positionOf: (starId: number) => LinkPoint | undefined): void {
|
||||||
|
const points = starIds.map(positionOf).filter((point): point is LinkPoint => point !== undefined);
|
||||||
|
this.routed = points.length >= 2;
|
||||||
|
this.applyOpacity();
|
||||||
|
const vertices = new Float32Array(points.length < 2 ? 0 : points.length * 3);
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
if (vertices.length > 0) {
|
||||||
|
vertices.set([point.x, point.y, point.z], i * 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.replaceGeometry(this.route, vertices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crossfaded with the local layer: from outside the Galaxy the graph is a smear. */
|
||||||
|
setStrength(strength: number): void {
|
||||||
|
this.strength = THREE.MathUtils.clamp(strength, 0, 1);
|
||||||
|
this.applyOpacity();
|
||||||
|
this.object.visible = this.strength > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyOpacity(): void {
|
||||||
|
this.linkMaterial.opacity = LINK_OPACITY * this.strength * (this.routed ? GROUND_WHILE_ROUTED : 1);
|
||||||
|
this.routeMaterial.opacity = ROUTE_OPACITY * this.strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.links.geometry.dispose();
|
||||||
|
this.route.geometry.dispose();
|
||||||
|
this.linkMaterial.dispose();
|
||||||
|
this.routeMaterial.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private replaceGeometry(target: THREE.LineSegments | THREE.Line, vertices: Float32Array): void {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
|
||||||
|
const previous = target.geometry;
|
||||||
|
target.geometry = geometry;
|
||||||
|
previous.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ReservedBox, ringPlacement } from './label-ring';
|
||||||
|
|
||||||
|
const VIEWPORT = { width: 1440, height: 900 };
|
||||||
|
|
||||||
|
/** Where a placement lands on screen, which is what the rule is really about. */
|
||||||
|
function screen(placement: { x: number; y: number }): { x: number; y: number } {
|
||||||
|
return { x: Math.round(((placement.x + 1) / 2) * VIEWPORT.width), y: Math.round(((1 - placement.y) / 2) * VIEWPORT.height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ringPlacement', () => {
|
||||||
|
it('puts a name where its bearing points, on a ring that is round on screen', () => {
|
||||||
|
const right = ringPlacement(0, 0.74, VIEWPORT);
|
||||||
|
const up = ringPlacement(Math.PI / 2, 0.74, VIEWPORT);
|
||||||
|
|
||||||
|
// Same distance from the centre in pixels, despite the frame being wider than it is tall.
|
||||||
|
const centre = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2 };
|
||||||
|
const radius = (p: { x: number; y: number }) => Math.hypot(screen(p).x - centre.x, screen(p).y - centre.y);
|
||||||
|
expect(radius(right!)).toBeCloseTo(radius(up!), 0);
|
||||||
|
expect(screen(right!).y).toBe(450);
|
||||||
|
expect(screen(up!).x).toBe(720);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the bearing alone when nothing is in the way', () => {
|
||||||
|
expect(ringPlacement(1.1, 0.74, VIEWPORT)?.angle).toBeCloseTo(1.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slides a name along the ring rather than printing it behind a panel', () => {
|
||||||
|
// The readout panel, bottom left, where the ring passes.
|
||||||
|
const readout: ReservedBox = { left: 24, top: 640, right: 536, bottom: 830 };
|
||||||
|
const behindIt = (5 * Math.PI) / 4;
|
||||||
|
|
||||||
|
const placed = ringPlacement(behindIt, 0.74, VIEWPORT, [readout]);
|
||||||
|
|
||||||
|
expect(placed).not.toBeNull();
|
||||||
|
expect(placed!.angle).not.toBeCloseTo(behindIt);
|
||||||
|
const { x, y } = screen(placed!);
|
||||||
|
expect(x > readout.right || x < readout.left || y < readout.top || y > readout.bottom).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves it the smallest distance that clears, and to the nearer side', () => {
|
||||||
|
const box: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 200 };
|
||||||
|
const straightUp = Math.PI / 2;
|
||||||
|
|
||||||
|
const placed = ringPlacement(straightUp, 0.74, VIEWPORT, [box]);
|
||||||
|
|
||||||
|
expect(placed).not.toBeNull();
|
||||||
|
expect(Math.abs(placed!.angle - straightUp)).toBeLessThanOrEqual(Math.PI / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up rather than half-hide a name, when everything near its bearing is covered', () => {
|
||||||
|
const wall: ReservedBox = { left: 0, top: 0, right: 1440, bottom: 900 };
|
||||||
|
|
||||||
|
expect(ringPlacement(0, 0.74, VIEWPORT, [wall])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the width of the text, not just the point it hangs from', () => {
|
||||||
|
// A panel the anchor clears by 40px but the text does not.
|
||||||
|
const justRight: ReservedBox = { left: 1150, top: 400, right: 1440, bottom: 500 };
|
||||||
|
|
||||||
|
const placed = ringPlacement(0, 0.74, VIEWPORT, [justRight]);
|
||||||
|
|
||||||
|
expect(placed!.angle).not.toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits a frame held upright, where sizing against the height alone would miss it entirely', () => {
|
||||||
|
const phone = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
const right = ringPlacement(0, 0.74, phone);
|
||||||
|
const up = ringPlacement(Math.PI / 2, 0.74, phone);
|
||||||
|
|
||||||
|
expect(Math.abs(right!.x)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(up!.y)).toBeLessThanOrEqual(1);
|
||||||
|
// Still a circle: the same number of pixels out, whichever way it is measured.
|
||||||
|
expect(Math.abs(right!.x) * (phone.width / 2)).toBeCloseTo(Math.abs(up!.y) * (phone.height / 2), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a viewport with no height rather than dividing by it', () => {
|
||||||
|
expect(ringPlacement(0, 0.74, { width: 0, height: 0 })).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Where a name goes on the ring around the view, given the direction it stands for and the
|
||||||
|
* panels already occupying the frame.
|
||||||
|
*
|
||||||
|
* Pure and in screen space, so the rule can be read and tested without a scene: the caller turns
|
||||||
|
* a direction into an angle, this decides where on the ring that angle can actually be printed,
|
||||||
|
* and the caller turns the answer back into a point the renderer can project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A box the ring must not print into, in pixels from the top-left of the viewport. */
|
||||||
|
export interface ReservedBox {
|
||||||
|
readonly left: number;
|
||||||
|
readonly top: number;
|
||||||
|
readonly right: number;
|
||||||
|
readonly bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RingViewport {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A place on the ring, in normalised device coordinates (-1..1, y up). */
|
||||||
|
export interface RingPlacement {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
/** The angle actually used, which is the requested one unless a panel was in the way. */
|
||||||
|
readonly angle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the bearing may be bent to get out from behind a panel, and in what steps. Bending is
|
||||||
|
* a lie about the direction, so it is kept small and always tried in the smallest amount that
|
||||||
|
* works, alternating sides so the name ends up on whichever side of the panel is nearer.
|
||||||
|
*/
|
||||||
|
const MAX_NUDGE_RADIANS = Math.PI / 3;
|
||||||
|
const NUDGE_STEP_RADIANS = Math.PI / 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label's text runs this far from its anchor, as a fraction of the viewport width, and this
|
||||||
|
* tall. A name clears a panel only if the whole line does, not just the point it hangs from.
|
||||||
|
*/
|
||||||
|
const LABEL_REACH_FRACTION = 0.13;
|
||||||
|
const LABEL_HEIGHT_PX = 30;
|
||||||
|
|
||||||
|
function overlaps(x: number, y: number, viewport: RingViewport, reserved: readonly ReservedBox[]): boolean {
|
||||||
|
const px = ((x + 1) / 2) * viewport.width;
|
||||||
|
const py = ((1 - y) / 2) * viewport.height;
|
||||||
|
const reach = viewport.width * LABEL_REACH_FRACTION;
|
||||||
|
// Either side, because which side the text hangs on is decided later, by the label pass.
|
||||||
|
const left = px - reach;
|
||||||
|
const right = px + reach;
|
||||||
|
const top = py - LABEL_HEIGHT_PX / 2;
|
||||||
|
const bottom = py + LABEL_HEIGHT_PX / 2;
|
||||||
|
return reserved.some((box) => left < box.right && right > box.left && top < box.bottom && bottom > box.top);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places one name on the ring at `angle`, moved along the ring if a panel is in the way, or
|
||||||
|
* `null` if the whole neighbourhood of that angle is covered — better absent than half hidden
|
||||||
|
* behind a readout.
|
||||||
|
*
|
||||||
|
* `radius` is a fraction of the frame's shorter side, so the ring is a circle on screen — and
|
||||||
|
* fits whichever way up the frame is. Sizing it against the height alone puts the ring a
|
||||||
|
* viewport and a half wide on a phone held upright, which is to say off both edges.
|
||||||
|
*/
|
||||||
|
export function ringPlacement(
|
||||||
|
angle: number,
|
||||||
|
radius: number,
|
||||||
|
viewport: RingViewport,
|
||||||
|
reserved: readonly ReservedBox[] = []
|
||||||
|
): RingPlacement | null {
|
||||||
|
const shorterSide = Math.min(viewport.width, viewport.height);
|
||||||
|
const scaleX = viewport.width === 0 ? radius : (radius * shorterSide) / viewport.width;
|
||||||
|
const scaleY = viewport.height === 0 ? radius : (radius * shorterSide) / viewport.height;
|
||||||
|
for (let nudge = 0; nudge <= MAX_NUDGE_RADIANS; nudge += NUDGE_STEP_RADIANS) {
|
||||||
|
for (const candidate of nudge === 0 ? [angle] : [angle + nudge, angle - nudge]) {
|
||||||
|
const x = Math.cos(candidate) * scaleX;
|
||||||
|
const y = Math.sin(candidate) * scaleY;
|
||||||
|
if (!overlaps(x, y, viewport, reserved)) {
|
||||||
|
return { x, y, angle: candidate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -152,13 +152,13 @@ describe('StarFieldRenderer', () => {
|
|||||||
it('finds the star under the pointer', () => {
|
it('finds the star under the pointer', () => {
|
||||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||||
// Both Near and Far project to the screen centre; either is a correct hit.
|
// Both Near and Far project to the screen centre; either is a correct hit.
|
||||||
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera));
|
expect([1, 2]).toContain(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect));
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined when the pointer is on empty sky', () => {
|
it('returns undefined when the pointer is on empty sky', () => {
|
||||||
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
const renderer = new StarFieldRenderer(picked, packPositions(picked));
|
||||||
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera)).toBeUndefined();
|
expect(renderer.pickAt(new THREE.Vector2(-0.9, 0.9), camera, camera.aspect)).toBeUndefined();
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
const behind = [star({ id: 7, x: 0, y: 0, z: 10 })];
|
||||||
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
const renderer = new StarFieldRenderer(behind, packPositions(behind));
|
||||||
|
|
||||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
|
|
||||||
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
// Aim at where star 2 projects, and confirm we get it rather than its neighbours.
|
||||||
const target = new THREE.Vector3(0, 2, -10).project(camera);
|
const target = new THREE.Vector3(0, 2, -10).project(camera);
|
||||||
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera)).toBe(2);
|
expect(renderer.pickAt(new THREE.Vector2(target.x, target.y), camera, camera.aspect)).toBe(2);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ describe('StarFieldRenderer', () => {
|
|||||||
// Walk outward from the centre until each stops being pickable.
|
// Walk outward from the centre until each stops being pickable.
|
||||||
const reach = (renderer: StarFieldRenderer): number => {
|
const reach = (renderer: StarFieldRenderer): number => {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera) !== undefined) {
|
while (offset < 1 && renderer.pickAt(new THREE.Vector2(0, offset), camera, camera.aspect) !== undefined) {
|
||||||
offset += 0.001;
|
offset += 0.001;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
@@ -212,13 +212,13 @@ describe('StarFieldRenderer', () => {
|
|||||||
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
const faint = [star({ id: 5, x: 0, y: 0, z: -10, magnitude: 15 })];
|
||||||
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
const renderer = new StarFieldRenderer(faint, packPositions(faint));
|
||||||
|
|
||||||
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera)).toBe(5);
|
expect(renderer.pickAt(new THREE.Vector2(0, 0.005), camera, camera.aspect)).toBe(5);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('finds nothing in an empty field', () => {
|
it('finds nothing in an empty field', () => {
|
||||||
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
const renderer = new StarFieldRenderer([], new Float32Array(0));
|
||||||
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera)).toBeUndefined();
|
expect(renderer.pickAt(new THREE.Vector2(0, 0), camera, camera.aspect)).toBeUndefined();
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { instancedBufferAttribute, smoothstep, uv, vec2 } from 'three/tsl';
|
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||||
|
|
||||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||||
|
import { SceneCamera } from '../../core/engine/engine.service';
|
||||||
import { StarRecord } from '../../shared/models/star.model';
|
import { StarRecord } from '../../shared/models/star.model';
|
||||||
|
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||||||
|
|
||||||
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
/** Apparent star diameters, in pixels at {@link REFERENCE_VIEWPORT_HEIGHT_PX}. */
|
||||||
const MIN_POINT_SIZE = 1.5;
|
const MIN_POINT_SIZE = 1.5;
|
||||||
const MAX_POINT_SIZE = 6;
|
const MAX_POINT_SIZE = 6;
|
||||||
|
|
||||||
/**
|
|
||||||
* Star size is expressed in pixels for readability, but the material works in angular size, so
|
|
||||||
* the two are related through the scene's vertical field of view and a reference viewport.
|
|
||||||
* Because the size is angular, a star keeps the same share of the screen at any window size —
|
|
||||||
* these pixel figures are exact only at this reference height.
|
|
||||||
*/
|
|
||||||
const REFERENCE_VIEWPORT_HEIGHT_PX = 900;
|
|
||||||
const REFERENCE_FOV_DEGREES = 55;
|
|
||||||
const PIXELS_TO_ANGULAR_SIZE =
|
|
||||||
(2 * Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 180 / 2)) / REFERENCE_VIEWPORT_HEIGHT_PX;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
* Extra click forgiveness added to a star's drawn radius, in NDC — roughly 4 px on the
|
||||||
@@ -169,6 +161,10 @@ export class StarFieldRenderer {
|
|||||||
/** How many of the catalogue's stars this field actually draws. */
|
/** How many of the catalogue's stars this field actually draws. */
|
||||||
readonly drawnCount: number;
|
readonly drawnCount: number;
|
||||||
|
|
||||||
|
/** 1 under a perspective camera, 0 under an orthographic one. See `setProjection`. */
|
||||||
|
private readonly perspective = uniform(1);
|
||||||
|
private readonly orthographicScale = uniform(float(0));
|
||||||
|
|
||||||
private readonly geometry: THREE.InstancedBufferGeometry;
|
private readonly geometry: THREE.InstancedBufferGeometry;
|
||||||
private readonly material: THREE.SpriteNodeMaterial;
|
private readonly material: THREE.SpriteNodeMaterial;
|
||||||
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
/** The subset of the catalogue that is drawn, and so the only set that can be clicked. */
|
||||||
@@ -209,10 +205,22 @@ export class StarFieldRenderer {
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
blending: THREE.AdditiveBlending
|
blending: THREE.AdditiveBlending
|
||||||
});
|
});
|
||||||
this.material.sizeAttenuation = false;
|
// The compensation that turns an angular size into a world size is done here rather than by
|
||||||
this.material.positionNode = instancedBufferAttribute(positionAttribute, 'vec3');
|
// `sizeAttenuation: false`, which three.js applies only when it is compiling against a
|
||||||
this.material.scaleNode = instancedBufferAttribute(sizeAttribute, 'float');
|
// perspective camera (SpriteNodeMaterial.js: `camera.isPerspectiveCamera && sizeAttenuation
|
||||||
this.material.colorNode = instancedBufferAttribute(colorAttribute, 'vec3');
|
// === false`). Under an orthographic one it is silently skipped and every star collapses to
|
||||||
|
// a thousandth of a parsec — invisible. Doing the same arithmetic in the node graph, behind
|
||||||
|
// a uniform, lets one material serve both cameras without being recompiled between them.
|
||||||
|
this.material.sizeAttenuation = true;
|
||||||
|
const position = instancedBufferAttribute<'vec3'>(positionAttribute, 'vec3');
|
||||||
|
const angularSize = instancedBufferAttribute<'float'>(sizeAttribute, 'float');
|
||||||
|
this.material.positionNode = position;
|
||||||
|
// Perspective: a star's world size is its angular size times how far away it is, which is
|
||||||
|
// exactly what the built-in does. Orthographic: distance does not set apparent size at all,
|
||||||
|
// the frustum does, so the same angular size is scaled by the frustum instead.
|
||||||
|
const viewDepth = modelViewMatrix.mul(vec4(position, 1)).z.negate();
|
||||||
|
this.material.scaleNode = angularSize.mul(mix(this.orthographicScale, viewDepth, this.perspective));
|
||||||
|
this.material.colorNode = instancedBufferAttribute<'vec3'>(colorAttribute, 'vec3');
|
||||||
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
// Soft radial falloff so each star is a small bright core inside a halo, rather than a
|
||||||
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
// hard-edged square. `uv` runs 0..1 across the quad, so 0.5 is its centre.
|
||||||
const radius = uv().sub(vec2(0.5)).length();
|
const radius = uv().sub(vec2(0.5)).length();
|
||||||
@@ -224,6 +232,19 @@ export class StarFieldRenderer {
|
|||||||
this.object.frustumCulled = false;
|
this.object.frustumCulled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the field which projection it is being drawn under.
|
||||||
|
*
|
||||||
|
* `halfHeightWorld` is half the orthographic frustum's height in world units; `null` means a
|
||||||
|
* perspective camera, where a star's distance sets its apparent size on its own.
|
||||||
|
*/
|
||||||
|
setProjection(halfHeightWorld: number | null): void {
|
||||||
|
this.perspective.value = halfHeightWorld === null ? 1 : 0;
|
||||||
|
// The world size that subtends the same share of the viewport an angular size would under
|
||||||
|
// the reference field of view: `angular * halfHeight / tan(fov/2)`.
|
||||||
|
this.orthographicScale.value = halfHeightWorld === null ? 0 : halfHeightWorld / Math.tan((REFERENCE_FOV_DEGREES * Math.PI) / 360);
|
||||||
|
}
|
||||||
|
|
||||||
/** Looks up the HYG star id for a given instance index. */
|
/** Looks up the HYG star id for a given instance index. */
|
||||||
starIdAt(instanceIndex: number): number | undefined {
|
starIdAt(instanceIndex: number): number | undefined {
|
||||||
return this.stars[instanceIndex]?.id;
|
return this.stars[instanceIndex]?.id;
|
||||||
@@ -239,8 +260,14 @@ export class StarFieldRenderer {
|
|||||||
* what the user sees at every zoom level instead of being over-permissive up close and
|
* what the user sees at every zoom level instead of being over-permissive up close and
|
||||||
* sub-pixel at the far end of the camera's range.
|
* sub-pixel at the far end of the camera's range.
|
||||||
*/
|
*/
|
||||||
pickAt(pointerNdc: THREE.Vector2, camera: THREE.PerspectiveCamera): number | undefined {
|
pickAt(pointerNdc: THREE.Vector2, camera: SceneCamera, aspect: number): number | undefined {
|
||||||
const tanHalfFov = Math.tan((camera.fov * Math.PI) / 360);
|
// What a unit of angular size is worth on screen. Under perspective the field of view sets
|
||||||
|
// it. Under an orthographic camera the frustum does — but `setProjection` sized the sprite
|
||||||
|
// as `angular * halfHeight / tan(REFERENCE_FOV/2)` in the first place, so dividing back out
|
||||||
|
// by that same half-height leaves the reference field of view and nothing else. Both cases
|
||||||
|
// are therefore one formula over a different angle.
|
||||||
|
const perspective = (camera as THREE.PerspectiveCamera).isPerspectiveCamera;
|
||||||
|
const tanHalfFov = Math.tan(((perspective ? (camera as THREE.PerspectiveCamera).fov : REFERENCE_FOV_DEGREES) * Math.PI) / 360);
|
||||||
const projected = new THREE.Vector3();
|
const projected = new THREE.Vector3();
|
||||||
|
|
||||||
let bestIndex: number | undefined;
|
let bestIndex: number | undefined;
|
||||||
@@ -258,7 +285,7 @@ export class StarFieldRenderer {
|
|||||||
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
// A sprite square in view space projects to an ellipse in NDC: the same half-extent in y,
|
||||||
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
// divided by the aspect ratio in x. Scaling dx by the aspect makes the comparison circular.
|
||||||
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
const ndcRadius = (0.5 * this.angularSizes[index]) / tanHalfFov + PICK_NDC_SLOP;
|
||||||
const dx = (projected.x - pointerNdc.x) * camera.aspect;
|
const dx = (projected.x - pointerNdc.x) * aspect;
|
||||||
const dy = projected.y - pointerNdc.y;
|
const dy = projected.y - pointerNdc.y;
|
||||||
const score = Math.hypot(dx, dy) / ndcRadius;
|
const score = Math.hypot(dx, dy) / ndcRadius;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import * as THREE from 'three/webgpu';
|
import * as THREE from 'three/webgpu';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
||||||
|
|
||||||
import { StarLabelOverlay } from './star-label-overlay';
|
import { StarLabelOverlay } from './star-label-overlay';
|
||||||
|
|
||||||
describe('StarLabelOverlay', () => {
|
describe('StarLabelOverlay', () => {
|
||||||
@@ -77,6 +79,66 @@ describe('StarLabelOverlay', () => {
|
|||||||
expect(labels()).toHaveLength(0);
|
expect(labels()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hangs a label on the side it is told to, and can move it across', () => {
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'left' }]);
|
||||||
|
const object = scene.children[0] as CSS2DObject;
|
||||||
|
expect(labels()[0].classList.contains('map-label--left')).toBe(true);
|
||||||
|
expect(object.center.x).toBe(1);
|
||||||
|
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0, side: 'right' }]);
|
||||||
|
expect(labels()[0].classList.contains('map-label--left')).toBe(false);
|
||||||
|
expect(object.center.x).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('brackets one selected point with the mark, moves it, and clears it', () => {
|
||||||
|
overlay.setSelection({ x: 1, y: 2, z: 3 });
|
||||||
|
overlay.setSelection({ x: 4, y: 5, z: 6 });
|
||||||
|
overlay.render(camera);
|
||||||
|
const marks = overlay.domElement.querySelectorAll('.map-select');
|
||||||
|
expect(marks).toHaveLength(1);
|
||||||
|
expect((scene.children[0] as THREE.Object3D).position.toArray()).toEqual([4, 5, 6]);
|
||||||
|
|
||||||
|
overlay.setSelection(null);
|
||||||
|
overlay.render(camera);
|
||||||
|
expect(overlay.domElement.querySelectorAll('.map-select')).toHaveLength(0);
|
||||||
|
expect(scene.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a neighbour more quietly than a body of this system', () => {
|
||||||
|
overlay.update([
|
||||||
|
{ id: 'earth', name: 'Earth', kind: 'Planet', x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 'neighbour:1', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 1, x: 0, y: 1, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [body, ghost] = labels();
|
||||||
|
expect(body.classList.contains('map-label--ghost')).toBe(false);
|
||||||
|
expect(ghost.classList.contains('map-label--ghost')).toBe(true);
|
||||||
|
expect(ghost.querySelector('.map-label-kind')?.textContent).toBe('2.64 pc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a label that offers a star a button, and hands back the star it names', () => {
|
||||||
|
const chosen: number[] = [];
|
||||||
|
overlay = new StarLabelOverlay(scene, (starId) => chosen.push(starId));
|
||||||
|
overlay.setSize(800, 600);
|
||||||
|
overlay.update([{ id: 'neighbour:42', name: 'Sirius', kind: '2.64 pc', tone: 'ghost', selectStarId: 42, x: 1, y: 0, z: 0 }]);
|
||||||
|
|
||||||
|
const ghost = labels()[0];
|
||||||
|
expect(ghost.tagName).toBe('BUTTON');
|
||||||
|
// Its two lines are adjacent spans, so without this it is announced as "Sirius2.64 pc".
|
||||||
|
expect(ghost.getAttribute('aria-label')).toBe('Go to Sirius, 2.64 pc away');
|
||||||
|
ghost.click();
|
||||||
|
|
||||||
|
expect(chosen).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a label that offers nothing untouchable, so the scene behind it stays clickable', () => {
|
||||||
|
overlay.update([{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 }]);
|
||||||
|
|
||||||
|
const label = labels()[0];
|
||||||
|
expect(label.tagName).toBe('DIV');
|
||||||
|
expect(label.classList.contains('map-label--select')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves nothing behind when disposed', () => {
|
it('leaves nothing behind when disposed', () => {
|
||||||
overlay.update([
|
overlay.update([
|
||||||
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
{ id: 1, name: 'Sirius', kind: 'Star', x: 1, y: 0, z: 0 },
|
||||||
|
|||||||
@@ -15,11 +15,49 @@ export interface LabeledPoint {
|
|||||||
* is called.
|
* is called.
|
||||||
*/
|
*/
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
/**
|
||||||
|
* Which side of the point the text hangs on. Right is the default; left is for a point close
|
||||||
|
* to the right edge of the view, or one whose right-hand text would run into a neighbour's.
|
||||||
|
*/
|
||||||
|
side?: LabelSide;
|
||||||
|
/**
|
||||||
|
* `ghost` is the quieter voice: a star outside the system the camera is in, named so its
|
||||||
|
* direction can be read without leaving. Dimmer, and it can be selected.
|
||||||
|
*/
|
||||||
|
tone?: LabelTone;
|
||||||
|
/**
|
||||||
|
* The star this label offers to fly to. Present makes the label a real button — focusable,
|
||||||
|
* clickable, and the only labels the pointer can reach at all. Whether a given id is
|
||||||
|
* selectable never changes between updates, so the element it needs is settled at creation.
|
||||||
|
*/
|
||||||
|
selectStarId?: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LabelSide = 'left' | 'right';
|
||||||
|
export type LabelTone = 'normal' | 'ghost';
|
||||||
|
|
||||||
|
/** Where the selection mark sits, in the same scene units as the labels. */
|
||||||
|
export interface SelectionPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classesFor(point: Pick<LabeledPoint, 'side' | 'tone' | 'selectStarId'>): string {
|
||||||
|
return [
|
||||||
|
'map-label',
|
||||||
|
point.side === 'left' ? 'map-label--left' : '',
|
||||||
|
point.tone === 'ghost' ? 'map-label--ghost' : '',
|
||||||
|
point.selectStarId === undefined ? '' : 'map-label--select',
|
||||||
|
'whitespace-nowrap font-body'
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
|
* Renders DOM-based (CSS2D) name labels anchored to 3D star positions. Labels are added as
|
||||||
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
|
* children of the main scene (so `CSS2DRenderer` can project them with the same camera) and
|
||||||
@@ -31,8 +69,13 @@ export class StarLabelOverlay {
|
|||||||
|
|
||||||
private readonly cssRenderer = new CSS2DRenderer();
|
private readonly cssRenderer = new CSS2DRenderer();
|
||||||
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
private readonly labelObjects = new Map<number | string, CSS2DObject>();
|
||||||
|
private selection?: CSS2DObject;
|
||||||
|
|
||||||
constructor(private readonly scene: THREE.Scene) {
|
constructor(
|
||||||
|
private readonly scene: THREE.Scene,
|
||||||
|
/** Called with the star a selectable label names, when it is clicked or keyed. */
|
||||||
|
private readonly onSelectStar?: (starId: number) => void
|
||||||
|
) {
|
||||||
this.cssRenderer.domElement.classList.add('star-label-layer');
|
this.cssRenderer.domElement.classList.add('star-label-layer');
|
||||||
this.domElement = this.cssRenderer.domElement;
|
this.domElement = this.cssRenderer.domElement;
|
||||||
}
|
}
|
||||||
@@ -61,12 +104,48 @@ export class StarLabelOverlay {
|
|||||||
const existing = this.labelObjects.get(point.id);
|
const existing = this.labelObjects.get(point.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.position.set(point.x, point.y, point.z);
|
existing.position.set(point.x, point.y, point.z);
|
||||||
|
this.applyPresentation(existing, point);
|
||||||
} else {
|
} else {
|
||||||
this.addLabel(point);
|
this.addLabel(point);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves one label that is already up, without going through `update`. For labels whose place
|
||||||
|
* is fixed relative to the camera rather than to anything in the scene: they have to be
|
||||||
|
* recomputed every frame, and rebuilding the whole label set at that rate would throw away
|
||||||
|
* the diffing that keeps the DOM still.
|
||||||
|
*/
|
||||||
|
moveLabel(id: number | string, x: number, y: number, z: number): void {
|
||||||
|
this.labelObjects.get(id)?.position.set(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the selected object in the scene: two thin arcs bracketing the point, the one thing
|
||||||
|
* borrowed from the ARK's control disc. `null` clears it. Kept out of `update` because it is
|
||||||
|
* a different rhythm — labels change on their own cadence, the mark follows a moving body
|
||||||
|
* every frame.
|
||||||
|
*/
|
||||||
|
setSelection(point: SelectionPoint | null): void {
|
||||||
|
if (!point) {
|
||||||
|
if (this.selection) {
|
||||||
|
this.scene.remove(this.selection);
|
||||||
|
this.selection.element.remove();
|
||||||
|
this.selection = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.selection) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'map-select';
|
||||||
|
element.setAttribute('aria-hidden', 'true');
|
||||||
|
this.selection = new CSS2DObject(element);
|
||||||
|
this.scene.add(this.selection);
|
||||||
|
}
|
||||||
|
this.selection.position.set(point.x, point.y, point.z);
|
||||||
|
}
|
||||||
|
|
||||||
render(camera: THREE.Camera): void {
|
render(camera: THREE.Camera): void {
|
||||||
this.cssRenderer.render(this.scene, camera);
|
this.cssRenderer.render(this.scene, camera);
|
||||||
}
|
}
|
||||||
@@ -75,13 +154,30 @@ export class StarLabelOverlay {
|
|||||||
for (const [id, object] of this.labelObjects) {
|
for (const [id, object] of this.labelObjects) {
|
||||||
this.removeLabel(id, object);
|
this.removeLabel(id, object);
|
||||||
}
|
}
|
||||||
|
this.setSelection(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addLabel(point: LabeledPoint): void {
|
private addLabel(point: LabeledPoint): void {
|
||||||
const element = document.createElement('div');
|
// A selectable label is a real button, so it is reachable by keyboard and announced as an
|
||||||
// Tailwind utility classes assigned directly since this element lives outside Angular's
|
// action rather than as text that happens to respond to a click.
|
||||||
// view encapsulation (see the class comment above) rather than through a component template.
|
const element = document.createElement(point.selectStarId === undefined ? 'div' : 'button');
|
||||||
element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body';
|
if (point.selectStarId !== undefined) {
|
||||||
|
const starId = point.selectStarId;
|
||||||
|
(element as HTMLButtonElement).type = 'button';
|
||||||
|
// Read out as a sentence rather than as the two lines run together — the name and the
|
||||||
|
// distance are adjacent spans, so the default accessible name is "Sirius2.64 pc" — and
|
||||||
|
// said as the action it is, since nothing else on screen says these labels are doors.
|
||||||
|
element.setAttribute('aria-label', `Go to ${point.name}${point.kind ? `, ${point.kind} away` : ''}`);
|
||||||
|
element.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.onSelectStar?.(starId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Classes assigned directly since this element lives outside Angular's view encapsulation
|
||||||
|
// (see the class comment above). The offset and leader line live in `.map-label` itself:
|
||||||
|
// CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
|
||||||
|
// would be overwritten — the margin is the offset it cannot touch.
|
||||||
|
element.className = classesFor(point);
|
||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'map-label-name';
|
name.className = 'map-label-name';
|
||||||
@@ -96,11 +192,25 @@ export class StarLabelOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const object = new CSS2DObject(element);
|
const object = new CSS2DObject(element);
|
||||||
|
// Anchor the label's near edge at the point, vertically centred. The default center of
|
||||||
|
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
|
||||||
|
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and
|
||||||
|
// the leader line points at empty space half the label's width from the star.
|
||||||
|
this.applyPresentation(object, point);
|
||||||
object.position.set(point.x, point.y, point.z);
|
object.position.set(point.x, point.y, point.z);
|
||||||
this.scene.add(object);
|
this.scene.add(object);
|
||||||
this.labelObjects.set(point.id, object);
|
this.labelObjects.set(point.id, object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Right-hand text hangs its left edge on the point; left-hand text hangs its right edge. */
|
||||||
|
private applyPresentation(object: CSS2DObject, point: LabeledPoint): void {
|
||||||
|
object.center.set(point.side === 'left' ? 1 : 0, 0.5);
|
||||||
|
const wanted = classesFor(point);
|
||||||
|
if (object.element.className !== wanted) {
|
||||||
|
object.element.className = wanted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private removeLabel(id: number | string, object: CSS2DObject): void {
|
private removeLabel(id: number | string, object: CSS2DObject): void {
|
||||||
this.scene.remove(object);
|
this.scene.remove(object);
|
||||||
object.element.remove();
|
object.element.remove();
|
||||||
|
|||||||
@@ -67,38 +67,16 @@ describe('StarmapHudComponent', () => {
|
|||||||
expect(emitted).toEqual(['galactic', 'galaxy']);
|
expect(emitted).toEqual(['galactic', 'galaxy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the readout panel from its inputs', () => {
|
|
||||||
fixture.componentRef.setInput('level', 'galactic');
|
|
||||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
|
||||||
fixture.componentRef.setInput('title', 'Milky Way');
|
|
||||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
|
||||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }]);
|
|
||||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
|
||||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
|
||||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', '21.5 kpc']) {
|
|
||||||
expect(text).toContain(expected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves out the optional lines it was given nothing for', () => {
|
|
||||||
const host = render('galaxy');
|
|
||||||
expect(host.querySelector('dl')).toBeNull();
|
|
||||||
expect(host.textContent).not.toContain('undefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names what the view is holding on the banner across the top', () => {
|
it('names what the view is holding on the banner across the top', () => {
|
||||||
fixture.componentRef.setInput('level', 'system');
|
fixture.componentRef.setInput('level', 'system');
|
||||||
fixture.componentRef.setInput('title', 'Sol');
|
fixture.componentRef.setInput('title', 'Sol');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol');
|
expect((fixture.nativeElement as HTMLElement).querySelector('[data-testid="hud-banner"]')?.textContent?.trim()).toBe('Sol');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no banner when the view is holding nothing', () => {
|
it('shows no banner when the view is holding nothing', () => {
|
||||||
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
// An empty nameplate is worse than none: it reads as a selection that failed to resolve.
|
||||||
expect(render('galaxy').querySelector('.hud-banner')).toBeNull();
|
expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
import { ViewLevel } from '../../shared/state/navigation.store';
|
import { ViewLevel } from '../../shared/state/navigation.store';
|
||||||
|
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||||
export interface HudReadout {
|
|
||||||
readonly label: string;
|
|
||||||
readonly value: string;
|
|
||||||
/**
|
|
||||||
* True when the figure was computed from other measurements rather than catalogued directly.
|
|
||||||
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
|
|
||||||
* an observed one.
|
|
||||||
*/
|
|
||||||
readonly derived?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LadderStep {
|
interface LadderStep {
|
||||||
readonly level: ViewLevel;
|
readonly level: ViewLevel;
|
||||||
@@ -30,9 +20,9 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The map's heads-up display: the scale ladder down the left, the readout panel across the
|
* The top of the map's heads-up display: the scale ladder on the left, the nameplate across
|
||||||
* bottom, a centre reticle on whatever the camera is holding, and the frame brackets around
|
* the centre, and a centre reticle on whatever the camera is holding. Readouts and tools live
|
||||||
* the whole viewport.
|
* in the dock along the bottom (`HudDockComponent`).
|
||||||
*
|
*
|
||||||
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
* Purely presentational — every value arrives as an input and the only thing it emits is a
|
||||||
* request to move to another scale. The scene owns the camera and decides what that means.
|
* request to move to another scale. The scene owns the camera and decides what that means.
|
||||||
@@ -45,29 +35,24 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-starmap-hud',
|
selector: 'app-starmap-hud',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [ReticleIconComponent],
|
||||||
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
host: { class: 'pointer-events-none absolute inset-0 block select-none' },
|
||||||
template: `
|
template: `
|
||||||
<div class="hud-frame absolute inset-0"></div>
|
|
||||||
<div class="hud-vignette absolute inset-0"></div>
|
|
||||||
|
|
||||||
@if (showReticle()) {
|
@if (showReticle()) {
|
||||||
<!-- A hexagon rather than a square bracket: the shape reads as a sensor lock on a body,
|
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
||||||
and stays distinct from the rectangular panel chrome everywhere else on screen. -->
|
for the whole instrument, whether it is holding a query or a body. -->
|
||||||
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
<app-reticle-icon class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" [strokeWidth]="1" />
|
||||||
<polygon points="28,4 48,16 48,40 28,52 8,40 8,16" stroke="currentColor" stroke-width="1" />
|
|
||||||
<path d="M28 22v-6M28 40v-6M22 28h-6M40 28h-6" stroke="currentColor" stroke-width="1" opacity="0.8" />
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
|
||||||
across the top of the display, clear of the search field above them. -->
|
across the top of the display; the search lives in the dock below, so nothing sits above. -->
|
||||||
<nav aria-label="Map scale" class="pointer-events-auto absolute top-16 left-6 flex items-stretch gap-px">
|
<nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-6 left-6 flex items-stretch divide-x divide-border/40">
|
||||||
@for (step of ladder(); track step.level) {
|
@for (step of ladder(); track step.level) {
|
||||||
@if (step.reachable) {
|
@if (step.reachable) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="levelSelected.emit(step.level)"
|
(click)="levelSelected.emit(step.level)"
|
||||||
class="hud-tab border-y border-border/70 bg-panel/70 px-4 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase backdrop-blur-sm transition-colors hover:bg-accent/15 hover:text-accent focus:bg-accent/15 focus:text-accent focus:outline-none"
|
class="type-eyebrow px-4 py-1.5 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
{{ step.label }}
|
{{ step.label }}
|
||||||
</button>
|
</button>
|
||||||
@@ -75,8 +60,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
<span
|
<span
|
||||||
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
[attr.aria-current]="step.state === 'current' ? 'step' : null"
|
||||||
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
|
||||||
class="hud-tab border-y px-4 py-1.5 font-display text-[10px] tracking-[0.22em] uppercase backdrop-blur-sm"
|
class="type-eyebrow px-4 py-1.5"
|
||||||
[class]="step.state === 'current' ? 'border-accent/70 bg-accent/20 text-accent' : 'border-border/40 bg-panel/40 text-border'"
|
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
|
||||||
>{{ step.label }}</span
|
>{{ step.label }}</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
@@ -84,60 +69,22 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (title()) {
|
@if (title()) {
|
||||||
<div class="absolute top-16 left-1/2 -translate-x-1/2">
|
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
|
||||||
<div class="hud-banner flex items-center gap-2.5 border-b border-accent/60 bg-panel/75 px-6 py-1.5 backdrop-blur-sm">
|
long star name runs into the scale rail on its left and under the object card on its
|
||||||
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
right — all three share the top-6 line. -->
|
||||||
<polygon points="6,1 10.5,3.5 10.5,8.5 6,11 1.5,8.5 1.5,3.5" stroke="currentColor" stroke-width="1" />
|
<div class="absolute top-6 left-1/2 hidden -translate-x-1/2 lg:block">
|
||||||
<circle cx="6" cy="6" r="1.4" fill="currentColor" />
|
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
|
||||||
</svg>
|
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
|
||||||
<span class="font-display text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
<span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div class="hud-panel max-w-lg px-4 py-3">
|
|
||||||
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p>
|
|
||||||
<p data-testid="hud-title" class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p>
|
|
||||||
@if (subtitle()) {
|
|
||||||
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
|
||||||
}
|
|
||||||
@if (readouts().length) {
|
|
||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
|
||||||
@for (readout of readouts(); track readout.label) {
|
|
||||||
<div>
|
|
||||||
<dt class="text-[10px] tracking-[0.18em] text-muted uppercase">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
|
||||||
<dd class="text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
}
|
|
||||||
@if (note() || hasDerived()) {
|
|
||||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hud-panel px-4 py-3 text-right">
|
|
||||||
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">Range</p>
|
|
||||||
<p class="mt-1 font-display text-lg text-accent tabular-nums">{{ range() }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class StarmapHudComponent {
|
export class StarmapHudComponent {
|
||||||
readonly level = input.required<ViewLevel>();
|
readonly level = input.required<ViewLevel>();
|
||||||
/** Headline for the readout panel — the selected star, or the name of the current scale. */
|
/** What the view is holding, for the nameplate — the selected star, or nothing. */
|
||||||
readonly title = input('');
|
readonly title = input('');
|
||||||
readonly subtitle = input('');
|
|
||||||
readonly eyebrow = input('');
|
|
||||||
readonly readouts = input<readonly HudReadout[]>([]);
|
|
||||||
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
|
||||||
readonly note = input('');
|
|
||||||
/** Whether any readout needs the derived-value footnote. */
|
|
||||||
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
|
||||||
|
|
||||||
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
|
||||||
readonly range = input('');
|
|
||||||
readonly showReticle = input(true);
|
readonly showReticle = input(true);
|
||||||
|
|
||||||
readonly levelSelected = output<ViewLevel>();
|
readonly levelSelected = output<ViewLevel>();
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
|
||||||
|
|
||||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||||
import { bodyReadouts } from '../body-detail/body-readouts';
|
import { bodyReadouts } from '../body-detail/body-readouts';
|
||||||
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
|
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
|
||||||
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The card shown for a body picked in the system view, without leaving it.
|
* The card shown for a body picked in the system view, without leaving it.
|
||||||
@@ -11,31 +15,44 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
|||||||
* shows the same numbers over the live view, and keeps the route as the deliberate step for the
|
* shows the same numbers over the live view, and keeps the route as the deliberate step for the
|
||||||
* full 3D inspection.
|
* full 3D inspection.
|
||||||
*
|
*
|
||||||
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only.
|
* The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the detail
|
||||||
|
* page. Presentational only.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-system-object-card',
|
selector: 'app-system-object-card',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
|
||||||
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
|
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
|
||||||
// the scene rather than against whatever box the inline default would have left it in — which
|
// the scene rather than against whatever box the inline default would have left it in — which
|
||||||
// put the card off the bottom-left corner of the viewport entirely.
|
// put the card off the bottom-left corner of the viewport entirely.
|
||||||
host: { class: 'pointer-events-none absolute inset-0 block' },
|
host: { class: 'pointer-events-none absolute inset-0 block' },
|
||||||
template: `
|
template: `
|
||||||
<!-- Positioning and panel styling stay on separate elements. The hud-panel rule sets
|
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
|
||||||
position: relative for its own inset border, which silently overrides an absolute on the
|
never fights the absolute placement. Same organism as the detail page's info panel:
|
||||||
same element and turns top/right into offsets from wherever the box already sat. -->
|
header, shared readout sections, and a route rail — there at the top, here at the
|
||||||
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]">
|
bottom, because here the route is the next step rather than the way back. -->
|
||||||
<div data-testid="object-card" class="hud-panel px-4 py-3 font-body">
|
<div class="pointer-events-auto absolute top-6 right-6 w-80 max-w-[calc(100%-3rem)]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
|
||||||
<div class="min-w-0">
|
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||||
<p class="truncate font-display text-lg tracking-[0.04em] text-text">{{ body().name }}</p>
|
<header class="min-w-0">
|
||||||
<p class="mt-0.5 font-display text-[10px] tracking-[0.22em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||||
</div>
|
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="(bookmarks.has('body', body().id) ? 'Forget ' : 'Keep ') + body().name"
|
||||||
|
[attr.aria-pressed]="bookmarks.has('body', body().id)"
|
||||||
|
(click)="bookmarks.toggle({ kind: 'body', id: body().id, name: body().name })"
|
||||||
|
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="bookmarks.has('body', body().id) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||||
|
>
|
||||||
|
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('body', body().id)" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="dismissed.emit()"
|
(click)="dismissed.emit()"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="-mt-1 -mr-1 shrink-0 rounded-sm p-1 text-muted transition-colors hover:text-accent focus:text-accent focus:outline-none"
|
class="-mt-2 -mr-2 shrink-0 p-1 text-muted transition-colors hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
<path d="M3 3l8 8M11 3l-8 8" />
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
@@ -43,35 +60,15 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (readouts().measured.length) {
|
<app-readout-sections [readouts]="readouts()" />
|
||||||
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
|
|
||||||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
|
||||||
@for (row of readouts().measured; track row.label) {
|
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
}
|
|
||||||
|
|
||||||
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
|
|
||||||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
|
||||||
@for (row of readouts().derived; track row.label) {
|
|
||||||
<dt class="text-muted">{{ row.label }}</dt>
|
|
||||||
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openRequested.emit()"
|
(click)="openRequested.emit()"
|
||||||
class="mt-3 flex w-full items-center justify-center gap-1.5 border border-border/70 bg-panel/60 px-3 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:border-accent focus:text-accent focus:outline-none"
|
class="type-label flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
Full view
|
Full view
|
||||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<app-chevron-icon class="h-3 w-3" direction="right" />
|
||||||
<path d="M9 6l6 6-6 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,5 +81,7 @@ export class SystemObjectCardComponent {
|
|||||||
/** Request for the full `/body/:id` route. */
|
/** Request for the full `/body/:id` route. */
|
||||||
readonly openRequested = output<void>();
|
readonly openRequested = output<void>();
|
||||||
|
|
||||||
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
|
||||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ function colorForKind(kind: SystemMemberKind): THREE.Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks orbit lines so the whole layer can be toggled without touching the bodies. */
|
||||||
|
const ORBIT_LINE_NAME = 'orbit-line';
|
||||||
|
|
||||||
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame: THREE.Quaternion): THREE.Line {
|
||||||
const points = orbitEllipsePoints(elements);
|
const points = orbitEllipsePoints(elements);
|
||||||
const positions = new Float32Array(points.length * 3);
|
const positions = new Float32Array(points.length * 3);
|
||||||
@@ -110,7 +113,9 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
|||||||
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
|
opacity: ORBIT_LINE_OPACITY_BY_KIND[kind]
|
||||||
});
|
});
|
||||||
|
|
||||||
return new THREE.Line(geometry, material);
|
const line = new THREE.Line(geometry, material);
|
||||||
|
line.name = ORBIT_LINE_NAME;
|
||||||
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -329,6 +334,21 @@ export class SystemOrbitsRenderer {
|
|||||||
return this.members.map((member) => member.marker);
|
return this.members.map((member) => member.marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows or hides the orbit lines and the reference grid, leaving the bodies themselves. */
|
||||||
|
setLayerVisibility(layers: { orbits: boolean; grid: boolean }): void {
|
||||||
|
this.object.traverse((child) => {
|
||||||
|
if (child.name === ORBIT_LINE_NAME) {
|
||||||
|
child.visible = layers.orbits;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.grid) {
|
||||||
|
this.grid.object.visible = layers.grid;
|
||||||
|
}
|
||||||
|
if (this.tethers) {
|
||||||
|
this.tethers.object.visible = layers.grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.grid?.dispose();
|
this.grid?.dispose();
|
||||||
this.tethers?.dispose();
|
this.tethers?.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||||
|
|
||||||
|
class EmptyDataLoaderService {
|
||||||
|
loadStars() {
|
||||||
|
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
|
||||||
|
}
|
||||||
|
loadBodies() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
loadExoplanets() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HudDockComponent', () => {
|
||||||
|
let fixture: ComponentFixture<HudDockComponent>;
|
||||||
|
|
||||||
|
function host(): HTMLElement {
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabNames(): string[] {
|
||||||
|
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(name: string): HTMLButtonElement {
|
||||||
|
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`no "${name}" tab`);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReadout(): void {
|
||||||
|
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||||
|
fixture.componentRef.setInput('title', 'Milky Way');
|
||||||
|
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||||
|
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
|
||||||
|
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||||
|
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [HudDockComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
|
||||||
|
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
fixture = TestBed.createComponent(HudDockComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the search and what has been kept, when it has nothing else', () => {
|
||||||
|
// Bookmarks are always offered: it is the only place that says the map can keep anything.
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabNames()).toEqual(['Search', 'Bookmarks']);
|
||||||
|
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows a tab per thing it has been given', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('routing', true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the default tab on mount and renders the readout from its inputs', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||||
|
const text = host().textContent ?? '';
|
||||||
|
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
|
||||||
|
expect(text).toContain(expected);
|
||||||
|
}
|
||||||
|
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out the optional lines it was given nothing for', () => {
|
||||||
|
fixture.componentRef.setInput('title', 'Local Stars');
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().querySelector('dl')).toBeNull();
|
||||||
|
expect(host().textContent).not.toContain('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'search');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().textContent).toContain('21.5 kpc');
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('range', '');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().textContent).not.toContain('Range');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles a tab closed when it is clicked while open', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
tab('Readout').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||||
|
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
|
||||||
|
|
||||||
|
tab('Search').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host().querySelector('input[type="text"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the flipped layer and nothing else', () => {
|
||||||
|
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const emitted: HudDisplay[] = [];
|
||||||
|
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
|
||||||
|
|
||||||
|
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
|
||||||
|
orbits?.click();
|
||||||
|
|
||||||
|
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the layer state it is given as pressed buttons', () => {
|
||||||
|
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'display');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
||||||
|
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says how to keep a place, rather than showing an empty list', () => {
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(host().querySelector('#dock-panel-bookmarks ul')).toBeNull();
|
||||||
|
expect(host().textContent).toContain('Nothing kept yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists what has been kept, newest first, and says which kind each is', () => {
|
||||||
|
const bookmarks = TestBed.inject(BookmarksStore);
|
||||||
|
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||||
|
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const rows = [...host().querySelectorAll('#dock-panel-bookmarks li')].map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
|
||||||
|
expect(rows[0]).toContain('Earth');
|
||||||
|
expect(rows[0]).toContain('Body');
|
||||||
|
expect(rows[1]).toContain('Sirius');
|
||||||
|
expect(rows[1]).toContain('System');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands back the place that was chosen, whole', () => {
|
||||||
|
const bookmarks = TestBed.inject(BookmarksStore);
|
||||||
|
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const chosen: unknown[] = [];
|
||||||
|
fixture.componentInstance.bookmarkChosen.subscribe((bookmark) => chosen.push(bookmark));
|
||||||
|
|
||||||
|
host().querySelector<HTMLButtonElement>('#dock-panel-bookmarks li button')?.click();
|
||||||
|
|
||||||
|
expect(chosen).toEqual([{ kind: 'star', id: 7, name: 'Sirius' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets one without disturbing the rest', () => {
|
||||||
|
const bookmarks = TestBed.inject(BookmarksStore);
|
||||||
|
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
|
||||||
|
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'bookmarks');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
host().querySelector<HTMLButtonElement>('[aria-label="Forget Earth"]')?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Sirius']);
|
||||||
|
expect(host().textContent).not.toContain('Earth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the star the readout is about, and says so on the control', () => {
|
||||||
|
const bookmarks = TestBed.inject(BookmarksStore);
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('title', 'Sirius');
|
||||||
|
fixture.componentRef.setInput('keepableStarId', 7);
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const keep = host().querySelector<HTMLButtonElement>('[aria-label="Keep Sirius"]');
|
||||||
|
expect(keep?.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
keep?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(bookmarks.has('star', 7)).toBe(true);
|
||||||
|
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
|
||||||
|
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
|
||||||
|
const bookmarks = TestBed.inject(BookmarksStore);
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('title', 'Sol');
|
||||||
|
fixture.componentRef.setInput('keepableStarId', 0);
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(bookmarks.has('star', 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing to keep where the readout is a scale rather than a place', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(host().querySelector('[aria-label^="Keep"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the search on "/" from anywhere but a text field', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the panel back to the readout once a result is picked', () => {
|
||||||
|
setReadout();
|
||||||
|
fixture.componentRef.setInput('defaultTab', 'search');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentInstance.onPicked();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core';
|
||||||
|
|
||||||
|
import { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
|
import { SearchComponent } from '../search/search.component';
|
||||||
|
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
|
||||||
|
|
||||||
|
export interface HudReadout {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: string;
|
||||||
|
/**
|
||||||
|
* True when the figure was computed from other measurements rather than catalogued directly.
|
||||||
|
* Marked in the panel and explained in its footnote, so a reasoned number is never mistaken for
|
||||||
|
* an observed one.
|
||||||
|
*/
|
||||||
|
readonly derived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which layers the scene draws. Every one is a real object in the scene, toggled by visibility. */
|
||||||
|
export interface HudDisplay {
|
||||||
|
readonly labels: boolean;
|
||||||
|
readonly orbits: boolean;
|
||||||
|
readonly grid: boolean;
|
||||||
|
readonly deepSky: boolean;
|
||||||
|
readonly sky: boolean;
|
||||||
|
/** The rings on stars known to host planets. */
|
||||||
|
readonly systems: boolean;
|
||||||
|
/** The graph of crossings within the range the Routes panel is set to. */
|
||||||
|
readonly jumpLinks: boolean;
|
||||||
|
/** Drawn flat: an orthographic projection, looking down on the plane. */
|
||||||
|
readonly plan: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
|
||||||
|
|
||||||
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
|
{ key: 'labels', label: 'Labels' },
|
||||||
|
{ key: 'orbits', label: 'Orbits' },
|
||||||
|
{ key: 'grid', label: 'Grid' },
|
||||||
|
{ key: 'deepSky', label: 'Deep sky' },
|
||||||
|
{ key: 'sky', label: 'Sky' },
|
||||||
|
{ key: 'systems', label: 'Systems' },
|
||||||
|
{ key: 'jumpLinks', label: 'Jump links' },
|
||||||
|
{ key: 'plan', label: 'Plan view' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
||||||
|
|
||||||
|
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||||
|
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||||
|
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||||
|
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
|
||||||
|
|
||||||
|
function isWideViewport(): boolean {
|
||||||
|
return wideViewportQuery?.matches ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instrument's dock: one rail across the bottom of the viewport carrying every tool and
|
||||||
|
* readout, so the top of the screen keeps only the scale ladder and the nameplate.
|
||||||
|
*
|
||||||
|
* The tab strip is pinned to the bottom edge and never moves; whichever panel is open grows
|
||||||
|
* upward from it. That is why the search field sits at the *bottom* of its panel with the
|
||||||
|
* results above — the thing being typed into stays put while the list grows.
|
||||||
|
*
|
||||||
|
* `null` for the active tab is a real state, not an error: the strip alone. It is the default
|
||||||
|
* below `sm`, where the panel would cover most of the scene, and wherever there is no readout
|
||||||
|
* to show by default. Purely presentational: readouts and layer state arrive as inputs, the
|
||||||
|
* only things it emits are layer toggles.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hud-dock',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||||
|
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
||||||
|
template: `
|
||||||
|
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||||
|
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||||
|
click in the empty band beside the panel — where the scene, and its labels, are. -->
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
@if (activeTab(); as tab) {
|
||||||
|
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
|
||||||
|
locking on, once per switch, never per keystroke. -->
|
||||||
|
@switch (tab) {
|
||||||
|
@case ('search') {
|
||||||
|
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
|
||||||
|
<app-search (picked)="onPicked()" />
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@case ('readout') {
|
||||||
|
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||||
|
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||||
|
<div class="mt-1 flex items-start gap-2">
|
||||||
|
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||||
|
<!-- Against null, not against falsiness: the Sun's catalogue id is 0, and a
|
||||||
|
truthiness test is what would quietly make the Solar System the one
|
||||||
|
system nobody could keep. -->
|
||||||
|
@if (keepableStarId() !== null) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
|
||||||
|
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||||
|
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
|
||||||
|
class="shrink-0 p-1 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="bookmarks.has('star', keepableStarId()!) ? 'text-accent' : 'text-muted hover:text-accent'"
|
||||||
|
>
|
||||||
|
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', keepableStarId()!)" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (subtitle()) {
|
||||||
|
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
|
||||||
|
}
|
||||||
|
@if (readouts().length) {
|
||||||
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
|
@for (readout of readouts(); track readout.label) {
|
||||||
|
<div>
|
||||||
|
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
||||||
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
@if (note() || hasDerived()) {
|
||||||
|
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@case ('routes') {
|
||||||
|
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
||||||
|
<app-routes-panel
|
||||||
|
[result]="routeResult()"
|
||||||
|
[options]="routeOptions()"
|
||||||
|
[currentStar]="currentStar()"
|
||||||
|
(queryChange)="routeQuery.emit($event)"
|
||||||
|
(routeRequested)="routeRequested.emit($event)"
|
||||||
|
(starSelected)="onRouteStarSelected($event)"
|
||||||
|
(rangeChange)="jumpRangeChange.emit($event)"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@case ('bookmarks') {
|
||||||
|
<section id="dock-panel-bookmarks" role="tabpanel" aria-labelledby="dock-tab-bookmarks" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg">
|
||||||
|
@if (bookmarks.bookmarks().length) {
|
||||||
|
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
||||||
|
@for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) {
|
||||||
|
<li class="flex items-stretch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="onBookmarkChosen(bookmark)"
|
||||||
|
class="flex min-w-0 flex-1 items-baseline gap-3 px-3 py-2 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ bookmark.name }}</span>
|
||||||
|
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-label]="'Forget ' + bookmark.name"
|
||||||
|
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
||||||
|
class="shrink-0 border-l border-border/25 px-3 text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="px-3 py-3 text-sm text-muted">
|
||||||
|
Nothing kept yet. The <app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a readout or a body keeps it here, in this browser.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@case ('display') {
|
||||||
|
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||||
|
<p class="type-label text-muted">Layers</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@for (layer of layers; track layer.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[attr.aria-pressed]="isOn(layer.key)"
|
||||||
|
(click)="toggleLayer(layer.key)"
|
||||||
|
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
||||||
|
>
|
||||||
|
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||||
|
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
|
||||||
|
{{ layer.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
|
||||||
|
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
|
||||||
|
@for (tab of tabs(); track tab) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
[id]="'dock-tab-' + tab"
|
||||||
|
[attr.aria-selected]="activeTab() === tab"
|
||||||
|
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||||
|
(click)="toggleTab(tab)"
|
||||||
|
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||||
|
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
|
||||||
|
>
|
||||||
|
{{ tabLabel(tab) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (range()) {
|
||||||
|
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4">
|
||||||
|
<span class="type-label text-muted">Range</span>
|
||||||
|
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class HudDockComponent implements OnInit {
|
||||||
|
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||||
|
readonly eyebrow = input('');
|
||||||
|
readonly title = input('');
|
||||||
|
readonly subtitle = input('');
|
||||||
|
readonly readouts = input<readonly HudReadout[]>([]);
|
||||||
|
/** Standing caveat for the current view, e.g. that galactic structure is a model. */
|
||||||
|
readonly note = input('');
|
||||||
|
/** Camera range, pre-formatted by the scene, which is the only thing that knows the units. */
|
||||||
|
readonly range = input('');
|
||||||
|
/** Layer state; `null` means the surface has no layers to toggle and no Display tab. */
|
||||||
|
readonly display = input<HudDisplay | null>(null);
|
||||||
|
/** Which panel is open on a wide viewport when the dock mounts. */
|
||||||
|
readonly defaultTab = input<DockTab | null>(null);
|
||||||
|
/** Routing: what the scene found, what it offers for the fields, and where the view is. */
|
||||||
|
readonly routeResult = input<RouteResult | null>(null);
|
||||||
|
readonly routeOptions = input<readonly RouteStarOption[]>([]);
|
||||||
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
|
/** The star the readout is about, where there is one to keep — a scale is not a place. */
|
||||||
|
readonly keepableStarId = input<number | null>(null);
|
||||||
|
/** Present makes the Routes tab available; absent means this surface cannot route. */
|
||||||
|
readonly routing = input(false);
|
||||||
|
|
||||||
|
readonly displayChange = output<HudDisplay>();
|
||||||
|
readonly routeQuery = output<string>();
|
||||||
|
readonly routeRequested = output<RouteRequest>();
|
||||||
|
readonly routeStarSelected = output<number>();
|
||||||
|
readonly jumpRangeChange = output<number>();
|
||||||
|
readonly bookmarkChosen = output<Bookmark>();
|
||||||
|
|
||||||
|
readonly layers = DISPLAY_LAYERS;
|
||||||
|
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
|
||||||
|
readonly tabs = computed<readonly DockTab[]>(() => [
|
||||||
|
'search',
|
||||||
|
...(this.title() ? (['readout'] as const) : []),
|
||||||
|
...(this.routing() ? (['routes'] as const) : []),
|
||||||
|
// Always offered, even with nothing in it: it is the only place that says the map can keep
|
||||||
|
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||||
|
'bookmarks',
|
||||||
|
...(this.display() ? (['display'] as const) : [])
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly activeTab = signal<DockTab | null>(null);
|
||||||
|
|
||||||
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
|
||||||
|
private readonly search = viewChild(SearchComponent);
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.activeTab.set(isWideViewport() ? this.defaultTab() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabLabel(tab: DockTab): string {
|
||||||
|
return TAB_LABELS[tab];
|
||||||
|
}
|
||||||
|
|
||||||
|
isOn(key: keyof HudDisplay): boolean {
|
||||||
|
return this.display()?.[key] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTab(tab: DockTab): void {
|
||||||
|
this.activeTab.set(this.activeTab() === tab ? null : tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLayer(key: keyof HudDisplay): void {
|
||||||
|
const current = this.display();
|
||||||
|
if (current) {
|
||||||
|
this.displayChange.emit({ ...current, [key]: !current[key] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A step on a plotted route was chosen: fly there, and leave the route up to walk it from. */
|
||||||
|
onRouteStarSelected(starId: number): void {
|
||||||
|
this.routeStarSelected.emit(starId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choosing a kept place is the same move as choosing a search result: the panel has done
|
||||||
|
* its job and the thing to look at is now the scene. */
|
||||||
|
onBookmarkChosen(bookmark: Bookmark): void {
|
||||||
|
this.bookmarkChosen.emit(bookmark);
|
||||||
|
this.onPicked();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPicked(): void {
|
||||||
|
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
|
||||||
|
// readout where there is one, and fold the sheet away where there is not. On a narrow
|
||||||
|
// viewport it always folds away — there the panel is a sheet over most of the scene, and
|
||||||
|
// reopening it onto whatever was just flown to is the opposite of what was asked for.
|
||||||
|
this.activeTab.set(isWideViewport() && this.title() ? 'readout' : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/` opens the search from anywhere, unless something else is already taking text. */
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.activeTab.set('search');
|
||||||
|
// The field only exists after the panel renders; defer the focus to after that pass.
|
||||||
|
setTimeout(() => this.search()?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||||
|
@HostListener('document:pointerdown', ['$event'])
|
||||||
|
onDocumentPointerDown(event: PointerEvent): void {
|
||||||
|
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
|
||||||
|
this.activeTab.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import { formatParsecs } from '../../shared/format/quantity';
|
||||||
|
|
||||||
|
/** A star offered for one of the two fields, as the panel needs to show it. */
|
||||||
|
export interface RouteStarOption {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the scene worked out, once it has been asked. */
|
||||||
|
export interface RouteResult {
|
||||||
|
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
||||||
|
readonly stars: readonly { id: number; name: string }[];
|
||||||
|
readonly totalPc: number;
|
||||||
|
/** The shortest range that would open a route, where none was found at the one asked for. */
|
||||||
|
readonly neededRangePc: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRequest {
|
||||||
|
readonly fromId: number;
|
||||||
|
readonly toId: number;
|
||||||
|
readonly rangePc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which end of the journey a query is for. */
|
||||||
|
type Field = 'from' | 'to';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Departure, destination, range, and the chain between them.
|
||||||
|
*
|
||||||
|
* The one genuinely two-sided tool in the instrument, and the reason the range lives here
|
||||||
|
* rather than beside the layer toggle that draws the graph: the number that decides which
|
||||||
|
* crossings are possible is the same number in both places, and a control is easier to trust
|
||||||
|
* where its consequence is printed.
|
||||||
|
*
|
||||||
|
* Presentational. It knows how to ask; the scene knows the catalogue and does the walking.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-routes-panel',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
@for (field of fields; track field) {
|
||||||
|
<div class="relative">
|
||||||
|
<label [for]="'route-' + field" class="type-label text-muted">{{ field === 'from' ? 'Departure' : 'Destination' }}</label>
|
||||||
|
<input
|
||||||
|
[id]="'route-' + field"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
[value]="text(field)"
|
||||||
|
[placeholder]="field === 'from' ? 'From this system' : 'Search a star'"
|
||||||
|
(input)="onInput(field, $event)"
|
||||||
|
(keydown.escape)="closeOptions()"
|
||||||
|
class="hud-surface mt-1 w-full px-2.5 py-1.5 text-sm text-text caret-accent placeholder:text-muted focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
@if (open() === field && options().length) {
|
||||||
|
<ul class="hud-surface absolute bottom-full left-0 z-10 mb-1 max-h-48 w-full overflow-y-auto divide-y divide-border/25">
|
||||||
|
@for (option of options(); track option.id) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="choose(field, option)"
|
||||||
|
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ option.name }}</span>
|
||||||
|
<span class="type-label shrink-0 truncate text-muted">{{ option.subtitle }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label for="route-range" class="type-label shrink-0 text-muted">Jump range</label>
|
||||||
|
<input
|
||||||
|
id="route-range"
|
||||||
|
type="range"
|
||||||
|
[min]="minRangePc"
|
||||||
|
[max]="maxRangePc"
|
||||||
|
step="0.1"
|
||||||
|
[value]="rangePc()"
|
||||||
|
(input)="onRange($event)"
|
||||||
|
class="h-1 min-w-0 flex-1 appearance-none rounded-none bg-border accent-accent"
|
||||||
|
/>
|
||||||
|
<output for="route-range" class="w-20 shrink-0 text-right text-sm text-accent tabular-nums">{{ rangeLabel() }}</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[disabled]="!canPlot()"
|
||||||
|
(click)="plot()"
|
||||||
|
class="type-label border border-border/60 px-3 py-1.5 text-muted transition-colors enabled:hover:border-accent/70 enabled:hover:text-accent disabled:opacity-40 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
Plot route
|
||||||
|
</button>
|
||||||
|
@if (result(); as plotted) {
|
||||||
|
@if (plotted.stars.length) {
|
||||||
|
<p data-testid="route-summary" class="text-sm text-text tabular-nums">
|
||||||
|
{{ plotted.stars.length - 1 }} {{ plotted.stars.length === 2 ? 'jump' : 'jumps' }} <span class="text-muted">·</span> {{ format(plotted.totalPc) }}
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p data-testid="route-summary" class="text-sm text-muted">
|
||||||
|
No route at this range.
|
||||||
|
@if (plotted.neededRangePc !== null) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="raiseTo(plotted.neededRangePc)"
|
||||||
|
class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
{{ format(plotted.neededRangePc) }} would reach.
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
Nothing in the catalogue bridges the gap.
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (result()?.stars?.length) {
|
||||||
|
<ol data-testid="route-steps" class="hud-surface divide-y divide-border/25">
|
||||||
|
@for (step of result()!.stars; track step.id; let i = $index) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="starSelected.emit(step.id)"
|
||||||
|
class="flex w-full items-baseline gap-3 px-2.5 py-1.5 text-left transition-colors hover:bg-accent/8 focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span class="type-label w-6 shrink-0 text-muted tabular-nums">{{ i + 1 }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ step.name }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class RoutesPanelComponent {
|
||||||
|
/** Whatever the scene found for the last query it was given. */
|
||||||
|
readonly result = input<RouteResult | null>(null);
|
||||||
|
/** Matches for the field currently being typed into, ranked by the scene. */
|
||||||
|
readonly options = input<readonly RouteStarOption[]>([]);
|
||||||
|
/** The star the view is currently inside, offered as the departure without typing. */
|
||||||
|
readonly currentStar = input<RouteStarOption | null>(null);
|
||||||
|
|
||||||
|
readonly queryChange = output<string>();
|
||||||
|
readonly routeRequested = output<RouteRequest>();
|
||||||
|
readonly starSelected = output<number>();
|
||||||
|
/** The graph is drawn at whatever range this panel is set to, so the scene follows it. */
|
||||||
|
readonly rangeChange = output<number>();
|
||||||
|
|
||||||
|
readonly fields: readonly Field[] = ['from', 'to'];
|
||||||
|
/** A tenth of a parsec is finer than the catalogue's own distances are known to. */
|
||||||
|
readonly minRangePc = 0.5;
|
||||||
|
/** Beyond this the graph is a solid sheet of lines and every pair of stars is connected. */
|
||||||
|
readonly maxRangePc = 8;
|
||||||
|
|
||||||
|
readonly rangePc = signal(3);
|
||||||
|
readonly open = signal<Field | null>(null);
|
||||||
|
|
||||||
|
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
|
||||||
|
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
|
||||||
|
|
||||||
|
/** Departure falls back to wherever the view already is, so one field is usually enough. */
|
||||||
|
private readonly departure = computed(() => this.chosen().from ?? this.currentStar());
|
||||||
|
|
||||||
|
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
|
||||||
|
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));
|
||||||
|
|
||||||
|
text(field: Field): string {
|
||||||
|
return this.chosen()[field]?.name ?? this.typed()[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
format(distancePc: number): string {
|
||||||
|
return formatParsecs(distancePc);
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(field: Field, event: Event): void {
|
||||||
|
const value = (event.target as HTMLInputElement).value;
|
||||||
|
this.typed.update((current) => ({ ...current, [field]: value }));
|
||||||
|
// Typing over a chosen star un-chooses it: the field says what it will be searched for.
|
||||||
|
this.chosen.update((current) => ({ ...current, [field]: null }));
|
||||||
|
this.open.set(field);
|
||||||
|
this.queryChange.emit(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
choose(field: Field, option: RouteStarOption): void {
|
||||||
|
this.chosen.update((current) => ({ ...current, [field]: option }));
|
||||||
|
this.closeOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOptions(): void {
|
||||||
|
this.open.set(null);
|
||||||
|
this.queryChange.emit('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onRange(event: Event): void {
|
||||||
|
const value = Number((event.target as HTMLInputElement).value);
|
||||||
|
this.rangePc.set(value);
|
||||||
|
this.rangeChange.emit(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseTo(rangePc: number): void {
|
||||||
|
// Rounded up to the control's own step, so the number shown is one it can actually hold —
|
||||||
|
// and up rather than down, since down would land just short of the crossing it names.
|
||||||
|
const stepped = Math.min(this.maxRangePc, Math.ceil(rangePc * 10) / 10);
|
||||||
|
this.rangePc.set(stepped);
|
||||||
|
this.rangeChange.emit(stepped);
|
||||||
|
this.plot();
|
||||||
|
}
|
||||||
|
|
||||||
|
plot(): void {
|
||||||
|
const from = this.departure();
|
||||||
|
const to = this.chosen().to;
|
||||||
|
if (from && to) {
|
||||||
|
this.routeRequested.emit({ fromId: from.id, toId: to.id, rangePc: this.rangePc() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, computed, signal } from '@angular/core';
|
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||||
|
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||||
|
|
||||||
const MAX_RESULTS = 8;
|
const MAX_RESULTS = 8;
|
||||||
@@ -22,54 +23,92 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search',
|
selector: 'app-search',
|
||||||
|
imports: [ReticleIconComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="fixed top-4 left-1/2 z-20 w-[22rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
|
<!-- Sits on the dock's tab strip: the field is pinned to the bottom of the block and the
|
||||||
<div class="relative">
|
results grow upward above it, so typing never moves the thing being typed into. DOM
|
||||||
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
order stays field-then-results (flex-col-reverse), which is also the focus order. -->
|
||||||
<circle cx="11" cy="11" r="7" />
|
<div class="flex flex-col-reverse font-body">
|
||||||
<path d="M21 21l-4.3-4.3" />
|
<!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
|
||||||
</svg>
|
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
|
||||||
|
the star field. -->
|
||||||
|
<div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
|
||||||
|
<app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
|
||||||
<input
|
<input
|
||||||
|
#field
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search stars, planets, exoplanets…"
|
placeholder="Search stars, planets, exoplanets…"
|
||||||
[value]="query()"
|
[value]="query()"
|
||||||
(input)="onInput($event)"
|
(input)="onInput($event)"
|
||||||
(keydown.escape)="clear()"
|
(keydown.escape)="clear()"
|
||||||
class="w-full rounded-md border border-border bg-panel/85 py-2 pl-9 pr-3 text-sm text-text backdrop-blur-md transition-colors placeholder:text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
|
class="w-full bg-transparent py-2.5 pr-3 pl-10 text-sm tracking-[0.02em] text-text caret-accent placeholder:text-muted focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@if (results().length) {
|
|
||||||
<ul data-testid="search-results" class="mt-1 divide-y divide-border/40 overflow-hidden rounded-md border border-border bg-panel/90 backdrop-blur-md">
|
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
|
||||||
@for (result of results(); track result.bodyId ?? result.starId) {
|
the rows instead of remounting the panel. Gated on the index being ready: "no matches"
|
||||||
<li>
|
may only ever describe a search that actually ran against the loaded catalogues. -->
|
||||||
<button
|
@if (indexReady() && hasQuery()) {
|
||||||
type="button"
|
<div class="hud-surface mb-2">
|
||||||
(click)="select(result)"
|
@if (results().length) {
|
||||||
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-accent/10 focus:bg-accent/10 focus:outline-none"
|
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
|
||||||
>
|
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
|
||||||
<span class="text-sm text-text">{{ result.name }}</span>
|
@if (matchTotal() > results().length) {
|
||||||
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
<span> · top {{ results().length }}</span>
|
||||||
</button>
|
}
|
||||||
</li>
|
</p>
|
||||||
|
<ul data-testid="search-results" class="divide-y divide-border/25">
|
||||||
|
@for (result of results(); track result.bodyId ?? result.starId) {
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="select(result)"
|
||||||
|
class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
|
||||||
|
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="type-label px-3 py-2.5 text-center text-muted">Nothing in the catalog matches <span class="normal-case text-text">“{{ query().trim() }}”</span></p>
|
||||||
}
|
}
|
||||||
</ul>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class SearchComponent {
|
export class SearchComponent {
|
||||||
readonly query = signal('');
|
readonly query = signal('');
|
||||||
|
/** Fires once a result has been chosen and navigation kicked off — the dock uses it to hand
|
||||||
|
* the view back to the readout, since the thing to look at is now the scene, not the box. */
|
||||||
|
readonly picked = output<void>();
|
||||||
|
|
||||||
|
private readonly field = viewChild.required<ElementRef<HTMLInputElement>>('field');
|
||||||
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
|
||||||
private readonly index = signal<IndexedSearchEntry[]>([]);
|
private readonly index = signal<IndexedSearchEntry[]>([]);
|
||||||
|
/** False until the catalogues have loaded — and forever if they fail, which beats a false
|
||||||
|
* "no matches" for bodies that exist but could not be fetched. */
|
||||||
|
readonly indexReady = signal(false);
|
||||||
|
|
||||||
readonly results = computed(() => {
|
/** True once the query is long enough to have been searched — so "no matches" is only ever
|
||||||
|
* reported about a search that actually ran, never about a half-typed word. */
|
||||||
|
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
|
||||||
|
|
||||||
|
/** Every match, ranked. Kept separate from the display slice so the "Matches" count can
|
||||||
|
* report the real total rather than the page size. */
|
||||||
|
private readonly matches = computed(() => {
|
||||||
const query = this.query().trim();
|
const query = this.query().trim();
|
||||||
if (query.length < MIN_QUERY_LENGTH) {
|
if (query.length < MIN_QUERY_LENGTH) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return rankSearchResults(this.index(), query, MAX_RESULTS);
|
return rankSearchResults(this.index(), query, Number.POSITIVE_INFINITY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly matchTotal = computed(() => this.matches().length);
|
||||||
|
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataLoader: DataLoaderService,
|
private readonly dataLoader: DataLoaderService,
|
||||||
private readonly navigationStore: NavigationStore,
|
private readonly navigationStore: NavigationStore,
|
||||||
@@ -86,6 +125,10 @@ export class SearchComponent {
|
|||||||
this.query.set('');
|
this.query.set('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.field().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
kindLabel(kind: SearchResultKind): string {
|
kindLabel(kind: SearchResultKind): string {
|
||||||
return KIND_LABELS[kind];
|
return KIND_LABELS[kind];
|
||||||
}
|
}
|
||||||
@@ -98,6 +141,7 @@ export class SearchComponent {
|
|||||||
} else if (result.bodyId) {
|
} else if (result.bodyId) {
|
||||||
void this.router.navigate(['/body', result.bodyId]);
|
void this.router.navigate(['/body', result.bodyId]);
|
||||||
}
|
}
|
||||||
|
this.picked.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildIndex(): Promise<void> {
|
private async buildIndex(): Promise<void> {
|
||||||
@@ -115,6 +159,7 @@ export class SearchComponent {
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.index.set(buildSearchIndex(entries));
|
this.index.set(buildSearchIndex(entries));
|
||||||
|
this.indexReady.set(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to build the search index.', error);
|
console.error('Failed to build the search index.', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { collectJumpLinks, minimumRangeBetween, routeBetween } from './jump-links';
|
||||||
|
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/** Stars a parsec apart along x, so a chain's length is the number of hops it takes. */
|
||||||
|
function chain(count: number): StarNeighbourhood {
|
||||||
|
return new StarNeighbourhood(Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function index(points: StarPoint[]): StarNeighbourhood {
|
||||||
|
return new StarNeighbourhood(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routeBetween', () => {
|
||||||
|
it('walks the chain a hop at a time when that is all the range allows', () => {
|
||||||
|
const route = routeBetween(chain(5), 0, 4, 1.5);
|
||||||
|
|
||||||
|
expect(route?.stars).toEqual([0, 1, 2, 3, 4]);
|
||||||
|
expect(route?.totalPc).toBeCloseTo(4);
|
||||||
|
expect(route?.longestHopPc).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes straight there when the range reaches, however many stars lie between', () => {
|
||||||
|
// The direct crossing is never longer than a chain through anything — Euclid says so — so a
|
||||||
|
// range that covers it makes it the answer, and the stars in between are just scenery.
|
||||||
|
const route = routeBetween(chain(5), 0, 4, 5);
|
||||||
|
|
||||||
|
expect(route?.stars).toEqual([0, 4]);
|
||||||
|
expect(route?.totalPc).toBeCloseTo(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the shorter of two ways round when neither is a straight line', () => {
|
||||||
|
// 0 to 3 is 10 pc, out of a 6 pc range. Two ways round, both inside it: through 1, barely
|
||||||
|
// off the line, or through 2, well off it. Shorter is what "the way there" means.
|
||||||
|
const route = routeBetween(
|
||||||
|
index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 5, y: 0.5, z: 0 },
|
||||||
|
{ id: 2, x: 5, y: 3, z: 0 },
|
||||||
|
{ id: 3, x: 10, y: 0, z: 0 }
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
6
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route?.stars).toEqual([0, 1, 3]);
|
||||||
|
expect(route?.totalPc).toBeCloseTo(10.05, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds nothing across a gap wider than the range', () => {
|
||||||
|
const split = index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 20, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(routeBetween(split, 0, 2, 5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers nothing for a star that is not there, or for going nowhere', () => {
|
||||||
|
const line = chain(3);
|
||||||
|
|
||||||
|
expect(routeBetween(line, 0, 0, 2)).toBeNull();
|
||||||
|
expect(routeBetween(line, 0, 99, 2)).toBeNull();
|
||||||
|
expect(routeBetween(line, 0, 2, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the longest hop, which is what the range has to cover', () => {
|
||||||
|
const route = routeBetween(
|
||||||
|
index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 5, y: 0, z: 0 }
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route?.longestHopPc).toBeCloseTo(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('minimumRangeBetween', () => {
|
||||||
|
it('names the shortest range that opens a way through', () => {
|
||||||
|
// Hops of 1 and 4: no range under 4 connects them, and 4 exactly does.
|
||||||
|
const stepped = index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 1, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 5, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(minimumRangeBetween(stepped, 0, 2, 50)).toBeCloseTo(4);
|
||||||
|
expect(routeBetween(stepped, 0, 2, 4)).not.toBeNull();
|
||||||
|
expect(routeBetween(stepped, 0, 2, 3.99)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a longer way whose worst hop is shorter, since that is what the range pays for', () => {
|
||||||
|
// Direct: one hop of 10. Round: three hops of at most 4. The range only has to cover 4.
|
||||||
|
const both = index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 0, y: 4, z: 0 },
|
||||||
|
{ id: 2, x: 6, y: 7, z: 0 },
|
||||||
|
{ id: 3, x: 10, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const needed = minimumRangeBetween(both, 0, 3, 50);
|
||||||
|
|
||||||
|
expect(needed).toBeLessThan(10);
|
||||||
|
expect(routeBetween(both, 0, 3, needed!)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds nothing when even the ceiling does not reach', () => {
|
||||||
|
const split = index([
|
||||||
|
{ id: 0, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 1, x: 100, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(minimumRangeBetween(split, 0, 1, 50)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collectJumpLinks', () => {
|
||||||
|
it('reports each pair once, not once from either end', () => {
|
||||||
|
const links = collectJumpLinks(chain(4), 1.5);
|
||||||
|
|
||||||
|
expect(links.map((link) => [link.from, link.to])).toEqual([
|
||||||
|
[0, 1],
|
||||||
|
[1, 2],
|
||||||
|
[2, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures every link it reports', () => {
|
||||||
|
const links = collectJumpLinks(chain(3), 2.5);
|
||||||
|
|
||||||
|
expect(links.find((link) => link.from === 0 && link.to === 2)?.distancePc).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing at no range', () => {
|
||||||
|
expect(collectJumpLinks(chain(4), 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with every route it makes possible', () => {
|
||||||
|
// The graph drawn and the graph walked have to be the same graph, or the map shows a way
|
||||||
|
// the route cannot take.
|
||||||
|
let seed = 11;
|
||||||
|
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 30 - 15;
|
||||||
|
const points: StarPoint[] = Array.from({ length: 120 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||||
|
const cloud = index(points);
|
||||||
|
// 9 rather than 6: at 6 this cloud falls into pieces and 0 never reaches 119, which an
|
||||||
|
// earlier version of this test hid by only checking the route it happened to find.
|
||||||
|
const range = 9;
|
||||||
|
|
||||||
|
const links = collectJumpLinks(cloud, range);
|
||||||
|
const drawn = new Set(links.map((link) => `${link.from}-${link.to}`));
|
||||||
|
|
||||||
|
const route = routeBetween(cloud, 0, 119, range);
|
||||||
|
// Asserted, not guarded: a skipped body would let the two disagree unnoticed.
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route!.stars.length).toBeGreaterThan(2);
|
||||||
|
for (let i = 1; i < route!.stars.length; i++) {
|
||||||
|
const [a, b] = [route!.stars[i - 1], route!.stars[i]].sort((x, y) => x - y);
|
||||||
|
expect(drawn.has(`${a}-${b}`)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(links.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Which stars are within reach of which, and how to get from one to another through them.
|
||||||
|
*
|
||||||
|
* A "jump link" is nothing more than a pair of catalogued stars closer together than some
|
||||||
|
* chosen range. It is not a feature of space — there are no corridors out there — it is a
|
||||||
|
* question asked of the catalogue: if a crossing of at most this far can be made, which stars
|
||||||
|
* can be strung together, and what is the shortest chain from here to there.
|
||||||
|
*
|
||||||
|
* Two facts about the catalogue shape everything here, and both are worth stating because the
|
||||||
|
* answers look like defects otherwise. It is magnitude-limited, so it is dense around the Sun
|
||||||
|
* and thins with distance: within 50 pc a 3 pc range links 99% of it into one piece, while over
|
||||||
|
* the whole 250 pc reach the same range leaves most stars alone. And a gap in it is a gap in
|
||||||
|
* what has been catalogued, not in what is there. So a route that cannot be found is a
|
||||||
|
* statement about the map, and `minimumRangeBetween` exists to say which.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StarNeighbourhood } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/** A chain of stars from one to another, each hop within the range that was asked for. */
|
||||||
|
export interface Route {
|
||||||
|
/** Star ids, departure first and destination last. One hop is two ids. */
|
||||||
|
readonly stars: readonly number[];
|
||||||
|
/** The sum of the hops, in parsecs. */
|
||||||
|
readonly totalPc: number;
|
||||||
|
/**
|
||||||
|
* The longest single hop. The range has to cover this and nothing wider, so it is what a
|
||||||
|
* reader checks a route against — and it is the figure `minimumRangeBetween` minimises.
|
||||||
|
*/
|
||||||
|
readonly longestHopPc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unordered pair of stars within range of each other. */
|
||||||
|
export interface JumpLink {
|
||||||
|
readonly from: number;
|
||||||
|
readonly to: number;
|
||||||
|
readonly distancePc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cap on how much of the catalogue one search may walk. Reached only where a route does not
|
||||||
|
* exist and the range is wide enough to make most of the catalogue one component; a search that
|
||||||
|
* hits it has already visited more stars than any real chain passes through.
|
||||||
|
*/
|
||||||
|
const MAX_VISITED = 20000;
|
||||||
|
|
||||||
|
/** Pops the smallest-cost entry. A linear scan: the frontier is small next to the work per node. */
|
||||||
|
function takeCheapest<T>(frontier: Map<number, T>, costOf: (value: T) => number): [number, T] | undefined {
|
||||||
|
let bestId: number | undefined;
|
||||||
|
let bestValue: T | undefined;
|
||||||
|
let bestCost = Number.POSITIVE_INFINITY;
|
||||||
|
for (const [id, value] of frontier) {
|
||||||
|
const cost = costOf(value);
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
bestId = id;
|
||||||
|
bestValue = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestId === undefined || bestValue === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
frontier.delete(bestId);
|
||||||
|
return [bestId, bestValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild(cameFrom: Map<number, number>, fromId: number, toId: number): number[] {
|
||||||
|
const stars = [toId];
|
||||||
|
let at = toId;
|
||||||
|
while (at !== fromId) {
|
||||||
|
const previous = cameFrom.get(at);
|
||||||
|
if (previous === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
stars.push(previous);
|
||||||
|
at = previous;
|
||||||
|
}
|
||||||
|
return stars.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortest chain from one star to another in which no single hop exceeds `rangePc`, or
|
||||||
|
* `null` where the catalogue holds no such chain.
|
||||||
|
*
|
||||||
|
* Shortest by total distance travelled rather than by number of hops: two chains of the same
|
||||||
|
* length are not equally good, and the one that covers less ground is the one a reader means by
|
||||||
|
* "the way there". Neighbours are asked for as the search reaches each star rather than built
|
||||||
|
* into a graph first, so finding one route never costs a pass over the whole catalogue.
|
||||||
|
*/
|
||||||
|
export function routeBetween(index: StarNeighbourhood, fromId: number, toId: number, rangePc: number): Route | null {
|
||||||
|
if (fromId === toId || rangePc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = new Map<number, number>([[fromId, 0]]);
|
||||||
|
const cameFrom = new Map<number, number>();
|
||||||
|
const settled = new Set<number>();
|
||||||
|
const frontier = new Map<number, number>([[fromId, 0]]);
|
||||||
|
|
||||||
|
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
||||||
|
const cheapest = takeCheapest(frontier, (cost) => cost);
|
||||||
|
if (!cheapest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const [starId, costHere] = cheapest;
|
||||||
|
if (settled.has(starId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
settled.add(starId);
|
||||||
|
|
||||||
|
if (starId === toId) {
|
||||||
|
const stars = rebuild(cameFrom, fromId, toId);
|
||||||
|
return stars.length === 0 ? null : { stars, totalPc: costHere, longestHopPc: longestHop(index, stars) };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const neighbour of index.within(starId, rangePc)) {
|
||||||
|
if (settled.has(neighbour.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cost = costHere + neighbour.distancePc;
|
||||||
|
if (cost < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
||||||
|
best.set(neighbour.id, cost);
|
||||||
|
cameFrom.set(neighbour.id, starId);
|
||||||
|
frontier.set(neighbour.id, cost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestHop(index: StarNeighbourhood, stars: readonly number[]): number {
|
||||||
|
let longest = 0;
|
||||||
|
for (let i = 1; i < stars.length; i++) {
|
||||||
|
const a = index.point(stars[i - 1]);
|
||||||
|
const b = index.point(stars[i]);
|
||||||
|
if (a && b) {
|
||||||
|
longest = Math.max(longest, Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortest range at which any chain at all exists between two stars, or `null` if none does
|
||||||
|
* within `ceilingPc`.
|
||||||
|
*
|
||||||
|
* This is what turns "no route" from a dead end into an answer: the range control can be told
|
||||||
|
* what it would have to be raised to. It is the minimax path — the chain whose longest hop is as
|
||||||
|
* short as possible — found by the same search as above, with the cost of reaching a star being
|
||||||
|
* the longest hop taken to get there rather than the sum of them.
|
||||||
|
*/
|
||||||
|
export function minimumRangeBetween(index: StarNeighbourhood, fromId: number, toId: number, ceilingPc: number): number | null {
|
||||||
|
if (fromId === toId || ceilingPc <= 0 || !index.point(fromId) || !index.point(toId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = new Map<number, number>([[fromId, 0]]);
|
||||||
|
const settled = new Set<number>();
|
||||||
|
const frontier = new Map<number, number>([[fromId, 0]]);
|
||||||
|
|
||||||
|
while (frontier.size > 0 && settled.size < MAX_VISITED) {
|
||||||
|
const cheapest = takeCheapest(frontier, (cost) => cost);
|
||||||
|
if (!cheapest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const [starId, worstHopHere] = cheapest;
|
||||||
|
if (settled.has(starId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
settled.add(starId);
|
||||||
|
|
||||||
|
if (starId === toId) {
|
||||||
|
return worstHopHere;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const neighbour of index.within(starId, ceilingPc)) {
|
||||||
|
if (settled.has(neighbour.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// What this chain would need: the longest hop on it, not the distance covered by it.
|
||||||
|
const needed = Math.max(worstHopHere, neighbour.distancePc);
|
||||||
|
if (needed < (best.get(neighbour.id) ?? Number.POSITIVE_INFINITY)) {
|
||||||
|
best.set(neighbour.id, needed);
|
||||||
|
frontier.set(neighbour.id, needed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every link within `rangePc` in the whole catalogue, each pair once.
|
||||||
|
*
|
||||||
|
* For drawing the graph, which is the only thing that wants all of it: routing asks for a
|
||||||
|
* star's neighbours as it reaches that star and never builds this.
|
||||||
|
*/
|
||||||
|
export function collectJumpLinks(index: StarNeighbourhood, rangePc: number): JumpLink[] {
|
||||||
|
const links: JumpLink[] = [];
|
||||||
|
index.forEachPairWithin(rangePc, (a, b, distancePc) => {
|
||||||
|
// The smaller id first, always. The grid hands pairs over in whatever order it walks its
|
||||||
|
// cells, and a link that is `3-7` here and `7-3` there is two links to anything comparing.
|
||||||
|
links.push(a.id < b.id ? { from: a.id, to: b.id, distancePc } : { from: b.id, to: a.id, distancePc });
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
|
||||||
|
|
||||||
|
/** A line of stars one parsec apart along x, so every expected distance is an integer. */
|
||||||
|
function line(count: number): StarPoint[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({ id: i, x: i, y: 0, z: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ids(found: { id: number }[]): number[] {
|
||||||
|
return found.map((neighbour) => neighbour.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StarNeighbourhood', () => {
|
||||||
|
it('names the nearest stars in order, and never the star itself', () => {
|
||||||
|
const index = new StarNeighbourhood(line(10));
|
||||||
|
|
||||||
|
expect(ids(index.nearest(4, 3))).toEqual([3, 5, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures the separation it found each star by', () => {
|
||||||
|
const index = new StarNeighbourhood([
|
||||||
|
{ id: 1, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 3, y: 4, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(index.nearest(1, 1)[0].distancePc).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches past its own cell for a star sitting alone in one', () => {
|
||||||
|
// 5 pc cells: these three are in three different cells, and the nearest is 12 pc out.
|
||||||
|
const index = new StarNeighbourhood([
|
||||||
|
{ id: 1, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 12, y: 0, z: 0 },
|
||||||
|
{ id: 3, x: 40, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ids(index.nearest(1, 2))).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stop at the first ring that fills the list, where the next holds something closer', () => {
|
||||||
|
// The diagonal neighbour is in the ring-1 shell but 8.7 pc away; the one straight along x is
|
||||||
|
// in the ring-2 shell and only 6 pc away. Stopping at the first full ring would miss it.
|
||||||
|
const index = new StarNeighbourhood([
|
||||||
|
{ id: 1, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 5, y: 5, z: 5 },
|
||||||
|
{ id: 3, x: 6, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ids(index.nearest(1, 1))).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrees with a brute-force scan over a pseudo-random cloud', () => {
|
||||||
|
// The property that matters: the grid is an optimisation, never a different answer.
|
||||||
|
let seed = 7;
|
||||||
|
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 100 - 50;
|
||||||
|
const cloud: StarPoint[] = Array.from({ length: 400 }, (_, id) => ({ id, x: random(), y: random(), z: random() }));
|
||||||
|
const index = new StarNeighbourhood(cloud);
|
||||||
|
|
||||||
|
for (const origin of [cloud[0], cloud[199], cloud[399]]) {
|
||||||
|
const brute = cloud
|
||||||
|
.filter((point) => point.id !== origin.id)
|
||||||
|
.map((point) => ({ id: point.id, distancePc: Math.hypot(point.x - origin.x, point.y - origin.y, point.z - origin.z) }))
|
||||||
|
.sort((a, b) => a.distancePc - b.distancePc);
|
||||||
|
|
||||||
|
expect(ids(index.nearest(origin.id, 5))).toEqual(ids(brute.slice(0, 5)));
|
||||||
|
expect(ids(index.within(origin.id, 20))).toEqual(ids(brute.filter((neighbour) => neighbour.distancePc <= 20)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes only the stars a filter accepts', () => {
|
||||||
|
const index = new StarNeighbourhood(line(10));
|
||||||
|
|
||||||
|
expect(ids(index.nearest(4, 2, (point) => point.id % 2 === 0))).toEqual([2, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the stars it is told to prefer first, and fills with the rest only when short', () => {
|
||||||
|
const index = new StarNeighbourhood(line(10));
|
||||||
|
const even = (point: StarPoint) => point.id % 2 === 0;
|
||||||
|
|
||||||
|
// Enough even stars: the odd ones next door, though nearer, do not get a look in.
|
||||||
|
expect(ids(index.nearestPreferring(4, 2, even))).toEqual([2, 6]);
|
||||||
|
// Not enough: every even star in reach, then the nearest of the others.
|
||||||
|
expect(ids(index.nearestPreferring(4, 6, even))).toEqual([2, 6, 0, 8, 3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers nothing for a star it has never heard of', () => {
|
||||||
|
const index = new StarNeighbourhood(line(3));
|
||||||
|
|
||||||
|
expect(index.nearest(99, 3)).toEqual([]);
|
||||||
|
expect(index.within(99, 10)).toEqual([]);
|
||||||
|
expect(index.point(99)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for nothing and gets nothing', () => {
|
||||||
|
const index = new StarNeighbourhood(line(5));
|
||||||
|
|
||||||
|
expect(index.nearest(0, 0)).toEqual([]);
|
||||||
|
expect(index.within(0, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds every star inside a radius and none on the far side of it', () => {
|
||||||
|
const index = new StarNeighbourhood(line(20));
|
||||||
|
|
||||||
|
expect(ids(index.within(10, 2.5))).toEqual([9, 11, 8, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds stars that share a position without losing either', () => {
|
||||||
|
// Real catalogue rows do this: Gl 65 A and B are one binary, two entries, one position.
|
||||||
|
const index = new StarNeighbourhood([
|
||||||
|
{ id: 1, x: 0, y: 0, z: 0 },
|
||||||
|
{ id: 2, x: 2.63, y: 0, z: 0 },
|
||||||
|
{ id: 3, x: 2.63, y: 0, z: 0 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ids(index.nearest(1, 2)).sort()).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Which stars are near which, over the whole catalogue.
|
||||||
|
*
|
||||||
|
* Two questions are asked of the same catalogue and answered here once: "what are the k nearest
|
||||||
|
* stars to this one" (the neighbour labels shown from inside a system) and "which pairs lie
|
||||||
|
* within n parsecs of each other" (the jump-link graph). A linear scan answers the first
|
||||||
|
* acceptably — 68 000 distance tests, once, on entering a system — and the second not at all: a
|
||||||
|
* graph over a few thousand nodes is a few thousand scans, and the quadratic shows.
|
||||||
|
*
|
||||||
|
* So both run on a uniform grid keyed by cell coordinates. The catalogue is a dense blob around
|
||||||
|
* the Sun thinning out to 250 pc, which is exactly the distribution a uniform grid handles
|
||||||
|
* badly in the dense middle and well everywhere else — but the queries are all small radii in
|
||||||
|
* that same dense middle, where a cell holds a handful of stars, so the cost lands where the
|
||||||
|
* answers are. A KD-tree would be tighter and is not yet worth its code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A catalogued star reduced to what proximity needs: an id and a position in parsecs. */
|
||||||
|
export interface StarPoint {
|
||||||
|
readonly id: number;
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A star found near another, with the separation that found it. */
|
||||||
|
export interface Neighbour {
|
||||||
|
readonly id: number;
|
||||||
|
readonly distancePc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell edge in parsecs. Sized so a cell in the crowded inner catalogue holds a few dozen stars:
|
||||||
|
* small enough that a 5 pc query touches a handful of cells, large enough that a 250 pc
|
||||||
|
* catalogue does not allocate a map with a million keys.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CELL_SIZE_PC = 5;
|
||||||
|
|
||||||
|
/** Grows the search a shell of cells at a time; the cap stops a query in empty space forever. */
|
||||||
|
const MAX_RING = 12;
|
||||||
|
|
||||||
|
function cellKey(ix: number, iy: number, iz: number): string {
|
||||||
|
return `${ix},${iy},${iz}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StarNeighbourhood {
|
||||||
|
private readonly cells = new Map<string, number[]>();
|
||||||
|
private readonly points: readonly StarPoint[];
|
||||||
|
private readonly indexById = new Map<number, number>();
|
||||||
|
private readonly cellSizePc: number;
|
||||||
|
|
||||||
|
constructor(points: readonly StarPoint[], cellSizePc: number = DEFAULT_CELL_SIZE_PC) {
|
||||||
|
this.points = points;
|
||||||
|
this.cellSizePc = cellSizePc > 0 ? cellSizePc : DEFAULT_CELL_SIZE_PC;
|
||||||
|
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
this.indexById.set(point.id, index);
|
||||||
|
const key = this.keyFor(point.x, point.y, point.z);
|
||||||
|
const cell = this.cells.get(key);
|
||||||
|
if (cell) {
|
||||||
|
cell.push(index);
|
||||||
|
} else {
|
||||||
|
this.cells.set(key, [index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The star this id names, or `undefined` — the caller's id may not be in the catalogue. */
|
||||||
|
point(id: number): StarPoint | undefined {
|
||||||
|
const index = this.indexById.get(id);
|
||||||
|
return index === undefined ? undefined : this.points[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `nearest`, but the stars `prefer` accepts come first, and the rest only fill what is
|
||||||
|
* left. The preferred pass exhausts the search before the fill runs, so a preferred star is
|
||||||
|
* never outranked by an ordinary one that happens to be closer — that is the point of asking.
|
||||||
|
*/
|
||||||
|
nearestPreferring(id: number, count: number, prefer: (point: StarPoint) => boolean): Neighbour[] {
|
||||||
|
const preferred = this.nearest(id, count, prefer);
|
||||||
|
if (preferred.length >= count) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
const taken = new Set(preferred.map((neighbour) => neighbour.id));
|
||||||
|
return preferred.concat(this.nearest(id, count - preferred.length, (point) => !taken.has(point.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `count` stars nearest to `id`, nearest first, excluding the star itself.
|
||||||
|
*
|
||||||
|
* Searches outward a shell of cells at a time and stops only once the shell it just finished
|
||||||
|
* lies further away than the furthest result held — the ring that contains the kth star can
|
||||||
|
* still be beaten by a closer star in the next ring out, since a cell's near corner is nearer
|
||||||
|
* than its centre.
|
||||||
|
*/
|
||||||
|
nearest(id: number, count: number, filter?: (point: StarPoint) => boolean): Neighbour[] {
|
||||||
|
const origin = this.point(id);
|
||||||
|
if (!origin || count <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const found: Neighbour[] = [];
|
||||||
|
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||||
|
|
||||||
|
for (let ring = 0; ring <= MAX_RING; ring++) {
|
||||||
|
// Everything in this ring is at least this far away, so once the results already held are
|
||||||
|
// all closer than that, no further ring can improve them.
|
||||||
|
if (found.length >= count && (ring - 1) * this.cellSizePc > found[found.length - 1].distancePc) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of this.ringIndices(ox, oy, oz, ring)) {
|
||||||
|
const candidate = this.points[index];
|
||||||
|
if (candidate.id === id || (filter && !filter(candidate))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
|
||||||
|
if (found.length >= count && distancePc >= found[found.length - 1].distancePc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Insertion sort into a list that is never longer than `count`: cheaper than sorting
|
||||||
|
// every candidate the rings turn up, of which there are far more than are kept.
|
||||||
|
const at = found.findIndex((other) => distancePc < other.distancePc);
|
||||||
|
found.splice(at === -1 ? found.length : at, 0, { id: candidate.id, distancePc });
|
||||||
|
if (found.length > count) {
|
||||||
|
found.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every star within `radiusPc` of `id`, nearest first, excluding the star itself. This is what
|
||||||
|
* a jump-link graph is built from: one call per node gives that node's edges.
|
||||||
|
*/
|
||||||
|
within(id: number, radiusPc: number): Neighbour[] {
|
||||||
|
const origin = this.point(id);
|
||||||
|
if (!origin || radiusPc <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const found: Neighbour[] = [];
|
||||||
|
const [ox, oy, oz] = this.cellFor(origin.x, origin.y, origin.z);
|
||||||
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
|
|
||||||
|
for (let ix = ox - reach; ix <= ox + reach; ix++) {
|
||||||
|
for (let iy = oy - reach; iy <= oy + reach; iy++) {
|
||||||
|
for (let iz = oz - reach; iz <= oz + reach; iz++) {
|
||||||
|
for (const index of this.cells.get(cellKey(ix, iy, iz)) ?? []) {
|
||||||
|
const candidate = this.points[index];
|
||||||
|
if (candidate.id === id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const distancePc = Math.hypot(candidate.x - origin.x, candidate.y - origin.y, candidate.z - origin.z);
|
||||||
|
if (distancePc <= radiusPc) {
|
||||||
|
found.push({ id: candidate.id, distancePc });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort((a, b) => a.distancePc - b.distancePc);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visits every pair of stars within `radiusPc` of each other, once per pair.
|
||||||
|
*
|
||||||
|
* The same question `within` answers, asked of the whole catalogue at once — and a different
|
||||||
|
* shape of answer, because asking it star by star is asking it twice per pair and paying for a
|
||||||
|
* sorted list of each star's neighbours that the caller then throws away. Sixty-eight thousand
|
||||||
|
* of those took eight seconds; walking the grid once takes a fraction of it.
|
||||||
|
*
|
||||||
|
* Each cell is paired with itself and with the half of its surrounding cells that lie after it
|
||||||
|
* in the scan, which is what makes each pair come up exactly once.
|
||||||
|
*/
|
||||||
|
forEachPairWithin(radiusPc: number, visit: (a: StarPoint, b: StarPoint, distancePc: number) => void): void {
|
||||||
|
if (radiusPc <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reach = Math.ceil(radiusPc / this.cellSizePc);
|
||||||
|
const radiusSq = radiusPc * radiusPc;
|
||||||
|
|
||||||
|
for (const [key, cell] of this.cells) {
|
||||||
|
const [ix, iy, iz] = key.split(',').map(Number);
|
||||||
|
for (let dx = 0; dx <= reach; dx++) {
|
||||||
|
for (let dy = dx === 0 ? 0 : -reach; dy <= reach; dy++) {
|
||||||
|
for (let dz = dx === 0 && dy === 0 ? 0 : -reach; dz <= reach; dz++) {
|
||||||
|
const other = dx === 0 && dy === 0 && dz === 0 ? cell : this.cells.get(cellKey(ix + dx, iy + dy, iz + dz));
|
||||||
|
if (!other) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sameCell = other === cell;
|
||||||
|
for (let i = 0; i < cell.length; i++) {
|
||||||
|
const a = this.points[cell[i]];
|
||||||
|
// Within one cell, only the pairs after this one; across two, all of them — the
|
||||||
|
// other cell is only ever visited from this side.
|
||||||
|
for (let j = sameCell ? i + 1 : 0; j < other.length; j++) {
|
||||||
|
const b = this.points[other[j]];
|
||||||
|
const dxp = b.x - a.x;
|
||||||
|
const dyp = b.y - a.y;
|
||||||
|
const dzp = b.z - a.z;
|
||||||
|
const distanceSq = dxp * dxp + dyp * dyp + dzp * dzp;
|
||||||
|
if (distanceSq <= radiusSq) {
|
||||||
|
visit(a, b, Math.sqrt(distanceSq));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyFor(x: number, y: number, z: number): string {
|
||||||
|
const [ix, iy, iz] = this.cellFor(x, y, z);
|
||||||
|
return cellKey(ix, iy, iz);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cellFor(x: number, y: number, z: number): [number, number, number] {
|
||||||
|
return [Math.floor(x / this.cellSizePc), Math.floor(y / this.cellSizePc), Math.floor(z / this.cellSizePc)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indices in the hollow shell of cells exactly `ring` cells out from the centre one. */
|
||||||
|
private *ringIndices(ox: number, oy: number, oz: number, ring: number): Generator<number> {
|
||||||
|
for (let ix = ox - ring; ix <= ox + ring; ix++) {
|
||||||
|
for (let iy = oy - ring; iy <= oy + ring; iy++) {
|
||||||
|
for (let iz = oz - ring; iz <= oz + ring; iz++) {
|
||||||
|
// Only the shell: everything inside it was searched by a previous, smaller ring.
|
||||||
|
const onShell = Math.abs(ix - ox) === ring || Math.abs(iy - oy) === ring || Math.abs(iz - oz) === ring;
|
||||||
|
if (!onShell) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield* this.cells.get(cellKey(ix, iy, iz)) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from './star-catalog';
|
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
|
||||||
import { StarRecord } from './star.model';
|
import { StarRecord } from './star.model';
|
||||||
|
|
||||||
const STARS: StarRecord[] = [
|
const STARS: StarRecord[] = [
|
||||||
@@ -131,4 +131,18 @@ describe('star catalogue provenance and derived names', () => {
|
|||||||
expect(named.index.names).toEqual(['Some Proper Name']);
|
expect(named.index.names).toEqual(['Some Proper Name']);
|
||||||
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
|
expect(decodeStarCatalog(named.index, named.positions, named.meta)[0].name).toBe('Some Proper Name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tells a name somebody gave from the designation a survey generates', () => {
|
||||||
|
expect(isDesignation(decoded[0])).toBe(false);
|
||||||
|
expect(isDesignation(decoded[1])).toBe(true);
|
||||||
|
// The real catalogue names Gaia stars by the survey's nineteen-digit source id, which no
|
||||||
|
// 32-bit row id can equal — so it is the prefix that decides, not a round trip through the id.
|
||||||
|
expect(isDesignation({ ...MIXED[1], name: 'Gaia DR3 5853498713190525696' })).toBe(true);
|
||||||
|
expect(isDesignation({ ...MIXED[1], name: 'Proxima Centauri' })).toBe(false);
|
||||||
|
// HYG's own last resort, once every designation it knows has come up empty. Upper case,
|
||||||
|
// whatever case the source id is in.
|
||||||
|
expect(isDesignation({ ...MIXED[0], name: 'HYG 5' })).toBe(true);
|
||||||
|
expect(isDesignation({ ...MIXED[0], source: undefined, name: 'HYG 5' })).toBe(true);
|
||||||
|
expect(isDesignation({ ...MIXED[0], name: 'HD 48915' })).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,11 +66,14 @@ export interface StarCatalogIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; HYG's own
|
* How a source names a star that has no name of its own. `Gaia DR3 <id>` for Gaia; `HYG <id>`
|
||||||
* fallbacks already produce real designations, so it never needs this.
|
* for HYG, whose ETL reaches for that only after a proper name, Bayer, Flamsteed, HD, Gliese and
|
||||||
|
* HIP have all come up empty (`tools/etl/fetchStars.ts`) — none in the current catalogue, but
|
||||||
|
* the path is there, and a name made that way is no more a name than Gaia's.
|
||||||
*/
|
*/
|
||||||
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
|
const DESIGNATION_PREFIXES: Readonly<Record<string, string>> = {
|
||||||
gaia: 'Gaia DR3'
|
gaia: 'Gaia DR3',
|
||||||
|
hyg: 'HYG'
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StarMetaColumns {
|
interface StarMetaColumns {
|
||||||
@@ -161,6 +164,18 @@ function designationFor(prefix: string | undefined, id: number): string | undefi
|
|||||||
return prefix === undefined ? undefined : `${prefix} ${id}`;
|
return prefix === undefined ? undefined : `${prefix} ${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a star's name is only the designation its source generates, rather than anything
|
||||||
|
* somebody called it. Judged by the prefix alone: the number after it is the survey's own id —
|
||||||
|
* nineteen digits for Gaia — which the 32-bit row id `designationFor` prints cannot hold, so a
|
||||||
|
* round-trip through the id would call every one of those stars named.
|
||||||
|
*/
|
||||||
|
export function isDesignation(star: StarRecord): boolean {
|
||||||
|
// No source at all is a single-catalogue build, whose fallback is HYG's — see `decodeStarCatalog`.
|
||||||
|
const prefix = star.source === undefined ? DESIGNATION_PREFIXES['hyg'] : (DESIGNATION_PREFIXES[star.source] ?? star.source);
|
||||||
|
return star.name.startsWith(`${prefix} `);
|
||||||
|
}
|
||||||
|
|
||||||
/** Rebuilds the star records the app works with from the three loaded assets. */
|
/** Rebuilds the star records the app works with from the three loaded assets. */
|
||||||
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
|
export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Array, meta: ArrayBuffer): StarRecord[] {
|
||||||
const columns = metaColumns(meta, index.count);
|
const columns = metaColumns(meta, index.count);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { Bookmark, BookmarksStore } from './bookmarks.store';
|
||||||
|
|
||||||
|
const KEY = 'star-map.bookmarks';
|
||||||
|
|
||||||
|
const SIRIUS: Bookmark = { kind: 'star', id: 32349, name: 'Sirius' };
|
||||||
|
const EARTH: Bookmark = { kind: 'body', id: 'earth', name: 'Earth' };
|
||||||
|
|
||||||
|
function store(): BookmarksStore {
|
||||||
|
// Constructed per test, because the list is read once on construction — which is the
|
||||||
|
// behaviour being tested for anything that seeds storage first.
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
return TestBed.inject(BookmarksStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookmarksStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts empty, and keeps what it is given', () => {
|
||||||
|
const bookmarks = store();
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([]);
|
||||||
|
|
||||||
|
expect(bookmarks.toggle(SIRIUS)).toBe(true);
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
|
||||||
|
expect(bookmarks.has('star', 32349)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops what it already had, when told the same thing twice', () => {
|
||||||
|
const bookmarks = store();
|
||||||
|
bookmarks.toggle(SIRIUS);
|
||||||
|
|
||||||
|
expect(bookmarks.toggle(SIRIUS)).toBe(false);
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([]);
|
||||||
|
expect(bookmarks.has('star', 32349)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a star from a body that happen to share an id', () => {
|
||||||
|
const bookmarks = store();
|
||||||
|
bookmarks.toggle({ kind: 'star', id: 1, name: 'A star' });
|
||||||
|
bookmarks.toggle({ kind: 'body', id: 1, name: 'A body' });
|
||||||
|
|
||||||
|
expect(bookmarks.bookmarks()).toHaveLength(2);
|
||||||
|
bookmarks.remove('star', 1);
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([{ kind: 'body', id: 1, name: 'A body' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the newest first, since that is the one being come back to', () => {
|
||||||
|
const bookmarks = store();
|
||||||
|
bookmarks.toggle(SIRIUS);
|
||||||
|
bookmarks.toggle(EARTH);
|
||||||
|
|
||||||
|
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Earth', 'Sirius']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives the visit it was kept in', () => {
|
||||||
|
store().toggle(SIRIUS);
|
||||||
|
|
||||||
|
expect(store().bookmarks()).toEqual([SIRIUS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads past whatever else is in there, rather than losing the lot', () => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { kind: 'moon', id: 1, name: 'No such kind' }, { id: 'no-kind' }, null, 42, EARTH]));
|
||||||
|
|
||||||
|
expect(store().bookmarks()).toEqual([SIRIUS, EARTH]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one entry per place, however many the stored list holds', () => {
|
||||||
|
// Two entries for one place would each toggle the other's control on and off.
|
||||||
|
localStorage.setItem(KEY, JSON.stringify([SIRIUS, { ...SIRIUS, name: 'Sirius (again)' }]));
|
||||||
|
|
||||||
|
expect(store().bookmarks()).toEqual([SIRIUS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a store that is not a list, or not JSON at all, as no bookmarks', () => {
|
||||||
|
localStorage.setItem(KEY, '{"not":"a list"}');
|
||||||
|
expect(store().bookmarks()).toEqual([]);
|
||||||
|
|
||||||
|
localStorage.setItem(KEY, 'nonsense{');
|
||||||
|
expect(store().bookmarks()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes on working where the browser will not store anything at all', () => {
|
||||||
|
// Private mode, a full quota, storage disabled by policy: reading throws, writing throws.
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('denied');
|
||||||
|
});
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookmarks = store();
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([]);
|
||||||
|
expect(() => bookmarks.toggle(SIRIUS)).not.toThrow();
|
||||||
|
// Kept for this visit, even though nothing will outlive it.
|
||||||
|
expect(bookmarks.bookmarks()).toEqual([SIRIUS]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds what a hand-edited store can make it hold', () => {
|
||||||
|
const many = Array.from({ length: 500 }, (_, i) => ({ kind: 'star' as const, id: i, name: `Star ${i}` }));
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(many));
|
||||||
|
|
||||||
|
expect(store().bookmarks()).toHaveLength(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A place someone chose to keep. Either a star, which is a system to fly into, or a body, which
|
||||||
|
* is a page to open — the two things the map lets you arrive at.
|
||||||
|
*/
|
||||||
|
export interface Bookmark {
|
||||||
|
readonly kind: 'star' | 'body';
|
||||||
|
/** A HYG star id, or a `bodies.json`/`exoplanets.json` id. */
|
||||||
|
readonly id: number | string;
|
||||||
|
/**
|
||||||
|
* The name as it read when it was kept. Stored rather than looked up, so the list can be
|
||||||
|
* shown before the catalogues have loaded — and so a bookmark to something a later catalogue
|
||||||
|
* no longer holds still says what it was rather than becoming a bare id.
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'star-map.bookmarks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many are kept. Not a limit anyone will reach by hand — it is a bound on what a corrupted
|
||||||
|
* or hand-edited store can make the app render, and on what is written back.
|
||||||
|
*/
|
||||||
|
const MAX_BOOKMARKS = 200;
|
||||||
|
|
||||||
|
/** `${kind}:${id}`, since a star id and a body id are different kinds of thing. */
|
||||||
|
function keyOf(kind: Bookmark['kind'], id: number | string): string {
|
||||||
|
return `${kind}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBookmark(value: unknown): value is Bookmark {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = value as Partial<Bookmark>;
|
||||||
|
return (
|
||||||
|
(candidate.kind === 'star' || candidate.kind === 'body') &&
|
||||||
|
(typeof candidate.id === 'number' || typeof candidate.id === 'string') &&
|
||||||
|
typeof candidate.name === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The places kept between visits, in this browser and nowhere else.
|
||||||
|
*
|
||||||
|
* Local storage rather than an account: the map asks nobody to sign in, and a list of stars
|
||||||
|
* somebody liked is not worth a server. Every read of it is defensive — the store is a string
|
||||||
|
* a user can edit, another tab can write, and a browser can refuse to give at all — and a
|
||||||
|
* failure to read or write one is never allowed to take the map down with it.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BookmarksStore {
|
||||||
|
private readonly kept = signal<readonly Bookmark[]>(this.read());
|
||||||
|
|
||||||
|
/** Most recently kept first, which is the order they are useful in. */
|
||||||
|
readonly bookmarks = this.kept.asReadonly();
|
||||||
|
|
||||||
|
has(kind: Bookmark['kind'], id: number | string): boolean {
|
||||||
|
return this.kept().some((bookmark) => bookmark.kind === kind && bookmark.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a place, or drops it if it was already kept. Returns whether it is kept now. */
|
||||||
|
toggle(bookmark: Bookmark): boolean {
|
||||||
|
const kept = this.has(bookmark.kind, bookmark.id);
|
||||||
|
this.write(kept ? this.kept().filter((other) => !(other.kind === bookmark.kind && other.id === bookmark.id)) : [bookmark, ...this.kept()].slice(0, MAX_BOOKMARKS));
|
||||||
|
return !kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(kind: Bookmark['kind'], id: number | string): void {
|
||||||
|
this.write(this.kept().filter((bookmark) => !(bookmark.kind === kind && bookmark.id === id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(bookmarks: readonly Bookmark[]): void {
|
||||||
|
this.kept.set(bookmarks);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
|
||||||
|
} catch {
|
||||||
|
// Full, disabled, or private-mode storage. The list still works for this visit; it just
|
||||||
|
// will not outlive it, which is a smaller loss than the alternative of failing here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(): Bookmark[] {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Filtered rather than rejected wholesale: one bad entry should not lose the others, and
|
||||||
|
// deduplicated because two entries for one place would each toggle the other's control.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return parsed
|
||||||
|
.filter(isBookmark)
|
||||||
|
.filter((bookmark) => !seen.has(keyOf(bookmark.kind, bookmark.id)) && seen.add(keyOf(bookmark.kind, bookmark.id)))
|
||||||
|
.slice(0, MAX_BOOKMARKS)
|
||||||
|
.map(({ kind, id, name }) => ({ kind, id, name }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark on a place worth coming back to. Hollow when it is not kept, filled when it is —
|
||||||
|
* the same two states as the Display panel's layer ticks, which is where the eye has already
|
||||||
|
* learned what a filled mark means here.
|
||||||
|
*
|
||||||
|
* Size and colour come from the classes on the host; the svg fills it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-bookmark-icon',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'block', 'aria-hidden': 'true' },
|
||||||
|
template: `
|
||||||
|
<svg class="h-full w-full" viewBox="0 0 24 24" [attr.fill]="kept() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||||
|
<path d="M7 4h10v16l-5-4-5 4z" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class BookmarkIconComponent {
|
||||||
|
readonly kept = input(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route-rail chevron — "back to" when pointing left, "on to" when pointing right. One
|
||||||
|
* geometry for every rail button, where each previously inlined its own copy of the svg.
|
||||||
|
* Size and colour come from the classes on the host; the svg fills it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chevron-icon',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'block', 'aria-hidden': 'true' },
|
||||||
|
template: `
|
||||||
|
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path [attr.d]="direction() === 'left' ? 'M15 6l-6 6 6 6' : 'M9 6l6 6-6 6'" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ChevronIconComponent {
|
||||||
|
readonly direction = input<'left' | 'right'>('left');
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The circle-and-ticks lock mark the instrument brands itself with. The search field, the
|
||||||
|
* nameplate and the viewport reticle all render this one geometry — it used to be drawn three
|
||||||
|
* times, once with hand-rescaled coordinates, and the copies could drift apart.
|
||||||
|
*
|
||||||
|
* Size and colour come from the classes on the host (`h-4 w-4 text-accent/70` etc.); the svg
|
||||||
|
* fills it. `vector-effect: non-scaling-stroke` keeps the stroke a screen-pixel width however
|
||||||
|
* far the 24-unit viewBox is scaled, so `strokeWidth` means the same at every size.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reticle-icon',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'block', 'aria-hidden': 'true' },
|
||||||
|
template: `
|
||||||
|
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" [attr.stroke-width]="strokeWidth()" stroke-linecap="round">
|
||||||
|
<circle cx="12" cy="12" r="5" vector-effect="non-scaling-stroke" />
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" vector-effect="non-scaling-stroke" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ReticleIconComponent {
|
||||||
|
readonly strokeWidth = input(1.5);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
|
<meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
|
||||||
<!-- Matches --color-void, so the browser chrome does not flash white around a black sky. -->
|
<!-- Matches --color-void, so the browser chrome does not flash white around a black sky. -->
|
||||||
<meta name="theme-color" content="#05070d">
|
<meta name="theme-color" content="#04060c">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
|
||||||
<!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so
|
<!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
+186
-62
@@ -1,19 +1,20 @@
|
|||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names
|
/* Design tokens for the "observatory instrument" HUD shared across the app. `--color-*`/`--font-*`
|
||||||
* automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */
|
* names automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`).
|
||||||
|
*
|
||||||
|
* One family only: every string in this UI is a measurement, an identifier, or a catalog label, so
|
||||||
|
* the readout face carries the whole hierarchy through weight, size, and tracking. */
|
||||||
@theme {
|
@theme {
|
||||||
--font-display: 'Orbitron', 'Segoe UI', sans-serif;
|
--font-body: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace;
|
||||||
--font-body: 'JetBrains Mono', 'Segoe UI', monospace;
|
|
||||||
|
|
||||||
--color-void: #05070d;
|
--color-void: #04060c;
|
||||||
--color-panel: #0b121f;
|
--color-panel: #070d16;
|
||||||
--color-accent: #4dd7ff;
|
--color-accent: #4dd7ff;
|
||||||
--color-accent-dim: #1c4f66;
|
--color-text: #e3ecf7;
|
||||||
--color-text: #dbe6f3;
|
--color-muted: #90a6bd;
|
||||||
--color-muted: #7f93ab;
|
--color-border: #24455a;
|
||||||
--color-border: #2a4a5c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -26,48 +27,90 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- HUD chrome ------------------------------------------------------------------------- */
|
/* The panel surface every HUD element shares: a hairline border over a near-opaque fill. One
|
||||||
|
* definition, because spelling the recipe out per call site is how it forked into /85 and /92
|
||||||
/* Angled top-left/bottom-right corners, a thin lit edge and a blurred dark fill: the panel
|
* variants within a single change. Deliberately no backdrop blur: the canvas underneath is
|
||||||
* shape the whole heads-up display is built from. Written here rather than as utilities
|
* redrawn every frame by the render loop, so a blur here is re-sampled continuously — a
|
||||||
* because `clip-path` and the layered border need to travel together. */
|
* standing GPU cost for an effect a 92%-opaque fill mostly hides anyway. */
|
||||||
.hud-panel {
|
@utility hud-surface {
|
||||||
position: relative;
|
border: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||||
background: color-mix(in srgb, var(--color-panel) 78%, transparent);
|
background-color: color-mix(in oklab, var(--color-panel) 92%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The lit inner hairline that reads as a bevel on the two square corners. */
|
/* The two label voices of the single-face type system, named so call sites cannot drift a few
|
||||||
.hud-panel::after {
|
* thousandths of an em apart. `type-label` is the working voice — section headings, readout
|
||||||
content: '';
|
* labels, buttons; `type-eyebrow` is the wider-tracked identity line above a title. */
|
||||||
position: absolute;
|
@utility type-label {
|
||||||
inset: 3px;
|
font-size: 10px;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
letter-spacing: 0.16em;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
text-transform: uppercase;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Corner brackets around the whole viewport, drawn as four gradients so the element stays a
|
@utility type-eyebrow {
|
||||||
* single node with no children. */
|
font-size: 10px;
|
||||||
.hud-frame {
|
letter-spacing: 0.18em;
|
||||||
pointer-events: none;
|
text-transform: uppercase;
|
||||||
--bracket: color-mix(in srgb, var(--color-accent) 45%, transparent);
|
}
|
||||||
--arm-long: 76px;
|
|
||||||
--arm-thin: 1px;
|
/* The HUD's signature: four corner tick brackets cut into a panel's hairline frame. Drawn as
|
||||||
|
* eight background gradients so any panel (or the viewport frame) picks them up without the
|
||||||
|
* eight extra DOM nodes the same mark would otherwise cost. Override `--hud-tick` per element
|
||||||
|
* (e.g. `[--hud-tick:1.75rem]`) to scale the marks with the surface they frame. */
|
||||||
|
@utility hud-brackets {
|
||||||
|
--hud-tick: 0.625rem;
|
||||||
|
--hud-mark: color-mix(in oklab, var(--color-accent) 65%, transparent);
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
|
||||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
|
||||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)),
|
linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
|
||||||
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket));
|
linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size:
|
background-size:
|
||||||
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long),
|
var(--hud-tick) 1px,
|
||||||
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long);
|
1px var(--hud-tick),
|
||||||
|
var(--hud-tick) 1px,
|
||||||
|
1px var(--hud-tick),
|
||||||
|
var(--hud-tick) 1px,
|
||||||
|
1px var(--hud-tick),
|
||||||
|
var(--hud-tick) 1px,
|
||||||
|
1px var(--hud-tick);
|
||||||
background-position:
|
background-position:
|
||||||
18px 18px, 18px 18px, right 18px top 18px, right 18px top 18px, 18px bottom 18px, 18px bottom 18px, right 18px bottom 18px,
|
left top,
|
||||||
right 18px bottom 18px;
|
left top,
|
||||||
|
right top,
|
||||||
|
right top,
|
||||||
|
left bottom,
|
||||||
|
left bottom,
|
||||||
|
right bottom,
|
||||||
|
right bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
|
||||||
|
* exponential ease-out the way a readout fills once an instrument locks onto a target. */
|
||||||
|
/* `backwards`, not `both`: `both` leaves the final keyframe applied for good, and that keyframe
|
||||||
|
* carries a clip-path — which goes on clipping the panel's own box forever after, so anything
|
||||||
|
* that has to escape it (a dropdown opening upward, a tooltip) is cut away and cannot even be
|
||||||
|
* clicked. `backwards` holds the opening frame before the wipe and lets the element return to
|
||||||
|
* its own styles after it. */
|
||||||
|
@utility hud-acquire {
|
||||||
|
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hud-acquire {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
clip-path: inset(0 0 100% 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hud-acquire {
|
||||||
|
animation-duration: 1ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
|
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
|
||||||
@@ -76,22 +119,9 @@ body {
|
|||||||
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A chamfered tab: the angled-corner shape the scale ladder and the selection banner share.
|
|
||||||
* The cut is on the leading and trailing edges rather than the panel's diagonal corners, which
|
|
||||||
* is what makes a row of them read as tabs on a rail instead of a row of cards. */
|
|
||||||
.hud-tab {
|
|
||||||
clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The selected-object banner: symmetric, flaring outward toward its lit bottom edge, so it
|
|
||||||
* reads as a nameplate hanging off the top of the display rather than another tab. */
|
|
||||||
.hud-banner {
|
|
||||||
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 100%, 0 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
||||||
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility
|
* Angular's view encapsulation as plain DOM nodes, so they're styled here rather than through
|
||||||
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */
|
* a scoped component stylesheet. */
|
||||||
.star-label-layer {
|
.star-label-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -100,17 +130,97 @@ body {
|
|||||||
|
|
||||||
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately
|
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately
|
||||||
* much quieter than the name — it should be readable when looked at and invisible when not,
|
* much quieter than the name — it should be readable when looked at and invisible when not,
|
||||||
* because on a crowded view it is printed as many times as there are labels. */
|
* because on a crowded view it is printed as many times as there are labels.
|
||||||
|
*
|
||||||
|
* Offset from the anchor with a margin, not a translate: `CSS2DRenderer` rewrites the inline
|
||||||
|
* `transform` every frame, and the margin is the one offset it can never overwrite. The
|
||||||
|
* hairline `::before` is the leader line back to the point the label names. */
|
||||||
.map-label {
|
.map-label {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 0.875rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9);
|
text-shadow:
|
||||||
|
0 0 6px var(--color-void),
|
||||||
|
0 0 3px var(--color-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geometry assumes the label's left edge sits at the star — StarLabelOverlay sets the
|
||||||
|
* CSS2DObject's center to (0, 0.5) to make that true. Under the default (0.5, 0.5) the
|
||||||
|
* renderer keeps the box centred on the point and this line floats in empty space. */
|
||||||
|
.map-label::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: -0.75rem;
|
||||||
|
height: 1px;
|
||||||
|
width: 0.625rem;
|
||||||
|
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The quieter voice: a star outside the system the camera is in, named so its direction can be
|
||||||
|
* read without leaving. Dimmed rather than shrunk — at the same size it still reads as a name,
|
||||||
|
* where a smaller one would read as a footnote to the system rather than a place beyond it. */
|
||||||
|
.map-label--ghost .map-label-name {
|
||||||
|
/* 65% of the accent over the void is 5.4:1 — a ghost is interactive, so it holds the same
|
||||||
|
* contrast floor as body text rather than the lower one large or decorative text could take. */
|
||||||
|
color: color-mix(in oklab, var(--color-accent) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--ghost .map-label-kind {
|
||||||
|
/* Quieter than the name it belongs to, and still 4.7:1. */
|
||||||
|
color: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--ghost::before {
|
||||||
|
background: color-mix(in oklab, var(--color-accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selectable labels are buttons: the only ones the pointer can reach, since the layer they
|
||||||
|
* live in is transparent to it. They brighten to full strength on approach, which is the whole
|
||||||
|
* hover affordance — a ghost lighting up is the map saying this one is somewhere to go. */
|
||||||
|
.map-label--select {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--select:hover .map-label-name,
|
||||||
|
.map-label--select:focus-visible .map-label-name {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--select:hover::before,
|
||||||
|
.map-label--select:focus-visible::before {
|
||||||
|
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--select:focus-visible {
|
||||||
|
outline: 1px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The mirror: text hangs off the point's left, right-aligned, leader line on its right. */
|
||||||
|
.map-label--left {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0.875rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-label--left::before {
|
||||||
|
left: auto;
|
||||||
|
right: -0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-label-name {
|
.map-label-name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,5 +228,19 @@ body {
|
|||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.3em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
color: color-mix(in oklab, var(--color-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The selection mark on a body in the scene: two hairline arcs bracketing the point — the one
|
||||||
|
* mark borrowed from the ARK's control disc, at hairline weight. Sized in pixels because it sits
|
||||||
|
* on the body's projected position and should read the same however far the camera is. Centred
|
||||||
|
* on the point by CSS2DRenderer's default (0.5, 0.5) anchor. */
|
||||||
|
.map-select {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
border-right-color: var(--color-accent);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user