Gradient Compass

A contour plot of a 2D scalar field with a draggable probe. At the probe a three-arrow compass visualises the gradient as a composition of its partial derivatives — the horizontal arrow is ∂f/∂x, the vertical arrow is ∂f/∂y, and the diagonal arrow is the resultant ∇f. Dashed construction lines show the parallelogram-of-components, so the eye sees how the two partials compose into the gradient vector.

Generic over the underlying field. Callers supply f and gradient, so the same component drives the bowl x² + y², a saddle x² − y², a Rosenbrock-style ridge, or any other smooth field with a closed-form gradient.

Point at (1, 2). Gradient [2, 4], magnitude 4.5.
Customize
Field
bowl
Interaction
0.25
16 px / unit

Installation

npx shadcn@latest add https://craftbits.dev/r/gradient-compass.json

Usage

import { useState } from "react";
import { GradientCompass } from "@craft-bits/viz/gradient-compass";
 
const [point, setPoint] = useState({ x: 1, y: 2 });
 
<GradientCompass
  point={point}
  onPointChange={setPoint}
  f={(x, y) => x * x + y * y}
  gradient={(x, y) => [2 * x, 2 * y]}
  domain={{ min: -3, max: 3 }}
  contourLevels={[1, 2, 4, 6, 8]}
/>;

Swap in a saddle and drop the contour circles when they no longer match the level sets:

<GradientCompass
  point={point}
  onPointChange={setPoint}
  f={(x, y) => x * x - y * y}
  gradient={(x, y) => [2 * x, -2 * y]}
  contourLevels={[]}
/>

Snap to integers for whole-number exploration:

<GradientCompass
  point={point}
  onPointChange={setPoint}
  snapStep={1}
  keyboardStep={1}
/>

Understanding the component

  1. Domain → SVG transform. The component renders the rectangle [domain.min, domain.max]² into the SVG viewBox. Math x grows rightward, math y grows upward — the SVG y axis is flipped internally so the plot reads like a textbook diagram.
  2. Contour circles. Each level c in contourLevels becomes a circle of radius sqrt(c) around the origin — the level set of x² + y² = c. With a different f, those circles stop being true level sets but still encode distance from the origin; non-radial fields will typically pass contourLevels={[]} and overlay their own.
  3. Probe point. Controlled via point / onPointChange. Drag updates round to multiples of snapStep (default 0.25); arrow-key updates step by keyboardStep (Shift doubles). Both clamp to the domain.
  4. Compass arrows. The probe spawns three arrows — horizontal ∂f/∂x, vertical ∂f/∂y, and the diagonal gradient ∇f. Dashed construction lines connect the gradient tip to each partial tip, making the parallelogram-of-components visible. Arrows fade when |∇f| falls below a small threshold so the compass disappears near a stationary point.
  5. Animated |∇f| readout. A monospaced magnitude readout in the top-right tweens between values on SPRINGS.snap. Reduced-motion users see instant snaps.
  6. Reduced motion. Magnitude tweening collapses to instant under prefers-reduced-motion: reduce. Drag and keyboard interactions are unaffected.

Props

PropTypeDefaultDescription
point{ x: number; y: number }requiredCurrent probe location in math coordinates. Controlled.
onPointChange(next) => voidrequiredCalled with the next clamped/snapped probe location.
f(x, y) => number(x, y) => x² + y²Scalar field. Used for the aria-label.
gradient(x, y) => [number, number](x, y) => [2x, 2y]Gradient of f. Drives the three arrows and the magnitude readout.
domain{ min: number; max: number }{ min: -3, max: 3 }Mathematical range rendered along each axis.
snapStepnumber0.25Snap step for drag updates. 0 disables snapping.
keyboardStepnumber0.25Arrow-key step. Shift doubles.
contourLevelsnumber[][1, 2, 4, 6, 8]Level values to draw as circles around the origin.
arrowScalenumber16Pixels per unit of gradient.
arrowMaxPxnumber85Hard cap on arrow length.
widthnumber420SVG viewBox width.
heightnumber380SVG viewBox height.
ariaLabelstringOverride the SVG aria-label.
transitionTransitionSPRINGS.snapMagnitude readout transition.
classNamestringMerged onto the root via cn().

Accessibility

  • The SVG is role="img" with an aria-label summarising the current probe, gradient components, and magnitude. Override via ariaLabel.
  • The drag/keyboard hit target is role="slider" with aria-valuetext reporting the current (x, y). Arrow keys move the probe; Shift doubles the step.
  • A polite live region announces the probe location and gradient outside of active drag (drag updates are silent to avoid flooding the buffer).
  • The decorative arrows, axes, contour circles, and component labels are aria-hidden — the same information is encoded in the SVG label and the live region.
  • Colour is never the only signal — every arrow has both a distinct hue and an attached ∂f/∂x / ∂f/∂y / ∇f label, and the focus ring on the probe is rendered with both a stronger stroke width and an accent hue when keyboard focus is present.
  • Motion respects prefers-reduced-motion: reduce — the magnitude readout snaps instantly rather than tweening.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/math/GradientCompass.tsx). The source hardwired f(x, y) = x² + y², baked in a six-branch narration generator keyed off a hasEverDragged first-drag flag, ran an idle "breathing" SVG animation on the probe, and rendered a fixed ca-narration panel below the plot. The viz extract reduces it to a generic, controlled compass: callers supply f and gradient, while the lesson-specific narration, breathing animation, and embedded copy live outside the component.