Surface the system view with the photographs it already had, lit by its star
Every body in the system view was an unlit sphere wearing a 32 by 16 pixel procedural texture — the size chosen when a marker was a few pixels across and what survived was its average colour. The thirteen real photographs in `src/assets/textures/bodies/` were used only by the detail page. So Mars was a pale grey ball with invented polar caps while its own NASA mosaic sat unread in the repository, and nothing had a day side or a night side. Each marker now takes its own photograph where one exists, at the size the detail page uses, and the derived texture only where none does — the five moons no probe mapped, and every exoplanet, none of which has ever been imaged. The material is lit, and the light is a point at the star, so each world shows the terminator where it really falls. The light does not fall off with distance. Under the inverse square that real light obeys, Neptune receives a thousandth of what Mercury does and reads as black; the map is a set of worlds to look at rather than a light meter, so each is lit as a photograph of it would be. That is the same concession the pixel floor makes for size, and it is only about brightness: the *direction* is real. Spheres are 32 by 24 rather than 16 by 12, since at true scale a body is drawn anywhere from a pixel to the whole frame and the old silhouette was visibly faceted at the near end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
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 { TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.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';
|
||||
|
||||
export interface HudReadout {
|
||||
readonly label: string;
|
||||
@@ -32,7 +49,16 @@ export interface HudDisplay {
|
||||
readonly plan: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = { labels: true, orbits: true, grid: true, deepSky: true, sky: true, systems: true, jumpLinks: false, plan: false };
|
||||
export const DEFAULT_HUD_DISPLAY: HudDisplay = {
|
||||
labels: true,
|
||||
orbits: true,
|
||||
grid: true,
|
||||
deepSky: true,
|
||||
sky: true,
|
||||
systems: true,
|
||||
jumpLinks: false,
|
||||
plan: false,
|
||||
};
|
||||
|
||||
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||
{ key: 'labels', label: 'Labels' },
|
||||
@@ -42,17 +68,26 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||
{ key: 'sky', label: 'Sky' },
|
||||
{ key: 'systems', label: 'Systems' },
|
||||
{ key: 'jumpLinks', label: 'Jump links' },
|
||||
{ key: 'plan', label: 'Plan view' }
|
||||
{ key: 'plan', label: 'Plan view' },
|
||||
];
|
||||
|
||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
||||
|
||||
const TAB_LABELS: Record<DockTab, string> = { search: 'Search', readout: 'Readout', routes: 'Routes', bookmarks: 'Bookmarks', display: 'Display' };
|
||||
const TAB_LABELS: Record<DockTab, string> = {
|
||||
search: 'Search',
|
||||
readout: 'Readout',
|
||||
routes: 'Routes',
|
||||
bookmarks: 'Bookmarks',
|
||||
display: 'Display',
|
||||
};
|
||||
|
||||
/** Tailwind's `sm` breakpoint: below it the dock is a bare tab strip and its panel is a sheet. */
|
||||
const WIDE_VIEWPORT = '(min-width: 640px)';
|
||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
||||
const wideViewportQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WIDE_VIEWPORT) : null;
|
||||
const wideViewportQuery =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(WIDE_VIEWPORT)
|
||||
: null;
|
||||
|
||||
function isWideViewport(): boolean {
|
||||
return wideViewportQuery?.matches ?? true;
|
||||
@@ -75,7 +110,10 @@ function isWideViewport(): boolean {
|
||||
selector: 'app-hud-dock',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [BookmarkIconComponent, RoutesPanelComponent, SearchComponent],
|
||||
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
|
||||
host: {
|
||||
class:
|
||||
'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6',
|
||||
},
|
||||
template: `
|
||||
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
|
||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||
@@ -86,29 +124,55 @@ function isWideViewport(): boolean {
|
||||
locking on, once per switch, never per keystroke. -->
|
||||
@switch (tab) {
|
||||
@case ('search') {
|
||||
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
|
||||
<section
|
||||
id="dock-panel-search"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-search"
|
||||
class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl"
|
||||
>
|
||||
<app-search (picked)="onPicked()" />
|
||||
</section>
|
||||
}
|
||||
@case ('readout') {
|
||||
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||
<section
|
||||
id="dock-panel-readout"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-readout"
|
||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||
>
|
||||
<p class="type-label text-muted">{{ eyebrow() }}</p>
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<p data-testid="hud-title" class="min-w-0 flex-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
|
||||
<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>
|
||||
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()) {
|
||||
@@ -118,30 +182,54 @@ function isWideViewport(): boolean {
|
||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||
@for (readout of readouts(); track readout.label) {
|
||||
<div>
|
||||
<dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
|
||||
<dt class="type-label text-muted">
|
||||
{{ readout.label }}
|
||||
@if (readout.derived) {
|
||||
<span class="text-accent/80" aria-hidden="true">*</span>
|
||||
}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
@if (note() || hasDerived()) {
|
||||
<p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
|
||||
<p
|
||||
class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted"
|
||||
>
|
||||
@if (hasDerived()) {
|
||||
<span class="text-accent/80">*</span> Derived, not catalogued.
|
||||
}
|
||||
{{ note() }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@case ('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">
|
||||
<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) {
|
||||
@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>
|
||||
<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"
|
||||
@@ -149,7 +237,15 @@ function isWideViewport(): boolean {
|
||||
(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">
|
||||
<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>
|
||||
@@ -158,13 +254,20 @@ function isWideViewport(): boolean {
|
||||
</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.
|
||||
Nothing kept yet. The
|
||||
<app-bookmark-icon class="inline-block h-3.5 w-3.5 -mb-0.5 text-accent" /> on a
|
||||
readout or a body keeps it here, in this browser.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@case ('display') {
|
||||
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
|
||||
<section
|
||||
id="dock-panel-display"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-display"
|
||||
class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3"
|
||||
>
|
||||
<p class="type-label text-muted">Layers</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (layer of layers; track layer.key) {
|
||||
@@ -173,10 +276,18 @@ function isWideViewport(): boolean {
|
||||
[attr.aria-pressed]="isOn(layer.key)"
|
||||
(click)="toggleLayer(layer.key)"
|
||||
class="type-label flex items-center gap-2 border px-3 py-1.5 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
[class]="isOn(layer.key) ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
||||
[class]="
|
||||
isOn(layer.key)
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<!-- The state mark: a filled tick when the layer is drawn, hollow when it is not. -->
|
||||
<span aria-hidden="true" class="h-1.5 w-1.5 border border-current" [class.bg-current]="isOn(layer.key)"></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-1.5 w-1.5 border border-current"
|
||||
[class.bg-current]="isOn(layer.key)"
|
||||
></span>
|
||||
{{ layer.label }}
|
||||
</button>
|
||||
}
|
||||
@@ -187,11 +298,19 @@ function isWideViewport(): boolean {
|
||||
<p class="type-label mt-4 text-muted">Clock</p>
|
||||
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
||||
carries that to a screen reader and to the arrow keys without any script. -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2" role="radiogroup" aria-label="Clock rate">
|
||||
<div
|
||||
class="mt-2 flex flex-wrap items-center gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="Clock rate"
|
||||
>
|
||||
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||
<label
|
||||
class="type-label cursor-pointer border px-3 py-1.5 transition-colors has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:-outline-offset-1 has-[:focus-visible]:outline-accent"
|
||||
[class]="time.rate() === rate.secondsPerSecond ? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18' : 'border-border/60 text-muted hover:border-border hover:text-text'"
|
||||
[class]="
|
||||
time.rate() === rate.secondsPerSecond
|
||||
? 'border-accent/60 bg-accent/12 text-accent hover:bg-accent/18'
|
||||
: 'border-border/60 text-muted hover:border-border hover:text-text'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -224,7 +343,13 @@ function isWideViewport(): boolean {
|
||||
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
|
||||
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||||
@if (routing()) {
|
||||
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" [hidden]="activeTab() !== 'routes'" class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
|
||||
<section
|
||||
id="dock-panel-routes"
|
||||
role="tabpanel"
|
||||
aria-labelledby="dock-tab-routes"
|
||||
[hidden]="activeTab() !== 'routes'"
|
||||
class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3"
|
||||
>
|
||||
<app-routes-panel
|
||||
[result]="routeResult()"
|
||||
[pending]="routePending()"
|
||||
@@ -249,7 +374,11 @@ function isWideViewport(): boolean {
|
||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||
(click)="toggleTab(tab)"
|
||||
class="type-eyebrow px-3 py-2 transition-colors focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent sm:px-4"
|
||||
[class]="activeTab() === tab ? 'bg-accent/15 text-accent' : 'text-muted hover:bg-accent/8 hover:text-accent'"
|
||||
[class]="
|
||||
activeTab() === tab
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-muted hover:bg-accent/8 hover:text-accent'
|
||||
"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
@@ -258,20 +387,26 @@ function isWideViewport(): boolean {
|
||||
<!-- Only while the clock is running faster than the world: at real time the date is
|
||||
today's, which the reader's own machine already says. -->
|
||||
@if (date()) {
|
||||
<p class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4" data-testid="hud-date">
|
||||
<p
|
||||
class="ml-auto flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||
data-testid="hud-date"
|
||||
>
|
||||
<span class="type-label text-muted">Date</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||
</p>
|
||||
}
|
||||
@if (range()) {
|
||||
<p class="flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4" [class.ml-auto]="!date()">
|
||||
<p
|
||||
class="flex items-baseline gap-2 border-l border-border/40 px-3 py-2 sm:px-4"
|
||||
[class.ml-auto]="!date()"
|
||||
>
|
||||
<span class="type-label text-muted">Range</span>
|
||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
})
|
||||
export class HudDockComponent implements OnInit {
|
||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
||||
@@ -315,7 +450,7 @@ export class HudDockComponent implements OnInit {
|
||||
// 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);
|
||||
@@ -377,7 +512,10 @@ export class HudDockComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -389,7 +527,11 @@ export class HudDockComponent implements OnInit {
|
||||
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||
@HostListener('document:pointerdown', ['$event'])
|
||||
onDocumentPointerDown(event: PointerEvent): void {
|
||||
if (this.activeTab() && !isWideViewport() && !this.host.nativeElement.contains(event.target as Node)) {
|
||||
if (
|
||||
this.activeTab() &&
|
||||
!isWideViewport() &&
|
||||
!this.host.nativeElement.contains(event.target as Node)
|
||||
) {
|
||||
this.activeTab.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user