Charts

Vector Field

A four-state quiver plot on a derived lattice: one centred arrow per node carrying direction, length and colour, evenly spaced streamlines traced through the same data, holes that stay holes, and a keyboard-reachable readout on every vector.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  arrowPath,
  buildStreamlines,
  buildVectorLattice,
  isFiniteNumber,
  projectVectorField,
  resolveAxisScale,
  strideForPositions,
  vectorBearing,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-vector-field.json

Prompt

Build a React + TypeScript + Tailwind "Vector Field" component (zod, a cn() class merger, no chart
library). It draws a 2-D field of measured vectors as hand-rolled SVG: one arrow per lattice node,
optionally with evenly spaced streamlines traced through the same data.

Contract
- zod schema, one source of truth:
    status: "loading" | "empty" | "error" | "ready"
    title: string, description?: string
    x / y: { label: string; unit?: string; domain?: [number, number] }   // omit domain to fit
    measure: { label: string; unit?: string }                            // what the LENGTH means
    samples: { x: number; y: number; u: number; v: number }[]
- Direction arrives as COMPONENTS, never as { angle, magnitude }. Components add, average and
  interpolate; angles wrap at 360 and the mean of 350 and 10 is 180, the exact opposite of the right
  answer. Every streamline here is an interpolation between four neighbouring nodes, so the contract
  must carry the interpolable form. Magnitude is derived, hypot(u, v), and never sent alongside: a
  magnitude that disagrees with its own components is a contradiction the renderer cannot resolve.
- v is UP-POSITIVE in data space. The screen flip happens exactly once, at projection.
- No sample id. A node's identity is its position; two vectors at one position are one measurement
  and the later one wins (counted as a replacement).
- Props on top of the contract: mode ("arrows" | "streamlines" | "both", default "arrows"),
  modeToggle (default true), aspect ("auto" | "equal", default "auto"), lengthScale
  ("linear" | "sqrt", default "linear"), colorByMagnitude (default true), minArrowSpacing (px,
  clamped 10-72, default 20), streamlineDensity (0.4-3, default 1), calmBelow (measure units,
  default 0), locale (default "en-US"), formatX / formatY, onSampleSelect, onRetry, className,
  ...div props. forwardRef.
- Keep the layout maths in exported pure functions beside the component - buildVectorLattice,
  projectVectorField, sampleVectorFieldAt, strideForPositions, arrowPath, vectorBearing,
  buildStreamlines - so a test can print the same numbers the picture is made of.

Behavior
- Derive the lattice, do not declare it. Distinct x values are the columns, distinct y values are the
  rows; nothing has to be complete, sorted or evenly spaced. That is what lets a masked field (an
  ocean with land in it) arrive as the rows it actually has, with the land simply absent.
- Canonicalise coordinates to 12 significant digits before comparing them for equality. A feed that
  computes its grid as i * 0.1 emits 0.30000000000000004 where its neighbour emits 0.3; two lattice
  lines one ulp apart double the column count, halve the fill and turn every node into a hole its
  neighbour cannot interpolate across.
- Cap the implied lattice (65536 nodes). Because it is derived, SCATTERED readings imply an n x n
  grid: 320 scattered points ask for 102,400 nodes of which 0.3% is measured. Over the cap, refuse:
  do not build the arrays, say how many samples implied how big a grid, and suggest binning upstream
  or a scatter plot. Put this branch BEFORE the empty branch - an overflowing lattice has no filled
  nodes either, and "nothing to show" would file 320 real readings under "you sent us nothing".
- Three states a node can be in, and they must never collapse into each other:
    hole   - nobody measured it: draw nothing, and stop interpolation at its edge.
    calm   - magnitude <= calmBelow: draw a ring, not an arrow. A 0.4px arrow is a direction claim
             nobody can read and a zero vector has no direction at all.
    vector - draw a centred arrow; a tiny one still gets its node dot so it is not mistaken for a hole.
- Project once, then do every geometric thing in plot space. Components go through the AXIS SCALES,
  not one shared factor: for a gradient field u and v have different units and there is no other
  option, and for a wind field it is what keeps the arrows tangent to the streamlines as drawn. Print
  bearings from the DATA components so the true angle is always available as text, and say out loud
  when the two axes got different pixels per unit ("one elevation unit takes 3.0x the pixels of one
  distance unit") - naming the wrong axis is worse than saying nothing.
- Arrows are CENTRED on their node. Tail-at-node moves each sample downstream by half its length, so
  the strongest vectors are displaced furthest from where they were measured and the field acquires a
  drift nobody put in the data. Shaft and head are one stroked path: a filled head at 3px is a dot.
- Thinning: compute the smallest stride that keeps every drawn neighbour at least minArrowSpacing
  apart, measured on the PROJECTED positions rather than an average pitch, so a lattice that is fine
  in one place and coarse in another is judged where it is fine. Start the subset at index 0 so the
  picture does not shuffle as the card resizes, and say in the caption how many arrows of how many
  vectors are drawn. Cap arrow length at 0.92 x the smallest drawn gap: the longest arrow in the
  field just fails to touch its neighbour.
- Streamlines (Jobard & Lefebvre): seed on a coarse grid, trace, and stop the moment the line comes
  within one separation of a line already drawn. That rule is what turns a hairball into a picture
  and it also bounds the work - total drawn length cannot exceed plot area / separation. Integrate
  with midpoint RK2 on the NORMALISED field: only direction shapes a streamline, and a fixed arc step
  keeps the polyline evenly sampled where the field is fast as well as slow. Plain Euler visibly
  spirals outward on a vortex, and a reader will believe that about the data. Visit seeds strongest
  field first (a sort, not a shuffle) so one field always yields one set of lines. Bilinear sampling
  returns null outside the lattice AND when any of the four corners is a hole - substituting for a
  missing corner invents a measurement exactly at the boundary of the masked region, the one place a
  reader is entitled to watch the field stop. Every line gets a mid-line arrow marker: a line drawn
  without one is exactly as compatible with the field as with the field reversed.
- Streamlines need at least a 2 x 2 lattice. Below that the mode buttons stay reachable and explain
  themselves via aria-disabled + a handler guard + a title - never the native disabled attribute,
  which drops the control out of the tab order and takes its explanation with it.
- Interaction, and a gesture is never the only path:
    pointer  - one transparent rect owns the plot and finds the NEAREST node (a 2px arrow is not a
               pointer target); click pins. Bail out of the state update when the nearest node has
               not changed, or every pointer event re-renders every arrow.
    keyboard - the whole field is ONE tab stop with a roving tabindex. Arrows move by the drawing
               stride and skip holes, Home/End jump within a row, Ctrl/Cmd+Home / Ctrl/Cmd+End to the
               corners, Enter or Space pins and unpins, Escape clears. preventDefault only for keys
               actually handled, so Tab still leaves.
  Both feed one readout line: position, magnitude, bearing in degrees plus a 45-degree compass point.
- Deterministic and SSR-stable: no Math.random and no Date.now at render. Streamline seed jitter
  comes from a seeded integer hash, so the server and the client draw the same lines.
- Degenerate data must not break geometry: non-finite components are dropped and COUNTED in a visible
  note (never silently), duplicate coordinates are replaced and counted, nodes outside a fixed domain
  are still interpolated but not drawn and are counted, a single distinct coordinate is padded to
  +/-50% of its own magnitude instead of dividing by a zero span, an all-zero field reports "no
  direction" rather than "0 degrees, north", and tick generation is a bounded for-loop so a
  pathological domain cannot spin the render thread.
- Four first-class branches. loading: a fixed swirl of placeholder arrows (indices, not random) with
  animate-pulse gated by motion-reduce, plus one sr-only status. empty and error: a centred message;
  error shows retry only when onRetry exists. ready: the field.

Rendering & styling
- Semantic tokens only. The five chart tokens are already a sequential ramp - they are monotone in
  lightness in both themes - so magnitude maps along var(--chart-1) .. var(--chart-5), mixing two
  neighbouring stops with color-mix(in oklab, ...) in between. Chrome is stroke-border,
  fill-muted-foreground, text-xs / fontSize 11 tick text; the card is bg-card + border + rounded-xl.
  No hex, rgb, hsl or oklch anywhere.
- Never colour alone. LENGTH carries magnitude as well as colour, so the picture survives greyscale
  and colorByMagnitude={false}; the strongest vector gets a DIRECT LABEL with a paint-order halo
  (drawn whether or not the stride keeps its arrow); calm nodes are a ring, a shape difference;
  bearings are written out in the readout and in the sr-only table. The legend is never the only
  place a number appears.
- Measure the card with a ResizeObserver and make the viewBox match the rendered width, so at scale 1
  a fontSize of 11 really is 11px - a fixed viewBox shrinks the axis to 5px on a 375px screen.
  Disconnect the observer on unmount.
- Clip the field to the plot rect, and define the clip rect in the LOCAL coordinates of the translated
  group: an element's own transform also transforms the clip path it references, so absolute
  coordinates there shift the window off the plot and cut the leftmost arrows away.
- Accessibility: a <figure> labelled by the heading and described by an sr-only summary sentence
  (counts, both axis ranges, the peak vector, what was dropped). NOT role="img" - that is
  children-presentational and would silence every focusable node inside. The drawn nodes are a
  role="grid" with role="row" / role="gridcell" and aria-rowindex / aria-colindex; each cell's
  aria-label reads position, magnitude and named direction. A polite live region carries only what
  focus cannot - the layer switch and the pin - because a focused cell already announces itself. The
  strongest vectors also ship as a real sr-only <table>, with the sr-only class on the WRAPPER: CSS
  width is only a lower bound for a table box, so width:1px on the table itself adds page scroll.
- Axis labels elide with a <title> rather than overflowing; the direct label is clamped inside the
  frame whatever corner its vector sits in.

Customization levers
- Colour: swap the --chart-N ramp for any monotone-in-lightness sequence, or set colorByMagnitude
  false and let length do all the work (useful when the card already spends colour on something else).
- Density: minArrowSpacing is the one knob that matters. 14-18px reads as a texture, 28-40px as a
  sample of a field whose structure the streamlines carry. streamlineDensity multiplies the automatic
  separation; MAX_STREAMLINES bounds the worst case.
- Emphasis: lengthScale "sqrt" for long-tailed fields (a 40:1 jet-to-ambient ratio draws the ambient
  at 2% linearly, 16% under a root). Say so in the caption - the legend already does.
- Geometry: aspect "equal" whenever x and y carry the same unit and the drawn angle is meant to be the
  real one; leave it "auto" when the axes measure different things and let the component report the
  exaggeration. Fix a domain when two renders have to line up.
- Semantics: calmBelow is the instrument's noise floor, not a style - set it to the speed under which
  your sensor's direction is meaningless. Meteorological feeds naming the direction wind comes FROM
  are 180 degrees off this convention; convert upstream and say which you use.
- Trim: modeToggle={false} for a static report, drop the peak label for a pure texture map, replace
  the sr-only table with your own summary, or drop the streamline layer entirely if the field is
  divergent rather than a flow.

Concepts

  • Components, not angle and magnitude — a field is carried as u / v because that is the only form that survives arithmetic. Angles wrap, so the average of 350° and 10° comes out as 180°: the exact opposite of north. Every streamline step is an average of four neighbours, so the contract has to hold the interpolable form and derive the magnitude from it.
  • The lattice is derived, not declared — the distinct coordinates are the grid. That is what lets a masked field arrive with its land missing and no padding, and it is also why scattered readings imply an n × n grid the component refuses to draw rather than inventing 99.7% of it.
  • Hole, calm and zero are three different facts — nobody measured it, we measured almost nothing, we measured exactly nothing. They get nothing, a ring and a ring with a dot; interpolation stops at a hole rather than stepping across it, so a streamline that reaches the coast stops at the coast.
  • Evenly spaced streamlines — seeds are visited strongest-field-first and every trace halts within one separation of a line already drawn. The rule is what makes the plot readable instead of a hairball, and it is also the budget: total drawn length can never exceed the plot area divided by the separation.
  • Vertical exaggeration — when the two axes get different pixels per unit, the drawn angle is a plot angle, not the vector's angle. The component says which axis was stretched and by how much, keeps the printed bearings computed from the data, and offers aspect="equal" for the cases where the drawn angle is supposed to be the real one.
  • Length is the second channel — magnitude is in the arrow length as well as in the colour, the peak vector carries a direct label, and every bearing is written out in the readout and the sr-only table. Nothing in the picture depends on being able to tell two ramp stops apart.

On This Page