Rainbow Face

source code

This is the result from a coding session with my daughter where we used ChatGPT to write this code. Here’s the video!

styles.css

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
}

#viz-container {
  width: 100%;
  height: 100%;
}

renderCircles.js

import { scaleLinear } from "d3";
const xScale = scaleLinear().domain([0, 960]);
const yScale = scaleLinear().domain([0, 500]);
export const renderCircles = (svg, { data, width, height }) => {
  xScale.range([0, width]);
  yScale.range([0, height]);

  return svg
    .selectAll("circle")
    .data(data)
    .join("circle")
    .attr("cx", (d) => xScale(d.x))
    .attr("cy", (d) => yScale(d.y))
    .attr("r", (d) => d.r)
    .attr("fill", (d) => d.fill)
    .attr("opacity", 700 / 1000);
};

renderSVG.js

import { select } from "d3";

export const renderSVG = (container, { width, height }) =>
  select(container)
    .selectAll("svg")
    .data([null])
    .join("svg")
    .attr("width", width)
    .attr("height", height)
    .style("background", "linear-gradient(to bottom, #87CEFA, #FFFFFF)");

index.js

import { observeDimensions } from "./observeDimensions.js";
import { renderSVG } from "./renderSVG.js";
import { renderArcs } from "./renderArcs.js";
import { renderEyes } from "./renderEyes.js";
import { renderMouth } from "./renderMouth.js";
import { renderRain } from "./renderRain.js";

export const main = (container, { state, setState }) => {
  const dimensions = observeDimensions(container, { state, setState });
  if (!dimensions) return;
  const { width, height } = dimensions;
  const svg = renderSVG(container, { width, height });
  renderArcs(svg, { width, height });
  renderEyes(svg, { width, height });
  renderMouth(svg, { width, height });
  renderRain(svg, { width, height });
};

observeDimensions.js

export const observeDimensions = (container, { state, setState }) => {
  if (!state.dimensions) {
    new ResizeObserver(() => {
      const dimensions = {
        width: container.clientWidth,
        height: container.clientHeight,
      };
      setState((state) => ({ ...state, dimensions }));
    }).observe(container);
    return null;
  }
  return state.dimensions;
};

data.js

export const data = [
  { x: 155, y: 382, r: 60, fill: "#FF0000" }, // Red
  { x: 280, y: 340, r: 60, fill: "#FF7F00" }, // Orange
  { x: 405, y: 298, r: 60, fill: "#FFFF00" }, // Yellow
  { x: 530, y: 256, r: 60, fill: "#00FF00" }, // Green
  { x: 655, y: 214, r: 60, fill: "#0000FF" }, // Blue
  { x: 780, y: 172, r: 60, fill: "#4B0082" }, // Indigo
  { x: 905, y: 130, r: 60, fill: "#9400D3" }, // Violet
];

clickableCircles.js

import { renderCircles } from "./renderCircles.js";
export const clickableCircles = (circles, { data, state, setState }) => {
  circles
    .attr("cursor", "pointer")
    .on("click", (event, selectedDatum) => {
      setState((state) => ({ ...state, selectedDatum }));
    })
    .attr("stroke", "none")
    .filter((d) => d === state.selectedDatum)
    .attr("stroke", "black")
    .attr("stroke-width", 5)
    .raise();
};

setup.js

import { main } from "./index.js";
const container = document.getElementById("viz-container");

let state = {};

const setState = (next) => {
  state = next(state);
  render();
};

const render = () => {
  main(container, { state, setState });
};

render();

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Clickable Circles</title>
    <link rel="stylesheet" href="styles.css" />
    <script type="importmap">
      { "imports": { "d3": "https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm" } }
    </script>
  </head>
  <body>
    <div id="viz-container"></div>
    <script type="module" src="./setup.js"></script>
  </body>
</html>

renderArcs.js

import { arc, scaleLinear, scaleOrdinal } from "d3";

const colors = [
  "#FF0000", // Red
  "#FF7F00", // Orange
  "#FFFF00", // Yellow
  "#00FF00", // Green
  "#0000FF", // Blue
  "#4B0082", // Indigo
  "#9400D3", // Violet
];

const arcGenerator = arc();

const xScale = scaleLinear().domain([0, 960]);
const yScale = scaleLinear().domain([0, 500]);
const colorScale = scaleOrdinal(colors).domain(colors.map((_, i) => i));

export const renderArcs = (svg, { width, height }) => {
  xScale.range([0, width]);
  yScale.range([0, height]);

  const outerRadius = Math.min(width, height) / 2;
  const minInnerRadius = outerRadius * 0.6; // Introducing inner space
  const innerRadiusStep = (outerRadius - minInnerRadius) / colors.length;

  const data = colors.map((color, i) => ({
    innerRadius: outerRadius - (i + 1) * innerRadiusStep,
    outerRadius: outerRadius - i * innerRadiusStep,
    startAngle: 0,
    endAngle: Math.PI,
    fill: color,
  }));

  return svg
    .selectAll("path")
    .data(data)
    .join("path")
    .attr("d", (d) =>
      arcGenerator({
        innerRadius: d.innerRadius,
        outerRadius: d.outerRadius,
        startAngle: d.startAngle,
        endAngle: d.endAngle,
      })
    )
    .attr("fill", (d) => d.fill)
    .attr("transform", `translate(${width / 2}, ${height / 2}) rotate(-90)`);
};

renderEyes.js

export const renderEyes = (svg, { width, height }) => {
  const eyeYPosition = height * 0.2;
  const eyeData = [
    { x: width * 0.35, y: eyeYPosition, r: 20 },
    { x: width * 0.65, y: eyeYPosition, r: 20 },
  ];

  const pupilData = eyeData.map((eye) => ({
    x: eye.x,
    y: eye.y,
    r: eye.r * 0.4,
  }));

  svg
    .selectAll(".eye")
    .data(eyeData)
    .join("circle")
    .attr("class", "eye")
    .attr("cx", (d) => d.x)
    .attr("cy", (d) => d.y)
    .attr("r", (d) => d.r)
    .attr("fill", "white")
    .attr("stroke", "black")
    .attr("stroke-width", 2);

  svg
    .selectAll(".pupil")
    .data(pupilData)
    .join("circle")
    .attr("class", "pupil")
    .attr("cx", (d) => d.x)
    .attr("cy", (d) => d.y)
    .attr("r", (d) => d.r)
    .attr("fill", "black");
};

renderMouth.js

import { arc } from "d3";

export const renderMouth = (svg, { width, height }) => {
  const mouthData = {
    innerRadius: 35,
    outerRadius: 45,
    startAngle: Math.PI * 0.2,
    endAngle: Math.PI * 0.8,
    x: width / 2,
    y: height * 0.24,
  };

  const arcGenerator = arc();

  svg
    .selectAll(".mouth")
    .data([mouthData])
    .join("path")
    .attr("class", "mouth")
    .attr("d", (d) =>
      arcGenerator({
        innerRadius: d.innerRadius,
        outerRadius: d.outerRadius,
        startAngle: d.startAngle,
        endAngle: d.endAngle,
      })
    )
    .attr("fill", "black")
    .attr("stroke", "white")
    .attr("transform", (d) => `translate(${d.x}, ${d.y}) rotate(90)`);
};

renderRain.js

import { easeLinear, select } from "d3";

export const renderRain = (svg, { width, height }) => {
  const numDrops = 50;
  const rainData = Array.from({ length: numDrops }, () => ({
    x: Math.random() * width,
    // Start slightly above the top of the SVG so that drops "come in" from the sky
    y: -Math.random() * 100,
    length: Math.random() * 20 + 10,
    // Give each raindrop its own random speed (duration in ms)
    speed: 2000 + Math.random() * 3000,
  }));

  const raindrops = svg
    .selectAll(".raindrop")
    .data(rainData)
    .join("line")
    .attr("class", "raindrop")
    .attr("x1", (d) => d.x)
    .attr("x2", (d) => d.x)
    .attr("y1", (d) => d.y)
    .attr("y2", (d) => d.y + d.length)
    .attr("stroke", "blue")
    .attr("stroke-width", 2)
    .attr("opacity", 0.7);

  // A recursive function that handles the "fall" animation
  function animateRain(raindrop, dropData) {
    // Reset raindrop to "top" position before transition (so it loops)
    raindrop
      .attr("y1", -dropData.length)
      .attr("y2", 0)
      .transition()
      .duration(dropData.speed)
      .ease(easeLinear)
      // Move to the bottom of the SVG
      .attr("y1", height)
      .attr("y2", height + dropData.length)
      // When transition ends, call animateRain again
      .on("end", () => animateRain(raindrop, dropData));
  }

  // Kick off the rain animation for each raindrop
  raindrops.each(function (d) {
    // 'this' is the DOM element for the current raindrop
    const raindrop = select(this);
    animateRain(raindrop, d);
  });
};