Public
Edited
Dec 28, 2022
2 forks
3 stars
Insert cell
Insert cell
Insert cell
canvas = html`<canvas id="myCanvas" width="800", height="800" style="border:1px solid #000000; background-color: #0F172A;"></canvas>`
Insert cell
viewof numCurves = Inputs.range([1, 70], {label: "Number of curves", step: 1, default: 10 })
Insert cell
viewof pixelsPerMillimeter = Inputs.range([0, 20], {label: "Pixels per millimeter", step: 1})
Insert cell
viewof maxInterpolationPeriodInSeconds = Inputs.range([2, 50], {label: "Max interpolation period in seconds", step: 0.5 })
Insert cell
viewof exampleType = Inputs.radio(["random", "concentric_circles"], {label: "Example type"})
Insert cell
curveColor = "#CBD5E1"
Insert cell
millimeter = {
return function(x) {
return x * 0.001;
}
}
Insert cell
function fromMillisecond(x) {
return x * 0.001;
}
Insert cell
glm = require('https://bundle.run/gl-matrix@3.4.3')
Insert cell
mathjs = require('mathjs@11.5.0/lib/browser/math.js')
Insert cell
random = mathjs.random
Insert cell
// Library
WindowParams = {canvas;
class WindowParams {
constructor() {
this.canvasElement = document.getElementById("myCanvas");
this.canvasWidth = this.canvasElement.width;
this.canvasHeight = this.canvasElement.height;
console.log('width, height =', this.canvasWidth, this.canvasHeight);

this.canvasCenter = glm.vec2.fromValues(
this.canvasWidth / 2,
this.canvasHeight / 2
);

this.pixels_per_meter = pixelsPerMillimeter * 1000;

// World x and y axes wrt canvas canvas coordinates. Don't know why we're
// keeping these.
this.worldXRelCanvas = glm.vec2.fromValues(this.pixels_per_meter, 0.0);
this.worldYRelCanvas = glm.vec2.fromValues(0.0, -this.pixels_per_meter);

this.canvasFromWorld = glm.mat3.fromScaling(
glm.mat3.create(),
glm.vec2.fromValues(this.pixels_per_meter, -this.pixels_per_meter)
);
this.canvasFromWorld = glm.mat3.mul(
glm.mat3.create(),
glm.mat3.fromTranslation(glm.mat3.create(), this.canvasCenter),
this.canvasFromWorld
);

console.log("canvasFromWorld =", this.canvasFromWorld);
this.canvas = this.canvasElement.getContext("2d");
}

clearRect() {
this.canvas.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
}

xPointToCanvas(out, point) {
out = glm.vec2.transformMat3(out, point, this.canvasFromWorld);
return out;
}

lengthToCanvas(l) {
return this.pixels_per_meter * l;
}

canvasBoundsInWorldUnits() {
const toWorld = glm.mat3.invert(glm.mat3.create(), this.canvasFromWorld);
console.log('worldFromCanvas =', toWorld);
const min = glm.vec2.transformMat3(
glm.vec2.create(),
glm.vec2.fromValues(0, 0),
toWorld
);
const max = glm.vec2.transformMat3(
glm.vec2.create(),
glm.vec2.fromValues(this.canvasWidth, this.canvasHeight),
toWorld
);
return { min, max };
}

moveTo(p) {
const canvasPoint = this.xPointToCanvas(glm.vec2.create(), p);
this.canvas.moveTo(canvasPoint[0], canvasPoint[1]);
}

lineTo(p) {
const canvasPoint = this.xPointToCanvas(glm.vec2.create(), p);
this.canvas.lineTo(canvasPoint[0], canvasPoint[1]);
}

bezierCurveTo(curvePoints) {
let points = curvePoints.map(p => this.xPointToCanvas(glm.vec2.create(), p));
// TODO: remove moveTo, be same as canvas' API
this.canvas.moveTo(points[0][0], points[0][1]); // starting end point
this.canvas.bezierCurveTo(
// cp 1
points[1][0],
points[1][1],
// cp 2
points[2][0],
points[2][1],
// ending end point
points[3][0],
points[3][1]
);
}
}
return WindowParams;
}
Insert cell
wp = {WindowParams;
return new WindowParams();
}
Insert cell
clearCanvas = {
return function(color) {
const c = wp.canvas;
c.fillStyle = color;
c.clearRect(0, 0, wp.canvasWidth, wp.canvasHeight);
}
}
Insert cell
class LinearInterpolator {
timestampStart;
timestampEnd;
maxT;

// Create a new Interpolator given the start and end timestamps. The unit of
// the timestamps does not matter.
constructor(timestampStart, timestampEnd, maxT = 1) {
this.timestampStart = timestampStart;
this.timestampEnd = timestampEnd;
this.maxT = maxT;
}

// Return the interpolated parameter which we call "t" at the current time
t(currentTime) {
if (currentTime < this.timestampStart) {
return 0;
}
if (currentTime > this.timestampEnd) {
return this.maxT;
}

const factor = (currentTime - this.timestampStart) / (this.timestampEnd - this.timestampStart);
return Math.min(this.maxT, Math.max(0, factor));
}
}
Insert cell
class LinearRoundtripInterpolator {
// Note that timestampEnd is the end time of the forward interpolation only.
// So total time is twice that of taken by forward interpolation.
constructor(timestampForwardStart, timestampForwardEnd, timestampBackwardStart, timestampBackwardEnd, oscillate = false, maxT = 1) {
this.timestampForwardStart = timestampForwardStart;
this.timestampForwardEnd = timestampForwardEnd;
this.timestampBackwardStart = timestampBackwardStart;
this.timestampBackwardEnd = timestampBackwardEnd;
this.maxT = maxT;
this.oscillate = oscillate;
this.totalPeriod = timestampBackwardEnd - timestampForwardStart;
}

forwardDuration() {
return this.timestampForwardEnd - this.timestampForwardStart;
}

backwardDuration() {
return this.timestampBackwardEnd - this.timestampBackwardStart;
}

t(currentTime) {
// Find the period number
// TODO: If you want an oscillating interpolator, the constructor api should be relative also, better to think that way.
if (this.oscillate) {
const durationSinceStart = currentTime - this.timestampForwardStart;
const numPeriods = durationSinceStart / this.totalPeriod;
currentTime = currentTime - (Math.floor(numPeriods) * this.totalPeriod + this.timestampForwardStart);
}
if (currentTime < this.timestampForwardStart) {
return 0;
}

if (currentTime <= this.timestampForwardEnd) {
const factor = (currentTime - this.timestampForwardStart) / this.forwardDuration();
return Math.min(this.maxT, Math.max(0, factor));
}

if (currentTime < this.timestampBackwardStart) {
return this.maxT;
}

if (currentTime <= this.timestampBackwardEnd) {
const factor = (currentTime - this.timestampBackwardStart) / this.backwardDuration();
return Math.min(this.maxT, Math.max(0, 1 - factor));
}
return 0;
}
}
Insert cell
class InterpolatedLine {
startPoint; // const
endPoint; // const

curEndPoint;

// startPoint: vec2, endPoint: vec2
constructor(startPoint, endPoint) {
this.startPoint = glm.vec2.clone(startPoint);
this.endPoint = glm.vec2.clone(endPoint);
this.curEndPoint = glm.vec2.clone(endPoint);
}

// currentTimestamp: number
// interpolator: LinearInterpolator
update(currentTimestamp, interpolator) {
const t = interpolator.t(currentTimestamp);
this.curEndPoint = glm.vec2.lerp(this.curEndPoint, this.startPoint, this.endPoint, t);
}
}
Insert cell
class InterpolatedBezierCurve {
originalPoints; // const
curTruncatedPoints;
arrowHeadLeft;
arrowHeadRight;

// points is an array of vec2 of length 4
constructor(points) {
if (!Array.isArray(points)) {
throw `expected points to be an array but received: ${points}`
}
if (points.length !== 4) {
throw `expected points to have length 4 but received length: %{points.length}`
}

this.originalPoints = [...points];
this.curTruncatedPoints = [...points];

this.arrowHeadLeft = glm.vec2.create();
this.arrowHeadRight = glm.vec2.create();

this.cacheArrowHeads();
}

update(currentTimestamp, interpolator) {
const t = interpolator.t(currentTimestamp);
// console.log('t =', t);
// if (t <= 0.0001) {
// return;
// }
if (t > 1) {
return;
}
this.curTruncatedPoints = this._truncate(0, t);
}

static evaluate(cp, t) {
// (1 - t) ^ 3 P_0 + 3t.(1 - t) ^ 2 P_1 + 3t ^ 2.(1 - t) P_2 + t ^ 3 P_3
const oneMinusT = (1 - t)
const c0 = oneMinusT * oneMinusT * oneMinusT;
const c1 = 3 * t * oneMinusT * oneMinusT;
const c2 = 3 * t * t * oneMinusT;
const c3 = t * t * t;

// CONSIDER: can be optimized to use just one out parameter
const a = glm.vec2.scale(glm.vec2.create(), cp[0], c0);
const b = glm.vec2.scale(glm.vec2.create(), cp[1], c1);
const c = glm.vec2.scale(glm.vec2.create(), cp[2], c2);
const d = glm.vec2.scale(glm.vec2.create(), cp[3], c3);

let r = glm.vec2.create();
r = glm.vec2.add(r, a, b);
r = glm.vec2.add(r, r, c);
r = glm.vec2.add(r, r, d);
return r;
}

static evaluateTangent(cp, t) {
// (-3 + 6t + -3t ^ 2) P_0 + (3 - 12t + 9t ^ 2) P_1 + (6t - 9t ^ 2) P_2 + 3t ^ 2.P_3
const c0 = -3 + (6 * t) - 3 * (t * t);
const c1 = 3 - (12 * t) + 9 * (t * t);
const c2 = (6 * t) - 9 * (t * t);
const c3 = 3 * (t * t);

// CONSIDER: can be optimized to use just one out parameter
const a = glm.vec2.scale(glm.vec2.create(), cp[0], c0);
const b = glm.vec2.scale(glm.vec2.create(), cp[1], c1);
const c = glm.vec2.scale(glm.vec2.create(), cp[2], c2);
const d = glm.vec2.scale(glm.vec2.create(), cp[3], c3);

let r = glm.vec2.create();
r = glm.vec2.add(r, a, b);
r = glm.vec2.add(r, r, c);
r = glm.vec2.add(r, r, d);
return r;
}

cacheArrowHeads(length, angleOffsetFromCurve) {
let headTangent = glm.vec2.sub(glm.vec2.create(), this.curTruncatedPoints[3], this.curTruncatedPoints[2]);
headTangent = glm.vec2.normalize(headTangent, headTangent);
headTangent = glm.vec2.scale(headTangent, headTangent, length);

let pointAtHeadTangent = glm.vec2.add(headTangent, this.curTruncatedPoints[3], headTangent);

this.arrowHeadLeft = glm.vec2.rotate(this.arrowHeadLeft, pointAtHeadTangent, this.curTruncatedPoints[3], Math.PI - angleOffsetFromCurve);
this.arrowHeadRight = glm.vec2.rotate(this.arrowHeadRight, this.arrowHeadLeft, this.curTruncatedPoints[3], 2 * angleOffsetFromCurve);
}

_truncate(tStart, tEnd) {
// Yeah I know my usecase will always make tStart = 0, but keeping it general.
const q0 = InterpolatedBezierCurve.evaluate(this.originalPoints, tStart);
const q3 = InterpolatedBezierCurve.evaluate(this.originalPoints, tEnd);

let tan0 = InterpolatedBezierCurve.evaluateTangent(this.originalPoints, tStart);
tan0 = glm.vec2.scale(tan0, tan0, (tEnd - tStart) / 3);

let tan3 = InterpolatedBezierCurve.evaluateTangent(this.originalPoints, tEnd);
tan3 = glm.vec2.scale(tan3, tan3, (tEnd - tStart) / 3);


const q1 = glm.vec2.add(glm.vec2.create(), q0, tan0);
const q2 = glm.vec2.sub(glm.vec2.create(), q3, tan3);

return [q0, q1, q2, q3];
}
}
Insert cell
function parabolicBezierCurve(startPoint, endPoint, peakLength) {
let midPoint = glm.vec2.add(glm.vec2.create(), startPoint, endPoint);
midPoint = glm.vec2.scale(midPoint, midPoint, 0.5);

const startToEnd = glm.vec2.sub(glm.vec2.create(), endPoint, startPoint);
const distance = glm.vec2.length(startToEnd);
const lineDir = glm.vec2.normalize(glm.vec2.create(), startToEnd);
const normal = glm.vec2.fromValues(-lineDir[1], lineDir[0]);

const halfPeakLengthNormal = glm.vec2.scale(glm.vec2.create(), normal, peakLength / 2);

const q0 = glm.vec2.clone(startPoint);
const q3 = glm.vec2.clone(endPoint);

// q1 = (q0 + (distance/4) * lineDir) + ((peakLength / 2) * normal)
let q1 = glm.vec2.add(glm.vec2.create(),
q0, glm.vec2.scale(glm.vec2.create(), lineDir, distance / 4));
q1 = glm.vec2.add(q1, q1, halfPeakLengthNormal);

// q2 = (q3 - (distance/4) * lineDir) + ((peakLength / 2) * normal)
let q2 = glm.vec2.add(glm.vec2.create(),
q3, glm.vec2.scale(glm.vec2.create(), lineDir, -distance / 4));
q2 = glm.vec2.add(q2, q2, halfPeakLengthNormal);

// console.log("points =", q0, q1, q2, q3);

return [q0, q1, q2, q3];
}
Insert cell
function saveCanvasStyle(callback) {
const strokeStyle = wp.canvas.strokeStyle;
const fillStyle = wp.canvas.fillStyle;
const lineWidth = wp.canvas.lineWidth;
callback();
wp.canvas.lineWidth = lineWidth;
wp.canvas.strokeStyle = strokeStyle;
wp.canvas.fillStyle = fillStyle;
}
Insert cell
function requestAnimationFrameInSeconds(callback) {
return window.requestAnimationFrame((ts) => {
callback(fromMillisecond(ts));
})
}
Insert cell
function runVisRandom() {
// Initialize the entities

const worldStartTimestamp = fromMillisecond(performance.now());

const curve = new InterpolatedBezierCurve(
parabolicBezierCurve(
glm.vec2.fromValues(millimeter(-20), millimeter(20)),
glm.vec2.fromValues(millimeter(20), millimeter(0)),
millimeter(20),
)
);

const curves = [];
const randomCurveCount = numCurves;

const createParabolicCurves = false;
let step = null;

if (createParabolicCurves) {
for (let i = 0; i < randomCurveCount; i++) {
const startPoint = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));

const endPoint = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));
const peakLength = random(millimeter(40));
const curve = new InterpolatedBezierCurve(parabolicBezierCurve(startPoint, endPoint, peakLength));
curves.push(curve);
}
} else {
for (let i = 0; i < randomCurveCount; i++) {
const startPoint = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));

const controlPoint1 = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));

const controlPoint2 = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));

const endPoint = glm.vec2.fromValues(
random(-millimeter(40), millimeter(40)),
random(-millimeter(40), millimeter(40)));

const peakLength = random(millimeter(40));
const curve = new InterpolatedBezierCurve([startPoint, controlPoint1, controlPoint2, endPoint]);
curves.push(curve);
}
}

const interpolatorQuick = new LinearInterpolator(0.2, 90);
const interpolatorSlow = new LinearInterpolator(0.2, maxInterpolationPeriodInSeconds);

const interpolatorToUse = curves.map((_) => {
// if (Math.random() < 0.5) {
// return interpolatorQuick;
// }
// return interpolatorSlow;
return interpolatorSlow;
})

step = (curTimestamp) => {
const worldTimestamp = curTimestamp - worldStartTimestamp;
// curve.update(worldTimestamp, interpolator);

for (let i = 0; i < curves.length; i++) {
const curve = curves[i];
const interpolator = interpolatorToUse[i];
curve.update(worldTimestamp, interpolator);
}

clearCanvas("#fafafa");

// Draw
saveCanvasStyle(() => {
wp.canvas.lineWidth = 2;

for (let curve of curves) {
wp.canvas.beginPath();
wp.canvas.strokeStyle = "#020202";
wp.moveTo(curve.curTruncatedPoints[0]);
wp.bezierCurveTo(curve.curTruncatedPoints);
wp.canvas.stroke();
wp.canvas.closePath();
}

for (let curve of curves) {
wp.canvas.beginPath();
wp.canvas.strokeStyle = "#a002020f";
wp.moveTo(curve.curTruncatedPoints[0]);
wp.lineTo(curve.curTruncatedPoints[1]);
wp.lineTo(curve.curTruncatedPoints[2]);
wp.lineTo(curve.curTruncatedPoints[3]);
wp.canvas.stroke();
wp.canvas.closePath();
}
})
requestAnimationFrameInSeconds(step);
}
requestAnimationFrameInSeconds(step);
}

Insert cell
function runVisCircles() {
// Initialize the entities

const worldStartTimestamp = fromMillisecond(performance.now());

const curve = new InterpolatedBezierCurve(
parabolicBezierCurve(
glm.vec2.fromValues(millimeter(-20), millimeter(20)),
glm.vec2.fromValues(millimeter(20), millimeter(0)),
millimeter(20),
)
);

const randomCurveCount = numCurves;
let interpolatorToUse = [];
let step = null;


const angleDelta = 2 * Math.PI / randomCurveCount;

const radiuses = [2, 10, 30, 40].map(millimeter);
const angleOffsets = [angleDelta / 4, 2 * angleDelta / 4, 3 * angleDelta / 4, angleDelta];
const origin = glm.vec2.zero(glm.vec2.create());

const curves = [...Array(randomCurveCount)].map((_, curveIndex) => {
const angleWithoutOffset = angleDelta * curveIndex;
const curvePoints = [...Array(4)].map((_, cpIndex) => {
const radius = radiuses[cpIndex];
const angle = angleWithoutOffset + angleOffsets[cpIndex] + random(0, Math.PI / 4);
return glm.vec2.rotate(glm.vec2.create(), glm.vec2.fromValues(radius, 0), origin, angle);
});
console.log("curvePoints =", curvePoints);
return new InterpolatedBezierCurve(curvePoints);
});

interpolatorToUse = curves.map((_) => {
const forwardStart = 0.2
const forwardEnd = forwardStart + random(1, maxInterpolationPeriodInSeconds);
const backwardStart = forwardEnd + random(0, 10) / 10;
const backwardEnd = backwardStart + random(1, maxInterpolationPeriodInSeconds);
return new LinearRoundtripInterpolator(forwardStart, forwardEnd, backwardStart, backwardEnd, true);
})

step = (curTimestamp) => {
const worldTimestamp = curTimestamp - worldStartTimestamp;

for (let i = 0; i < curves.length; i++) {
const curve = curves[i];
const interpolator = interpolatorToUse[i];
curve.update(worldTimestamp, interpolator);
}

clearCanvas("#fafafa");

// Draw
saveCanvasStyle(() => {
wp.canvas.lineWidth = 2;

for (let curve of curves) {
wp.canvas.beginPath();
wp.canvas.strokeStyle = curveColor;
wp.moveTo(curve.curTruncatedPoints[0]);
wp.bezierCurveTo(curve.curTruncatedPoints);
wp.canvas.stroke();
}

for (let curve of curves) {
wp.canvas.beginPath();
wp.canvas.strokeStyle = "#a002024f";
wp.moveTo(curve.curTruncatedPoints[0]);
wp.lineTo(curve.curTruncatedPoints[1]);
wp.lineTo(curve.curTruncatedPoints[2]);
wp.lineTo(curve.curTruncatedPoints[3]);
wp.canvas.stroke();
}
})
requestAnimationFrameInSeconds(step);
};
requestAnimationFrameInSeconds(step);
}
Insert cell
vis = {
console.log("example type =", exampleType);
if (exampleType === "random") {
runVisRandom()
} else {
runVisCircles()
}
}
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