Merge pull request #10 from avalon-vanguard/feat/hud-bookmarks

Keep a place, and come back to it
This commit is contained in:
Senrokai
2026-08-27 19:23:01 +02:00
committed by GitHub
10 changed files with 531 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">
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1> <div class="min-w-0 flex-1">
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <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> </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>
` `
@@ -1210,6 +1213,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,95 @@ 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('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', () => { it('opens the search on "/" from anywhere but a text field', () => {
setReadout(); setReadout();
fixture.componentRef.setInput('defaultTab', 'readout'); fixture.componentRef.setInput('defaultTab', 'readout');
+72 -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,24 @@ 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>
<!-- 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()) { @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 +138,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 +240,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 +250,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 +258,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 +299,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);
}