Robert Crocker

Craft obsessed developer who designs.

← Back to the lab

33Fey Radar Chart

II · Drawing with SVG

The polygon morphs on a spline while the labels count along the same curve.

Click

Source

1 file
"use client";

import { cubicBezier } from "framer-motion";
import { useEffect, useId, useRef, useState } from "react";

/**
 * Native port of the "fey-chart-animated-numbers" sandbox — a radar
 * chart in the style of Fey. The polygon morphs via SMIL spline
 * interpolation while the labels count up/down on a matching rAF curve.
 */

type ChartData = {
  neutral: number;
  buy: number;
  sell: number;
  strongSell: number;
  strongBuy: number;
};

const MIN = 1;
const MAX = 30;

const AXES: Record<keyof ChartData, { p0: [number, number]; p1: [number, number] }> = {
  neutral: { p0: [50, 20], p1: [50, 44] },
  sell: { p0: [22, 40], p1: [45, 48] },
  strongSell: { p0: [32, 74], p1: [47, 54] },
  strongBuy: { p0: [68, 74], p1: [53, 54] },
  buy: { p0: [78, 40], p1: [55, 48] },
};

const ORDER: (keyof ChartData)[] = [
  "neutral",
  "buy",
  "strongBuy",
  "strongSell",
  "sell",
];

const transform = (
  value: number,
  [a, b]: [number, number],
  [c, d]: [number, number]
) => ((value - a) * (d - c)) / (b - a) + c;

function toPoints(data: ChartData): string {
  return ORDER.map((name) => {
    const { p0, p1 } = AXES[name];
    const x = transform(data[name], [MIN, MAX], [p1[0], p0[0]]);
    const y = transform(data[name], [MIN, MAX], [p1[1], p0[1]]);
    return `${x},${y}`;
  }).join(" ");
}

function usePrevious<T>(value: T): T {
  const ref = useRef(value);
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current;
}

const ease = cubicBezier(0.25, 0.1, 0.25, 1);

function animateValue({
  from,
  to,
  duration,
  onUpdate,
}: {
  from: number;
  to: number;
  duration: number;
  onUpdate: (value: number) => void;
}) {
  let startTime: number | null = null;
  const frame = (timestamp: number) => {
    if (startTime === null) startTime = timestamp;
    const progress = ease(Math.min((timestamp - startTime) / duration, 1));
    onUpdate(progress * (to - from) + from);
    if (progress < 1) requestAnimationFrame(frame);
  };
  requestAnimationFrame(frame);
}

function AnimatedLabel({
  x,
  y,
  name,
  value,
}: {
  x: string;
  y: string;
  name: string;
  value: number;
}) {
  const previous = usePrevious(value);
  const ref = useRef<SVGTextElement>(null);

  useEffect(() => {
    animateValue({
      from: previous,
      to: value,
      duration: 300,
      onUpdate: (v) => {
        if (!ref.current) return;
        ref.current.textContent = `${name} ${v.toFixed(0)}`;
      },
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);

  return <text ref={ref} x={x} y={y} />;
}

function Background({ data }: { data: ChartData }) {
  return (
    <g>
      <g strokeWidth="0.3">
        <polygon
          points="50,20 22,40 32,74 68,74 78,40"
          stroke="#8A5B43"
          fill="#0E0F13"
        />
        <g stroke="#7D7D4E" strokeDasharray="0.5" fill="none">
          <polygon points="50,44 45,48 47,54 53,54 55,48" />
          <line x1="50" y1="20" x2="50" y2="44" />
          <line x1="22" y1="40" x2="45" y2="48" />
          <line x1="32" y1="74" x2="47" y2="54" />
          <line x1="68" y1="74" x2="53" y2="54" />
          <line x1="78" y1="40" x2="55" y2="48" />
          <g stroke="#4F512F" strokeOpacity="0.26">
            <polygon points="50,40 41,47 44,58 56,58 59,47" />
            <polygon points="50,35 36,45 41,62 59,62 64,45" />
            <polygon points="50,30 31,43 38,66 62,66 69,43" />
            <polygon points="50,25 26,42 35,70 65,70 74,42" />
          </g>
        </g>
      </g>
      <g
        fill="#b4b4b4"
        dominantBaseline="middle"
        textAnchor="middle"
        fontSize="3"
      >
        <AnimatedLabel x="50" y="15" name="Neutral" value={data.neutral} />
        <AnimatedLabel x="15" y="39" name="Sell" value={data.sell} />
        <AnimatedLabel x="29" y="80" name="Strong sell" value={data.strongSell} />
        <AnimatedLabel x="71" y="80" name="Strong buy" value={data.strongBuy} />
        <AnimatedLabel x="85" y="39" name="Buy" value={data.buy} />
      </g>
    </g>
  );
}

function Data({ data }: { data: ChartData }) {
  const animateRef = useRef<SVGElement>(null);
  const previousData = usePrevious(data);
  const id = useId();

  useEffect(() => {
    (animateRef.current as unknown as { beginElement?: () => void })?.beginElement?.();
  }, [data]);

  return (
    <g>
      <defs>
        <marker id={id} markerWidth="4" markerHeight="4" refX="2" refY="2">
          <circle cx="2" cy="2" r="2" fill="currentColor" />
        </marker>
      </defs>
      <polygon
        points={toPoints(data)}
        stroke="currentColor"
        strokeWidth="0.3"
        fill="#EAEC8A"
        fillOpacity="0.18"
        markerStart={`url(#${id})`}
        markerMid={`url(#${id})`}
      >
        <animate
          ref={animateRef}
          attributeName="points"
          from={toPoints(previousData)}
          to={toPoints(data)}
          dur="0.3s"
          fill="freeze"
          begin="indefinite"
          calcMode="spline"
          keyTimes="0; 1"
          keySplines="0.25 0.1 0.25 1"
        />
      </polygon>
    </g>
  );
}

const randomBetween = (min: number, max: number) =>
  Math.floor(Math.random() * (max - min + 1) + min);

export function FeyChartExhibit() {
  const [data, setData] = useState<ChartData>({
    neutral: 7,
    buy: 22,
    sell: 5,
    strongSell: 1,
    strongBuy: 13,
  });

  return (
    <div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-[#111111] text-[#EAEC8A]">
      <svg viewBox="0 0 100 100" width="210">
        <Background data={data} />
        <Data data={data} />
      </svg>
      <button
        type="button"
        className="absolute bottom-4 rounded-full border border-[#313131] bg-[#222222] px-3 py-1 text-sm font-medium text-[#eeeeee] transition-colors duration-200 hover:bg-[#2a2a2a]"
        onClick={() =>
          setData({
            neutral: randomBetween(1, 30),
            buy: randomBetween(1, 30),
            sell: randomBetween(1, 30),
            strongSell: randomBetween(1, 30),
            strongBuy: randomBetween(1, 30),
          })
        }
      >
        Shuffle
      </button>
    </div>
  );
}