code: https://observablehq.com/d/7a0c4a879c8b8c0a

A reimplementation of Mike Bostock's wonderful work with Charming.js. After I finished, I thought it would be interesting to reverse the colors of the circles and background. Here's the final result:

iShot_2025-02-20_09.20.51.mp4

iShot_2025-02-19_00.44.09.mp4

The crucial element of this animation lies in calculating the rotation angle:

const l = ((Math.hypot(x, y) + Math.atan2(y, x) / (Math.PI * 2) - (now / 3000)) % 1) * -360;

Let’s break it down:

const dist = Math.hypot(x, y);
const normalizedAngle = Math.atan2(y, x) / (Math.PI * 2);
const time = now / 3000;
const rawRotation = dist + normalizedAngle - time;
const normalizedRotation = rawRotation % 1;
const l = normalizedRotation * -360;

The is the final code:

const width = 640;
const height = 640;
const inset = 40;
const radius = 12;
const data = d3.cross(d3.ticks(-1, 1, 20), d3.ticks(-1, 1, 20));
const circle = d3.geoCircle()();
const projection = d3.geoOrthographic().translate([0, 0]).scale(radius);
const path = d3.geoPath(projection);
const scaleX = d3.scaleLinear([-1, 1], [inset, width - inset]);
const scaleY = d3.scaleLinear([-1, 1], [inset, height - inset]);
return SVG.svg({
  width,
  height,
  style: "background:black",
  children: [
    SVG.g(data, {
      transform: ([x, y]) => `translate(${scaleX(x)}, ${scaleY(y)})`,
      children: [
        ([x, y]) => {
          // prettier-ignore
          const l = ((Math.hypot(x, y) + Math.atan2(y, x) / (Math.PI * 2) - (now / 3000)) % 1) * -360;
          projection.rotate([0, l, -l]);
          return SVG.path({d: path(circle), fill: "white"});
        },
      ],
    }),
  ],
});