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:
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@ import * as THREE from 'three/webgpu';
|
|||||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||||
import { gmForParent } from '../../shared/astro/constants';
|
import { gmForParent } from '../../shared/astro/constants';
|
||||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||||
import { MARKER_TEXTURE_HEIGHT, MARKER_TEXTURE_WIDTH, planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||||
|
import { bodyTexturePath, loadCachedTexture } from '../../shared/rendering/texture-catalog';
|
||||||
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
import { isPropagatableOrbit, orbitEllipsePoints, propagateOrbit, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||||
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||||
@@ -121,21 +122,46 @@ function buildOrbitLine(elements: OrbitalElements, kind: SystemMemberKind, frame
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A marker sphere, surfaced with the body's own derived appearance rather than a flat category
|
* A marker sphere, surfaced with the body's own photograph where one has ever been taken, and
|
||||||
* colour — so a system reads as a set of distinct worlds at a glance, and the colour of each is
|
* with a texture derived from its measurements where none has — and lit by its star either way,
|
||||||
* a consequence of its measurements rather than of which list it came from.
|
* so a world shows the day and night it actually has.
|
||||||
*
|
*
|
||||||
* The texture is tiny (see `MARKER_TEXTURE_WIDTH`): a marker is a few pixels across, so what
|
* The photographs were already in the repository, used only by the detail page: the system view
|
||||||
* survives is essentially its average colour, and generating it costs well under a millisecond.
|
* drew every body from a 32 by 16 pixel procedural texture instead, which at a few pixels across
|
||||||
|
* was indistinguishable from its average colour and, once the camera closed in, was a blur. A
|
||||||
|
* marker can now fill the frame, so it takes the real image at the size the detail page uses.
|
||||||
*/
|
*/
|
||||||
function buildMarker(kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
function buildMarker(id: string | undefined, kind: SystemMemberKind, radiusKm: number | undefined, appearance: PlanetAppearance | undefined): THREE.Mesh {
|
||||||
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), 16, 12);
|
// 32 by 24 rather than 16 by 12: at true scale a body is drawn as small as a pixel and as large
|
||||||
const material = appearance
|
// as the screen, and the silhouette of the old sphere was visibly faceted at the near end.
|
||||||
? new THREE.MeshBasicMaterial({ map: planetTexture(appearance, { width: MARKER_TEXTURE_WIDTH, height: MARKER_TEXTURE_HEIGHT }) })
|
const geometry = new THREE.SphereGeometry(bodyMarkerRadiusAu(radiusKm), 32, 24);
|
||||||
: new THREE.MeshBasicMaterial({ color: colorForKind(kind) });
|
const photograph = id ? bodyTexturePath(id) : undefined;
|
||||||
|
const map = photograph ? loadCachedTexture(photograph) : appearance ? planetTexture(appearance) : undefined;
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
map,
|
||||||
|
color: map ? 0xffffff : colorForKind(kind),
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0
|
||||||
|
});
|
||||||
return new THREE.Mesh(geometry, material);
|
return new THREE.Mesh(geometry, material);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The star's own light, at the centre of the system it lights.
|
||||||
|
*
|
||||||
|
* `decay` is 0, which is not what light does: a point source falls off with the square of the
|
||||||
|
* distance, and under that law 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 — the same concession the pixel floor makes for size. What the light
|
||||||
|
* does carry truthfully is *direction*: every body shows its day side toward the star and its
|
||||||
|
* night side away from it, and the terminator falls where it really falls.
|
||||||
|
*/
|
||||||
|
function starLight(): THREE.PointLight {
|
||||||
|
const light = new THREE.PointLight(0xfff4e0, 2.2, 0, 0);
|
||||||
|
light.position.set(0, 0, 0);
|
||||||
|
return light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Local axis a sphere is built around, and what the spin is applied about. */
|
/** Local axis a sphere is built around, and what the spin is applied about. */
|
||||||
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
|
const SPIN_AXIS = new THREE.Vector3(0, 1, 0);
|
||||||
@@ -336,6 +362,9 @@ export class SystemOrbitsRenderer {
|
|||||||
|
|
||||||
this.object.add(this.grid.object, this.tethers.object);
|
this.object.add(this.grid.object, this.tethers.object);
|
||||||
}
|
}
|
||||||
|
// The star lights its own system. The star marker itself is unlit — it is the source, not a
|
||||||
|
// surface — so nothing here changes how it is drawn.
|
||||||
|
this.object.add(starLight());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
/** Recomputes every marker's position for the given Julian date. Call once per tick. */
|
||||||
@@ -418,7 +447,7 @@ export class SystemOrbitsRenderer {
|
|||||||
rotation?: { periodHours?: number; obliquityDeg?: number }
|
rotation?: { periodHours?: number; obliquityDeg?: number }
|
||||||
): TrackedTopLevelBody {
|
): TrackedTopLevelBody {
|
||||||
const orbitLine = buildOrbitLine(elements, kind, frame);
|
const orbitLine = buildOrbitLine(elements, kind, frame);
|
||||||
const marker = buildMarker(kind, radiusKm, appearance);
|
const marker = buildMarker(id, kind, radiusKm, appearance);
|
||||||
this.object.add(orbitLine, marker);
|
this.object.add(orbitLine, marker);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
this.trackDisposable(marker.geometry, marker.material as THREE.Material);
|
||||||
@@ -442,7 +471,7 @@ export class SystemOrbitsRenderer {
|
|||||||
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
const orbitLine = buildOrbitLine(elements, 'moon', frame);
|
||||||
// A moon's own orbit is the thing it must not swallow: drawn at the system's exaggeration it
|
// A moon's own orbit is the thing it must not swallow: drawn at the system's exaggeration it
|
||||||
// is the same size as its planet, and every moon here orbits inside one.
|
// is the same size as its planet, and every moon here orbits inside one.
|
||||||
const marker = buildMarker('moon', radiusKm, appearance);
|
const marker = buildMarker(id, 'moon', radiusKm, appearance);
|
||||||
pivot.add(orbitLine, marker);
|
pivot.add(orbitLine, marker);
|
||||||
this.object.add(pivot);
|
this.object.add(pivot);
|
||||||
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
this.trackDisposable(orbitLine.geometry, orbitLine.material as THREE.Material);
|
||||||
|
|||||||
@@ -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 { Bookmark, BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
import { TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
import { TIME_RATES, TimeStore } from '../../shared/state/time.store';
|
||||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
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';
|
||||||
|
|
||||||
export interface HudReadout {
|
export interface HudReadout {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -32,7 +49,16 @@ export interface HudDisplay {
|
|||||||
readonly plan: boolean;
|
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 }[] = [
|
const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
||||||
{ key: 'labels', label: 'Labels' },
|
{ key: 'labels', label: 'Labels' },
|
||||||
@@ -42,17 +68,26 @@ const DISPLAY_LAYERS: readonly { key: keyof HudDisplay; label: string }[] = [
|
|||||||
{ key: 'sky', label: 'Sky' },
|
{ key: 'sky', label: 'Sky' },
|
||||||
{ key: 'systems', label: 'Systems' },
|
{ key: 'systems', label: 'Systems' },
|
||||||
{ key: 'jumpLinks', label: 'Jump links' },
|
{ key: 'jumpLinks', label: 'Jump links' },
|
||||||
{ key: 'plan', label: 'Plan view' }
|
{ key: 'plan', label: 'Plan view' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type DockTab = 'search' | 'readout' | 'routes' | 'bookmarks' | 'display';
|
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. */
|
/** 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)';
|
||||||
/** One live query, read on every pointer-down, rather than a new MediaQueryList per read. */
|
/** 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 {
|
function isWideViewport(): boolean {
|
||||||
return wideViewportQuery?.matches ?? true;
|
return wideViewportQuery?.matches ?? true;
|
||||||
@@ -75,7 +110,10 @@ function isWideViewport(): boolean {
|
|||||||
selector: 'app-hud-dock',
|
selector: 'app-hud-dock',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [BookmarkIconComponent, 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
|
||||||
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
wide as the strip and as tall as the open panel, so a solid one would swallow every
|
||||||
@@ -86,28 +124,54 @@ function isWideViewport(): boolean {
|
|||||||
locking on, once per switch, never per keystroke. -->
|
locking on, once per switch, never per keystroke. -->
|
||||||
@switch (tab) {
|
@switch (tab) {
|
||||||
@case ('search') {
|
@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()" />
|
<app-search (picked)="onPicked()" />
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@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>
|
||||||
<div class="mt-1 flex items-start gap-2">
|
<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
|
<!-- 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
|
truthiness test is what would quietly make the Solar System the one
|
||||||
system nobody could keep. -->
|
system nobody could keep. -->
|
||||||
@if (keepableStarId() !== null) {
|
@if (keepableStarId() !== null) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[attr.aria-label]="(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()"
|
[attr.aria-label]="
|
||||||
|
(bookmarks.has('star', keepableStarId()!) ? 'Forget ' : 'Keep ') + title()
|
||||||
|
"
|
||||||
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
[attr.aria-pressed]="bookmarks.has('star', keepableStarId()!)"
|
||||||
(click)="bookmarks.toggle({ kind: 'star', id: keepableStarId()!, name: title() })"
|
(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="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'"
|
[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()!)" />
|
<app-bookmark-icon
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
[kept]="bookmarks.has('star', keepableStarId()!)"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,30 +182,54 @@ function isWideViewport(): boolean {
|
|||||||
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
|
||||||
@for (readout of readouts(); track readout.label) {
|
@for (readout of readouts(); track readout.label) {
|
||||||
<div>
|
<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>
|
<dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
@if (note() || hasDerived()) {
|
@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>
|
</section>
|
||||||
}
|
}
|
||||||
@case ('bookmarks') {
|
@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) {
|
@if (bookmarks.bookmarks().length) {
|
||||||
<ul class="max-h-64 divide-y divide-border/25 overflow-y-auto">
|
<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">
|
<li class="flex items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="onBookmarkChosen(bookmark)"
|
(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"
|
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="min-w-0 flex-1 truncate text-sm text-text">{{
|
||||||
<span class="type-label shrink-0 text-muted">{{ bookmark.kind === 'star' ? 'System' : 'Body' }}</span>
|
bookmark.name
|
||||||
|
}}</span>
|
||||||
|
<span class="type-label shrink-0 text-muted">{{
|
||||||
|
bookmark.kind === 'star' ? 'System' : 'Body'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -149,7 +237,15 @@ function isWideViewport(): boolean {
|
|||||||
(click)="bookmarks.remove(bookmark.kind, bookmark.id)"
|
(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"
|
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" />
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -158,13 +254,20 @@ function isWideViewport(): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="px-3 py-3 text-sm text-muted">
|
<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>
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</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>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@for (layer of layers; track layer.key) {
|
@for (layer of layers; track layer.key) {
|
||||||
@@ -173,10 +276,18 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-pressed]="isOn(layer.key)"
|
[attr.aria-pressed]="isOn(layer.key)"
|
||||||
(click)="toggleLayer(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="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. -->
|
<!-- 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 }}
|
{{ layer.label }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -187,11 +298,19 @@ function isWideViewport(): boolean {
|
|||||||
<p class="type-label mt-4 text-muted">Clock</p>
|
<p class="type-label mt-4 text-muted">Clock</p>
|
||||||
<!-- Radios rather than buttons: the rates are one-of-four, and the native control
|
<!-- 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. -->
|
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) {
|
@for (rate of timeRates; track rate.secondsPerSecond) {
|
||||||
<label
|
<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="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
|
<input
|
||||||
type="radio"
|
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
|
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. -->
|
runs, its clip path swallows clicks on the suggestions it just brought back. -->
|
||||||
@if (routing()) {
|
@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
|
<app-routes-panel
|
||||||
[result]="routeResult()"
|
[result]="routeResult()"
|
||||||
[pending]="routePending()"
|
[pending]="routePending()"
|
||||||
@@ -249,7 +374,11 @@ function isWideViewport(): boolean {
|
|||||||
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
[attr.aria-controls]="activeTab() === tab ? 'dock-panel-' + tab : null"
|
||||||
(click)="toggleTab(tab)"
|
(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="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) }}
|
{{ tabLabel(tab) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -258,20 +387,26 @@ function isWideViewport(): boolean {
|
|||||||
<!-- Only while the clock is running faster than the world: at real time the date is
|
<!-- 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. -->
|
today's, which the reader's own machine already says. -->
|
||||||
@if (date()) {
|
@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="type-label text-muted">Date</span>
|
||||||
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
<span class="text-sm text-accent tabular-nums">{{ date() }}</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
@if (range()) {
|
@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="type-label text-muted">Range</span>
|
||||||
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
<span class="text-sm text-accent tabular-nums">{{ range() }}</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`,
|
||||||
})
|
})
|
||||||
export class HudDockComponent implements OnInit {
|
export class HudDockComponent implements OnInit {
|
||||||
/** Readout panel contents. An empty title means there is nothing to read out, and no tab for it. */
|
/** 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
|
// 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.
|
// anything at all, and a tab that appears once you already know is a tab that never taught.
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
...(this.display() ? (['display'] as const) : [])
|
...(this.display() ? (['display'] as const) : []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly activeTab = signal<DockTab | null>(null);
|
readonly activeTab = signal<DockTab | null>(null);
|
||||||
@@ -377,7 +512,10 @@ export class HudDockComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = event.target as HTMLElement | null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
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. */
|
/** On a narrow viewport the panel is a sheet over the scene: tapping the scene folds it away. */
|
||||||
@HostListener('document:pointerdown', ['$event'])
|
@HostListener('document:pointerdown', ['$event'])
|
||||||
onDocumentPointerDown(event: PointerEvent): void {
|
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);
|
this.activeTab.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const TIME_RATES = [
|
|||||||
{ label: 'Real time', secondsPerSecond: 1 },
|
{ label: 'Real time', secondsPerSecond: 1 },
|
||||||
{ label: '1 h/s', secondsPerSecond: 3600 },
|
{ label: '1 h/s', secondsPerSecond: 3600 },
|
||||||
{ label: '1 d/s', secondsPerSecond: 86_400 },
|
{ label: '1 d/s', secondsPerSecond: 86_400 },
|
||||||
{ label: '1 mo/s', secondsPerSecond: 2_629_800 }
|
{ label: '1 mo/s', secondsPerSecond: 2_629_800 },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|||||||
Reference in New Issue
Block a user