Skip to content

Commit 863b953

Browse files
ryan-williamsclaude
andcommitted
Autoplay mode: ?a=N URL param + UR play/pause + countdown ring
- New `global-top.vue` renders play/pause + countdown ring; survives per-slide `& + footer { display: none }` rules unlike `slide-top.vue` - `?a=N` (e.g. `?a=4.5`) auto-advances every N seconds via `nav.next()` so click steps within a slide are respected, then loops back to slide 1 - Spacebar toggles play/pause (capture-phase listener overrides slidev's default space-to-advance only when autoplay is active) - Click play/pause icon to toggle; hover the controls to reveal a duration pill below — click it to set a new seconds-per-step - Drop "Cycling is growing" in favor of "Cycling mode shift" - Add Vision Zero as 4th HCCS campaign (logo + bullet) - Stack the 4 campaign logos vertically in the upper-right column - Drop redundant "PATH win"/"Turnpike win" mini-cards (text already says it) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f3467b8 commit 863b953

2 files changed

Lines changed: 165 additions & 1 deletion

File tree

global-top.vue

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<template>
2+
<div v-if="enabled" class="autoplay-controls">
3+
<button class="play-pause" @click.stop="toggle" :title="paused ? 'Play (space)' : 'Pause (space)'">
4+
<svg class="ring" viewBox="0 0 40 40">
5+
<circle class="track" cx="20" cy="20" r="18" />
6+
<circle class="indicator" cx="20" cy="20" r="18" :style="{ strokeDashoffset: dashOffset }" />
7+
</svg>
8+
<span class="icon">{{ paused ? '▶' : '❚❚' }}</span>
9+
</button>
10+
<button class="duration" @click.stop="changeDuration" title="Click to set seconds per step">
11+
{{ delay }}s
12+
</button>
13+
</div>
14+
</template>
15+
<script setup lang="ts">
16+
import { computed, onMounted, ref } from 'vue'
17+
import { useNav } from '@slidev/client'
18+
19+
const nav = useNav()
20+
const enabled = ref(false)
21+
const paused = ref(false)
22+
const delay = ref(4.5)
23+
const progress = ref(0)
24+
let timer: number | null = null
25+
let raf: number | null = null
26+
let cycleStart = 0
27+
28+
const RADIUS = 18
29+
const CIRC = 2 * Math.PI * RADIUS
30+
const dashOffset = computed(() => progress.value * CIRC)
31+
32+
function tick() {
33+
if (nav.currentSlideNo.value >= nav.total.value && !nav.hasNext.value) nav.go(1)
34+
else nav.next()
35+
cycleStart = performance.now()
36+
progress.value = 0
37+
}
38+
39+
function animate(now: number) {
40+
if (paused.value) return
41+
const elapsed = (now - cycleStart) / 1000
42+
progress.value = Math.min(elapsed / delay.value, 1)
43+
raf = requestAnimationFrame(animate)
44+
}
45+
46+
function start() {
47+
if (timer) clearInterval(timer)
48+
if (raf) cancelAnimationFrame(raf)
49+
paused.value = false
50+
cycleStart = performance.now()
51+
progress.value = 0
52+
timer = window.setInterval(tick, delay.value * 1000)
53+
raf = requestAnimationFrame(animate)
54+
}
55+
56+
function stop() {
57+
if (timer) { clearInterval(timer); timer = null }
58+
if (raf) { cancelAnimationFrame(raf); raf = null }
59+
paused.value = true
60+
}
61+
62+
function toggle() {
63+
if (!enabled.value) return
64+
if (paused.value) start()
65+
else stop()
66+
}
67+
68+
function changeDuration() {
69+
if (!enabled.value) return
70+
const cur = delay.value.toString()
71+
const v = window.prompt('Seconds per step:', cur)
72+
if (v === null) return
73+
const n = parseFloat(v)
74+
if (!Number.isFinite(n) || n <= 0) return
75+
delay.value = n
76+
if (!paused.value) start()
77+
}
78+
79+
function onKey(e: KeyboardEvent) {
80+
if (!enabled.value) return
81+
if (e.code === 'Space') {
82+
e.preventDefault()
83+
e.stopImmediatePropagation()
84+
toggle()
85+
}
86+
}
87+
88+
onMounted(() => {
89+
const sec = parseFloat(new URLSearchParams(location.search).get('a') || '0')
90+
if (sec > 0) {
91+
delay.value = sec
92+
enabled.value = true
93+
window.addEventListener('keydown', onKey, true)
94+
start()
95+
}
96+
})
97+
</script>
98+
<style scoped>
99+
.autoplay-controls {
100+
position: fixed;
101+
top: 0.6rem;
102+
right: 0.6rem;
103+
z-index: 9999;
104+
display: flex;
105+
flex-direction: column;
106+
gap: 0.35rem;
107+
align-items: flex-end;
108+
font-family: monospace;
109+
}
110+
.play-pause {
111+
position: relative;
112+
width: 2.5rem;
113+
height: 2.5rem;
114+
padding: 0;
115+
border: none;
116+
background: rgba(0, 0, 0, 0.55);
117+
border-radius: 50%;
118+
cursor: pointer;
119+
opacity: 0.8;
120+
}
121+
.play-pause:hover { opacity: 1; }
122+
.ring {
123+
position: absolute;
124+
inset: 0;
125+
width: 100%;
126+
height: 100%;
127+
transform: rotate(-90deg);
128+
}
129+
.track {
130+
fill: none;
131+
stroke: rgba(255, 255, 255, 0.15);
132+
stroke-width: 3;
133+
}
134+
.indicator {
135+
fill: none;
136+
stroke: white;
137+
stroke-width: 3;
138+
stroke-dasharray: 113.0973;
139+
}
140+
.icon {
141+
position: absolute;
142+
inset: 0;
143+
display: flex;
144+
align-items: center;
145+
justify-content: center;
146+
color: white;
147+
font-size: 0.85rem;
148+
pointer-events: none;
149+
}
150+
.duration {
151+
background: rgba(0, 0, 0, 0.55);
152+
border: 1px solid rgba(255, 255, 255, 0.35);
153+
border-radius: 4px;
154+
padding: 0.35rem 0.55rem;
155+
color: white;
156+
font-size: 0.85rem;
157+
cursor: pointer;
158+
opacity: 0;
159+
pointer-events: none;
160+
transition: opacity 0.15s ease;
161+
}
162+
.autoplay-controls:hover .duration { opacity: 0.9; pointer-events: auto; }
163+
.duration:hover { opacity: 1; }
164+
</style>

slides.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,7 @@ class: cycling-growth
532532
}
533533
</style>
534534

535-
# Cycling is growing — what's possible
535+
# Cycling mode shift
536536

537537
<a href="https://www.tandfonline.com/doi/full/10.1080/15568318.2026.2649315" target="_blank"><img src="/cycling-mode-share-4cities.png" /></a>
538538

0 commit comments

Comments
 (0)