Public
Edited
Jan 26, 2024
Importers
Insert cell
Insert cell
slide`# Demo`
Insert cell
Insert cell
Insert cell
slide`
# Markdown stuff

The default slide supports markdown. The style changes in fullscreen.
`
Insert cell
slide.code`
here {
is: some-code;
}
`
Insert cell
notes.below`
You can also add notes like this, which also support markdown. \`notes.below\` means the notes are below the slide it refers to, \`notes.above\` are placed above the associated slide in the document order.
`
Insert cell
Insert cell
Insert cell
slide.plot({
caption: "Athletes",
marks: [Plot.dot(athletes.data, { x: "weight", y: "height", stroke: "sex" })]
})
Insert cell
Insert cell
slide.withBuild(
(build) =>
build.md(0)`
# A slide with a ${build.highlight(1, "build")}

${build.md(2)`Where stuff can appear...`}
${build.md({
appear: 2,
hide: 3,
transitionIn: "transition-appear",
transitionOut: "transition-slideout"
})`...and disappaer`}

`
)
Insert cell
Insert cell
slide.withBuild(
(build) =>
build.html`<div>
<h2>You can opt into animations</h2>
${build.animateChildren(html`<div style="display: flex; gap: 10px">
${build.html(
3
)`<div style="width: 5em; height: 5em; border: 1px solid gray;">Foo2</div>`}
${build.html(
1
)`<div style="width: 5em; height: 5em; border: 1px solid gray;">Foo</div>`}
${build.html(
2
)`<div style="width: 5em; height: 5em; border: 1px solid gray;">Foo2</div>`}
</div>`)}
<p>End</p>
</div>
`
)
Insert cell
Insert cell
{
const data = [
{
key: "Ewa",
data: [
{ key: "Math", value: 3 },
{ key: "Chemistry", value: 2 }
]
},
{
key: "Borys",
data: [
{ key: "Math", value: 5 },
{ key: "Chemistry", value: 1 }
]
},
{
key: "Filip",
data: [
{ key: "Math", value: 2 },
{ key: "Chemistry", value: 4 }
]
},
{
key: "Greg",
data: [
{ key: "Math", value: 1 },
{ key: "Chemistry", value: 6 }
]
},
{
key: "Alicja",
data: [
{ key: "Math", value: 4 },
{ key: "Chemistry", value: 4 }
]
},
{
key: "Celina",
data: [
{ key: "Math", value: 7 },
{ key: "Chemistry", value: 2 }
]
},
{
key: "Dorian",
data: [
{ key: "Math", value: 6 },
{ key: "Chemistry", value: 4 }
]
}
];

const title = "Animated Charts using Plotteus",
showLegend = true,
legendAnchor = "start",
verticalAxis = {
title:
"Example lifted from https://observablehq.com/@bartok32/hello-plotteus"
},
palette = "vivid";

return slide.plotteus([
{
key: "0",
chartType: "bar",
title,
showLegend,
legendAnchor,
verticalAxis,
palette,
groups: data
.map((obj) => ({
...obj,
data: obj.data.filter((v) => v.key == "Math")
}))
.sort((a, b) => {
return d3.ascending(a.key, b.key);
})
},
{
key: "1",
chartType: "bar",
title,
showLegend,
legendAnchor,
palette,
verticalAxis,
groups: data
.map((obj) => ({
...obj,
data: obj.data.filter((v) => v.key == "Math")
}))
.sort((a, b) => {
return d3.ascending(a.data[0].value, b.data[0].value);
})
},
{
key: "2",
chartType: "bar",
chartSubtype: "stacked",
title,
showLegend,
legendAnchor,
palette,
verticalAxis,
groups: [...data].sort((a, b) => {
return d3.ascending(a.data[0].value, b.data[0].value);
})
},
{
key: "3",
chartType: "bar",
chartSubtype: "stacked",
title,
showLegend,
legendAnchor,
palette,
verticalAxis,
groups: [...data].sort((a, b) => {
return d3.ascending(
d3.sum(a.data, (x) => x.value),
d3.sum(b.data, (x) => x.value)
);
})
},
{
key: "4",
chartType: "pie",
title,
palette,
legendAnchor,
groups: [...data]
.sort((a, b) => {
return d3.descending(
d3.sum(a.data, (x) => x.value),
d3.sum(b.data, (x) => x.value)
);
})
.slice(0, 3)
}
]);
}
Insert cell
Insert cell
slide.mdWithBuildLists`
# There is a special helper

- For markdown with lists
- That should appear
- By bullet point
`
Insert cell
slide.customAnimation(
({ gl, uniforms }, t, build) => {
// First argument here is an arbitrary context returned from the `init` function
// Second is the animation time (0..1)
// Third is the number of the current build step (0..numBuilds>
const aBase = build == 1 ? t : 0;
gl.uniform1f(uniforms.u_a, -2 + Math.sin(1 - aBase));
gl.uniform1f(uniforms.u_b, -2);
gl.uniform1f(uniforms.u_c, -1.2);
gl.uniform1f(uniforms.u_d, 2);
gl.drawArrays(gl.POINTS, 0, Math.pow(2, 18));
},
{
numBuilds: 2,
init: (container) => {
const h = isFullscreen ? window.innerHeight - 400 : 400;
const w = isFullscreen ? width - 400 : width;
const canvas = DOM.canvas(w * devicePixelRatio, h * devicePixelRatio);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const gl = (canvas.value = canvas.getContext("webgl"));
gl.viewport(0, 0, canvas.width, canvas.height);

const array = new Float32Array(Math.pow(2, 18) * 2).map(
() => Math.random() * 2 - 1
);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, array, gl.STATIC_DRAW);

const fragment = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(
fragment,
`
precision highp float;

void main() {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}
`
);
gl.compileShader(fragment);
if (!gl.getShaderParameter(fragment, gl.COMPILE_STATUS))
throw gl.getShaderInfoLog(fragment);

const shader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(
shader,
`
precision highp float;

const float PI = 3.14159265359;

uniform float a;
uniform float b;
uniform float c;
uniform float d;

attribute vec2 position;

void main() {
float x1, x2 = position.x;
float y1, y2 = position.y;
for (int i = 0; i < 8; i++) {
x1 = x2, y1 = y2;
x2 = sin(a * y1) - cos(b * x1);
y2 = sin(c * x1) - cos(d * y1);
}
gl_Position = vec4(x2 / 2.0, y2 / 2.0, 0.0, 1.0);
gl_PointSize = 1.0;
}
`
);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
throw gl.getShaderInfoLog(shader);

const program = gl.createProgram();
gl.attachShader(program, shader);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw gl.getProgramInfoLog(program);

invalidation.then(() => {
gl.deleteShader(shader);
gl.deleteShader(fragment);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
});
const u_a = gl.getUniformLocation(program, "a");
const u_b = gl.getUniformLocation(program, "b");
const u_c = gl.getUniformLocation(program, "c");
const u_d = gl.getUniformLocation(program, "d");

const a_position = gl.getAttribLocation(program, "position");
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(a_position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_position);
container.appendChild(htl.html`<h2>Custom animation</h2>`);
container.appendChild(canvas);
container.style.flexDirection = "column";
return { container, gl, uniforms: { u_a, u_b, u_c, u_d } };
}
}
)
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
mutable isFullscreen = false
Insert cell
makeSlideshowControls = () => {
let current = 0;
var show = {
notes: () => {}
};

function formatTime(seconds) {
return [
parseInt(seconds / 60 / 60),
parseInt((seconds / 60) % 60),
parseInt(seconds % 60)
]
.join(":")
.replace(/\b(\d)\b/g, "0$1");
}

const showNotesWindow = () => {
const controlsWindow = open(
"about:blank",
"notes",
"width=1000,height=800"
);
const controls = controlsWindow.document.body;

controlsWindow.document.title = "Presenter Notes";

controls.replaceChildren(html`<div>
<style>${fullscreenStyle(
(n) => `${(n / 100) * window.screen.width}px`,
(n) => `${(n / 100) * window.screen.height}px`
)}
.time { font-size: 300%; }
.notes {font-size: 130%; }
</style>
<div style="display:flex; height: fit-content">
<div style="width: 49%"><h4>Elapsed</h4> <span class="time"></time></div>
<div style="width: 50%; height: 400px"><h4>Next slide</h4><div style="transform: scale(${
(490 / (window.screen.width + 28)) * 100
}%); transform-origin: top left"><div style="width: ${
window.screen.width + 28
}px; height: ${
window.screen.height + 30
}px; border: 2px solid black; padding: 14px"><div class="next-slide"></div></div></div></div>
</div>
<div style="height: fit-content"><h4>Notes</h4><div class="notes"></div></div>
</div>`);
const time = controls.querySelector(".time");
const nextSlide = controls.querySelector(".next-slide");
const notes = controls.querySelector(".notes");

const start = new Date();

setInterval(() => {
time.replaceChildren(
html`<span>${formatTime((new Date() - start) / 1000)}</span>`
);
}, 1000);

show.notes = (slide) => {
notes.innerHTML = "";
const cell = slide.parentNode;
const siblings = Array.from(document.querySelectorAll(".observablehq"));
const currentCellIndex = siblings.indexOf(cell);

if (
currentCellIndex > 0 &&
siblings[currentCellIndex - 1].querySelector(".notes.above")
) {
notes.replaceChildren(siblings[currentCellIndex - 1].cloneNode(true));
}
if (
currentCellIndex > 0 &&
siblings[currentCellIndex + 1].querySelector(".notes.below")
) {
notes.replaceChildren(siblings[currentCellIndex + 1].cloneNode(true));
}
for (const sibling of siblings.slice(siblings.indexOf(cell) + 1)) {
if (sibling.querySelector(".slide")) {
nextSlide.replaceChildren(sibling.cloneNode(true));

break;
}
}
};
show.notes([...document.querySelectorAll(".observablehq .slide")][0]);
};

const present = () => {
if (
(document.fullScreenElement && document.fullScreenElement !== null) ||
(!document.mozFullScreen && !document.webkitIsFullScreen)
) {
if (document.documentElement.requestFullScreen) {
document.documentElement.requestFullScreen();
} else if (document.documentElement.mozRequestFullScreen) {
document.documentElement.mozRequestFullScreen();
} else if (document.documentElement.webkitRequestFullScreen) {
document.documentElement.webkitRequestFullScreen(
Element.ALLOW_KEYBOARD_INPUT
);
}
}
};

var changeHandler = function () {
const slides = [...document.querySelectorAll(".observablehq .slide")];
var fs =
(document.fullScreenElement && document.fullScreenElement !== null) ||
(!document.mozFullScreen && !document.webkitIsFullScreen);
if (fs) {
slides.forEach((e) => {
e.style.height = "";
});
mutable isFullscreen = false;
document.querySelector(".fullscreen-style").innerHTML = "";
} else {
mutable isFullscreen = true;
document.querySelector(".fullscreen-style").appendChild(
html`<style>${fullscreenStyle(
(n) => `${n}vw`,
(n) => `${n}vh`
)}</style>`
);
slides.forEach((e) => {
e.style.height = window.screen.height + "px";
});
var elmnt = slides[0];
current = 0;
elmnt.scrollIntoView();
}
};
document.addEventListener("fullscreenchange", changeHandler, false);
document.addEventListener("webkitfullscreenchange", changeHandler, false);
document.addEventListener("mozfullscreenchange", changeHandler, false);

// keymap
const onkeyup = function (e) {
const slides = [...document.querySelectorAll(".observablehq .slide")];

const changeSlide = (index) => {
console.log(index, slides.length);
if (index === slides.length) {
document.querySelector(".end-slide").scrollIntoView();
current = index;
} else if (index > slides.length) {
document.exitFullscreen();
} else {
index = Math.max(index, 0);

current = index;
var elmnt = slides[current];
elmnt.resetBuild?.();
elmnt.scrollIntoView();
show.notes(elmnt);
}
};

const forward = () => {
var elmnt = slides[current];

if (elmnt?.canGoForward?.()) {
elmnt.forward();
} else {
changeSlide(current + 1);
}
};

if (e.which === 83) {
changeSlide(0);
}
if ((e.which === 32) | (e.which === 39) | (e.which === 40)) {
forward();
}

if ((e.which === 38) | (e.which === 37)) {
changeSlide(current - 1);
}
};
document.addEventListener("keyup", onkeyup);

invalidation.then(() => document.removeEventListener("keyup", onkeyup));

return htl.html`<div><label>Slideshow controls:</label> <button onClick=${present}>Fullscreen</button> <button onClick=${showNotesWindow}>Presenter notes</button><div class="fullscreen-style"></div><div class="end-slide"</div></div>`;
}
Insert cell
notes = ({
below: (...args) => htl.html`<div class="notes below">${md(...args)}</div>`,
above: (...args) => htl.html`<div class="notes above">${md(...args)}</div>`
})
Insert cell
slide = {
function slide() {
const container = document.createElement("div");
container.className = "slide";
container.appendChild(md.apply(this, arguments));
return container;
}
function iframe(strings) {
const container = document.createElement("div");
container.innerHTML =
"<iframe style='position:absolute;display:block; top:0px; left:-8px; bottom:0px; right:0px; width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden; z-index:999999;' frameBorder='0' src='" +
strings +
"'></iframe>";
container.className = "slide";
return container;
}
slide.iframe = iframe;

slide.html = (strings) => {
const container = document.createElement("div");
container.innerHTML = strings;
container.className = "slide slide--html";
return container;
};
function htmldark(strings) {
const container = document.createElement("div");
container.innerHTML = strings;
container.className = "slide slide--html dark";
return container;
}
slide.htmldark = htmldark;
function code(strings) {
const container = document.createElement("div");
const pre = container.appendChild(document.createElement("pre"));
const code = pre.appendChild(document.createElement("code"));
let string = strings[0] + "",
i = 0,
n = arguments.length;
while (++i < n) string += arguments[i] + "" + strings[i];
code.textContent = string.trim();
container.className = "slide slide--code";
return container;
}
slide.code = code;

slide.img = async function (hrefOrImg, opts = { scale: "contain" }) {
let img;
if (typeof hrefOrImg == "string") {
img = new Image();

img.src = hrefOrImg.trim();
} else if (hrefOrImg instanceof FileAttachment) {
img = await hrefOrImg.image();
}
img.className = `slide slide--img slide--img--${opts.scale}`;
return img;
};

slide.customAnimation = (
fn,
{
numBuilds = 1,
animationDuration = 1000,
className = "",
init = () => ({})
} = {}
) => {
const container = document.createElement("div");
container.className = "slide " + className;

let currentIdx = -1;
let t = 0;
let maxIds = numBuilds - 1;
let prevTimestamp = null;

const state = init(container);

const animate = (timestamp) => {
if (prevTimestamp == null) {
t = 0;
} else {
const delta = timestamp - prevTimestamp;
t += delta / animationDuration;
}
t = Math.min(t, 1);
prevTimestamp = timestamp;
fn(state, t, currentIdx);
if (t < 1) requestAnimationFrame(animate);
};

const forward = () => {
currentIdx++;
t = 0;
prevTimestamp = null;
requestAnimationFrame(animate);
};

container.canGoForward = () => currentIdx < maxIds;
container.forward = forward;
container.resetBuild = () => {
currentIdx = -1;
forward();
};

forward();

return animationPlayer(container);
};

slide.withBuild = (fn) => {
const container = document.createElement("div");
container.className = "slide";

let currentIdx = 0;
let maxIds = 0;

const conditional = (indexOpts, trueContent, falseContent) => {
let appearIndex =
typeof indexOpts == "number" ? indexOpts : indexOpts.appear;
let hideIndex = indexOpts.hide;
maxIds = Math.max(appearIndex, hideIndex || 0, maxIds);

if (appearIndex <= currentIdx && (!hideIndex || currentIdx < hideIndex)) {
return trueContent;
} else {
return falseContent;
}
};

const animator =
(contentRenderer) =>
(indexOpts, ...args) => {
if (indexOpts.raw) {
return contentRenderer(indexOpts, ...args);
}
let appearIndex =
typeof indexOpts == "number" ? indexOpts : indexOpts.appear;
let hideIndex = indexOpts.hide;
let { transitionIn, transitionOut } = indexOpts;
maxIds = Math.max(appearIndex, hideIndex || 0, maxIds);
return (...args) => {
if (
currentIdx >= appearIndex &&
(!hideIndex ||
(transitionOut
? currentIdx <= hideIndex
: currentIdx < hideIndex))
) {
let result = contentRenderer(...args);
if (currentIdx == appearIndex) result.classList.add(transitionIn);
else result.classList.remove(transitionIn);

if (currentIdx == hideIndex) result.classList.add(transitionOut);
else result.classList.remove(transitionOut);

return result;
} else {
return html`<!-- -->`;
}
};
};

const highlight = (options, content) =>
conditional(
typeof options == "number"
? { appear: options, hide: options + 1 }
: options,
htl.html`<span class=${options.class || "highlight"}>${content}</span>`,
content
);

const build = {
md: animator(md),
html: animator(html),
svg: animator(svg),
conditional,
highlight,
animateChildren: (child) => {
autoAnimate(child);
return child;
}
};

let contents = fn(build);

container.appendChild(contents);

const forward = () => {
currentIdx++;
let newContents = fn(build);
let p = diff.diff(contents, newContents);

//container.replaceChild(newContents, contents);
//contents = newContents;
diff.apply(contents, p);
};
container.canGoForward = () => currentIdx < maxIds;
container.forward = forward;
container.resetBuild = () => {
currentIdx = 0;
let newContents = fn(build);
container.replaceChild(newContents, contents);
contents = newContents;
};

return animationPlayer(container);
};

slide.plotteus = (steps) => {
return slide.customAnimation(
(chart, t, currentIdx) => {
chart.render(steps[currentIdx].key, d3.easeCubicInOut(t));
},
{
numBuilds: steps.length,
className: "slide--plotteus",
init: (container) => {
container.style.height = "400px";
return plotteus.makeStory(container, steps);
}
}
);
};

slide.mdWithBuildLists = (strings) => {
return slide.withBuild((build) => {
const result = md(strings);
let idx = 1;
for (let el of Array.from(result.getElementsByTagName("LI"))) {
el.id = DOM.uid();
el.parentElement.replaceChild(
build.html(idx++)`${el.cloneNode(true)}`,
el
);
}
for (let el of Array.from(result.getElementsByTagName("UL"))) {
autoAnimate(el);
}
for (let el of Array.from(result.getElementsByTagName("OL"))) {
autoAnimate(el);
}
return clean(result);
});
};

slide.plot = function (plotOpts) {
const pl = Plot.plot({
style: isFullscreen
? { background: "rgb(247, 247, 249)", fontSize: 20 }
: {},
width,
...(isFullscreen ? { height: window.outerHeight * 0.6 } : {}),
...plotOpts
});
const container = document.createElement("div");
const wrapper = document.createElement("div");
wrapper.appendChild(pl);
container.appendChild(wrapper);
container.className = "slide slide--plot";
return container;
};
return slide;
}
Insert cell
animationPlayer = (container) => {
if (isFullscreen) {
return container;
}
let canGoForward = container.canGoForward();
const button = htl.html`<button onClick=${() => {
if (container.canGoForward()) {
container.forward();
} else {
container.resetBuild();
}
canGoForward = container.canGoForward();
button.innerText = canGoForward ? "▶ Next step" : "⟲ Restart";
}} style="margin-bottom: 1rem">▶ Next step</button>`;

return htl.html`<div class="animation-player">${button}${container}</div>`;
}
Insert cell
fullscreenStyle = (
vw,
vh
) => `cite{font-size:2vw;position: absolute; bottom:2%;right:2%; text-align: right;align:right; }


div,iframe{width:100%;height:100%}

.slide {
width: calc(100% + 28px);
margin: 0 -14px;
padding: 10%;
box-sizing: border-box;
color: rgb(27, 30, 35);
background: rgb(247, 247, 249);
min-height: ${vw(65)};
font-size: ${vw(5)};
line-height: 1.15;
display: flex;
align-items: center;
}

.end-slide {
width: 100%;
height: ${vh(100)};
background: black;
}


.slide a {
color: #DE5E60;
}

.slide h1, .slide h2, .slide h3 {
margin-top: 0;
}

.slide p,
.slide pre,
.slide h1, .slide h2, .slide h3, .slide h4,
.slide img {
max-width: 100%;
}

.slide--img {
max-width: none;
padding: 0;
width: 100%;
}
.slide--img--contain {
object-fit: contain;
}
.slide--img--cover {
object-fit: cover;
}


.slide blockquote,
.slide ol,
.slide ul {
max-width: none;
}

.slide table {
font-size: ${vw(3)};
}
.slide table th, .slide table td {
padding: ${vw(1)} ${vw(2)} ${vw(1)} 0;
}
.slide > * {
width: 100%;
}

.slide code {
font-size: ${vw(1.8)};
}

.slide--code pre,
.slide--code code {
font-size: ${vw(2.3)};
}

.slide--code {
color: rgb(27, 30, 35);
background: rgb(247, 247, 249);
border-bottom: solid 1px white;
}

.slide--html {
color: rgb(27, 30, 35);
background: rgb(247, 247, 249);
border-bottom: solid 1px white;
}

.slide--plotteus {
height: ${vh(100)};
}

.slide--plotteus .title {
font-size: 4vw !important;
transform: translate(16px, 0);
}
.slide--plotteus > div {
display: none;
}

.slide--plot {
color: rgb(27, 30, 35);
background: rgb(247, 247, 249);
border-bottom: solid 1px white;
max-height: ${vh(100)};
}

.slide--plot > div {
display: flex;
flex-direction: column;
justify-content: center;
max-height: ${window.outerHeight}px;
height: ${vh(100)};
}

.slide--plot figure {
display: flex;
flex-direction: column-reverse;

}

.slide--plot figure > div {
flex-basis: 16px;
}

.slide--plot figcaption {
font-size: 4vw;
max-width: ${vh(80)};
margin-bottom: ${vw(2)};
}

.dark {
color: rgb(247, 247, 249);
background: #333;
border-bottom: solid 1px white;
}

.transition-appear {
animation: 1s appear;
}

.highlight {
background: yellow;
}

.transition-slideout {
animation: 4s slideout;
animation-fill-mode: forwards;
position: absolute;
}

@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

@keyframes slideout {
0% {
transform: translate(0px);
}
100% {
transform: translate(-${vw(100)});
}
}

.dark h1,.dark h2,.dark h3,.dark h4,.dark h5{color: rgb(247, 247, 249)}`
Insert cell
autoAnimate = (
await import("https://unpkg.com/@formkit/auto-animate@0.8.1/index.mjs?module")
).default
Insert cell
diff = new (await import("https://cdn.skypack.dev/diff-dom")).DiffDOM()
Insert cell
clean = function clean(node) {
for (var n = 0; n < node.childNodes.length; n++) {
var child = node.childNodes[n];
if (
child.nodeType === 8 ||
(child.nodeType === 3 && !/\S/.test(child.nodeValue))
) {
node.removeChild(child);
n--;
} else if (child.nodeType === 1) {
clean(child);
}
}
return node;
}
Insert cell
import { athletes } from "@observablehq/plot-test-data"
Insert cell
plotteus = import("https://unpkg.com/plotteus@1.1.2/dist/index.js?module")
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