New: Activity Terrain +12 more

Activity Terrain

A contribution heatmap that tilts up into a 3D terrain you can turn, built with React and canvas.

1 exampleFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/activity-terrain.json
Install manually
  1. Copy this into components/kokonut/activity-terrain.tsx:

    activity-terrain.tsx
    "use client";
    
    /**
     * @author: @dorianbaffier
     * @description: Activity Terrain
     * @version: 1.0.0
     * @date: 2026-10-14
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import {
      type KeyboardEvent,
      type PointerEvent,
      useCallback,
      useEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    import { cn } from "@/lib/utils";
    
    const DAYS = 7;
    const GAP = 0.18;
    // Tallest column, in cells.
    const MAX_HEIGHT = 4.2;
    const DEG = Math.PI / 180;
    // Tilt of the terrain view; flat is looking straight down.
    const PITCH_TERRAIN = 56 * DEG;
    const YAW_TERRAIN = 18 * DEG;
    // How far it turns before the edges start pulling back.
    const YAW_LIMIT = 38 * DEG;
    const PITCH_LIMIT = 66 * DEG;
    // Drag, in radians per px.
    const DRAG_YAW = 0.32 * DEG;
    const DRAG_PITCH = 0.36 * DEG;
    // Spin keeps going after a flick and slows on this half-life, in seconds.
    const SPIN_HALF_LIFE = 0.16;
    // The speed that coasts from straight on to the terrain's resting turn.
    const SPIN_IN = (YAW_TERRAIN * Math.LN2) / SPIN_HALF_LIFE;
    // Springs, per second squared and per second.
    const PITCH_STIFFNESS = 140;
    const PITCH_DAMPING = 2 * Math.sqrt(PITCH_STIFFNESS);
    const EDGE_STIFFNESS = 180;
    const BUMP_STIFFNESS = 400;
    const BUMP_DAMPING = 2 * Math.sqrt(BUMP_STIFFNESS) * 0.7;
    const BUMP = 0.45;
    // Columns rise in a wave from where you grabbed; the far end lags by this
    // share of the whole rise.
    const WAVE = 0.7;
    const PADDING = 28;
    const REST_EPSILON = 0.0005;
    const LABELS = ["Mon", "", "Wed", "", "Fri", "", ""];
    
    export interface ActivityTerrainProps {
      /** Count per day, oldest first. */
      data?: number[];
      /** The day of the last value, as YYYY-MM-DD. */
      endDate?: string;
      title?: string;
      /** Start in the flat, heatmap view. */
      defaultFlat?: boolean;
      className?: string;
    }
    
    interface Cell {
      index: number;
      col: number;
      row: number;
      count: number;
      level: number;
      height: number;
      date: Date;
    }
    
    interface Pose {
      yaw: number;
      pitch: number;
      scale: number;
      cx: number;
      cy: number;
    }
    
    type Rgb = [number, number, number];
    
    interface Palette {
      levels: Rgb[];
      accent: Rgb;
      text: string;
      font: string;
    }
    
    const clamp = (v: number, min: number, max: number) =>
      Math.min(max, Math.max(min, v));
    
    // A gentle overshoot, so each column pops as it lands.
    function easeOutBack(t: number) {
      const c = 1.4;
      const u = t - 1;
      return 1 + (c + 1) * u * u * u + c * u * u;
    }
    
    // Resistance past an edge: the further out, the less it moves.
    function rubber(value: number, min: number, max: number) {
      if (value < min) {
        return min - 0.18 * (1 - Math.exp((value - min) / 0.18));
      }
      if (value > max) {
        return max + 0.18 * (1 - Math.exp((max - value) / 0.18));
      }
      return value;
    }
    
    // Deterministic demo data: busy weekdays, quiet weekends, a few streaks.
    function demoData(weeks: number) {
      let seed = 7;
      const random = () => {
        seed = (seed * 16_807) % 2_147_483_647;
        return seed / 2_147_483_647;
      };
      const out: number[] = [];
      for (let i = 0; i < weeks * DAYS; i++) {
        const weekend = i % DAYS >= 5;
        const season = 0.55 + 0.45 * Math.sin(i / 23);
        const streak = random() > 0.82 ? 2.2 : 1;
        const quiet = random() < (weekend ? 0.55 : 0.12);
        out.push(
          quiet ? 0 : Math.round((weekend ? 3 : 9) * season * streak * random())
        );
      }
      return out;
    }
    
    const DEFAULT_DATA = demoData(26);
    
    function parseDay(value: string) {
      const [y, m, d] = value.split("-").map(Number);
      return new Date(Date.UTC(y, m - 1, d));
    }
    
    function buildCells(data: number[], endDate: string) {
      const end = parseDay(endDate);
      const start = new Date(end.getTime() - (data.length - 1) * 86_400_000);
      // Rows run Monday to Sunday.
      const offset = (start.getUTCDay() + 6) % 7;
      const max = Math.max(1, ...data);
      return data.map((count, index): Cell => {
        const slot = index + offset;
        const norm = count / max;
        return {
          index,
          col: Math.floor(slot / DAYS),
          row: slot % DAYS,
          count,
          level: count === 0 ? 0 : Math.ceil(norm * 4),
          height: 0.06 + norm ** 0.85 * MAX_HEIGHT,
          date: new Date(start.getTime() + index * 86_400_000),
        };
      });
    }
    
    function hexToRgb(value: string): Rgb {
      const hex = value.trim().replace("#", "");
      const n = Number.parseInt(
        hex.length === 3 ? hex.replace(/./g, "$&$&") : hex,
        16
      );
      return [Math.floor(n / 65_536) % 256, Math.floor(n / 256) % 256, n % 256];
    }
    
    function shade([r, g, b]: Rgb, amount: number) {
      const k = 1 - amount;
      return `rgb(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)})`;
    }
    
    function readPalette(node: HTMLElement): Palette {
      const style = getComputedStyle(node);
      const read = (name: string) => style.getPropertyValue(name) || "#888888";
      return {
        levels: [0, 1, 2, 3, 4].map((l) => hexToRgb(read(`--terrain-${l}`))),
        accent: hexToRgb(read("--terrain-accent")),
        text: read("--terrain-text"),
        font: style.fontFamily,
      };
    }
    
    function project(pose: Pose, x: number, y: number, z: number) {
      const cos = Math.cos(pose.yaw);
      const sin = Math.sin(pose.yaw);
      const xr = x * cos - z * sin;
      const zr = x * sin + z * cos;
      return [
        pose.cx + xr * pose.scale,
        pose.cy +
          (zr * Math.cos(pose.pitch) - y * Math.sin(pose.pitch)) * pose.scale,
      ];
    }
    
    /** The faces of one column that face the camera, top last. */
    function visibleFaces(
      pose: Pose,
      x0: number,
      z0: number,
      h: number
    ): { points: number[][]; tone: number }[] {
      const x1 = x0 + 1 - GAP;
      const z1 = z0 + 1 - GAP;
      const p = (x: number, y: number, z: number) => project(pose, x, y, z);
      const faces: { points: number[][]; tone: number }[] = [];
      const sin = Math.sin(pose.yaw);
      const cos = Math.cos(pose.yaw);
      if (h > 0.02 && pose.pitch > 0.01) {
        // Of the four walls, the ones whose outward side points at the camera.
        if (cos > 0) {
          faces.push({
            points: [p(x0, 0, z1), p(x1, 0, z1), p(x1, h, z1), p(x0, h, z1)],
            tone: 0.22,
          });
        } else {
          faces.push({
            points: [p(x0, 0, z0), p(x1, 0, z0), p(x1, h, z0), p(x0, h, z0)],
            tone: 0.22,
          });
        }
        if (sin > 0.001) {
          faces.push({
            points: [p(x1, 0, z0), p(x1, 0, z1), p(x1, h, z1), p(x1, h, z0)],
            tone: 0.38,
          });
        } else if (sin < -0.001) {
          faces.push({
            points: [p(x0, 0, z0), p(x0, 0, z1), p(x0, h, z1), p(x0, h, z0)],
            tone: 0.38,
          });
        }
      }
      faces.push({
        points: [p(x0, h, z0), p(x1, h, z0), p(x1, h, z1), p(x0, h, z1)],
        tone: 0,
      });
      return faces;
    }
    
    function inside(points: number[][], x: number, y: number) {
      let hit = false;
      for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
        const [xi, yi] = points[i];
        const [xj, yj] = points[j];
        if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
          hit = !hit;
        }
      }
      return hit;
    }
    
    function polygon(ctx: CanvasRenderingContext2D, points: number[][]) {
      ctx.beginPath();
      ctx.moveTo(points[0][0], points[0][1]);
      for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i][0], points[i][1]);
      }
      ctx.closePath();
    }
    
    const formatDay = (date: Date) =>
      date.toLocaleDateString("en-US", {
        weekday: "short",
        month: "short",
        day: "numeric",
        timeZone: "UTC",
      });
    const formatMonth = (date: Date) =>
      date.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" });
    
    export default function ActivityTerrain({
      data = DEFAULT_DATA,
      endDate = "2026-10-11",
      title = "Activity",
      defaultFlat = false,
      className,
    }: ActivityTerrainProps) {
      const cells = useMemo(() => buildCells(data, endDate), [data, endDate]);
      const weeks = (cells.at(-1)?.col ?? 0) + 1;
      const total = useMemo(() => data.reduce((sum, n) => sum + n, 0), [data]);
      const [flat, setFlat] = useState(defaultFlat);
      const [active, setActive] = useState<Cell | null>(null);
      const [dragging, setDragging] = useState(false);
    
      const canvasRef = useRef<HTMLCanvasElement>(null);
      const paletteRef = useRef<Palette | null>(null);
      const sim = useRef({
        yaw: 0,
        yawSpeed: 0,
        // The intro tilts it up from flat once it's on screen.
        pitch: 0,
        pitchSpeed: 0,
        pitchTarget: defaultFlat ? 0 : PITCH_TERRAIN,
        bumps: new Float64Array(cells.length),
        bumpSpeeds: new Float64Array(cells.length),
        hovered: -1,
        origin: { col: 0, row: 3 },
        width: 0,
        height: 0,
        dpr: 1,
        drag: null as null | {
          id: number;
          x: number;
          y: number;
          yaw: number;
          pitch: number;
          moved: boolean;
          samples: { t: number; x: number; y: number }[];
        },
        raf: 0,
        last: 0,
        visible: false,
        started: false,
        reduceMotion: false,
      });
      const cellsRef = useRef(cells);
      cellsRef.current = cells;
      if (sim.current.bumps.length !== cells.length) {
        sim.current.bumps = new Float64Array(cells.length);
        sim.current.bumpSpeeds = new Float64Array(cells.length);
      }
    
      // How tall each column stands right now: the rise travels as a wave out
      // from the origin, and each column overshoots a touch as it lands.
      const columnHeight = useCallback(
        (cell: Cell, lift: number) => {
          const s = sim.current;
          const span = Math.hypot(weeks, DAYS) || 1;
          const d =
            Math.hypot(cell.col - s.origin.col, cell.row - s.origin.row) / span;
          const local = clamp(lift * (1 + WAVE) - d * WAVE, 0, 1);
          return cell.height * easeOutBack(local) + s.bumps[cell.index];
        },
        [weeks]
      );
    
      const pose = useCallback((): Pose & { lift: number } => {
        const s = sim.current;
        const lift = clamp(s.pitch / PITCH_TERRAIN, 0, 1.2);
        // Fit the whole board, at its current turn and tilt, into the canvas.
        const w = weeks;
        const corners = [
          [0, 0],
          [w, 0],
          [0, DAYS],
          [w, DAYS],
        ];
        const probe: Pose = { yaw: s.yaw, pitch: s.pitch, scale: 1, cx: 0, cy: 0 };
        let minX = Number.POSITIVE_INFINITY;
        let maxX = Number.NEGATIVE_INFINITY;
        let minY = Number.POSITIVE_INFINITY;
        let maxY = Number.NEGATIVE_INFINITY;
        for (const [x, z] of corners) {
          for (const y of [0, MAX_HEIGHT * Math.min(lift, 1)]) {
            const [px, py] = project(probe, x - w / 2, y, z - DAYS / 2);
            minX = Math.min(minX, px);
            maxX = Math.max(maxX, px);
            minY = Math.min(minY, py);
            maxY = Math.max(maxY, py);
          }
        }
        const scale = Math.min(
          (s.width - PADDING * 2) / (maxX - minX),
          (s.height - PADDING * 2) / (maxY - minY)
        );
        return {
          yaw: s.yaw,
          pitch: s.pitch,
          scale,
          cx: s.width / 2 - ((minX + maxX) / 2) * scale,
          cy: s.height / 2 - ((minY + maxY) / 2) * scale + 6,
          lift,
        };
      }, [weeks]);
    
      // Back to front: the further a column's base, the earlier it's drawn.
      const ordered = useCallback(
        (p: Pose) => {
          const sin = Math.sin(p.yaw);
          const cos = Math.cos(p.yaw);
          const depth = (c: Cell) =>
            (c.col + 0.5 - weeks / 2) * sin + (c.row + 0.5 - DAYS / 2) * cos;
          return [...cellsRef.current].sort((a, b) => depth(a) - depth(b));
        },
        [weeks]
      );
    
      const drawLabels = useCallback(
        (
          ctx: CanvasRenderingContext2D,
          p: Pose,
          lift: number,
          palette: Palette
        ) => {
          // Labels lie on the ground, so they'd hide behind the columns; they
          // fade out as the terrain rises, and the readout names each day.
          const alpha = clamp(1 - lift * 1.6, 0, 1);
          if (alpha === 0) {
            return;
          }
          const list = cellsRef.current;
          ctx.globalAlpha = alpha;
          ctx.fillStyle = palette.text;
          ctx.font = `11px ${palette.font}`;
          ctx.textBaseline = "middle";
          ctx.textAlign = "left";
          let month = -1;
          for (const cell of list) {
            if (cell.row !== 0 && cell.index !== 0) {
              continue;
            }
            const m = cell.date.getUTCMonth();
            if (m !== month && cell.col < weeks - 1) {
              month = m;
              const [x, y] = project(p, cell.col - weeks / 2, 0, -0.75 - DAYS / 2);
              ctx.fillText(formatMonth(cell.date), x, y);
            }
          }
          ctx.textAlign = "right";
          for (let row = 0; row < DAYS; row++) {
            if (LABELS[row]) {
              const [x, y] = project(p, -0.35 - weeks / 2, 0, row + 0.4 - DAYS / 2);
              ctx.fillText(LABELS[row], x, y);
            }
          }
          ctx.globalAlpha = 1;
        },
        [weeks]
      );
    
      const draw = useCallback(() => {
        const s = sim.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!(canvas && ctx && s.width)) {
          return;
        }
        // Colours come from CSS, read once per theme rather than every frame.
        paletteRef.current ??= readPalette(canvas);
        const palette = paletteRef.current;
        const p = pose();
        ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
        ctx.clearRect(0, 0, s.width, s.height);
        drawLabels(ctx, p, p.lift, palette);
        ctx.lineJoin = "round";
        for (const cell of ordered(p)) {
          const h = columnHeight(cell, p.lift);
          const color =
            cell.index === s.hovered ? palette.accent : palette.levels[cell.level];
          for (const face of visibleFaces(
            p,
            cell.col - weeks / 2,
            cell.row - DAYS / 2,
            h
          )) {
            polygon(ctx, face.points);
            const fill = shade(color, face.tone);
            ctx.fillStyle = fill;
            ctx.fill();
            // A hairline in the same colour closes the seams between faces.
            ctx.strokeStyle = fill;
            ctx.lineWidth = 0.6;
            ctx.stroke();
          }
        }
      }, [columnHeight, drawLabels, ordered, pose, weeks]);
    
      const hitTest = useCallback(
        (x: number, y: number) => {
          const p = pose();
          const list = ordered(p);
          for (let i = list.length - 1; i >= 0; i--) {
            const cell = list[i];
            const h = columnHeight(cell, p.lift);
            for (const face of visibleFaces(
              p,
              cell.col - weeks / 2,
              cell.row - DAYS / 2,
              h
            )) {
              if (inside(face.points, x, y)) {
                return cell;
              }
            }
          }
          return null;
        },
        [columnHeight, ordered, pose, weeks]
      );
    
      const stepSprings = useCallback((dt: number) => {
        const s = sim.current;
        let energy = 0;
        if (!s.drag) {
          // Spin coasts, then the edge pulls it back in range.
          s.yawSpeed *= 0.5 ** (dt / SPIN_HALF_LIFE);
          let target = s.yaw;
          if (s.pitchTarget === 0) {
            // Flat is always square on.
            target = 0;
          } else if (s.yaw > YAW_LIMIT) {
            target = YAW_LIMIT;
          } else if (s.yaw < -YAW_LIMIT) {
            target = -YAW_LIMIT;
          }
          if (target !== s.yaw) {
            s.yawSpeed +=
              ((target - s.yaw) * EDGE_STIFFNESS -
                s.yawSpeed * 2 * Math.sqrt(EDGE_STIFFNESS)) *
              dt;
          }
          s.yaw += s.yawSpeed * dt;
          energy = Math.max(energy, Math.abs(s.yawSpeed), Math.abs(target - s.yaw));
    
          const force =
            (s.pitchTarget - s.pitch) * PITCH_STIFFNESS -
            s.pitchSpeed * PITCH_DAMPING;
          s.pitchSpeed += force * dt;
          s.pitch += s.pitchSpeed * dt;
          energy = Math.max(
            energy,
            Math.abs(s.pitchSpeed),
            Math.abs(s.pitchTarget - s.pitch)
          );
        }
        for (let i = 0; i < s.bumps.length; i++) {
          const target = i === s.hovered && s.pitch > 0.1 ? BUMP : 0;
          s.bumpSpeeds[i] +=
            ((target - s.bumps[i]) * BUMP_STIFFNESS -
              s.bumpSpeeds[i] * BUMP_DAMPING) *
            dt;
          s.bumps[i] += s.bumpSpeeds[i] * dt;
          energy = Math.max(
            energy,
            Math.abs(s.bumpSpeeds[i]) * 0.1,
            Math.abs(target - s.bumps[i])
          );
        }
        return energy;
      }, []);
    
      const tick = useCallback(
        (now: number) => {
          const s = sim.current;
          s.raf = 0;
          const dt = s.last ? Math.min((now - s.last) / 1000, 1 / 30) : 1 / 60;
          s.last = now;
          const energy = stepSprings(dt);
          draw();
          if ((s.drag || energy > REST_EPSILON) && s.visible) {
            s.raf = requestAnimationFrame(tick);
          } else {
            s.last = 0;
          }
        },
        [draw, stepSprings]
      );
    
      // Swing round from straight on as it tilts up, coasting to a stop at
      // the resting turn.
      const turnIn = useCallback(() => {
        const s = sim.current;
        if (Math.abs(s.yaw) > 2 * DEG) {
          return;
        }
        if (s.reduceMotion) {
          s.yaw = YAW_TERRAIN;
        } else {
          s.yawSpeed = SPIN_IN;
        }
      }, []);
    
      const wake = useCallback(() => {
        const s = sim.current;
        if (s.reduceMotion) {
          // No travel: everything lands where it's going.
          s.pitch = s.pitchTarget;
          s.yaw = clamp(s.yaw, -YAW_LIMIT, YAW_LIMIT);
          s.pitchSpeed = 0;
          s.yawSpeed = 0;
          s.bumps.fill(0);
          draw();
          return;
        }
        if (!s.raf && s.visible) {
          s.raf = requestAnimationFrame(tick);
        }
      }, [draw, tick]);
    
      useEffect(() => {
        const s = sim.current;
        const canvas = canvasRef.current;
        if (!canvas) {
          return;
        }
        const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
        s.reduceMotion = motion.matches;
        const resize = new ResizeObserver(([entry]) => {
          s.width = entry.contentRect.width;
          s.height = entry.contentRect.height;
          s.dpr = Math.min(window.devicePixelRatio || 1, 2);
          canvas.width = Math.round(s.width * s.dpr);
          canvas.height = Math.round(s.height * s.dpr);
          draw();
        });
        resize.observe(canvas);
    
        let onScreen = false;
        const update = () => {
          s.visible = onScreen && !document.hidden;
          if (s.visible) {
            if (!s.started) {
              s.started = true;
              if (s.pitchTarget > 0) {
                turnIn();
              }
            }
            wake();
          } else if (s.raf) {
            cancelAnimationFrame(s.raf);
            s.raf = 0;
            s.last = 0;
          }
        };
        const intersection = new IntersectionObserver(([entry]) => {
          onScreen = entry.isIntersecting;
          update();
        });
        intersection.observe(canvas);
        document.addEventListener("visibilitychange", update);
    
        // Repaint when the theme flips, since the canvas reads its colours.
        const repaint = () => {
          paletteRef.current = null;
          draw();
        };
        const theme = new MutationObserver(repaint);
        theme.observe(document.documentElement, {
          attributes: true,
          attributeFilter: ["class", "style", "data-theme"],
        });
        const scheme = window.matchMedia("(prefers-color-scheme: dark)");
        scheme.addEventListener("change", repaint);
    
        return () => {
          resize.disconnect();
          intersection.disconnect();
          theme.disconnect();
          scheme.removeEventListener("change", repaint);
          document.removeEventListener("visibilitychange", update);
          cancelAnimationFrame(s.raf);
          s.raf = 0;
        };
      }, [draw, turnIn, wake]);
    
      const setView = (
        nextFlat: boolean,
        origin?: { col: number; row: number }
      ) => {
        const s = sim.current;
        setFlat(nextFlat);
        s.origin = origin ?? { col: nextFlat ? weeks : 0, row: 3 };
        s.pitchTarget = nextFlat ? 0 : PITCH_TERRAIN;
        s.yawSpeed = 0;
        if (!nextFlat) {
          turnIn();
        }
        wake();
      };
    
      const localPoint = (event: PointerEvent) => {
        const rect = canvasRef.current?.getBoundingClientRect();
        return rect
          ? { x: event.clientX - rect.left, y: event.clientY - rect.top }
          : { x: 0, y: 0 };
      };
    
      const hover = (cell: Cell | null) => {
        const s = sim.current;
        const index = cell ? cell.index : -1;
        if (index !== s.hovered) {
          s.hovered = index;
          setActive(cell);
          if (s.reduceMotion) {
            draw();
          } else {
            wake();
          }
        }
      };
    
      const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
        if (event.pointerType === "mouse" && event.button !== 0) {
          return;
        }
        const s = sim.current;
        const { x, y } = localPoint(event);
        event.currentTarget.setPointerCapture(event.pointerId);
        const cell = hitTest(x, y);
        if (cell) {
          s.origin = { col: cell.col, row: cell.row };
        }
        s.drag = {
          id: event.pointerId,
          x: event.clientX,
          y: event.clientY,
          yaw: s.yaw,
          pitch: s.pitch,
          moved: false,
          samples: [{ t: event.timeStamp, x: event.clientX, y: event.clientY }],
        };
        s.yawSpeed = 0;
        s.pitchSpeed = 0;
        setDragging(true);
        hover(cell);
        wake();
      };
    
      const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
        const s = sim.current;
        const drag = s.drag;
        if (!drag || drag.id !== event.pointerId) {
          if (event.pointerType === "mouse") {
            const { x, y } = localPoint(event);
            hover(hitTest(x, y));
          }
          return;
        }
        const dx = event.clientX - drag.x;
        const dy = event.clientY - drag.y;
        if (!drag.moved && Math.hypot(dx, dy) > 4) {
          drag.moved = true;
        }
        if (!drag.moved) {
          return;
        }
        s.yaw = rubber(drag.yaw + dx * DRAG_YAW, -YAW_LIMIT, YAW_LIMIT);
        s.pitch = rubber(drag.pitch + dy * DRAG_PITCH, 0, PITCH_LIMIT);
        drag.samples.push({
          t: event.timeStamp,
          x: event.clientX,
          y: event.clientY,
        });
        if (drag.samples.length > 5) {
          drag.samples.shift();
        }
        if (s.reduceMotion) {
          draw();
        } else {
          wake();
        }
      };
    
      const handlePointerUp = (event: PointerEvent<HTMLCanvasElement>) => {
        const s = sim.current;
        const drag = s.drag;
        if (!drag || drag.id !== event.pointerId) {
          return;
        }
        s.drag = null;
        setDragging(false);
        if (!drag.moved) {
          wake();
          return;
        }
        // Velocity from the last few samples, in px per second.
        const first = drag.samples[0];
        const lastSample = drag.samples.at(-1) ?? first;
        const dt = Math.max((lastSample.t - first.t) / 1000, 0.016);
        const vx = (lastSample.x - first.x) / dt;
        const vy = (lastSample.y - first.y) / dt;
        const recent = event.timeStamp - lastSample.t < 80;
        s.yawSpeed = recent ? vx * DRAG_YAW : 0;
        s.pitchSpeed = recent ? vy * DRAG_PITCH : 0;
        // Land on whichever view the throw is heading for.
        const projected = s.pitch + s.pitchSpeed * 0.15;
        const nextFlat = projected < PITCH_TERRAIN / 2;
        setFlat(nextFlat);
        s.pitchTarget = nextFlat ? 0 : PITCH_TERRAIN;
        wake();
      };
    
      const handlePointerLeave = (event: PointerEvent<HTMLCanvasElement>) => {
        if (!sim.current.drag && event.pointerType === "mouse") {
          hover(null);
        }
      };
    
      const handleKeyDown = (event: KeyboardEvent<HTMLCanvasElement>) => {
        const moves: Record<string, [number, number]> = {
          ArrowLeft: [-1, 0],
          ArrowRight: [1, 0],
          ArrowUp: [0, -1],
          ArrowDown: [0, 1],
        };
        const move = moves[event.key];
        if (!move) {
          return;
        }
        event.preventDefault();
        const list = cellsRef.current;
        const current = list[sim.current.hovered] ?? list.at(-1);
        if (!current) {
          return;
        }
        const col = current.col + move[0];
        const row = current.row + move[1];
        const next = list.find((c) => c.col === col && c.row === row);
        hover(next ?? current);
      };
    
      const readout = active
        ? `${active.count} ${active.count === 1 ? "contribution" : "contributions"} on ${formatDay(active.date)}`
        : `${total.toLocaleString("en-US")} contributions in the last ${weeks} weeks`;
    
      return (
        <div
          className={cn(
            "w-full max-w-2xl select-none rounded-3xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100",
            "[--terrain-0:#f4f4f5] [--terrain-1:#d4d4d8] [--terrain-2:#a1a1aa] [--terrain-3:#52525b] [--terrain-4:#18181b] [--terrain-accent:#10b981] [--terrain-text:#a1a1aa]",
            "dark:[--terrain-0:#27272a] dark:[--terrain-1:#3f3f46] dark:[--terrain-2:#71717a] dark:[--terrain-3:#a1a1aa] dark:[--terrain-4:#f4f4f5] dark:[--terrain-accent:#34d399] dark:[--terrain-text:#71717a]",
            className
          )}
        >
          <div className="flex items-start justify-between gap-4 p-5 pb-0">
            <div className="min-w-0">
              <p className="font-medium text-sm">{title}</p>
              <p
                aria-live="polite"
                className="mt-0.5 truncate text-sm text-zinc-500 tabular-nums dark:text-zinc-400"
              >
                {readout}
              </p>
            </div>
            <div className="flex shrink-0 rounded-full bg-zinc-100 p-0.5 text-xs dark:bg-zinc-900">
              {(
                [
                  ["Flat", true],
                  ["Terrain", false],
                ] as const
              ).map(([name, value]) => (
                <button
                  aria-pressed={flat === value}
                  className={cn(
                    "rounded-full px-3 py-1 font-medium outline-none transition-colors duration-200 ease-out focus-visible:ring-2 focus-visible:ring-zinc-400",
                    flat === value
                      ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-zinc-50"
                      : "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
                  )}
                  key={name}
                  onClick={() => flat !== value && setView(value)}
                  type="button"
                >
                  {name}
                </button>
              ))}
            </div>
          </div>
    
          <canvas
            aria-label={`${title}: ${total.toLocaleString("en-US")} contributions over ${weeks} weeks. Use the arrow keys to read each day.`}
            className={cn(
              "block h-72 w-full touch-pan-y rounded-b-3xl outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-inset",
              dragging ? "cursor-grabbing" : "cursor-grab"
            )}
            onBlur={() => {
              // Keyboard reading ends with focus; don't leave a day lit.
              if (!sim.current.drag) {
                hover(null);
              }
            }}
            onKeyDown={handleKeyDown}
            onLostPointerCapture={handlePointerUp}
            onPointerCancel={handlePointerUp}
            onPointerDown={handlePointerDown}
            onPointerLeave={handlePointerLeave}
            onPointerMove={handlePointerMove}
            onPointerUp={handlePointerUp}
            ref={canvasRef}
            role="img"
            tabIndex={0}
          />
    
          <div className="flex items-center justify-end gap-1 px-5 pb-4 text-xs text-zinc-500 dark:text-zinc-400">
            <span className="mr-1">Less</span>
            {[0, 1, 2, 3, 4].map((level) => (
              <span
                className="size-2.5 rounded-[3px]"
                key={level}
                style={{ background: `var(--terrain-${level})` }}
              />
            ))}
            <span className="ml-1">More</span>
          </div>
        </div>
      );
    }

Feedback

Questions, bugs or ideas for Activity Terrain? Post them here, or reply to someone else. Sign in with GitHub to comment.