diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts new file mode 100644 index 0000000..ad5d801 --- /dev/null +++ b/e2e/bookmarks.spec.ts @@ -0,0 +1,56 @@ +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('/'); + await expect(page.getByTestId('scene-canvas')).toBeVisible(); + 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 }); + }); +}); diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index b9b58c7..9252089 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -1,5 +1,5 @@ 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 * as THREE from 'three/webgpu'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; @@ -12,6 +12,7 @@ import { atmosphereColorFor, bodyTexturePath, loadCachedTexture, MILKY_WAY_SKYBO import { BodyRecord } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; +import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore } from '../../shared/state/navigation.store'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { HudDockComponent } from '../hud/hud-dock.component'; @@ -57,12 +58,23 @@ const GLOW_SCALE = 2.6; } - - + + ` }) 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>('canvas'); private controls?: OrbitControls; @@ -87,6 +99,7 @@ export class BodyDetailSceneComponent implements AfterViewInit, OnDestroy { private readonly engine: EngineService, private readonly dataLoader: DataLoaderService, private readonly route: ActivatedRoute, + private readonly router: Router, private readonly navigationStore: NavigationStore ) {} diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 7a0603a..565414f 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,6 +1,8 @@ -import { Component, computed, input } from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; import { Router } from '@angular/router'; +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 { BodyDetailViewModel } from './body-detail.model'; @@ -17,7 +19,7 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; */ @Component({ selector: 'app-info-panel', - imports: [ChevronIconComponent, ReadoutSectionsComponent], + imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent], template: `
@@ -30,9 +32,21 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; System -
-

{{ body().name }}

-

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+
+

{{ body().name }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+
+
@@ -42,6 +56,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component'; export class InfoPanelComponent { readonly body = input.required(); + readonly bookmarks = inject(BookmarksStore); + readonly readouts = computed(() => bodyReadouts(this.body())); constructor(private readonly router: Router) {} diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index 5a7b5e6..46ec544 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -13,6 +13,7 @@ import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { applyMilkyWaySkybox, createGlowSprite } from '../../shared/rendering/skybox'; import { loadCachedTexture, MILKY_WAY_SKYBOX_PATH, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog'; import { StarRecord } from '../../shared/models/star.model'; +import { Bookmark } from '../../shared/state/bookmarks.store'; import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store'; import { CameraRigController } from './camera-rig-controller'; import { DeepSkyRenderer } from './deep-sky-renderer'; @@ -225,12 +226,14 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector [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)" />
` @@ -1204,6 +1207,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy { 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. */ openObjectDetail(id: string): void { this.navigationStore.selectBody(id); diff --git a/src/app/features/galaxy-system/system-object-card.component.ts b/src/app/features/galaxy-system/system-object-card.component.ts index 4a68ef2..369332e 100644 --- a/src/app/features/galaxy-system/system-object-card.component.ts +++ b/src/app/features/galaxy-system/system-object-card.component.ts @@ -1,7 +1,9 @@ -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 { 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'; @@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; @Component({ selector: 'app-system-object-card', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ChevronIconComponent, ReadoutSectionsComponent], + imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent], // 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 // put the card off the bottom-left corner of the viewport entirely. @@ -36,6 +38,16 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';

{{ body().name }}

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+