Skip to content

Broken Heart ​

A heart whose outline wobbles with noise, with text running along it and eyes following the mouse. Based on a Processing sketch.

js
const message = Inputs.text({label: "Text", value: "Sad News!!!"});
js
const width = 640;
const height = 640;
const count = 200;
const radius = 100;
const fontSize = 100;
const fontFamily = "sans-serif";
const noise = cm.noise();
const renderer = cm.canvas({width, height});
const root = cm.flow().renderer(renderer);
const node = renderer.node();
let mouseX = width / 2;
let mouseY = height / 2;
let frameCount = 0;

node.addEventListener("mousemove", (event) => {
  const rect = node.getBoundingClientRect();
  mouseX = ((event.clientX - rect.left) / rect.width) * width;
  mouseY = ((event.clientY - rect.top) / rect.height) * height;
});

// Spread the characters along the heart by their widths.
const context = cm.context2d();
context.font = `${fontSize}px ${fontFamily}`;
let prev = 0;
const chars = message.split("").map((c) => {
  const step = context.measureText(c).width / (radius * 1.8);
  const t = prev + step / 2;
  prev += step;
  return {t, c};
});

function noiseRadius(theta, time) {
  const s = noise(Math.cos(theta) + 1, Math.sin(theta) + 1, time) + 1;
  return radius * (1 + Math.cos(theta - Math.PI / 2)) * s;
}

function noiseAngle(theta, time) {
  const theta1 = theta - 0.06;
  const theta2 = theta + 0.06;
  const a = noiseRadius(theta1, time);
  const b = noiseRadius(theta2, time);
  return Math.atan2(a * Math.sin(theta1) - b * Math.sin(theta2), a * Math.cos(theta1) - b * Math.cos(theta2)) + Math.PI;
}

function drawHeart(flow, x, y, time) {
  flow
    .append(cm.group, {x, y})
    .data(Array.from({length: count}, (_, i) => (i / count) * Math.PI * 2))
    .process(cm.derive, {t: (d) => d, r: (d) => noiseRadius(d, time)})
    .append(cm.polygon, {
      x: (d) => Math.cos(d.t) * d.r,
      y: (d) => Math.sin(d.t) * d.r,
      fill: "red",
    });
}

function drawEyes(flow, x, y) {
  const r = radius / 2;
  const eyes = flow
    .data([-1, 1])
    .process(cm.derive, {x: (d) => x + (d * radius) / 1.8, y: y + height / 6})
    .append(cm.group, {
      x: (d) => d.x,
      y: (d) => d.y,
      rotate: (d) => Math.atan2(mouseY - d.y, mouseX - d.x),
    });
  eyes.append(cm.circle, {x: 0, y: 0, r, fill: "white"});
  eyes.append(cm.circle, {x: r / 3, y: 0, r: r / 2, fill: "black"});
}

function drawTexts(flow, x, y, time) {
  const start = Math.atan2(mouseY - y, mouseX - x);
  flow
    .append(cm.group, {x, y})
    .data(chars)
    .process(cm.derive, {t: (d) => d.t + start})
    .process(cm.derive, {r: (d) => noiseRadius(d.t, time), a: (d) => noiseAngle(d.t, time)})
    .append(cm.group, {
      x: (d) => Math.cos(d.t) * d.r,
      y: (d) => Math.sin(d.t) * d.r,
      rotate: (d) => d.a,
    })
    .append(cm.text, {x: 0, y: 0, text: (d) => d.c, fill: "#7214E3", fontSize, fontFamily});
}

requestAnimationFrame(function frame() {
  const time = frameCount / 100;
  frameCount++;
  root.append(cm.clear, {fill: "black"});
  root.call(drawHeart, width / 2, height / 3, time);
  root.call(drawEyes, width / 2, height / 3);
  root.call(drawTexts, width / 2, height / 3, time);

  root.render();
  requestAnimationFrame(frame);
});

document.body.append(node);