Skip to content

Wind Rose Dial

A compass rose that sets a page-wide wind: drag the knob round to turn it and out to blow harder, with grains streaming behind it the way the wind blows.

Canvas 2D0 deps1 file · 15 KB TSXreduced-motion aware

FreeAeolianminimal
Preview

Props

Grain streams
No runtime dependencies
"use client";

import { useEffect, useRef } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react";

/**
 * WIND ROSE DIAL.
 * A compass rose that sets a page-wide wind: drag the knob round to turn it
 * and out to blow harder, with grains streaming behind it the way the wind
 * blows.
 *
 * FONTS. Archivo with its width axis for display, Instrument Serif italic for
 * the accent word, Geist Mono for labels. In Next: Archivo({ subsets:
 * ["latin"], axes: ["wdth"], variable: "--font-ae" }), Instrument_Serif({
 * subsets: ["latin"], weight: "400", style: "italic", variable:
 * "--font-ae-serif" }) and Geist_Mono({ subsets: ["latin"], variable:
 * "--font-ae-mono" }) on <html>. Without them it falls back to system fonts.
 *
 * CLIENT. The file starts with "use client". onChange is a function, so render
 * it from a client component (a server page.tsx cannot pass functions).
 *
 * WIND. Every Aeolian entry reads one page-wide wind (aeWind on globalThis),
 * so separately pasted entries still turn together.
 */

/** The deepest ground: dusk, the footer. */
const AE_NIGHT = "#150E0A";

/** Ink on the light grounds. */
const AE_UMBER = "#2B1B12";

/** Lit sand: marks on the night ground. */
const AE_SAND = "#DDAA72";

/** The pale ground, and the colour a sandstorm turns everything. */
const AE_HAZE = "#EFE2CD";

/** The one filled accent: the low sun. */
const AE_EMBER = "#E0602C";

/** Labels and readouts. */
const AE_MONO = 'var(--font-ae-mono, var(--font-geist-mono, "Geist Mono")), "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace';

const aeClamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);

const aeReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * The page's wind. `angle` is the way it blows TOWARD, in degrees on the
 * ground: 0 = to the right (east), 90 = away from you (north), 180 = left,
 * 270 = toward you. `speed` is in km/h. The dune stage, the grains, the dust
 * of every dissolve, the nav's vane and the footer all read this one store,
 * so turning the rose in the hero turns every grain on the page. It lives on
 * globalThis, so entries pasted as separate files still share one wind.
 */
const aeWind: { angle: number; speed: number; subs: Set<() => void> } = ((globalThis as unknown as { __aeolianWind?: { angle: number; speed: number; subs: Set<() => void> } }).__aeolianWind ??= { angle: 55, speed: 30, subs: new Set() });

/** Set the wind (any part may be omitted) and tell every listener. */
function aeSetWind(next: { angle?: number; speed?: number }) {
  if (next.angle !== undefined) aeWind.angle = ((next.angle % 360) + 360) % 360;
  if (next.speed !== undefined) aeWind.speed = aeClamp(next.speed, 0, 60);
  aeWind.subs.forEach((f) => f());
}

/** Hear every wind change; returns an unsubscribe. Listeners write to the DOM, not to React state. */
function aeOnWind(fn: () => void): () => void {
  aeWind.subs.add(fn);
  return () => {
    aeWind.subs.delete(fn);
  };
}

/**
 * The wind as a direction on a flat screen: x right, y down. Wind blowing
 * away from you reads as rising, toward you as falling, a little flattened
 * (the ground is seen at a low angle). Unit length.
 */
function aeWindScreen(angle = aeWind.angle): [number, number] {
  const a = (angle * Math.PI) / 180;
  const x = Math.cos(a);
  const y = -Math.sin(a) * 0.55;
  const l = Math.hypot(x, y) || 1;
  return [x / l, y / l];
}

const AE_POINTS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];

/** Where the wind comes FROM, as a compass point (north is away from you): blowing to the right is a westerly. */
function aeWindFrom(angle = aeWind.angle): string {
  const toward = (((90 - angle) % 360) + 360) % 360;
  const from = (toward + 180) % 360;
  return AE_POINTS[Math.round(from / 22.5) % 16] ?? "N";
}

/** "From WSW · 26 km/h". */
function aeWindText(angle = aeWind.angle, speed = aeWind.speed): string {
  return "From " + aeWindFrom(angle) + " · " + Math.round(speed) + " km/h";
}

const WR_CSS =
  ".ae-wr{width:var(--wr-size,132px);touch-action:none;cursor:grab;-webkit-tap-highlight-color:transparent}.ae-wr:active{cursor:grabbing}" +
  ".ae-wr:focus-visible{border-radius:9999px}" +
  ".ae-wr [data-wr-knob]{transition:r 200ms}.ae-wr:hover [data-wr-knob]{r:7.5}" +
  "@container (max-width: 699px){.ae-wr{width:var(--wr-size,104px)}}";

export interface WindRoseDialProps {
  /** Diameter in px; by default 132 (104 under a 700px container). */
  size?: number;
  /** The ground it sits on. */
  tone?: "night" | "haze";
  /** Set the page's wind once, when the dial mounts. */
  angle?: number;
  speed?: number;
  /** Grains streaming behind the rose in the wind's direction. */
  streams?: boolean;
  /** The wind in words under the rose ("From ENE · 26 km/h"). */
  caption?: boolean;
  /** Called after the wind changes (with the new angle and speed). */
  onChange?: (wind: { angle: number; speed: number }) => void;
  label?: string;
  className?: string;
  style?: CSSProperties;
}

/**
 * WindRoseDial — a compass rose that sets the page's wind. Drag the knob
 * round to turn it (it sits where the wind comes FROM, like the caption says;
 * the arrow across the rose points where it blows), out to blow harder;
 * arrows turn it by 15°, up and down change the speed. Every Aeolian engine on
 * the page follows.
 */
export function WindRoseDial({ size, tone = "night", angle, speed, streams = true, caption = true, onChange, label = "Wind", className = "", style }: WindRoseDialProps) {
  const ref = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const onChangeRef = useRef(onChange);
  useEffect(() => {
    onChangeRef.current = onChange;
  });
  const ink = tone === "night" ? AE_HAZE : AE_UMBER;
  const R0 = 13;
  const R1 = 40;

  useEffect(() => {
    if (angle !== undefined || speed !== undefined) aeSetWind({ angle, speed });
    // Only the first values: after that the dial (and anything else on the page) owns the wind.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Draw the rose from the store whenever the wind changes (no React render).
  useEffect(() => {
    const el = ref.current;
    const svg = svgRef.current;
    if (!el || !svg) return;
    const draw = () => {
      const a = (aeWind.angle * Math.PI) / 180;
      const r = R0 + (aeWind.speed / 60) * (R1 - R0);
      // The knob sits upwind: you place the wind's source and it blows through the rose.
      const kx = 50 - Math.cos(a) * r;
      const ky = 50 + Math.sin(a) * r;
      svg.querySelector("[data-wr-needle]")?.setAttribute("transform", "rotate(" + (-aeWind.angle).toFixed(2) + " 50 50)");
      const knob = svg.querySelector("[data-wr-knob]");
      knob?.setAttribute("cx", kx.toFixed(2));
      knob?.setAttribute("cy", ky.toFixed(2));
      svg.querySelector("[data-wr-reach]")?.setAttribute("r", r.toFixed(2));
      const cap = el.parentElement?.querySelector("[data-wr-caption]");
      if (cap) cap.textContent = aeWindText();
      el.setAttribute("aria-valuenow", String(Math.round(aeWind.angle)));
      el.setAttribute("aria-valuetext", "Blowing from " + aeWindFrom() + ", " + Math.round(aeWind.speed) + " km/h");
    };
    draw();
    return aeOnWind(draw);
  }, []);

  // Pointer: the wind comes from where you hold it (so it blows the other way), speed from the distance.
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let drag = false;
    const set = (e: PointerEvent) => {
      const r = el.getBoundingClientRect();
      const x = ((e.clientX - r.left) / r.width) * 100 - 50;
      const y = ((e.clientY - r.top) / r.height) * 100 - 50;
      const d = Math.hypot(x, y);
      if (d < 3) return;
      aeSetWind({ angle: (Math.atan2(-y, x) * 180) / Math.PI + 180, speed: aeClamp((d - R0) / (R1 - R0)) * 60 });
      onChangeRef.current?.({ angle: aeWind.angle, speed: aeWind.speed });
    };
    const down = (e: PointerEvent) => {
      drag = true;
      el.setPointerCapture(e.pointerId);
      set(e);
      e.preventDefault();
    };
    const move = (e: PointerEvent) => {
      if (drag) set(e);
    };
    const up = () => {
      drag = false;
    };
    el.addEventListener("pointerdown", down);
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerup", up);
    el.addEventListener("pointercancel", up);
    return () => {
      el.removeEventListener("pointerdown", down);
      el.removeEventListener("pointermove", move);
      el.removeEventListener("pointerup", up);
      el.removeEventListener("pointercancel", up);
    };
  }, []);

  // Grains streaming through the rose, the way the wind blows.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !streams) return;
    const c2 = canvas.getContext("2d");
    if (!c2) return;
    const ctx = c2;
    const N = 240;
    const g = Array.from({ length: N }, (_, i) => ({ x: ((i * 0.618034) % 1) * 2 - 1, y: ((i * 0.381966 + 0.3) % 1) * 2 - 1, s: 0.5 + ((i * 0.7548) % 1), w: (i * 0.2649) % 1 }));
    let raf = 0;
    let last = 0;
    let on = false;
    const still = aeReducedSnap();
    const draw = (now: number) => {
      raf = 0;
      const w = canvas.clientWidth;
      if (!w) return;
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      if (canvas.width !== Math.round(w * dpr)) {
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(w * dpr);
      }
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
      last = now;
      const [dx, dy] = aeWindScreen();
      const v = aeWind.speed / 60;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, w);
      const h = w / 2;
      for (const p of g) {
        const k = (0.35 + 0.9 * v) * p.s;
        p.x += dx * k * dt * 1.1;
        p.y += dy * k * dt * 1.1 + Math.sin(now / 700 + p.w * 9) * 0.0016 * v;
        if (p.x > 1) p.x -= 2;
        if (p.x < -1) p.x += 2;
        if (p.y > 1) p.y -= 2;
        if (p.y < -1) p.y += 2;
        const d = Math.hypot(p.x, p.y);
        const a = aeClamp(1.15 - d) * (0.42 + 0.5 * p.w);
        if (a <= 0.01) continue;
        const len = 3 + 26 * v * p.s;
        ctx.strokeStyle = tone === "night" ? "rgba(221,170,114," + a.toFixed(3) + ")" : "rgba(184,116,63," + a.toFixed(3) + ")";
        ctx.lineWidth = 0.6 + p.w * 0.9;
        ctx.beginPath();
        ctx.moveTo(h + p.x * h, h + p.y * h);
        ctx.lineTo(h + p.x * h - dx * len, h + p.y * h - dy * len);
        ctx.stroke();
      }
      if (on && !still) raf = requestAnimationFrame(draw);
    };
    const io = new IntersectionObserver((es) => {
      on = !!es[es.length - 1]?.isIntersecting;
      if (on && !raf) raf = requestAnimationFrame(draw);
    });
    io.observe(canvas);
    const off = aeOnWind(() => {
      if (!raf) raf = requestAnimationFrame(draw);
    });
    return () => {
      io.disconnect();
      off();
      if (raf) cancelAnimationFrame(raf);
    };
  }, [streams, tone]);

  const step = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const k = e.key;
    let a = aeWind.angle;
    let s = aeWind.speed;
    if (k === "ArrowLeft") a += 15;
    else if (k === "ArrowRight") a -= 15;
    else if (k === "ArrowUp") s += 2;
    else if (k === "ArrowDown") s -= 2;
    else if (k === "PageUp") s += 10;
    else if (k === "PageDown") s -= 10;
    else if (k === "Home") s = 0;
    else if (k === "End") s = 60;
    else return;
    e.preventDefault();
    aeSetWind({ angle: a, speed: s });
    onChangeRef.current?.({ angle: aeWind.angle, speed: aeWind.speed });
  };

  const ticks = Array.from({ length: 32 }, (_, i) => i);
  return (
    <div className={"ae-scope inline-block " + className} style={{ ...(size ? ({ "--wr-size": size + "px" } as CSSProperties) : null), ...style }}>
      <style>{WR_CSS}</style>
      <div
        ref={ref}
        role="slider"
        tabIndex={0}
        aria-label={label + " direction and speed"}
        aria-valuemin={0}
        aria-valuemax={359}
        aria-valuenow={18}
        onKeyDown={step}
        className="ae-wr relative aspect-square select-none"
        style={{ color: ink }}
      >
        {streams ? <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-[-18%] h-[136%] w-[136%] rounded-full" /> : null}
        <div aria-hidden className="absolute inset-0 rounded-full" style={{ background: tone === "night" ? "radial-gradient(circle at 50% 40%, rgba(43,27,18,.55), rgba(21,14,10,.72))" : "radial-gradient(circle at 50% 40%, rgba(255,250,242,.7), rgba(239,226,205,.85))", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)", border: "1px solid " + (tone === "night" ? "rgba(239,226,205,.2)" : "rgba(43,27,18,.16)") }} />
        <svg ref={svgRef} viewBox="0 0 100 100" aria-hidden className="absolute inset-0 h-full w-full">
          {ticks.map((i) => {
            const a = (i / 32) * Math.PI * 2;
            const long = i % 8 === 0;
            const r0 = long ? 41.5 : 44;
            return <line key={i} x1={(50 + Math.cos(a) * r0).toFixed(2)} y1={(50 + Math.sin(a) * r0).toFixed(2)} x2={(50 + Math.cos(a) * 46.5).toFixed(2)} y2={(50 + Math.sin(a) * 46.5).toFixed(2)} stroke="currentColor" strokeWidth={long ? 1.1 : 0.6} opacity={long ? 0.75 : 0.35} />;
          })}
          {(
            [
              ["N", 50, 13.5],
              ["E", 87, 51.8],
              ["S", 50, 90],
              ["W", 13, 51.8],
            ] as const
          ).map(([t, x, y]) => (
            <text key={t} x={x} y={y} textAnchor="middle" fontSize="6.2" fill="currentColor" opacity="0.7" style={{ fontFamily: AE_MONO, letterSpacing: "0.08em" }}>
              {t}
            </text>
          ))}
          <circle data-wr-reach="" cx="50" cy="50" r="24" fill="none" stroke={AE_SAND} strokeWidth="0.5" strokeDasharray="1 2.2" opacity="0.7" />
          <g data-wr-needle="" transform="rotate(-18 50 50)">
            <path d="M14 50 Q 32 47.6 50 50 Q 68 52.4 82 50" fill="none" stroke={AE_SAND} strokeWidth="1.1" strokeLinecap="round" opacity="0.55" />
            <path d="M84.5 50 L76.5 46.2 Q 78.6 50 76.5 53.8 Z" fill={AE_SAND} />
          </g>
          <circle cx="50" cy="50" r="1.6" fill="currentColor" opacity="0.8" />
          <circle data-wr-knob="" cx="73" cy="42" r="6" fill={AE_EMBER} stroke={tone === "night" ? AE_NIGHT : "#FFF8EE"} strokeWidth="1.6" />
        </svg>
      </div>
      {caption ? (
        <p data-wr-caption="" className="ae-num mt-3 text-center text-[10.5px] uppercase tracking-[0.16em]" style={{ fontFamily: AE_MONO, color: ink, opacity: 0.75 }}>
          {aeWindText()}
        </p>
      ) : null}
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A control for something you cannot see. The rose has thirty-two ticks and its four points; the knob sits where the wind comes from and an arrow across the rose points where it blows: drag the knob round to turn the wind and out from the centre to make it blow harder. Grains stream through the rose behind it in the wind's direction, longer and faster as it picks up. Under it the wind is said in words, the way a forecast says it: From SW · 30 km/h at the page's default wind. Use it to drive anything that should move with a wind.

Curator’s note

Free. The angle is the way the wind blows to, with north away from you; the knob's distance from the centre is the speed (0 to 60 km/h). It writes to a tiny module store (aeWind, aeSetWind, aeOnWind) that every other Aeolian engine reads, so in the Barchan site it steers the hero's grains, the dissolves, the nav's vane and the footer's spindrift. The streams behind it are a 2D canvas that only runs on screen. Every Aeolian entry is cut byte for byte from one site (Barchan, a made-up camp that moves with the dunes) and shares its light and its wind: sand, umber and haze with one ember accent, Archivo set wide and light, Instrument Serif italic for one word, and a single page-wide wind that every engine reads.

Pairs well with

Pro