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 a8d6d6a..ca688bc 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)" />
` @@ -1210,6 +1213,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 }}

+ + } + @if (subtitle()) {

{{ subtitle() }}

} @@ -119,6 +138,40 @@ function isWideViewport(): boolean { /> } + @case ('bookmarks') { +
+ @if (bookmarks.bookmarks().length) { +
    + @for (bookmark of bookmarks.bookmarks(); track bookmark.kind + ':' + bookmark.id) { +
  • + + +
  • + } +
+ } @else { +

+ Nothing kept yet. The on a readout or a body keeps it here, in this browser. +

+ } +
+ } @case ('display') {

Layers

@@ -187,6 +240,8 @@ export class HudDockComponent implements OnInit { readonly routeResult = input(null); readonly routeOptions = input([]); readonly currentStar = input(null); + /** The star the readout is about, where there is one to keep — a scale is not a place. */ + readonly keepableStarId = input(null); /** Present makes the Routes tab available; absent means this surface cannot route. */ readonly routing = input(false); @@ -195,6 +250,7 @@ export class HudDockComponent implements OnInit { readonly routeRequested = output(); readonly routeStarSelected = output(); readonly jumpRangeChange = output(); + readonly bookmarkChosen = output(); readonly layers = DISPLAY_LAYERS; readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived)); @@ -202,11 +258,16 @@ export class HudDockComponent implements OnInit { '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(null); + readonly bookmarks = inject(BookmarksStore); + private readonly search = viewChild(SearchComponent); private readonly host = inject>(ElementRef); @@ -238,6 +299,13 @@ export class HudDockComponent implements OnInit { 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 diff --git a/src/app/shared/state/bookmarks.store.spec.ts b/src/app/shared/state/bookmarks.store.spec.ts new file mode 100644 index 0000000..5b3a84f --- /dev/null +++ b/src/app/shared/state/bookmarks.store.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/state/bookmarks.store.ts b/src/app/shared/state/bookmarks.store.ts new file mode 100644 index 0000000..704e28d --- /dev/null +++ b/src/app/shared/state/bookmarks.store.ts @@ -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; + 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(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(); + 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 []; + } + } +} diff --git a/src/app/shared/ui/bookmark-icon.component.ts b/src/app/shared/ui/bookmark-icon.component.ts new file mode 100644 index 0000000..e84fb90 --- /dev/null +++ b/src/app/shared/ui/bookmark-icon.component.ts @@ -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: ` + + + + ` +}) +export class BookmarkIconComponent { + readonly kept = input(false); +}