Published
Edited
Jan 20, 2021
Importers
2 stars
Insert cell
Insert cell
typewriter
Insert cell
viewof typewriter = stepper(slices)
Insert cell
slices = {
const slices = [];
const text = "for a man, one giant leap for mankind";
const startIndex = 0;
for (let endIndex = startIndex; endIndex <= text.length; endIndex++) {
const slice = text.slice(startIndex, endIndex);
slices.push(slice);
}
return slices;
}
Insert cell
{
const options = {
alternate: true,
format: (currentValue, index, values) => {
const stepCount = (values.length - 1).toString();
const zeroPaddedStep = index.toString().padStart(stepCount.length, "0");
return `Step: ${zeroPaddedStep}/${stepCount}`;
},
slider: false,
visibility
};
return stepper(slices, options);
}
Insert cell
defaults = ({
alternate: false, // do not revert playback direction after the end is reached (or start, in further consequence)
autoplay: true,
delay: 50, // between steps when playing, in milliseconds
firstStepDelay: null, // same as delay
format: null, // do not provide textual output
initial: 0, // index of the values array
loop: true, // repeat from the start when the end is reached
loopDelay: 1000, // between loops when playing, in milliseconds
slider: true, // give user a slider thumb to move within its groove
visibility: null // see: https://observablehq.com/@observablehq/awaiting-visibility
})
Insert cell
// an adaptation of https://observablehq.com/@mbostock/scrubber supporting same options
function stepper(values, config = {}) {
const minIndex = 0;
const maxIndex = values.length - 1;
const options = Object.assign({}, defaults, config);
const showText = options.format !== null;
const form = createForm(showText, options.slider, maxIndex);
let currentIndex = null;
let playing = false;
let direction = PlaybackDirection.FORWARD;
let cancelableDelay = null;

const updateText = () => {
if (showText) {
form.output.textContent = options.format(values[currentIndex], currentIndex, values);
}
};
const updateSlider = () => {
const slider = form.slider ?? form.querySelector("progress");
slider.value = currentIndex;
};
const updateButtons = () => {
form.skipStartButton.disabled = (currentIndex === minIndex);
form.skipBackwardButton.disabled = (currentIndex === minIndex) || playing;
form.playOrPauseButton.querySelector("i").className = playing ? "bi-pause-fill" : "bi-play-fill";
form.skipForwardButton.disabled = (currentIndex === maxIndex) || playing;
form.skipEndButton.disabled = (currentIndex === maxIndex);
};
const updateFormValue = () => {
form.value = values[currentIndex];
form.dispatchEvent(new CustomEvent("input"));
};
const setCurrentIndex = (newIndex) => {
currentIndex = newIndex;
updateText();
updateSlider();
updateButtons();
updateFormValue();
};
const stop = () => {
playing = false;
cancelableDelay.cancel();
};
const pause = () => {
stop();
updateButtons();
};
const pauseAt = (newIndex) => {
stop();
setCurrentIndex(newIndex); // updates buttons, i.a.
};

const move = (newIndex, duration) => {
cancelableDelay = delay(duration);
return cancelableDelay.promise.then(() => setCurrentIndex(newIndex))
.catch(() => {}); // prevents "Uncaught (in promise)" errors (reported in console, by the Observable runtime)
};
const invert = (direction) => direction * -1;
const updateDirection = () => {
if (options.alternate) {
direction = invert(direction);
}
};
const step = async (isFirstStep) => {
let newIndex = currentIndex + direction;
let duration = options.delay ?? 0;
if (isFirstStep) {
duration = options.firstStepDelay ?? duration;
}
if ((newIndex >= minIndex) && (newIndex <= maxIndex)) {
await move(newIndex, duration);
} else {
updateDirection();
if (options.loop) {
if (options.alternate) {
newIndex = currentIndex + direction;
} else {
newIndex = (direction === PlaybackDirection.FORWARD) ? minIndex : maxIndex
}
if (!isFirstStep) {
duration = options.loopDelay ?? duration;
}
await move(newIndex, duration);
} else {
pause();
}
}
}
const play = async () => {
let isFirstStep = true;
playing = true;
updateButtons();
while (playing) {
if (options.visibility !== null) {
await options.visibility().then(() => step(isFirstStep));
} else {
await step(isFirstStep);
}
isFirstStep = false;
}
};
const playOrPause = () => {
if (playing) {
pause();
} else {
play();
}
};
const handleEvents = () => {
const slider = form.slider;
if (slider !== undefined) {
slider.oninput = (event) => {
if (event.isTrusted) {
pauseAt(+form.slider.value);
}
};
}
form.skipStartButton.onclick = () => pauseAt(minIndex);
form.skipBackwardButton.onclick = () => pauseAt(currentIndex - 1);
form.playOrPauseButton.onclick = () => playOrPause();
form.skipForwardButton.onclick = () => pauseAt(currentIndex + 1);
form.skipEndButton.onclick = () => pauseAt(maxIndex);
};

setCurrentIndex(options.initial);
handleEvents();
if (options.autoplay) {
play();
}
return form;
}
Insert cell
createForm = (showText, showSlider, maxValue) => {
let literal = '<form class="stepper">';
if (showText) {
literal += '<output name="output"></output>';
}

if (showSlider) {
literal += `<input type="range" name="slider" min="0" max="${maxValue}" value="0" step="1">`;
} else {
literal += `<progress max="${maxValue}" value="0"></progress>`;
}
// swapping bi-skip-start (single triangle) and bi-skip-backward (double triangle) icons by design,
// as bi-skip-forward and bi-skip-end
literal += `
<div class="buttons">
<button type="button" name="skipStartButton"><i class="bi-skip-backward-fill"></i></button>
<button type="button" name="skipBackwardButton" type="button"><i class="bi-skip-start-fill"></i></button>
<button type="button" name="playOrPauseButton" type="button"><i class="bi-play-fill"></i></button>
<button type="button" name="skipForwardButton" type="button"><i class="bi-skip-end-fill"></i></button>
<button type="button" name="skipEndButton" type="button"><i class="bi-skip-forward-fill"></i></button>
</div>`;
literal += "</form>";
return html`${literal}`;
}
Insert cell
delay = (duration) => {
let finished = false;
let cancel = () => finished = true; // to be overwritten by the promise executor function

const promise = new Promise((resolve, reject) => {
const id = setTimeout(resolve, duration);
// overwriting the returned (besides promise) cancel function
cancel = () => {
if (finished) {
return;
}

clearTimeout(id);
reject();
};

if (finished) {
cancel();
}
});
promise.finally(() => finished = true);
return {promise, cancel};
}
Insert cell
PlaybackDirection = ({
FORWARD: 1,
BACKWARD: -1
})
Insert cell
html`<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css">`
Insert cell
html`<style>
.stepper {
display: flex;
flex-direction: column;
align-items: center;
max-width: 320px;
}

.stepper output {
font-family: var(--mono_fonts);
font-size: 16px;
line-height: 1;
}

.stepper progress,
.stepper input[name="slider"] {
width: 100%;
}

.stepper button i[class^="bi-"]::before {
display: block;
}
</style>`
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more