orbit-studio / src /components /EntityInfoPanel.vue
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
26.3 kB
<!-- What is selected, in four species of data: identity, live position, the pass
list, and the reference material.
Only two of those are permanent. The header says what this is, and the strip
under it says where it is — the two facts worth having on screen whatever else
you are reading. The rest is two tabs, because stacking all four produced a
1384 px scroll inside a 500 px window, two thirds of it below the fold, with
the element table under thirty rows of the same station name.
`Passes` is the default tab and leads with the countdown, which is the reason
the panel is usually open at all — then the timeline, which says which of the
next several are worth going outside for, then the list. `Details` is what you
go looking for rather than glance at. -->
<template>
<div v-if="selection" class="entity-info-panel">
<UCard :ui="{ root: 'bg-[#303336]/95 text-[#edffff] divide-neutral-600', header: 'p-2 sm:px-3', body: 'p-0 sm:p-0' }">
<template #header>
<div class="head">
<div class="head__title">
<span v-if="orbitClass" class="dot" :style="{ background: orbitColor }" :title="orbitClass" />
<input
v-if="renaming"
ref="renameEl"
v-model="draftName"
class="head__rename"
type="text"
placeholder="unnamed"
aria-label="Station name"
@keydown.enter="commitRename"
@keydown.esc="cancelRename"
@blur="commitRename"
/>
<span v-else class="head__name">{{ name }}</span>
<UTooltip v-if="canRename" :text="renaming ? 'Done' : 'Rename'">
<!-- Keeps focus in the input, so pressing it commits once through
the click rather than once through the blur and then reopening. -->
<UButton
:icon="renaming ? 'i-lucide-check' : 'i-lucide-pencil'"
variant="ghost"
color="neutral"
size="xs"
:aria-label="renaming ? 'Finish renaming' : 'Rename station'"
@mousedown.prevent
@click="toggleRename"
/>
</UTooltip>
<span class="head__id">{{ satnum ? `#${satnum}` : "" }}</span>
<UTooltip text="Notify for upcoming passes">
<UButton icon="i-lucide-bell" variant="ghost" color="neutral" size="xs" aria-label="Notify for upcoming passes" @click="notifyPasses" />
</UTooltip>
<UTooltip v-if="canEnterSkyView" text="View the sky from here">
<UButton icon="i-lucide-telescope" variant="ghost" color="neutral" size="xs" aria-label="View the sky from this ground station" @click="enterSkyView" />
</UTooltip>
<!-- Hidden rather than disabled in the sky view, which owns the camera:
tracking there has no meaning to convey, and a button that silently
did nothing would read as broken. -->
<UTooltip v-if="!inSkyView" :text="isTracked ? 'Stop tracking' : 'Track entity'">
<UButton icon="i-lucide-video" variant="ghost" :color="isTracked ? 'primary' : 'neutral'" size="xs" aria-label="Track entity" @click="toggleTrack" />
</UTooltip>
<UButton icon="i-lucide-x" variant="ghost" color="neutral" size="xs" aria-label="Close" @click="deselect" />
</div>
<div v-if="chips.length > 0" class="head__chips">
<span v-for="chip in chips" :key="chip">{{ chip }}</span>
</div>
</div>
</template>
<!-- Where it is. Four cells and three-letter labels, because nobody reads a
longitude to two decimals — they check it has not gone somewhere absurd. -->
<div v-if="liveRows.length > 0" class="strip">
<span v-for="row in liveRows" :key="row.label" class="strip__cell">
<span class="strip__label">{{ row.label.slice(0, 3) }}</span>
<span class="strip__value">{{ row.value }}</span>
</span>
</div>
<!-- The row shows even when there is one tab, which a ground station has: it is
the handle the fold hangs off, so a lone tab switches to nothing but still
does something.
`min-h-8` is the height a trigger takes once it carries a badge. Reserved
always, because the badge appears only once the pass count is known: the
row grew by 4 px the moment prediction landed, and everything under it
dropped with it. -->
<UTabs
v-model="activeTab"
:items="tabs"
variant="link"
size="sm"
:ui="{
root: 'gap-0',
list: 'px-2 border-y border-neutral-600',
trigger: 'min-h-8',
content: collapsed ? 'hidden' : 'px-3 pb-3 info-body',
}"
>
<template #passes>
<!-- Restores the top padding the scroll container gave up so that the
table header below can pin flush with its top edge. With the padding
on the container, rows scrolled through the gap above the pinned
header and stayed visible there. -->
<div class="tab-body__pad">
<!-- No empty state for the hero: when there is no upcoming pass the
message below says so once. -->
<template v-if="!passesPending && hasAnyPasses">
<div v-if="nextPass" class="hero" :class="{ 'hero--live': isOngoing }">
<div class="hero__label">
{{ isOngoing ? "Overhead now" : "Next pass" }}
<span v-if="nextPassSubject">· {{ nextPassSubject }}</span>
</div>
<div class="hero__value">{{ formatCountdown(nowMs, nextPass) }}</div>
<div class="hero__meta">{{ passSummary(nextPass) }}</div>
<div v-if="isOngoing" class="hero__progress"><span :style="{ width: `${ongoingFraction * 100}%` }" /></div>
</div>
<pass-timeline v-if="showTimeline" :passes="passes" :now-ms="nowMs" :mode="overpassMode" :picked="pickedPassMs" @pick="pickPass" />
</template>
<!-- Outside every empty branch on purpose. The mode decides what the two
right-hand columns and the strip mean, and it can produce a list
with nothing in it — swath mode over a station the ground track
misses — so a control that appears only when there are passes is one
you cannot use to undo the switch that emptied the tab. -->
<div class="passes__bar">
<div class="modes" role="group" aria-label="Overpass calculation">
<button
v-for="option in OVERPASS_MODES"
:key="option.value"
type="button"
class="modes__option"
:class="{ 'modes__option--on': overpassMode === option.value }"
:aria-pressed="overpassMode === option.value"
@click="overpassMode = option.value"
>
{{ option.label }}
</button>
</div>
<USwitch v-model="showPastPasses" label="Past" size="xs" />
</div>
<div v-if="passesPending" class="empty">Computing passes…</div>
<div v-else-if="!hasAnyPasses" class="empty">{{ emptyPassText }}</div>
<div v-else-if="passRows.length === 0" class="empty">No upcoming passes</div>
<table v-else ref="tableEl" class="info-table">
<thead>
<tr>
<th v-if="subjectCount > 1">{{ selection.kind === "groundstation" ? "Satellite" : "Station" }}</th>
<th>In</th>
<th>Start</th>
<th>End</th>
<th class="num">{{ overpassMode === "swath" ? "Dist" : "El" }}</th>
<th class="num">{{ overpassMode === "swath" ? "Swath" : "Az" }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in passRows"
:key="row.key"
:data-start-ms="row.startMs"
:class="{ 'is-next': row.startMs === nextPass?.start, 'is-picked': row.startMs === pickedPassMs }"
>
<td v-if="subjectCount > 1">{{ row.name }}</td>
<td class="mono">{{ row.countdown }}</td>
<td>
<a class="link" @click="cc.setTime(row.startMs)">{{ row.startLabel }}</a>
</td>
<td>{{ row.endLabel }}</td>
<td class="num">{{ row.primary }}</td>
<td class="num">{{ row.secondary }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<template #details>
<div class="tab-body__pad">
<table v-if="satelliteInfo.length > 0" class="info-table info-table--facts">
<tbody>
<tr v-for="[label, value] in satelliteInfo" :key="label">
<th>{{ label }}</th>
<td class="right">{{ value }}</td>
</tr>
</tbody>
</table>
<template v-if="links.length > 0">
<div class="section">Links</div>
<div class="links">
<a v-for="link in links" :key="link.label" class="links__item" :href="link.href" :title="link.title" target="_blank" rel="noopener">{{ link.label }}</a>
</div>
</template>
<template v-if="elements">
<div class="section">{{ elements.kind === "tle" ? "TLE" : "Elements" }} · epoch {{ elements.epoch }}</div>
<pre v-if="elements.kind === 'tle'" class="info-code"><code>{{ elements.lines }}</code></pre>
<table v-else class="info-table info-table--facts">
<tbody>
<tr v-for="[label, value] in elements.rows" :key="label">
<th>{{ label }}</th>
<td class="right mono">{{ value }}</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
</UTabs>
</UCard>
</div>
</template>
<script setup lang="ts">
import { useToast } from "@nuxt/ui/composables/useToast";
import dayjs from "dayjs";
import { storeToRefs } from "pinia";
import { computed, nextTick, ref, useTemplateRef, watch } from "vue";
import { useController } from "../composables/useController";
import { useSelectedEntity } from "../composables/useSelectedEntity";
import { externalLinks } from "../config/externalLinks";
import { ORBIT_CLASS_COLOR, type OrbitClass } from "../config/orbitClass";
import { SKY_MODE } from "../config/viewModes";
import { formatCountdown, passSummary, type Pass } from "../modules/PassPredictor";
import { renamed } from "../modules/util/groundStationEdits";
import { useCesiumStore } from "../stores/cesium";
import { useSatStore } from "../stores/sat";
import PassTimeline from "./PassTimeline.vue";
const cc = useController();
const toast = useToast();
const {
selection,
isTracked,
name,
position,
passRows,
passes,
nextPass,
subjectCount,
nowMs,
hasAnyPasses,
passesPending,
showPastPasses,
pickedPassMs,
pickPass,
preferredTab,
groundStationAvailable,
elements,
satelliteInfo,
canEnterSkyView,
enterSkyView,
deselect,
toggleTrack,
} = useSelectedEntity(cc);
const satStore = useSatStore();
const { overpassMode } = storeToRefs(satStore);
const { sceneMode } = storeToRefs(useCesiumStore());
const inSkyView = computed(() => sceneMode.value === SKY_MODE);
const OVERPASS_MODES = [
{ value: "elevation", label: "Elevation" },
{ value: "swath", label: "Swath" },
] as const;
/** Asks whether a station exists, not what kind of thing is selected: a satellite
* with a station set and an empty window has a different problem. */
const emptyPassText = computed(() => (groundStationAvailable.value ? "No passes in the prediction window" : "No ground station set"));
const satnum = computed(() => (selection.value?.kind === "satellite" ? selection.value.sat.props.satnum : undefined));
const links = computed(() => (satnum.value ? externalLinks(satnum.value) : []));
// The derived facts lead `satelliteInfo`, so the orbit class is its first row.
const facts = computed(() => new Map(satelliteInfo.value));
const orbitClass = computed(() => facts.value.get("Orbit")?.split(" · ")[0] as OrbitClass | undefined);
const orbitColor = computed(() => (orbitClass.value ? ORBIT_CLASS_COLOR[orbitClass.value] : "transparent"));
// The three facts worth carrying in the header rather than leaving in Details:
// what regime it flies in, whose it is, whether it still works.
const chips = computed(() => ["Orbit", "Owner", "Status"].map((key) => facts.value.get(key)).filter((value): value is string => value !== undefined));
// The card title already carries the name.
const liveRows = computed(() => position.value.filter((row) => row.label !== "Name"));
const isOngoing = computed(() => !!nextPass.value && nextPass.value.start <= nowMs.value);
const ongoingFraction = computed(() => {
const pass = nextPass.value;
if (!pass || pass.end <= pass.start) {
return 0;
}
return Math.min(1, Math.max(0, (nowMs.value - pass.start) / (pass.end - pass.start)));
});
const nextPassSubject = computed(() => (nextPass.value ? (nextPass.value.groundStationName ?? nextPass.value.name) : ""));
// A station's list is every satellite that crosses it, which is too dense to draw
// as a strip — see PassTimeline.vue.
const showTimeline = computed(() => selection.value?.kind === "satellite");
const tabs = computed(() => {
const items: { label: string; slot: string; value: string; badge?: number }[] = [];
if (satelliteInfo.value.length > 0 || elements.value) {
items.push({ label: "Details", slot: "details", value: "details" });
}
items.push({ label: "Passes", slot: "passes", value: "passes", ...(passRows.value.length > 0 ? { badge: passRows.value.length } : {}) });
return items;
});
/**
* The active tab: whichever of the remembered choice and the available set can be
* satisfied.
*
* Controlled rather than left to UTabs. The tab set depends on what is selected: a
* ground station has no Details. This component is not remounted across a selection
* change. So an uncontrolled UTabs kept pointing at a tab that had gone, and
* rendered an empty body. What is remembered, and why, is `preferredTab`.
*/
const resolvedTab = computed(() => (tabs.value.some((item) => item.value === preferredTab.value) ? preferredTab.value : (tabs.value[0]?.value ?? "passes")));
// Tapping the tab you are already on folds the body away. On a phone the panel is
// most of the screen, and what it covers — the globe, and the passes the clock deck
// marks on its ruler — is often what you opened it to look at. The selection stays,
// so the marks stay with it.
const collapsed = ref(false);
// A new selection is a request to see it, so it arrives unfolded. Watched rather than
// cleared on close, because the panel is not remounted between entities.
watch(selection, () => {
collapsed.value = false;
});
const activeTab = computed({
get: () => resolvedTab.value,
set: (value: string) => {
// Reka's trigger sets the model on every press, unchanged value included, so the
// second press on the tab you are on arrives here as the value it already had.
if (value === resolvedTab.value) {
collapsed.value = !collapsed.value;
return;
}
collapsed.value = false;
preferredTab.value = value;
},
});
// Bring the picked row into view. A block on the strip says when and how good and
// nothing else. The useful destination is the row, where the azimuth and the exact
// times are.
const tableEl = useTemplateRef<HTMLTableElement>("tableEl");
watch(pickedPassMs, (startMs) => {
if (startMs === null) {
return;
}
// After the row has its class, so `nearest` measures the right element.
nextTick(() => {
tableEl.value?.querySelector(`tr[data-start-ms="${startMs}"]`)?.scrollIntoView({ block: "nearest" });
});
});
// Only a ground station has a name of its own to change; a satellite's comes from
// the catalog.
const canRename = computed(() => selection.value?.kind === "groundstation");
const renaming = ref(false);
const renameEl = useTemplateRef<HTMLInputElement>("renameEl");
/**
* What is being typed, held locally rather than bound to the station.
*
* Vue patches a `:value` against the *live* DOM value, so a bound input loses what
* was typed on every one-second refresh. Seeded from `givenName`, not the displayed
* name: an unnamed station displays its coordinates, and that is a name with a
* comma in it, which `wireSafeName` in the store refuses.
*/
const draftName = ref("");
// A rename in progress belongs to the station it started on.
watch(selection, () => {
renaming.value = false;
});
function toggleRename(): void {
if (renaming.value) {
commitRename();
return;
}
// The name it actually has, which is empty when it has none — not the coordinates
// it falls back to displaying.
draftName.value = selection.value?.kind === "groundstation" ? selection.value.gs.givenName : "";
renaming.value = true;
// Focus after the input exists, and select the text so a replacement is one
// keystroke rather than a clear-then-type.
nextTick(() => renameEl.value?.select());
}
function cancelRename(): void {
renaming.value = false;
}
/**
* Write the typed name through, then leave edit mode.
*
* Blur commits as well as Enter, because leaving the field is how most edits end.
* Escape gets there first by clearing `renaming`, which is what the guard below
* reads: the input unmounting fires one last blur, and without it Escape would
* commit the very text it was pressed to abandon.
*
* Matched on the station's position in the list rather than on its old name, which
* is the thing being changed.
*/
function commitRename(): void {
if (!renaming.value) {
return;
}
renaming.value = false;
const sel = selection.value;
if (sel?.kind !== "groundstation") {
return;
}
const index = cc.sats.groundStations.indexOf(sel.gs);
if (index < 0) {
return;
}
satStore.setGroundStations(renamed(satStore.groundStations, index, draftName.value));
// The write rebuilds every station entity, which drops the selection and closes
// the panel — on the station you were in the middle of editing. Re-select the
// replacement, which is the same list position, once it exists.
nextTick(() => cc.sats.groundStations[index]?.select());
}
function notifyForPass(pass: Pass, aheadMin = 5): void {
const start = dayjs(pass.start).startOf("second");
cc.pm.notifyAtDate(start.subtract(aheadMin, "minute").toDate(), `${pass.name} pass in ${aheadMin} minutes`);
cc.pm.notifyAtDate(start.toDate(), `${pass.name} pass starting now`);
}
function notifyPasses(): void {
if (!cc.sats.groundStationAvailable) {
toast.add({
color: "warning",
title: "Warning",
description: "Ground station required to notify for passes",
duration: 3000,
});
return;
}
let upcoming: Pass[] = [];
if (selection.value?.kind === "satellite") {
upcoming = selection.value.sat.props.passPredictor.passes(cc.viewer.clock.currentTime);
} else if (selection.value?.kind === "groundstation") {
upcoming = selection.value.gs.passes(cc.viewer.clock.currentTime);
}
if (upcoming.length === 0) {
toast.add({
color: "info",
title: "Info",
description: "No passes available",
duration: 3000,
});
return;
}
upcoming.forEach((pass) => notifyForPass(pass));
toast.add({
color: "success",
title: "Success",
description: `Notifying for ${upcoming.length} passes`,
duration: 3000,
});
}
</script>
<style scoped>
/* Below the toolbars and inside the same insets: sideways the notch is beside this
panel, and the width has to lose both sides or the card overflows. */
.entity-info-panel {
position: absolute;
top: calc(50px + var(--safe-top, 0px));
right: calc(5px + var(--safe-right, 0px));
width: calc(100% - 10px - var(--safe-left, 0px) - var(--safe-right, 0px));
max-width: 540px;
z-index: 5;
font-size: 14px;
}
/*
* The strip and the header are permanent, and the hero lives inside the body. The
* cap is what is left of a reasonable card height. The dvh term keeps the whole
* thing on screen on a short window, where the card has no cap of its own. It has to
* be dvh: an installed iOS app reports vh as the full screen, taller than the
* viewport it can paint.
*/
.entity-info-panel :deep(.info-body) {
max-height: min(520px, 58dvh);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.head {
display: flex;
flex-direction: column;
gap: 3px;
}
.head__title {
display: flex;
align-items: center;
gap: 4px;
}
.head__name {
overflow: hidden;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Sized and weighted like the name it replaces, so switching into edit mode moves
nothing but the caret. */
.head__rename {
min-width: 0;
flex: 1;
padding: 0 4px;
border: 1px solid #ffffff40;
border-radius: 3px;
background: #0000004d;
color: inherit;
font-size: 16px;
font-weight: 600;
}
/* Takes the slack whether or not there is a catalog number to show. */
.head__id {
flex-grow: 1;
color: #edffff70;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 999px;
}
.head__chips {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
padding-left: 2px;
color: #edffffb0;
font-size: 11px;
}
.head__chips span:not(:last-child)::after {
margin-left: 6px;
color: #edffff60;
content: "·";
}
/* Edge to edge, with hairline gaps: a band across the card rather than a card
within it, which is what keeps it reading as chrome and not as content. */
.strip {
display: flex;
gap: 1px;
background: #ffffff14;
}
.strip__cell {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
padding: 5px 10px;
background: #ffffff08;
}
.strip__label {
color: #edffff80;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.strip__value {
overflow: hidden;
font-size: 13px;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero {
margin-bottom: 10px;
padding: 8px 10px 10px;
border-radius: 4px;
background: linear-gradient(180deg, #4caf5024, #4caf5008);
}
.hero--live {
background: linear-gradient(180deg, #56b4e930, #56b4e910);
}
.hero__label {
color: #edffffb0;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero__value {
font-size: 34px;
font-variant-numeric: tabular-nums;
font-weight: 300;
line-height: 1.1;
}
.hero__meta {
margin-top: 2px;
color: #edffffcc;
font-size: 12.5px;
}
.hero__progress {
margin-top: 8px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: #ffffff20;
}
.hero__progress span {
display: block;
height: 100%;
background: #56b4e9;
}
/* The padding the scroll container gives up so its sticky table header can pin
flush with the top edge. */
.tab-body__pad {
padding-top: 12px;
}
/* Two states of one setting, so a pair rather than a switch: a switch would have
to label itself with the state it is in, and "Swath: off" is not what the other
mode is called. */
.modes {
display: flex;
overflow: hidden;
border: 1px solid #ffffff2b;
border-radius: 999px;
}
.modes__option {
padding: 1px 8px;
cursor: pointer;
color: #edffff90;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.modes__option:hover {
color: #edffff;
}
.modes__option--on {
background: #4caf5033;
color: #edffff;
}
.passes__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0 6px;
color: #edffff90;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.section {
margin: 12px 0 5px;
color: #edffff90;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Zebra rather than a border grid, and no coloured header bar: the bars carried no
information, and four of them stacked is what made the panel loud. */
.info-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.info-table th,
.info-table td {
padding: 3px 6px;
text-align: left;
}
.info-table tbody tr:nth-child(odd) {
background: #ffffff08;
}
.info-table--facts tbody th {
color: #edffffb0;
font-weight: 400;
}
/* Sticky, so the columns stay named while thirty passes scroll under them. */
.info-table thead th {
position: sticky;
top: 0;
background: #383b3e;
color: #edffffb0;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.info-table tbody tr.is-next {
box-shadow: inset 3px 0 #4caf50;
}
.info-table tbody tr.is-picked {
background: #edffff1f;
box-shadow: inset -3px 0 #edffff;
}
.info-table tbody tr.is-picked.is-next {
box-shadow:
inset 3px 0 #4caf50,
inset -3px 0 #edffff;
}
.num,
.right {
text-align: right !important;
}
.mono {
font-variant-numeric: tabular-nums;
}
.link {
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.links__item {
padding: 2px 7px;
border: 1px solid #ffffff2b;
border-radius: 999px;
color: #edffffdd;
font-size: 11.5px;
}
.links__item:hover {
border-color: #ffffff70;
background: #ffffff14;
color: #ffffff;
}
.info-code {
margin: 0;
padding: 4px;
overflow-x: auto;
background: #26262699;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.empty {
padding: 10px 0;
color: #edffffb0;
text-align: center;
font-size: 13px;
}
</style>