Last active
April 11, 2026 12:28
-
-
Save bquast/b1181c009ead3f45e195c32a349d5ec7 to your computer and use it in GitHub Desktop.
calm animation for blog charts live, example in the comments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (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 | |
| }; | |
| })(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
example of a plot
being drawn:
finished drawing: