Keep a place, and come back to it

The map had no memory. Every visit started at the same overview, and a system
worth returning to had to be found again by name each time. A mark on the
readout and on a body's panel now keeps it, a Bookmarks tab lists what has
been kept, and choosing one goes there — a star by flying into its system, a
body by opening its page.

Local storage, not an account. This map asks nobody to sign in, and a list of
stars somebody liked is not worth a server. Every read of that store is
defensive, because it is a string a person can edit, another tab can write,
and a browser can refuse to hand over at all: a bad entry is skipped rather
than losing the rest, duplicates are collapsed since two entries for one place
would each toggle the other's control, the list is bounded so a hand-edited
store cannot decide how much this renders, and where storage is denied
outright the bookmarks still work for the visit — they just do not outlive it.

The name is stored alongside the id rather than looked up. That way the list
reads before the catalogues have loaded, and a bookmark to something a later
catalogue no longer holds still says what it was instead of decaying into a
bare number.

The tab is offered even when it is empty, and says what the mark does. A tab
that appears only once you have already found the feature is a tab that never
taught anyone anything.

Choosing a kept place hands the panel back to the readout, which is the same
move as choosing a search result and for the same reason: the panel has done
its job and the thing to look at is now the scene. That behaviour is what the
end-to-end spec caught missing — the readout it asserted on did not exist,
because the panel that had just been used was still covering it.

Verified: build clean, 587/587 unit, 11/11 end-to-end including a spec that
keeps Earth, leaves the page, comes back to it from the list and forgets it,
and one that keeps Proxima Centauri, flies out to the field and flies back in
by what was kept. Design detector clean, screenshots at 1440x900 and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-20 19:07:46 +02:00
co-authored by Claude Fable 5
parent 68a919bd84
commit bd3a5a48c9
10 changed files with 513 additions and 18 deletions
+56
View File
@@ -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 });
});
});
@@ -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,6 +12,7 @@ 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 { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { HudDockComponent } from '../hud/hud-dock.component'; import { HudDockComponent } from '../hud/hud-dock.component';
@@ -57,12 +58,23 @@ const GLOW_SCALE = 2.6;
</a> </a>
</div> </div>
} }
<!-- Search only: there is no scene readout here, the info panel is the reading. --> <!-- Search and what has been kept: there is no scene readout here, the info panel is
<app-hud-dock /> 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;
@@ -87,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,6 +1,8 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router'; 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 { 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';
@@ -17,7 +19,7 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
*/ */
@Component({ @Component({
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent], imports: [BookmarkIconComponent, ChevronIconComponent, ReadoutSectionsComponent],
template: ` template: `
<!-- Top-right, clear of the dock along the bottom; nothing else shares the top edge here. --> <!-- 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"> <div class="hud-brackets hud-acquire hud-surface absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text">
@@ -30,9 +32,21 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
System System
</button> </button>
<header class="px-4 pt-4 pb-3"> <header class="flex items-start gap-2 px-4 pt-4 pb-3">
<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> <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> <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> </header>
<app-readout-sections [readouts]="readouts()" /> <app-readout-sections [readouts]="readouts()" />
@@ -42,6 +56,8 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
export class InfoPanelComponent { export class InfoPanelComponent {
readonly body = input.required<BodyDetailViewModel>(); readonly body = input.required<BodyDetailViewModel>();
readonly bookmarks = inject(BookmarksStore);
readonly readouts = computed(() => bodyReadouts(this.body())); readonly readouts = computed(() => bodyReadouts(this.body()));
constructor(private readonly router: Router) {} constructor(private readonly router: Router) {}
@@ -13,6 +13,7 @@ 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 { 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';
@@ -225,12 +226,14 @@ function galacticOverviewPose(): { position: THREE.Vector3; target: THREE.Vector
[routeResult]="routeResult()" [routeResult]="routeResult()"
[routeOptions]="routeOptions()" [routeOptions]="routeOptions()"
[currentStar]="currentStarOption()" [currentStar]="currentStarOption()"
[keepableStarId]="navigationStore.selectedStarId()"
defaultTab="readout" defaultTab="readout"
(displayChange)="display.set($event)" (displayChange)="display.set($event)"
(routeQuery)="onRouteQuery($event)" (routeQuery)="onRouteQuery($event)"
(routeRequested)="onRouteRequested($event)" (routeRequested)="onRouteRequested($event)"
(routeStarSelected)="navigationStore.selectStar($event)" (routeStarSelected)="navigationStore.selectStar($event)"
(jumpRangeChange)="jumpRangePc.set($event)" (jumpRangeChange)="jumpRangePc.set($event)"
(bookmarkChosen)="goToBookmark($event)"
/> />
</div> </div>
` `
@@ -1204,6 +1207,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);
@@ -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 { 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 { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@@ -19,7 +21,7 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
@Component({ @Component({
selector: 'app-system-object-card', selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush, 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 // 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.
@@ -36,6 +38,16 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p> <p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header> </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()"
@@ -69,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()));
} }
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService } from '../../core/data/data-loader.service'; 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'; import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
class EmptyDataLoaderService { class EmptyDataLoaderService {
@@ -46,6 +47,7 @@ describe('HudDockComponent', () => {
} }
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [HudDockComponent], imports: [HudDockComponent],
providers: [ providers: [
@@ -56,9 +58,10 @@ describe('HudDockComponent', () => {
fixture = TestBed.createComponent(HudDockComponent); fixture = TestBed.createComponent(HudDockComponent);
}); });
it('offers only the search when it has nothing to read out and no layers', () => { 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(); fixture.detectChanges();
expect(tabNames()).toEqual(['Search']); expect(tabNames()).toEqual(['Search', 'Bookmarks']);
expect(host().querySelector('[role="tabpanel"]')).toBeNull(); expect(host().querySelector('[role="tabpanel"]')).toBeNull();
}); });
@@ -66,11 +69,11 @@ describe('HudDockComponent', () => {
setReadout(); setReadout();
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY); fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges(); fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Display']); expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
fixture.componentRef.setInput('routing', true); fixture.componentRef.setInput('routing', true);
fixture.detectChanges(); fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Display']); expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
}); });
it('opens the default tab on mount and renders the readout from its inputs', () => { it('opens the default tab on mount and renders the readout from its inputs', () => {
@@ -141,6 +144,80 @@ describe('HudDockComponent', () => {
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false']); expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=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('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', () => { it('opens the search on "/" from anywhere but a text field', () => {
setReadout(); setReadout();
fixture.componentRef.setInput('defaultTab', 'readout'); fixture.componentRef.setInput('defaultTab', 'readout');
+69 -4
View File
@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, ElementRef, HostListener, inject, input, OnInit, output, signal, viewChild } from '@angular/core'; 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 { SearchComponent } from '../search/search.component';
import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component'; import { RouteRequest, RouteResult, RoutesPanelComponent, RouteStarOption } from './routes-panel.component';
@@ -39,9 +41,9 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
{ key: 'jumpLinks', label: 'Jump links' } { key: 'jumpLinks', label: 'Jump links' }
]; ];
export type DockTab = 'search' | 'readout' | 'routes' | 'display'; export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', display: '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. */ /** 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)'; const WIDE_VIEWPORT = '(min-width: 640px)';
@@ -68,7 +70,7 @@ function isWideViewport(): boolean {
@Component({ @Component({
selector: 'app-hud-dock', selector: 'app-hud-dock',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RoutesPanelComponent, SearchComponent], 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' }, host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
template: ` template: `
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as <!-- The column is transparent to the pointer and each surface in it opts back in: it is as
@@ -87,7 +89,21 @@ function isWideViewport(): boolean {
@case ('readout') { @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"> <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> <p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</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>
@if (keepableStarId(); as starId) {
<button
type="button"
[attr.aria-label]="(bookmarks.has('star', starId) ? 'Forget ' : 'Keep ') + title()"
[attr.aria-pressed]="bookmarks.has('star', starId)"
(click)="bookmarks.toggle({ kind: 'star', id: starId, 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', starId) ? 'text-accent' : 'text-muted hover:text-accent'"
>
<app-bookmark-icon class="h-3.5 w-3.5" [kept]="bookmarks.has('star', starId)" />
</button>
}
</div>
@if (subtitle()) { @if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p> <p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
} }
@@ -119,6 +135,40 @@ function isWideViewport(): boolean {
/> />
</section> </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') { @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"> <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> <p class="type-label text-muted">Layers</p>
@@ -187,6 +237,8 @@ export class HudDockComponent implements OnInit {
readonly routeResult = input<RouteResult | null>(null); readonly routeResult = input<RouteResult | null>(null);
readonly routeOptions = input<readonly RouteStarOption[]>([]); readonly routeOptions = input<readonly RouteStarOption[]>([]);
readonly currentStar = input<RouteStarOption | null>(null); 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. */ /** Present makes the Routes tab available; absent means this surface cannot route. */
readonly routing = input(false); readonly routing = input(false);
@@ -195,6 +247,7 @@ export class HudDockComponent implements OnInit {
readonly routeRequested = output<RouteRequest>(); readonly routeRequested = output<RouteRequest>();
readonly routeStarSelected = output<number>(); readonly routeStarSelected = output<number>();
readonly jumpRangeChange = output<number>(); readonly jumpRangeChange = output<number>();
readonly bookmarkChosen = output<Bookmark>();
readonly layers = DISPLAY_LAYERS; readonly layers = DISPLAY_LAYERS;
readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived)); readonly hasDerived = computed(() => this.readouts().some((readout) => readout.derived));
@@ -202,11 +255,16 @@ export class HudDockComponent implements OnInit {
'search', 'search',
...(this.title() ? (['readout'] as const) : []), ...(this.title() ? (['readout'] as const) : []),
...(this.routing() ? (['routes'] 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) : []) ...(this.display() ? (['display'] as const) : [])
]); ]);
readonly activeTab = signal<DockTab | null>(null); readonly activeTab = signal<DockTab | null>(null);
readonly bookmarks = inject(BookmarksStore);
private readonly search = viewChild(SearchComponent); private readonly search = viewChild(SearchComponent);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef); private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
@@ -238,6 +296,13 @@ export class HudDockComponent implements OnInit {
this.routeStarSelected.emit(starId); 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 { onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the // 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 // readout where there is one, and fold the sheet away where there is not. On a narrow
@@ -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);
});
});
+111
View File
@@ -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);
}