Skip to content

Instantly share code, notes, and snippets.

@bquast
Last active April 11, 2026 12:28
Show Gist options
  • Select an option

  • Save bquast/b1181c009ead3f45e195c32a349d5ec7 to your computer and use it in GitHub Desktop.

Select an option

Save bquast/b1181c009ead3f45e195c32a349d5ec7 to your computer and use it in GitHub Desktop.
calm animation for blog charts live, example in the comments
(function () {
function parseDuration(value) {
var text = String(value || "").trim();
if (text.endsWith("ms")) {
return parseFloat(text.slice(0, -2));
}
if (text.endsWith("s")) {
return parseFloat(text.slice(0, -1)) * 1000;
}
return parseFloat(text) || 0;
}
function parsePair(value) {
var parts = String(value || "").split(",");
return [parseFloat(parts[0]), parseFloat(parts[1])];
}
function createSvgElement(name) {
return document.createElementNS("http://www.w3.org/2000/svg", name);
}
function parse(sourceText) {
var lines = String(sourceText || "").replace(/\r/g, "").split("\n");
var spec = {
title: "",
x_label: "",
y_label: "",
x_range: [0, 10],
y_range: [0, 100],
width: 700,
height: 400,
legend: "",
lines: [],
labels: [],
animate: {
axes: { type: "draw", duration: 600 },
lines: { type: "draw", duration: 2400 },
labels: { type: "fade", duration: 600 },
delay_between: 200
}
};
var i = 0;
while (i < lines.length) {
var rawLine = lines[i];
var trimmed = rawLine.trim();
if (!trimmed || trimmed.startsWith("#")) {
i += 1;
continue;
}
if (trimmed.startsWith("line ")) {
var lineName = trimmed.slice(5, -1).trim();
var series = {
name: lineName,
points: []
};
i += 1;
while (i < lines.length) {
var pointLine = lines[i];
var pointTrimmed = pointLine.trim();
if (!pointTrimmed) {
i += 1;
continue;
}
if (!/^\s+/.test(pointLine)) {
break;
}
var pair = parsePair(pointTrimmed);
if (!isNaN(pair[0]) && !isNaN(pair[1])) {
series.points.push(pair);
}
i += 1;
}
spec.lines.push(series);
continue;
}
if (trimmed === "label:") {
var label = {
text: "",
at: [0, 0],
dx: 0,
dy: 0
};
i += 1;
while (i < lines.length) {
var labelLine = lines[i];
var labelTrimmed = labelLine.trim();
if (!labelTrimmed) {
i += 1;
continue;
}
if (!/^\s+/.test(labelLine)) {
break;
}
if (labelTrimmed.startsWith("text:")) {
label.text = labelTrimmed.slice(5).trim();
} else if (labelTrimmed.startsWith("at:")) {
label.at = parsePair(labelTrimmed.slice(3).trim());
} else if (labelTrimmed.startsWith("dx:")) {
label.dx = parseFloat(labelTrimmed.slice(3).trim()) || 0;
} else if (labelTrimmed.startsWith("dy:")) {
label.dy = parseFloat(labelTrimmed.slice(3).trim()) || 0;
}
i += 1;
}
spec.labels.push(label);
continue;
}
if (trimmed === "animate:") {
i += 1;
while (i < lines.length) {
var animLine = lines[i];
var animTrimmed = animLine.trim();
if (!animTrimmed) {
i += 1;
continue;
}
if (!/^\s+/.test(animLine)) {
break;
}
if (animTrimmed.startsWith("axes:")) {
var axesValue = animTrimmed.slice(5).trim().split(/\s+/);
spec.animate.axes.type = axesValue[0] || "draw";
spec.animate.axes.duration = parseDuration(axesValue[1] || "600ms");
} else if (animTrimmed.startsWith("lines:")) {
var linesValue = animTrimmed.slice(6).trim().split(/\s+/);
spec.animate.lines.type = linesValue[0] || "draw";
spec.animate.lines.duration = parseDuration(linesValue[1] || "2400ms");
} else if (animTrimmed.startsWith("labels:")) {
var labelsValue = animTrimmed.slice(7).trim().split(/\s+/);
spec.animate.labels.type = labelsValue[0] || "fade";
spec.animate.labels.duration = parseDuration(labelsValue[1] || "600ms");
} else if (animTrimmed.startsWith("delay_between:")) {
spec.animate.delay_between = parseDuration(animTrimmed.slice(14).trim());
}
i += 1;
}
continue;
}
var colonIndex = trimmed.indexOf(":");
if (colonIndex !== -1) {
var key = trimmed.slice(0, colonIndex).trim();
var value = trimmed.slice(colonIndex + 1).trim();
if (key === "title") {
spec.title = value;
} else if (key === "x_label") {
spec.x_label = value;
} else if (key === "y_label") {
spec.y_label = value;
} else if (key === "x_range") {
spec.x_range = parsePair(value);
} else if (key === "y_range") {
spec.y_range = parsePair(value);
} else if (key === "width") {
spec.width = parseFloat(value) || 700;
} else if (key === "height") {
spec.height = parseFloat(value) || 400;
} else if (key === "legend") {
spec.legend = value;
}
}
i += 1;
}
return spec;
}
function render(spec) {
var margin = {
top: 50,
right: 50,
bottom: 60,
left: 70
};
var width = spec.width;
var height = spec.height;
var plotLeft = margin.left;
var plotTop = margin.top;
var plotRight = width - margin.right;
var plotBottom = height - margin.bottom;
var plotWidth = plotRight - plotLeft;
var plotHeight = plotBottom - plotTop;
function mapX(value) {
var x0 = spec.x_range[0];
var x1 = spec.x_range[1];
return plotLeft + ((value - x0) / (x1 - x0)) * plotWidth;
}
function mapY(value) {
var y0 = spec.y_range[0];
var y1 = spec.y_range[1];
return plotBottom - ((value - y0) / (y1 - y0)) * plotHeight;
}
var svg = createSvgElement("svg");
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.setAttribute("class", "calmplot-svg");
var style = createSvgElement("style");
style.textContent =
".calmplot-bg{fill:#ffffff;}" +
".calmplot-border{fill:none;stroke:#dddddd;stroke-width:1;}" +
".calmplot-axis{fill:none;stroke:#000000;stroke-width:2;}" +
".calmplot-title{font:20px sans-serif;fill:#000000;}" +
".calmplot-axis-label{font:16px sans-serif;fill:#000000;}" +
".calmplot-legend{opacity:0;}" +
".calmplot-legend-text{font:14px sans-serif;fill:#000000;}" +
".calmplot-legend-line{fill:none;stroke:#000000;stroke-width:2;}" +
".calmplot-series-line{fill:none;stroke:#000000;stroke-width:2;}" +
".calmplot-label{font:16px sans-serif;fill:#000000;opacity:0;}" +
".calmplot-tick{font:12px sans-serif;fill:#666666;}" +
".calmplot-tick-line{stroke:#cccccc;stroke-width:1;}";
svg.appendChild(style);
var bg = createSvgElement("rect");
bg.setAttribute("x", 0);
bg.setAttribute("y", 0);
bg.setAttribute("width", width);
bg.setAttribute("height", height);
bg.setAttribute("class", "calmplot-bg");
svg.appendChild(bg);
var border = createSvgElement("rect");
border.setAttribute("x", 0.5);
border.setAttribute("y", 0.5);
border.setAttribute("width", width - 1);
border.setAttribute("height", height - 1);
border.setAttribute("class", "calmplot-border");
svg.appendChild(border);
if (spec.title) {
var title = createSvgElement("text");
title.setAttribute("x", plotLeft);
title.setAttribute("y", 30);
title.setAttribute("class", "calmplot-title");
title.textContent = spec.title;
svg.appendChild(title);
}
var tickCount = 5;
for (var tx = 0; tx <= tickCount; tx += 1) {
var xValue = spec.x_range[0] + (tx / tickCount) * (spec.x_range[1] - spec.x_range[0]);
var xPixel = mapX(xValue);
var tickLine = createSvgElement("line");
tickLine.setAttribute("x1", xPixel);
tickLine.setAttribute("y1", plotTop);
tickLine.setAttribute("x2", xPixel);
tickLine.setAttribute("y2", plotBottom);
tickLine.setAttribute("class", "calmplot-tick-line");
svg.appendChild(tickLine);
var tickLabel = createSvgElement("text");
tickLabel.setAttribute("x", xPixel);
tickLabel.setAttribute("y", plotBottom + 22);
tickLabel.setAttribute("text-anchor", "middle");
tickLabel.setAttribute("class", "calmplot-tick");
tickLabel.textContent = Math.round(xValue * 100) / 100;
svg.appendChild(tickLabel);
}
for (var ty = 0; ty <= tickCount; ty += 1) {
var yValue = spec.y_range[0] + (ty / tickCount) * (spec.y_range[1] - spec.y_range[0]);
var yPixel = mapY(yValue);
var yTickLine = createSvgElement("line");
yTickLine.setAttribute("x1", plotLeft);
yTickLine.setAttribute("y1", yPixel);
yTickLine.setAttribute("x2", plotRight);
yTickLine.setAttribute("y2", yPixel);
yTickLine.setAttribute("class", "calmplot-tick-line");
svg.appendChild(yTickLine);
var yTickLabel = createSvgElement("text");
yTickLabel.setAttribute("x", plotLeft - 12);
yTickLabel.setAttribute("y", yPixel + 4);
yTickLabel.setAttribute("text-anchor", "end");
yTickLabel.setAttribute("class", "calmplot-tick");
yTickLabel.textContent = Math.round(yValue * 100) / 100;
svg.appendChild(yTickLabel);
}
var xAxis = createSvgElement("line");
xAxis.setAttribute("x1", plotLeft);
xAxis.setAttribute("y1", plotBottom);
xAxis.setAttribute("x2", plotRight);
xAxis.setAttribute("y2", plotBottom);
xAxis.setAttribute("class", "calmplot-axis calmplot-axis-x");
svg.appendChild(xAxis);
var yAxis = createSvgElement("line");
yAxis.setAttribute("x1", plotLeft);
yAxis.setAttribute("y1", plotBottom);
yAxis.setAttribute("x2", plotLeft);
yAxis.setAttribute("y2", plotTop);
yAxis.setAttribute("class", "calmplot-axis calmplot-axis-y");
svg.appendChild(yAxis);
if (spec.x_label) {
var xLabel = createSvgElement("text");
xLabel.setAttribute("x", (plotLeft + plotRight) / 2);
xLabel.setAttribute("y", height - 15);
xLabel.setAttribute("text-anchor", "middle");
xLabel.setAttribute("class", "calmplot-axis-label");
xLabel.textContent = spec.x_label;
svg.appendChild(xLabel);
}
if (spec.y_label) {
var yLabel = createSvgElement("text");
yLabel.setAttribute("x", 20);
yLabel.setAttribute("y", (plotTop + plotBottom) / 2);
yLabel.setAttribute("text-anchor", "middle");
yLabel.setAttribute("transform", "rotate(-90 20 " + ((plotTop + plotBottom) / 2) + ")");
yLabel.setAttribute("class", "calmplot-axis-label");
yLabel.textContent = spec.y_label;
svg.appendChild(yLabel);
}
for (var i = 0; i < spec.lines.length; i += 1) {
var series = spec.lines[i];
var d = "";
for (var j = 0; j < series.points.length; j += 1) {
var point = series.points[j];
var px = mapX(point[0]);
var py = mapY(point[1]);
d += (j === 0 ? "M" : " L") + px + " " + py;
}
var path = createSvgElement("path");
path.setAttribute("d", d);
path.setAttribute("class", "calmplot-series-line");
path.setAttribute("data-series-name", series.name);
svg.appendChild(path);
}
for (var k = 0; k < spec.labels.length; k += 1) {
var labelSpec = spec.labels[k];
var label = createSvgElement("text");
label.setAttribute("x", mapX(labelSpec.at[0]) + labelSpec.dx);
label.setAttribute("y", mapY(labelSpec.at[1]) + labelSpec.dy);
label.setAttribute("class", "calmplot-label");
label.textContent = labelSpec.text;
svg.appendChild(label);
}
if (spec.legend && spec.lines.length > 0) {
var legendGroup = createSvgElement("g");
legendGroup.setAttribute("class", "calmplot-legend");
var legendX = plotRight - 110;
var legendY = plotTop + 14;
for (var m = 0; m < spec.lines.length; m += 1) {
var legendLine = createSvgElement("line");
legendLine.setAttribute("x1", legendX);
legendLine.setAttribute("y1", legendY + m * 22);
legendLine.setAttribute("x2", legendX + 24);
legendLine.setAttribute("y2", legendY + m * 22);
legendLine.setAttribute("class", "calmplot-legend-line");
legendGroup.appendChild(legendLine);
var legendText = createSvgElement("text");
legendText.setAttribute("x", legendX + 32);
legendText.setAttribute("y", legendY + 6 + m * 22);
legendText.setAttribute("class", "calmplot-legend-text");
legendText.textContent = spec.lines[m].name;
legendGroup.appendChild(legendText);
}
svg.appendChild(legendGroup);
}
svg._calmplotSpec = spec;
return svg;
}
function animate(svg, spec) {
var axes = svg.querySelectorAll(".calmplot-axis");
var lines = svg.querySelectorAll(".calmplot-series-line");
var labels = svg.querySelectorAll(".calmplot-label");
var legend = svg.querySelector(".calmplot-legend");
function animateStroke(element, duration, delay) {
var length = element.getTotalLength();
element.style.strokeDasharray = length;
element.style.strokeDashoffset = length;
element.animate(
[
{ strokeDashoffset: length },
{ strokeDashoffset: 0 }
],
{
duration: duration,
delay: delay,
easing: "ease-in-out",
fill: "forwards"
}
);
}
var axesDuration = spec.animate.axes.duration;
var linesDuration = spec.animate.lines.duration;
var labelsDuration = spec.animate.labels.duration;
var gap = spec.animate.delay_between;
for (var i = 0; i < axes.length; i += 1) {
animateStroke(axes[i], axesDuration, 0);
}
for (var j = 0; j < lines.length; j += 1) {
animateStroke(lines[j], linesDuration, axesDuration + gap);
}
var fadeDelay = axesDuration + gap + linesDuration + gap;
for (var k = 0; k < labels.length; k += 1) {
labels[k].animate(
[
{ opacity: 0 },
{ opacity: 1 }
],
{
duration: labelsDuration,
delay: fadeDelay,
easing: "ease-in-out",
fill: "forwards"
}
);
}
if (legend) {
legend.animate(
[
{ opacity: 0 },
{ opacity: 1 }
],
{
duration: labelsDuration,
delay: fadeDelay,
easing: "ease-in-out",
fill: "forwards"
}
);
}
}
function renderElement(codeElement) {
var sourceText = codeElement.textContent;
var spec = parse(sourceText);
var svg = render(spec);
var wrapper = document.createElement("div");
wrapper.className = "calmplot-wrapper";
wrapper.appendChild(svg);
var pre = codeElement.closest("pre");
if (pre) {
pre.replaceWith(wrapper);
} else {
codeElement.replaceWith(wrapper);
}
animate(svg, spec);
}
function renderAll(root) {
var scope = root || document;
var blocks = scope.querySelectorAll('code.language-calmplot');
for (var i = 0; i < blocks.length; i += 1) {
renderElement(blocks[i]);
}
}
window.calmplot = {
parse: parse,
render: render,
animate: animate,
renderAll: renderAll
};
})();
@bquast
Copy link
Copy Markdown
Author

bquast commented Apr 11, 2026

example of a plot

being drawn:

state1

finished drawing:

Screenshot 2026-04-11 at 12 30 21 PM
<!DOCTYPE html>
--
<html>
<head>
<meta charset="utf-8" />
<title>calmplot demo</title>
<script src="calmplot.js"></script>
<style>
body {
font-family: sans-serif;
background: white;
color: black;
margin: 40px;
line-height: 1.5;
}
 
h1 {
margin-top: 0;
font-weight: normal;
}
 
pre {
background: #f7f7f7;
border: 1px solid #dddddd;
padding: 16px;
overflow-x: auto;
}
 
.calmplot-wrapper {
max-width: 700px;
}
 
.calmplot-wrapper svg {
width: 100%;
height: auto;
display: block;
}
</style>
</head>
<body>
<h1>calmplot demo</h1>
 
<p>
Below is the markdown-style example. The library finds the
<code>language-calmplot</code> block and replaces it with an animated SVG.
</p>
 
<pre><code class="language-calmplot">title: Growth
x_label: Time
y_label: Revenue
x_range: 0, 5
y_range: 0, 100
width: 700
height: 400
legend: top-right
 
line Growth:
0, 8
1, 14
2, 28
3, 43
4, 67
5, 92
 
animate:
axes: draw 600ms
lines: draw 2400ms
labels: fade 600ms
delay_between: 200ms</code></pre>
 
<script>
document.addEventListener("DOMContentLoaded", function () {
calmplot.renderAll();
});
</script>
</body>
</html>

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>calmplot demo</title>
  <script src="[calmplot.js](https://gist.github.com/bquast/calmplot.js)"></script>
  <style>
    body {
      font-family: sans-serif;
      background: white;
      color: black;
      margin: 40px;
      line-height: 1.5;
    }

    h1 {
      margin-top: 0;
      font-weight: normal;
    }

    pre {
      background: #f7f7f7;
      border: 1px solid #dddddd;
      padding: 16px;
      overflow-x: auto;
    }

    .calmplot-wrapper {
      max-width: 700px;
    }

    .calmplot-wrapper svg {
      width: 100%;
      height: auto;
      display: block;
    }
  </style>
</head>
<body>
  <h1>calmplot demo</h1>

  <p>
    Below is the markdown-style example. The library finds the
    <code>language-calmplot</code> block and replaces it with an animated SVG.
  </p>

  <pre><code class="language-calmplot">title: Growth
x_label: Time
y_label: Revenue
x_range: 0, 5
y_range: 0, 100
width: 700
height: 400
legend: top-right

line Growth:
  0, 8
  1, 14
  2, 28
  3, 43
  4, 67
  5, 92

animate:
  axes: draw 600ms
  lines: draw 2400ms
  labels: fade 600ms
  delay_between: 200ms</code></pre>

  <script>
    document.addEventListener("DOMContentLoaded", function () {
      calmplot.renderAll();
    });
  </script>
</body>
</html>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment