NEMAWASHI LAB

Cook

SVG

An analog clock face where the hour and second hands are concentric text arcs -- 'A Uniform for the Sharp,' orbits at 123px radius, 'the Critical, and the Authentic.' at 106px -- rotating in real time via requestAnimationFrame. The hour numerals are static SVG text nodes positioned on a 155px ring; the current hour highlights in black while the rest stay mid-grey. No libraries, just SVG textPath elements whose parent groups get a rotate transform each frame.

Source

CookCanvas.tsx174 lines
"use client";

import { useEffect, useRef } from "react";
import ScrambleLink from "../ScrambleLink";

const SIZE = 377;
const CX = SIZE / 2;
const CY = SIZE / 2;
const HOUR_R = 155;
const OUTER_R = 123;
const INNER_R = 106;

function circlePath(cx: number, cy: number, r: number): string {
  return [
    `M ${cx} ${cy - r}`,
    `A ${r} ${r} 0 1 1 ${cx} ${cy + r}`,
    `A ${r} ${r} 0 1 1 ${cx} ${cy - r}`,
  ].join(" ");
}

const FONT: React.CSSProperties = {
  fontFamily: "var(--font-kt-flux)",
  fontVariationSettings: "'wght' 600",
};

export default function CookCanvas() {
  const hourHandRef = useRef<SVGGElement>(null);
  const secondHandRef = useRef<SVGGElement>(null);
  const hourTextsRef = useRef<(SVGTextElement | null)[]>([]);
  const rafRef = useRef(0);
  const prevHourRef = useRef(-1);

  useEffect(() => {
    const tick = () => {
      const now = new Date();
      const h = now.getHours() % 12;
      const m = now.getMinutes();
      const s = now.getSeconds();
      const ms = now.getMilliseconds();

      const sec = s + ms / 1000;
      const min = m + sec / 60;
      const hr = h + min / 60;

      hourHandRef.current?.setAttribute(
        "transform",
        `rotate(${hr * 30} ${CX} ${CY})`,
      );
      secondHandRef.current?.setAttribute(
        "transform",
        `rotate(${sec * 6} ${CX} ${CY})`,
      );

      const display = h === 0 ? 12 : h;
      if (display !== prevHourRef.current) {
        prevHourRef.current = display;
        hourTextsRef.current.forEach((el, i) => {
          el?.setAttribute("fill", i + 1 === display ? "#000" : "#a8a8a8");
        });
      }

      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
  }, []);

  return (
    <div className="fixed inset-0 flex items-center justify-center bg-white font-[family-name:var(--font-kt-flux)]">
      {/* Back link */}
      <div className="fixed top-8 left-8 z-10">
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className="text-[14px] tracking-[0.08em] text-foreground/60 uppercase pointer-events-auto font-[family-name:var(--font-kt-flux)]"
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>

      <div className="relative" style={{ width: SIZE, height: SIZE }}>
        {/* Circle with inner shadow */}
        <div
          className="absolute inset-0 rounded-full"
          style={{ boxShadow: "inset 8px 8px 24px rgba(0,0,0,0.07)" }}
        />

        <svg
          className="absolute inset-0"
          width={SIZE}
          height={SIZE}
          viewBox={`0 0 ${SIZE} ${SIZE}`}
        >
          {/* Hour numbers */}
          {Array.from({ length: 12 }, (_, i) => {
            const hour = i + 1;
            return (
              <text
                key={hour}
                ref={(el) => {
                  hourTextsRef.current[i] = el;
                }}
                x={CX}
                y={CY - HOUR_R}
                fill="#a8a8a8"
                fontSize={14}
                letterSpacing="0.28px"
                textAnchor="middle"
                dominantBaseline="central"
                style={FONT}
                transform={`rotate(${hour * 30} ${CX} ${CY})`}
              >
                {hour}
              </text>
            );
          })}

          {/* Hour hand — outer motto arc */}
          <g ref={hourHandRef}>
            <path
              id="hour-ring"
              d={circlePath(CX, CY, OUTER_R)}
              fill="none"
            />
            <text
              fill="#000"
              fontSize={14}
              letterSpacing="0.28px"
              style={FONT}
            >
              <textPath href="#hour-ring" startOffset="0%">
                A Uniform for the Sharp,
              </textPath>
            </text>
          </g>

          {/* Second hand — inner motto arc */}
          <g ref={secondHandRef}>
            <path
              id="second-ring"
              d={circlePath(CX, CY, INNER_R)}
              fill="none"
            />
            <text
              fill="#000"
              fontSize={14}
              letterSpacing="0.28px"
              style={FONT}
            >
              <textPath href="#second-ring" startOffset="0%">
                the Critical, and the Authentic.
              </textPath>
            </text>
          </g>

          {/* Static center text */}
          <text
            x={CX}
            y={CY}
            fill="#000"
            fontSize={14}
            letterSpacing="0.28px"
            textAnchor="middle"
            dominantBaseline="central"
            style={FONT}
          >
            GIRAGIRA
          </text>
        </svg>
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe