Published
Edited
Jan 10, 2022
Importers
43 stars
Insert cell
Insert cell
Insert cell
{
const s = scene(options.height, options.theme);
invalidation.then(() => s.dispose());
return s.start();
}
Insert cell
Insert cell
scene = (height = 500, theme = "Day") => {
const
day = theme === "Day",
sunset = theme === "Sunset",
twilight = theme === "Twilight";

if (!(day || sunset || twilight)) throw "Invalid theme! The available options are \"Day\", \"Sunset\" and \"Twilight\".";

const
empty = (() => () => Plot.dot([]))(),
sky = (() => {
const
data = Array.from({length: Y.max}, (_, i) => ({x1: X.min, y1: i, x2: X.max, y2: i+2})),
fill = day ? "skyblue" : sunset ? "orange" : "purple";
return () => Plot.rect(data, {x1: "x1", y1: "y1", x2: "x2", y2: "y2", fill: fill, opacity: d => d.y2 / Y.max});
})(),
sun = (() => () => sunset ? Plot.dot([{x:7.8, y:20}], {x:"x", y:"y", r: 50, fill: "salmon"}) : empty())(),
mountains = (() => {
const data = [
{
shapes: [[{x:0,y:20},{x:3,y:40},{x:6,y:20}], [{x:3,y:40},{x:6,y:20}]],
shades: ["#828ba4", "#6c758b"]
},
{
shapes: [[{x:4,y:20},{x:6,y:50},{x:8,y:10}], [{x:6,y:50},{x:8,y:10}]],
shades: ["#6c758b", "#686c7c"]
},
{
shapes: [[{x:9,y:10},{x:12,y:62.5},{x:16,y:10}], [{x:12,y:62.5},{x:16,y:10}]],
shades: ["#ffffff", "#dddddd"]
},
{
shapes: [[{x:8,y:10},{x:12,y:60},{x:17,y:10}], [{x:12,y:60},{x:17,y:10}]],
shades: ["#686c7c", "#626977"]
},
{
shapes: [[{x:7,y:10},{x:10,y:50},{x:13,y:10}], [{x:10,y:50},{x:13,y:10}]],
shades: ["#79829b", "#6c758b"]
},
{
shapes: [[{x:10,y:10},{x:15,y:40},{x:19,y:0}], [{x:15,y:40},{x:19,y:0}]],
shades: ["#7e8a9d", "#6c758b"]
}
];
return () => data.flatMap(m => m.shapes.map((s, i) => Plot.areaY(s, {x:"x", y:"y", fill:m.shades[i]})));
})(),
mask = (() => {
if (day) return empty;
let fill = "orange", opacity = 0.2;
if (twilight) {
fill = "black";
opacity = 0.4;
}
return () => Plot.rect([{x1: X.min, y1: Y.min, x2: X.max, y2: Y.max}], {x1: "x1", y1: "y1", x2: "x2", y2: "y2", fill: fill, opacity: opacity});
})(),
land = (() => {
const data = [
...Array(20).fill().map((_, i) => ({x: i, y: 20 - i + Math.random(), layer: 0, fill: "#8a3700"})),
...Array(15).fill().map((_, i) => ({x: 5 + i, y: i / 2, layer: 1, fill: "#b96b31"})),
];
return () => Plot.areaY(data, Plot.stackY({x: "x", y: "y", curve: "natural", fill: "fill"}));
})(),
hills = (() => {
const data = [
{x:0,y:35,l:0}, {x:3, y: 25, l:0}, {x:7, y: 10, l:0},
{x:3,y:0,l:1}, {x:8, y:22, l:1}, {x:12, y: 5, l:1},
{x:6,y:0,l:2}, {x:12, y:20, l:2}, {x:16, y: 7, l:2},
{x:10,y:0,l:3}, {x:14.5, y:16, l:3}, {x:19, y: 0, l:3},
{x:12,y:5,l:0}, {x:16, y:20, l:0}, {x:19, y: 23, l:0}
];
const colors = ["#007f5f", "#80b918", "#bfd200", "#55a630"]
return () => Plot.areaY(data, Plot.stackY({x: "x", y: "y", fill: d => colors[d.l], curve: "natural"}));
})();
class Scene {
constructor() {
this.clouds = new CloudManager(sunset);
this.water = new WaterManager();
this.stars = new StarManager();
this.shootingStar = new ShootingStar();
this.airplane = new Airplane();
this.frameId;
}

start() {
const
that = this,
div = document.createElement("div");
update();
animate();
return div;
function animate(ts) {
if (move(ts)) update();
that.frameId = requestAnimationFrame(animate);
}

function move(ts) {
return that.water.animate(ts)
|| !twilight && that.clouds.animate(ts)
|| twilight && (that.stars.animate(ts) | that.shootingStar.animate(ts) | that.airplane.animate(ts));
}

function update() {
if (div.children.length) div.children[0].remove();
div.append(that.render());
}
}

dispose() {
cancelAnimationFrame(this.frameId);
}

render() {
return Plot.plot({
width: width,
height: height,
x: {
axis: null
},
y: {
axis: null,
domain: [Y.min, Y.max]
},
color: { type: "identity" },
marks: [
sky(),
twilight ? this.stars.render() : empty(),
twilight ? this.shootingStar.render() : empty(),
twilight ? this.airplane.render() : empty(),
sun(),
mountains(),
hills(),
land(),
this.water.render(),
!twilight ? this.clouds.render() : empty(),
mask()
]
});
}
}
return new Scene();
}
Insert cell
class Animatable {
constructor() {
this.start = null;
this.elapsed = null;
}

get interval() {
return 100;
}

animate(timeSpan) {
if (!this.start) this.start = timeSpan;
this.elapsed = timeSpan - this.start;
if (this.elapsed >= this.interval) {
this.update();
this.start = timeSpan;
return true;
}
return false;
}

update() {}
}
Insert cell
class StarManager extends Animatable {
constructor() {
super();
this.stars = Array(100).fill().map(() => ({
x: Math.random() * X.max,
y: Math.random() * (Y.max - 1),
o: Math.random()
}));
this.update();
}

get interval() {
return 1000;
}

update() {
this.stars.forEach(d => d.o = Math.random());
}

render() {
return Plot.dot(this.stars, {x: "x", y: "y", fill: "white", r: 2, opacity: "o"});
}
}
Insert cell
class Airplane extends Animatable {
constructor() {
super();
this.x = 0;
this.y = 0;
this.color = "red";
this.step = 0.01;
this.newAirplane();
}

get interval() {
return 10;
}

get expired() {
return this.x >= X.max || this.x <= X.min;
}

newAirplane() {
const d = Math.random() * 10;
this.x = d > 5 ? X.max - 0.01 : X.min + 0.01;
this.step = d > 5 ? -0.01 : 0.01;
this.y = Math.random() * 20 + 70;
}
update() {
this.x += this.step;
this.color = new Date().getSeconds() % 2 === 0 ? "red" : "lightgreen";
if (this.expired) this.newAirplane();
}

render() {
return Plot.dot([{x: this.x, y: this.y}], {x: "x", y: "y", r:3, fill: this.color});
}
}
Insert cell
class ShootingStar extends Animatable {
constructor() {
super();
this.x = 0;
this.y = 0;
this.m = 0;
this.b = 0;
this.step = 1;
this.newStar();
}

get interval() {
return 10;
}

get expired() {
return this.x >= X.max || this.x <= X.min || this.y <= Y.min;
}

updateX() {
this.x = (this.y - this.b) / this.m + 10;
}

newStar() {
while(this.expired) {
this.y = Math.random() * 20 + 79;
this.m = (Math.random() * 10 + 5.5) * (Math.random() > 0.5 ? 1 : -1);
this.b = Math.random() * 50;
this.step = Math.random() + 0.5;
this.updateX();
}
}

update() {
this.y -= this.step;
this.updateX();
if (this.expired) this.newStar();
}

render() {
return Plot.dot([{x: this.x, y: this.y}], {x: "x", y: "y", r: 3, fill: "white"});
}
}
Insert cell
WaterManager = {
const layers = 5;
const colors = d3.scaleSequential(d3.interpolateBlues).domain([layers - 1, 0]);
class WaterManager extends Animatable {
constructor() {
super();
this.waves = null;
this.splashes = null;
this.update();
}

get interval() {
return 500;
}

update() {
this.waves = [];
for(let i = 0; i < layers; i++) {
for(let j = 0; j <= X.max; j++) {
this.waves.push({x: j, y: Math.random() * (i + 2), l: i});
}
}
this.splashes = Array(200).fill().map(() => ({
x: Math.random() * X.max,
y: Math.random() * 10
}));
}

render() {
return [
Plot.areaY(this.waves, Plot.stackY({x: "x", y: "y", fill: d => colors(d.l), stroke: d => colors(d.l), curve: "natural", opacity: 0.95})),
Plot.dot(this.splashes, {x: "x", y: "y", fill: "white", r: 1.5, opacity: 0.75})
];
}
}
return WaterManager;
}
Insert cell
class CloudManager extends Animatable {
constructor(isSunset) {
super();
this.isSunset = isSunset;
this.data = Array(20).fill().map(_ => new Cloud(X.max - 1, isSunset));
}

get interval() {
return 200;
}

update() {
this.data.forEach(c => c.move());
this.data = this.data.filter(c => !c.expired);
const gap = 20 - this.data.length;
if (gap > 0) {
for(let i = 0; i < gap; i++) {
this.data.push(new Cloud(X.min + 3, this.isSunset));
}
}
}

render() {
return this.data.map(c => c.render());
}
}
Insert cell
Cloud = {
const boundary = X.max - 1;
const expiration = 15000;
const colors = [["#ffffff", "#fcfcfc", "#fafafa", "#f8f8f8"], ["#faf3dd", "#fce0ce", "#fdcdbe", "#febaae85"]];
class Cloud {
constructor(range, isSunset) {
this.left = Math.random() * range;
this.y = Math.random() * 40 + 50;
this.step = Math.random() + 0.05;
this.timeStamp = Date.now();
this.data = Array(30).fill().map(_ => ({
x: Math.random() * 1.5,
y: Math.random() * 6 + this.y,
c: colors[isSunset ? 1 : 0][Math.floor(Math.random() * 4)],
o: Math.random()
}));
this.max = d3.max(this.data.map(d => d.x));
}

get expired() {
return this.left > boundary - this.max || Date.now() - this.timeStamp > expiration;
}

render() {
return Plot.dot(this.data, {x: d => d.x + this.left, y: "y", r: Math.random() * 10 + 6.5, fill: "c", opacity: "o"});
}

move() {
this.left += this.step / 2;
}
}

return Cloud;
}
Insert cell
X = ({min: 0, max: 19})
Insert cell
Y = ({min: 0, max: 100})
Insert cell
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