Hilbert Curve
A single line snakes through a square, visiting every cell of a grid once without crossing itself. It starts as a simple U. Then every corner splits into four smaller turns, again and again, until the line reaches into every part of the square, and a bright pulse runs its whole length.
How to use it
- Paste the snippet just before
</head>. - Add
data-hilbert-curveto a box with a height.
Options
Add any of these attributes to the same element.
data-order | How many times to split, 1 to 7. Default fits the box. |
data-step | Time per split. Default 1000 ms. |
data-colors | Two colours along the line. Default #6d28d9,#ec4899. |
data-pulse | Colour of the running pulse, or none. Default #f59e0b. |
data-loop | Fold back down to the U and grow again. |
Points that are close along the line are always close in the square, which is why Hilbert curves are used to lay out maps and databases. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the finished curve.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Hilbert Curve: a single line snakes through a square, visiting every
// cell of a grid once without ever crossing itself. It starts as a simple
// U, then each corner splits into four smaller turns, again and again,
// until the line reaches into every part of the square. A bright pulse
// then runs the whole length of it. With data-loop it folds back down to
// the U and starts again. Drawn on a transparent canvas and paused when
// off screen.
// Usage: <div data-hilbert-curve style="height:360px"></div>
// Optional: data-order (how many times to split, 1 to 7, default fits
// the box), data-step (time per split in ms, default 1000), data-colors
// (two colours along the line, default "#6d28d9,#ec4899"), data-pulse
// (colour of the running pulse, default #f59e0b, or "none"), data-loop
// (fold back down and grow again).
var DEFAULTS = { step: 1000, colors: '#6d28d9,#ec4899', pulse: '#f59e0b', pause: 450, run: 3200 };
var BINS = 48;
var css =
'[data-hilbert-curve]{position:relative;isolation:isolate;overflow:hidden}' +
'.hlb-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.trim().replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// Cell (x, y) of the d-th step along a Hilbert curve through an n by n grid.
function d2xy(n, d) {
var x = 0, y = 0, t = d;
for (var s = 1; s < n; s *= 2) {
var rx = 1 & (t >> 1), ry = 1 & (t ^ rx);
if (!ry) {
if (rx) { x = s - 1 - x; y = s - 1 - y; }
var tmp = x; x = y; y = tmp;
}
x += s * rx;
y += s * ry;
t >>= 2;
}
return [x, y];
}
// Every step's centre for one order, scaled to a unit square.
function curve(order) {
var n = 1 << order, out = new Float32Array(n * n * 2);
for (var i = 0; i < n * n; i++) {
var p = d2xy(n, i);
out[i * 2] = (p[0] + 0.5) / n;
out[i * 2 + 1] = 1 - (p[1] + 0.5) / n;
}
return out;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'hlb-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var stepMs = +d.step || DEFAULTS.step;
var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
if (cs.length < 2) cs.push(cs[0]);
var pulse = (d.pulse || DEFAULTS.pulse) === 'none' ? null : d.pulse || DEFAULTS.pulse;
var loop = d.loop !== undefined && d.loop !== 'false';
var colors = [];
for (var b = 0; b < BINS; b++) {
var k = b / (BINS - 1);
colors.push('rgb(' + Math.round(cs[0][0] + (cs[1][0] - cs[0][0]) * k) + ',' + Math.round(cs[0][1] + (cs[1][1] - cs[0][1]) * k) + ',' + Math.round(cs[0][2] + (cs[1][2] - cs[0][2]) * k) + ')');
}
var curves = [null, curve(1)];
var w = 0, h = 0, dpr = 1, maxOrder = 5, side = 0, ox = 0, oy = 0;
// order: the curve on show; morph: 0..1 towards order + 1 (or back).
var order = 1, morph = 0, dir = 1, phase = 'trace', age = 0;
function get(o) {
if (!curves[o]) curves[o] = curve(o);
return curves[o];
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
var pad = Math.max(14, Math.min(w, h) * 0.08);
side = Math.min(w, h) - pad * 2;
ox = (w - side) / 2;
oy = (h - side) / 2;
// Split until the cells are about 4.5 px.
maxOrder = +d.order || Math.floor(Math.log(side / 4.5) / Math.LN2);
maxOrder = Math.max(1, Math.min(7, maxOrder));
if (order > maxOrder) { order = maxOrder; morph = 0; }
}
function ease(k) { return k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; }
// `upto` (0..1) draws only the first part of the line.
function draw(upto) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var from = get(order), to = morph > 0 ? get(order + 1) : null;
var pts = to || from, count = pts.length / 2, e = ease(morph);
var cell = side / (1 << order) * (to ? Math.pow(0.5, e) : 1);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1.2, Math.min(7, cell * 0.32));
// Point i, sliding out from its parent's centre while morphing.
var X = new Float32Array(count), Y = new Float32Array(count);
for (var i = 0; i < count; i++) {
var x = pts[i * 2], y = pts[i * 2 + 1];
if (to) {
var j = i >> 2;
x = from[j * 2] + (x - from[j * 2]) * e;
y = from[j * 2 + 1] + (y - from[j * 2 + 1]) * e;
}
X[i] = ox + x * side;
Y[i] = oy + y * side;
}
var end = (count - 1) * (upto === undefined ? 1 : upto);
var per = Math.max(1, Math.ceil((count - 1) / BINS));
for (var s = 0; s < end; s += per) {
var stop = Math.min(end, s + per);
ctx.beginPath();
ctx.moveTo(X[s], Y[s]);
for (i = s + 1; i <= Math.floor(stop); i++) ctx.lineTo(X[i], Y[i]);
if (stop % 1) {
var f = stop % 1, a = Math.floor(stop);
ctx.lineTo(X[a] + (X[a + 1] - X[a]) * f, Y[a] + (Y[a + 1] - Y[a]) * f);
}
ctx.strokeStyle = colors[Math.min(BINS - 1, Math.floor(s / (count - 1) * BINS))];
ctx.stroke();
}
// The pulse: a short bright stretch running along the line.
if (phase === 'run' && pulse) {
var head = (count - 1) * Math.min(1, age / DEFAULTS.run), tail = Math.max(0, head - Math.max(3, count * 0.04));
ctx.beginPath();
ctx.moveTo(X[Math.floor(tail)], Y[Math.floor(tail)]);
for (i = Math.ceil(tail); i <= Math.floor(head); i++) ctx.lineTo(X[i], Y[i]);
ctx.strokeStyle = pulse;
ctx.shadowColor = pulse;
ctx.shadowBlur = 10;
ctx.lineWidth *= 1.6;
ctx.stroke();
ctx.shadowBlur = 0;
}
}
function tick(dt) {
age += dt;
if (phase === 'trace') {
if (age >= 900) { phase = 'pause'; age = 0; }
} else if (phase === 'pause') {
if (age < DEFAULTS.pause) return;
age = 0;
if (dir > 0 && order >= maxOrder) { phase = pulse ? 'run' : 'hold'; return; }
if (dir < 0 && order <= 1) { dir = 1; return; }
phase = 'morph';
// Going down: start from the finer curve and fold it in.
if (dir < 0) { order--; morph = 1; }
} else if (phase === 'morph') {
var k = Math.min(1, age / stepMs);
morph = dir > 0 ? k : 1 - k;
if (k >= 1) {
if (dir > 0) order++;
morph = 0;
phase = 'pause';
age = 0;
}
} else if (phase === 'run') {
if (age >= DEFAULTS.run + 600) {
age = 0;
if (loop) { dir = -1; phase = 'pause'; } else phase = 'run';
}
} else if (phase === 'hold' && loop && age > 2500) {
dir = -1; phase = 'pause'; age = 0;
}
}
resize();
if (reduced()) {
order = maxOrder;
phase = 'hold';
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); order = maxOrder; draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
tick(dt);
draw(phase === 'trace' ? ease(Math.min(1, age / 900)) : undefined);
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-hilbert-curve]').forEach(function (el) {
if (el._hlb) return;
el._hlb = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-hilbert-curve data-loop style="height: 360px"></div>