Show a spinner by the time while play waits on data

Play holds while a layer loads the frame for its time, and the bar's only
sign of it was a faint pulse, so a stopped handle read as a stall. The
bubble now shows a small spinner beside the time while play is held. It
comes on once a hold lasts long enough to notice and stays through a run of
slow frames, so neither a quick frame nor the gaps between frames flash it.
This commit is contained in:
Sameh Khamis committed 2026-11-02 15:11:47 -08:00
1 parent 287c881c17
commit 4fc9722356
3 files changed
+73 -9

No files matched your search

+18 -7
View File
@@ -106,21 +106,32 @@ body.cockpit-mode #world-timeline {
pointer-events: none;
}
/* Playing, held while a layer loads the time's data. */
#world-timeline .world-timeline-time.is-buffering {
animation: world-timeline-buffering 1s ease-in-out infinite alternate;
/* Playing, held while a layer loads the time's data: a spinner by the time. */
#world-timeline .world-timeline-time.is-waiting {
display: inline-flex;
align-items: center;
gap: 6px;
}
@keyframes world-timeline-buffering {
#world-timeline .world-timeline-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: world-timeline-spin 0.9s linear infinite;
}
@keyframes world-timeline-spin {
to {
opacity: 0.45;
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
#world-timeline .world-timeline-time.is-buffering {
#world-timeline .world-timeline-spinner {
animation: none;
opacity: 0.6;
border-color: var(--accent);
}
}
@@ -411,3 +411,32 @@ test('zoomed, a scrub near now stays off live, and the view keeps a still handle
assert.equal(track.classList.contains('is-zoomed'), false);
assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was');
});
test('play held for a layer to load shows a spinner once the hold lasts', async (t) => {
const bar = mount(t);
const at = Date.UTC(2026, 9, 30, 6, 15, 42);
supportingLayer(t, at);
worldClock.seek(at - 1_800_000);
worldClock.play({ continuous: true });
await nextTick();
const bubble = () => bar().querySelector('.world-timeline-time');
const release = worldClock.wait('test-radar');
t.after(release);
// A clock tick (a quarter second) tells of the hold; the spinner waits on.
await new Promise((resolve) => setTimeout(resolve, 250));
await nextTick();
assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame');
// The clock tells of the hold on its next tick, and the bar shows it after.
const until = async (shown) => {
for (let waited = 0; waited < 3_000; waited += 50) {
if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true;
await new Promise((resolve) => setTimeout(resolve, 50));
await nextTick();
}
return false;
};
assert.ok(await until(true), 'spinner once the hold lasts');
assert.match(bubble().textContent, /UTC$/, 'the time stays');
release();
assert.ok(await until(false), 'gone once play goes on');
});
+26 -2
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, shallowRef, watch } from 'vue';
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js';
@@ -62,6 +62,29 @@ const bubble = computed(() =>
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
: '',
);
// Play held while a layer loads the time's data shows a spinner by the time,
// once the hold lasts long enough to notice, so a quick frame does not flash
// it, and until play has run a while, so a run of slow frames keeps it still.
const WAIT_SHOWN_MS = 300;
const WAIT_HIDDEN_MS = 500;
const waiting = shallowRef(false);
let waitTimer = null;
watch(
() => time.value.buffering,
(buffering) => {
clearTimeout(waitTimer);
waitTimer = null;
if (buffering === waiting.value) return;
waitTimer = setTimeout(
() => {
waitTimer = null;
waiting.value = buffering;
},
buffering ? WAIT_SHOWN_MS : WAIT_HIDDEN_MS,
);
},
);
onBeforeUnmount(() => clearTimeout(waitTimer));
const span = computed(() => time.value.span);
// Where the handle is, or null with no track.
const at = computed(() => {
@@ -222,7 +245,8 @@ const onKey = (event) => {
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom · double-click to see it all')" @wheel="onWheel" @mousedown="onPress" @dblclick="showAll" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" @touchcancel="onTouchEnd"
><time class="world-timeline-time" :class="{ 'is-buffering': time.buffering }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ bubble }}</time
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
><input