p5 storm chasing project

This sketch creates a TV-style weather radar display showing a southwest-to-northeast band of rain with a stratiform shield, tornado markers, and a Providence, KY location indicator. It combines procedural weather generation, color-mapped reflectivity visualization, and interactive regeneration on click.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change the storm angle — Rotate the entire squall line by changing frontAngle — try PI / 2 to make storms run top-to-bottom instead of diagonal.
  2. Add bright magenta hail cores instead of red — Change the tornado fill color from red (hue 0) to magenta (hue 300) — instantly changes the visual theme.
  3. Slow down the tornado spin — The spinning triangle rotates faster with higher frameCount multipliers. Try 0.05 for a slow creep.
  4. Make the background lighter — The radar background color (210, 40, 5) is a dark cyan. Try (210, 20, 30) for a lighter, grayer display.
  5. Increase the chance of tornadoes — The rarity threshold (0.7) means 70% of generations have no tornadoes. Try 0.3 to make them appear in 70% of generations.
  6. Narrow the rain band — The bandWidth (0.18) controls how wide the squall line is. Try 0.08 for a thin line, 0.35 for a huge shield.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch recreates the look of a real National Weather Service (NWS) radar composite, the kind you see on storm-chasing streams and weather apps. A dynamic band of rain sweeps diagonally across the screen in authentic green-to-red reflectivity colors, with rare tornado markers spinning ominously above the strongest cores. The code uses procedural generation, HSB color mapping, and 2D rotation math to build a convincing storm visualization that changes every time you tap.

The sketch is organized into three layers: a procedural weather generator that creates a SW-NE oriented squall line plus a stratiform rain shield, a radar echo renderer that colors pixels by intensity, and an interactive marker system for tornadoes. By studying it you will learn how to generate natural-looking weather patterns algorithmically, map continuous data to color ramps (reflectivity → hue), and place markers intelligently on top of generated fields.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas in HSB color mode (which makes weather colors easier to work with) and calls generateWeather() once, then stops drawing until the user taps.
  2. generateWeather() builds a grid across the canvas and computes a weather field at each point by rotating coordinate systems to create a diagonal band of storms. It blends a strong squall-line core with a weaker stratiform rain region, adds random texture, and stores every echo as {x, y, intensity}.
  3. A second pass scans for the strongest echoes (intensity > 0.9) and randomly picks tornado locations from them, ensuring they don't overlap.
  4. When draw() runs, it renders three layers: a subtle grid-lined base map with a Providence marker, colorful radar echoes using a reflectivity ramp (green for light rain, red for severe hail), and spinning red tornado symbols with labels.
  5. Every tap or touch calls mousePressed() or touchStarted(), which regenerates the weather and redraws the canvas — letting users explore infinite storm variations.

🎓 Concepts You'll Learn

Procedural generationCoordinate rotationHSB color mappingReflectivity visualizationInteractive event handlingGrid-based field generationCollision separation

📝 Code Breakdown

setup()

setup() runs once at sketch start. It configures the canvas, colors, and initial state. The noLoop() call is key — it makes the sketch event-driven rather than frame-driven, reducing CPU load.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);
  textFont('monospace');
  textSize(13);

  generateWeather();
  noLoop(); // draw only when we explicitly ask for it
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Full-window canvas createCanvas(windowWidth, windowHeight);

Creates a canvas that fills the entire browser window for an immersive radar display

calculation HSB color space colorMode(HSB, 360, 100, 100, 100);

Switches to Hue-Saturation-Brightness color space, making it natural to map storm intensity to color ramps

calculation Disable continuous drawing noLoop(); // draw only when we explicitly ask for it

Stops the draw loop from running automatically — saves CPU and waits for user interaction

createCanvas(windowWidth, windowHeight);
Creates a canvas sized to fill the entire browser window, giving the radar map a full-screen presence
colorMode(HSB, 360, 100, 100, 100);
Switches color space to HSB (Hue 0–360°, Saturation 0–100%, Brightness 0–100%, Alpha 0–100), which makes it easy to map weather intensity to hue gradients
textFont('monospace');
Sets text to a monospace font, mimicking the look of classic weather station displays
textSize(13);
Sizes all text labels at 13 pixels for readable radar annotations
generateWeather();
Builds the initial weather field, radar echoes, and tornado positions
noLoop();
Disables automatic looping — draw() only runs when redraw() is called after user interaction, saving CPU and battery

draw()

draw() is called by redraw() after user interaction, not on every frame. It's organized as a simple layer-stack pattern: clear, base map, echoes, overlays. This order ensures storms draw on top of the grid.

function draw() {
  // Dark bluish background like TV radar
  background(210, 40, 5);

  drawBaseMap();
  drawEchoes();
  drawTornados();
  drawHUD(); // now empty (no HUD text)
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Radar background background(210, 40, 5);

Sets the canvas to a dark cyan-blue typical of TV weather displays

calculation Render layers in order drawBaseMap(); drawEchoes(); drawTornados(); drawHUD();

Stacks the map grid, rain echoes, and tornado markers from back to front

background(210, 40, 5);
Fills the entire canvas with a dark cyan-blue (hue 210°, low saturation and brightness). This is the base TV-radar look.
drawBaseMap();
Renders the lat/long grid lines, border, and Providence marker on top of the background
drawEchoes();
Draws all radar echoes (rain/hail) colored by reflectivity intensity, layered above the map
drawTornados();
Draws spinning tornado symbols and labels on top of all other layers
drawHUD(); // now empty (no HUD text)
Called for consistency but intentionally left blank — no extra text or chrome is rendered

generateWeather()

This is the heart of the sketch — procedural weather generation. It demonstrates coordinate rotation (a key technique for aligned patterns), blending multiple fields, and efficiently storing only strong echoes. The math looks dense, but it boils down to: rotate coordinates, compute distance from a rotated line, apply falloff functions, and combine layers. Study the comments and try tweaking bandWidth or frontAngle to see how each piece shapes the storm.

🔬 The 1.4 multiplier boosts the squall line's intensity. What happens if you change it to 2.0? To 0.7? What visual effect does this have on the strongest storms?

      // Combine fields
      let field = mainField * 1.4 + stratField;

🔬 This noise range controls radar speckle. Try changing it to random(0, 0.2) for less noise, or random(-0.1, 0.2) for a grainier look. How does it change the radar's realism?

      // Add small random texture
      field += random(-0.05, 0.12);
function generateWeather() {
  echoes = [];
  tornadoes = [];

  // Choose grid resolution so each radar pixel is ~10px
  const targetCellSize = 10;
  gridCols = max(80, floor(width / targetCellSize));
  gridRows = max(50, floor(height / targetCellSize));

  cellW = width / gridCols;
  cellH = height / gridRows;

  // Line of storms oriented SW → NE (roughly -45° in math coords)
  const frontAngle = -PI / 4; // radians, math angle (0=+x, CCW)
  const cosF = cos(frontAngle);
  const sinF = sin(frontAngle);

  const bandWidth = 0.18; // core half-width in normalized coords

  for (let gy = 0; gy < gridRows; gy++) {
    for (let gx = 0; gx < gridCols; gx++) {
      // Normalized map coords (nx, ny) in approx [-1.2..1.2] × [-0.9..0.9]
      const nx = map(gx + 0.5, 0, gridCols, -1.2, 1.2);
      const ny = map(gy + 0.5, 0, gridRows, -0.9, 0.9);

      // Rotate into coordinates along (u) and across (v) the front
      const u = nx * cosF + ny * sinF;      // along line
      const v = -nx * sinF + ny * cosF;     // perpendicular to line

      const bandDist = Math.abs(v);

      // Main squall line core: strongest near v = 0, fading with distance
      const bandFactor = Math.exp(-Math.pow(bandDist / bandWidth, 2)); // 0..1

      // Along-line variation: cells / segments along the band
      const stripe = Math.pow(Math.sin(u * 3.0) * 0.5 + 0.5, 2); // 0..1

      let mainField = bandFactor * stripe;

      // Broad stratiform region on one (cool) side of the front
      let stratField = 0;
      if (v < 0) { // choose one side as the cool sector
        const d = Math.abs(v) / (bandWidth * 2.5);
        if (d < 1) {
          stratField = (1 - d) * 0.55; // lighter but widespread echoes
        }
      }

      // Combine fields
      let field = mainField * 1.4 + stratField;

      // Fade near the outer edges of the map
      const edgeX = smoothstep(0.95, 1.2, Math.abs(nx));
      const edgeY = smoothstep(0.95, 1.2, Math.abs(ny));
      const edgeMask = (1 - edgeX) * (1 - edgeY);
      field *= edgeMask;

      // Add small random texture
      field += random(-0.05, 0.12);

      let intensity = constrain(field, 0, 1);

      // Skip very weak returns (no echo)
      if (intensity < 0.10) continue;

      // Map normalized coords to screen pixels
      const sx = map(nx, -1.2, 1.2, 0, width);
      const sy = map(ny, 0.9, -0.9, 0, height); // ny positive = north (up)

      echoes.push({ x: sx, y: sy, intensity });
    }
  }

  // Extra light background drizzle / speckles
  const drizzleCount = floor(gridCols * gridRows * 0.08);
  for (let i = 0; i < drizzleCount; i++) ++) {
    const sx = random(width);
    const sy = random(height);
    const intensity = random(0.06, 0.18);
    echoes.push({ x: sx, y: sy, intensity });
  }

  // Pick tornado locations from the strongest echoes
  generateTornadoesFromEchoes();
}
Line-by-line explanation (20 lines)

🔧 Subcomponents:

calculation Grid resolution setup const targetCellSize = 10; gridCols = max(80, floor(width / targetCellSize)); gridRows = max(50, floor(height / targetCellSize));

Calculates how many grid cells to sample across the canvas — larger cells = fewer computations but chunkier radar

calculation Front angle and rotation const frontAngle = -PI / 4; // radians, math angle (0=+x, CCW) const cosF = cos(frontAngle); const sinF = sin(frontAngle);

Precomputes sine and cosine of the storm band angle to efficiently rotate coordinates and align rain perpendicular to the front

for-loop Grid scan and field computation for (let gy = 0; gy < gridRows; gy++) { for (let gx = 0; gx < gridCols; gx++) { // ... compute nx, ny, u, v, bandFactor, stripe, mainField, stratField, field ... echoes.push({ x: sx, y: sy, intensity }); }

Loops over every grid cell, computes normalized and rotated coordinates, blends squall-line and stratiform fields, and stores strong echoes

for-loop Drizzle speckles const drizzleCount = floor(gridCols * gridRows * 0.08); for (let i = 0; i < drizzleCount; i++) { const sx = random(width); const sy = random(height); const intensity = random(0.06, 0.18); echoes.push({ x: sx, y: sy, intensity }); }

Adds random light rain scattered across the whole map to break up the hard edge of the main band

echoes = [];
Clears the array of rain echoes from any previous generation
tornadoes = [];
Clears the array of tornado markers, starting fresh
const targetCellSize = 10;
Sets the desired radar pixel size — each grid cell will be roughly 10 screen pixels
gridCols = max(80, floor(width / targetCellSize));
Calculates the number of grid columns, ensuring at least 80 to maintain resolution even on small screens
const frontAngle = -PI / 4;
Defines the storm band direction as -45° (southwest to northeast) in math coordinates
const cosF = cos(frontAngle); const sinF = sin(frontAngle);
Precomputes cosine and sine of the front angle to avoid recalculating them millions of times in the loop
const bandWidth = 0.18;
Sets the half-width of the main rain core in normalized coordinates — controls how narrow or wide the squall line appears
const nx = map(gx + 0.5, 0, gridCols, -1.2, 1.2); const ny = map(gy + 0.5, 0, gridRows, -0.9, 0.9);
Converts grid indices (gx, gy) into normalized map coordinates (nx, ny) in the range ±1.2 × ±0.9, creating a coordinate system independent of window size
const u = nx * cosF + ny * sinF; const v = -nx * sinF + ny * cosF;
Rotates normalized coordinates into front-aligned space: u runs along the storm band, v runs perpendicular to it
const bandFactor = Math.exp(-Math.pow(bandDist / bandWidth, 2));
Uses a Gaussian falloff — echoes are strongest at the center of the band (v=0) and fade exponentially with distance from it
const stripe = Math.pow(Math.sin(u * 3.0) * 0.5 + 0.5, 2);
Creates a wavy, segmented pattern along the storm band using sine — makes the line look like discrete cells instead of a smooth blob
let stratField = 0; if (v < 0) { const d = Math.abs(v) / (bandWidth * 2.5); if (d < 1) { stratField = (1 - d) * 0.55; } }
Adds a weaker, broader rain region on one side of the front (the cool sector in real storms), creating the characteristic 'stratiform shield' behind the main line
let field = mainField * 1.4 + stratField;
Combines the strong squall-line core (boosted 1.4×) with the stratiform region to create the final reflectivity value
const edgeMask = (1 - edgeX) * (1 - edgeY); field *= edgeMask;
Fades echoes to zero near the map edges using smoothstep, preventing hard cutoffs at the boundary
field += random(-0.05, 0.12);
Adds random noise to the field, creating a speckled radar texture that looks more realistic and less synthetic
if (intensity < 0.10) continue;
Skips very weak echoes (below 10% intensity), keeping the array sparse and focused on visible storms
echoes.push({ x: sx, y: sy, intensity });
Stores the echo location and strength in the global echoes array for later rendering
const drizzleCount = floor(gridCols * gridRows * 0.08);
Calculates how many random drizzle speckles to add (8% of the grid cells)
echoes.push({ x: sx, y: sy, intensity });
Adds scattered light rain echoes across the whole map, breaking up hard edges and filling empty space
generateTornadoesFromEchoes();
Scans the strongest echoes and randomly picks tornado locations from them

drawBaseMap()

drawBaseMap() creates the static background — grid, border, and location marker. It demonstrates basic shapes (lines, ellipses, rectangles), coordinate mapping via lonLatToScreen(), and text labeling. This is the layer everything else draws on top of.

🔬 This loop draws vertical grid lines. What happens if you change vLines to 3? To 15? How does the grid density change the map's appearance?

  const vLines = 7;
  for (let i = 1; i < vLines; i++) {
    const x = (width / vLines) * i;
    line(x, 0, x, height);
  }
function drawBaseMap() {
  rectMode(CORNER);

  // Subtle overlay to separate map area
  noStroke();
  fill(210, 40, 12, 80);
  rect(0, 0, width, height);

  // Lat/long-style grid lines
  stroke(210, 20, 40, 35);
  strokeWeight(1);

  const vLines = 7;
  for (let i = 1; i < vLines; i++) {
    const x = (width / vLines) * i;
    line(x, 0, x, height);
  }

  const hLines = 5;
  for (let i = 1; i < hLines; i++) {
    const y = (height / hLines) * i;
    line(0, y, width, y);
  }

  // Map border
  stroke(120, 30, 70, 80);
  noFill();
  rect(2, 2, width - 4, height - 4);

  // Providence, KY marker
  const prov = lonLatToScreen(-87.76, 37.38);
  noStroke();
  fill(0, 0, 100, 100);
  ellipse(prov.x, prov.y, 6, 6);

  stroke(0, 0, 100, 70);
  noFill();
  ellipse(prov.x, prov.y, 18, 18);

  // City label
  noStroke();
  fill(0, 0, 100, 100);
  textAlign(LEFT, BOTTOM);
  text('YOU', prov.x + 10, prov.y - 4);
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

for-loop Vertical grid lines const vLines = 7; for (let i = 1; i < vLines; i++) { const x = (width / vLines) * i; line(x, 0, x, height); }

Draws 7 equally-spaced vertical lines mimicking longitude gridlines on a weather map

for-loop Horizontal grid lines const hLines = 5; for (let i = 1; i < hLines; i++) { const y = (height / hLines) * i; line(0, y, width, y); }

Draws 5 equally-spaced horizontal lines mimicking latitude gridlines

calculation Providence location marker const prov = lonLatToScreen(-87.76, 37.38); noStroke(); fill(0, 0, 100, 100); ellipse(prov.x, prov.y, 6, 6); stroke(0, 0, 100, 70); noFill(); ellipse(prov.x, prov.y, 18, 18);

Converts lon/lat to screen coordinates and draws a white filled dot with a fainter halo around Providence, KY

rectMode(CORNER);
Sets rectangle drawing mode to start from the top-left corner (x, y) — the default for most rect() calls
fill(210, 40, 12, 80); rect(0, 0, width, height);
Overlays a semi-transparent cyan-blue tint across the entire canvas (80% opacity) to establish the TV-radar look
stroke(210, 20, 40, 35);
Sets grid line color to a dark cyan with very low opacity (35%), so they're subtle and don't overpower the radar echoes
const x = (width / vLines) * i;
Divides the canvas width into equal segments and places vertical lines at each division boundary
line(x, 0, x, height);
Draws a vertical line from top to bottom of the canvas at position x
const y = (height / hLines) * i;
Divides the canvas height into equal segments for horizontal grid placement
line(0, y, width, y);
Draws a horizontal line spanning the full width at height y
stroke(120, 30, 70, 80); rect(2, 2, width - 4, height - 4);
Draws a thin reddish-purple border around the entire map, 2 pixels inset from the edges, framing the radar display
const prov = lonLatToScreen(-87.76, 37.38);
Converts the real-world coordinates of Providence, KY (-87.76°W, 37.38°N) into screen pixel coordinates
fill(0, 0, 100, 100); ellipse(prov.x, prov.y, 6, 6);
Draws a small white (hue 0, full brightness) filled circle marking Providence's exact position
stroke(0, 0, 100, 70); ellipse(prov.x, prov.y, 18, 18);
Draws a larger unfilled circle around Providence with a semi-transparent white stroke, creating a targeting reticle
text('YOU', prov.x + 10, prov.y - 4);
Labels the Providence marker with 'YOU' in white text, positioned slightly to the right and above the marker

drawTornados()

drawTornados() demonstrates animation without a main loop. Even though noLoop() stops automatic redraws, frameCount still increments whenever redraw() is called, allowing the triangle to spin. It also shows push/pop for clean transformations and map() for data-driven sizing.

🔬 The tornado marker uses both a circle and a spinning triangle. What happens if you change the triangle's fill color from (0, 100, 100, 90) to (300, 100, 100, 90)? What happens if you remove the rotate() line entirely?

    // Red warning ring
    noFill();
    stroke(0, 100, 100, 90); // bright red
    strokeWeight(2);
    ellipse(t.x, t.y, r * 2, r * 2);

    // Tornado "funnel" triangle
    push();
    translate(t.x, t.y);
    // Slight rotation that changes with frameCount
    rotate(frameCount * 0.15);
    noStroke();
    fill(0, 100, 100, 90); // red
    const size = r * 0.9;
    triangle(
      -size * 0.4, size * 0.5,
      0,          -size * 0.7,
      size * 0.4, size * 0.5
    );
function drawTornados() {
  for (const t of tornadoes) {
    const r = map(t.strength, 0.8, 1, 16, 28, true);

    // Red warning ring
    noFill();
    stroke(0, 100, 100, 90); // bright red
    strokeWeight(2);
    ellipse(t.x, t.y, r * 2, r * 2);

    // Tornado "funnel" triangle
    push();
    translate(t.x, t.y);
    // Slight rotation that changes with frameCount
    rotate(frameCount * 0.15);
    noStroke();
    fill(0, 100, 100, 90); // red
    const size = r * 0.9;
    triangle(
      -size * 0.4, size * 0.5,
      0,          -size * 0.7,
      size * 0.4, size * 0.5
    );
    pop();

    // Label text
    noStroke();
    fill(0, 0, 100, 100);
    textAlign(LEFT, CENTER);
    text('TORNADO', t.x + r + 6, t.y);
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

calculation Dynamic marker size const r = map(t.strength, 0.8, 1, 16, 28, true);

Maps tornado strength (0.8–1.0) to visual radius (16–28 pixels), making stronger tornadoes visually larger

calculation Red warning circle stroke(0, 100, 100, 90); // bright red strokeWeight(2); ellipse(t.x, t.y, r * 2, r * 2);

Draws a bright red unfilled circle as a warning indicator around each tornado

calculation Spinning funnel symbol push(); translate(t.x, t.y); rotate(frameCount * 0.15); triangle(...); pop();

Draws a triangle centered on the tornado location that spins continuously, creating an animated warning icon

for (const t of tornadoes) {
Loops through each tornado in the global tornadoes array, drawing a marker for each one
const r = map(t.strength, 0.8, 1, 16, 28, true);
Maps the tornado's strength value (0.8 to 1.0) onto a visual radius of 16 to 28 pixels — stronger tornadoes appear larger
stroke(0, 100, 100, 90);
Sets the stroke color to bright red (hue 0, full saturation and brightness) at 90% opacity
ellipse(t.x, t.y, r * 2, r * 2);
Draws a red circle (diameter r*2) centered on the tornado's location as a warning ring
push();
Saves the current drawing state (position, rotation, fill, stroke) so transformations don't affect following shapes
translate(t.x, t.y);
Moves the origin to the tornado's center, so all following drawing happens relative to that point
rotate(frameCount * 0.15);
Rotates the coordinate system by an angle based on frameCount, causing the triangle to spin continuously
const size = r * 0.9;
Scales the triangle size proportionally to the tornado's strength (slightly smaller than the ring radius)
triangle(-size * 0.4, size * 0.5, 0, -size * 0.7, size * 0.4, size * 0.5);
Draws a red filled triangle pointing upward, centered at the origin — the classic tornado funnel shape
pop();
Restores the saved drawing state, so subsequent shapes are no longer rotated or translated
text('TORNADO', t.x + r + 6, t.y);
Labels each tornado with the word 'TORNADO' in white text, positioned to the right of the marker for readability

drawEchoes()

drawEchoes() demonstrates HSB color mapping — a powerful technique for visualizing continuous data. By mapping intensity to hue, saturation, brightness, and alpha separately, we create a layered visual hierarchy where every attribute of color carries information. This is the core of the radar's appearance.

🔬 This multi-branch color ramp maps intensity to hue. What happens if you replace the entire if-else chain with a single line: hue = map(i, 0, 1, 240, 0)? (Hint: that maps intensity directly to hue without branches.) Does it still look like a weather radar?

    // NWS-ish reflectivity color ramp: green → yellow → red
    let hue;
    if (i < 0.25) {
      hue = map(i, 0, 0.25, 140, 120);   // light → medium green
    } else if (i < 0.5) {
      hue = map(i, 0.25, 0.5, 120, 90);  // green → yellow-green
    } else if (i < 0.75) {
      hue = map(i, 0.5, 0.75, 90, 60);   // yellow-green → yellow/orange
    } else {
      hue = map(i, 0.75, 1, 60, 0);      // orange → red
    }
function drawEchoes() {
  rectMode(CENTER);
  noStroke();

  const w = cellW * 1.2;
  const h = cellH * 1.2;

  for (const e of echoes) {
    const i = constrain(e.intensity, 0, 1);

    // NWS-ish reflectivity color ramp: green → yellow → red
    let hue;
    if (i < 0.25) {
      hue = map(i, 0, 0.25, 140, 120);   // light → medium green
    } else if (i < 0.5) {
      hue = map(i, 0.25, 0.5, 120, 90);  // green → yellow-green
    } else if (i < 0.75) {
      hue = map(i, 0.5, 0.75, 90, 60);   // yellow-green → yellow/orange
    } else {
      hue = map(i, 0.75, 1, 60, 0);      // orange → red
    }

    const sat = map(i, 0, 1, 60, 100);
    const bri = map(i, 0, 1, 70, 100);
    const alpha = map(i, 0, 1, 75, 100);

    fill(hue, sat, bri, alpha);
    rect(e.x, e.y, w, h);

    // Extra: hail cores (magenta spots) on very strong reflectivity
    if (i > 0.9) {
      fill(300, 80, 100, 90); // magenta in HSB
      ellipse(e.x, e.y, w * 0.6, h * 0.6);
    }
  }
}
Line-by-line explanation (13 lines)

🔧 Subcomponents:

conditional Reflectivity color mapping if (i < 0.25) { hue = map(i, 0, 0.25, 140, 120); } else if (i < 0.5) { hue = map(i, 0.25, 0.5, 120, 90); } else if (i < 0.75) { hue = map(i, 0.5, 0.75, 90, 60); } else { hue = map(i, 0.75, 1, 60, 0); }

Maps radar intensity (0–1) to a meteorologically realistic hue ramp: green (weak) → red (severe)

calculation Intensity-linked saturation and brightness const sat = map(i, 0, 1, 60, 100); const bri = map(i, 0, 1, 70, 100); const alpha = map(i, 0, 1, 75, 100);

Makes stronger echoes more vivid, brighter, and more opaque — layering visual cues for intensity

conditional Hail core highlights if (i > 0.9) { fill(300, 80, 100, 90); // magenta in HSB ellipse(e.x, e.y, w * 0.6, h * 0.6); }

Adds a small magenta circle inside the strongest echoes to highlight hail cores, a signature of severe thunderstorms

rectMode(CENTER);
Sets rectangle mode to draw from center point, so each echo is centered on its x,y location
const w = cellW * 1.2;
Calculates echo width as 20% larger than the grid cell width, so adjacent echoes slightly overlap and create a cohesive pattern
for (const e of echoes) {
Loops through every echo stored during weather generation and renders it
const i = constrain(e.intensity, 0, 1);
Clamps intensity to the safe range 0–1, protecting against any out-of-bounds data
if (i < 0.25) { hue = map(i, 0, 0.25, 140, 120);
For weak rain (0–25% intensity), maps intensity to hue 140–120°, the green range
} else if (i < 0.5) { hue = map(i, 0.25, 0.5, 120, 90);
For light-moderate rain (25–50%), transitions from medium green (120°) to yellow-green (90°)
} else if (i < 0.75) { hue = map(i, 0.5, 0.75, 90, 60);
For moderate-strong rain (50–75%), transitions from yellow-green (90°) to orange (60°)
} else { hue = map(i, 0.75, 1, 60, 0);
For very strong rain/hail (75–100%), transitions from orange (60°) to red (0°)
const sat = map(i, 0, 1, 60, 100);
Maps intensity to saturation: weak echoes are desaturated (60%), strong echoes are vivid (100%)
const bri = map(i, 0, 1, 70, 100);
Maps intensity to brightness: weak echoes are dimmer (70%), strong echoes are brighter (100%)
const alpha = map(i, 0, 1, 75, 100);
Maps intensity to opacity: weak echoes are semi-transparent (75%), strong echoes are fully opaque (100%)
fill(hue, sat, bri, alpha); rect(e.x, e.y, w, h);
Sets the fill color using the computed hue, saturation, brightness, and alpha, then draws a small rectangle centered on the echo
if (i > 0.9) { fill(300, 80, 100, 90); ellipse(e.x, e.y, w * 0.6, h * 0.6); }
If the echo is extremely strong (>90%), adds a smaller magenta circle inside it to highlight the hail core, a real radar signature of dangerous storms

generateTornadoesFromEchoes()

This function demonstrates intelligent marker placement: filtering data by threshold, probabilistic rarity (the 0.7 random check), and collision avoidance via separation distance. The safety counter pattern is important — it prevents infinite loops when deterministic placement fails. Study this if you want to learn how to place objects intelligently on generated fields.

🔬 The threshold 0.9 filters for only the strongest echoes. What happens if you change it to 0.7? To 0.95? How does this affect which echoes can become tornadoes?

  // Candidate echoes: VERY strong reflectivity
  const candidates = [];
  for (const e of echoes) {
    if (e.intensity > 0.9) { // higher threshold → fewer candidates
      candidates.push(e);
    }
  }
function generateTornadoesFromEchoes() {
  tornadoes = [];

  // Candidate echoes: VERY strong reflectivity
  const candidates = [];
  for (const e of echoes) {
    if (e.intensity > 0.9) { // higher threshold → fewer candidates
      candidates.push(e);
    }
  }

  if (candidates.length === 0) return;

  // Make tornadoes rare: only some scans have any tornado at all
  if (random() < 0.7) return; // ~70% of frames: no tornadoes

  const maxTornadoes = 1; // at most one tornado per frame
  const minSeparation = min(width, height) * 0.12;
  let safety = 0;

  while (tornadoes.length < maxTornadoes && safety < 200 && candidates.length > 0) {
    safety++;
    const pick = random(candidates); // p5.random(array) → random element

    let ok = true;
    for (const t of tornadoes) {
      if (dist(pick.x, pick.y, t.x, t.y) < minSeparation) {
        ok = false;
        break;
      }
    }
    if (ok) {
      tornadoes.push({
        x: pick.x,
        y: pick.y,
        strength: pick.intensity
      });
    }
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

for-loop Strong echo filtering const candidates = []; for (const e of echoes) { if (e.intensity > 0.9) { candidates.push(e); } }

Selects only the very strongest echoes (>0.9 intensity) as potential tornado locations, ensuring tornadoes appear only in the most severe storms

conditional Tornado rarity threshold if (random() < 0.7) return; // ~70% of frames: no tornadoes

Makes tornadoes rare by skipping generation 70% of the time, even when strong echoes exist

while-loop Tornado placement with separation while (tornadoes.length < maxTornadoes && safety < 200 && candidates.length > 0) { safety++; const pick = random(candidates); let ok = true; for (const t of tornadoes) { if (dist(pick.x, pick.y, t.x, t.y) < minSeparation) { ok = false; break; } } if (ok) { tornadoes.push({...}); } }

Repeatedly tries to place tornadoes at random strong echo locations, but skips any that are too close to existing tornadoes

tornadoes = [];
Clears the tornadoes array, starting fresh for this weather generation
const candidates = [];
Creates a temporary array to hold only the strongest echoes suitable for tornado placement
for (const e of echoes) {
Iterates through all generated echoes
if (e.intensity > 0.9) {
Filters for very strong echoes only — the kind that can support tornadoes in real storms
candidates.push(e);
Adds the strong echo to the candidates array for potential tornado placement
if (candidates.length === 0) return;
If no strong echoes exist, exit early — no tornadoes can form
if (random() < 0.7) return; // ~70% of frames: no tornadoes
Generates a random value 0–1; if it's less than 0.7 (~70% chance), exit and create no tornadoes this generation. This makes tornadoes rare even when strong echoes exist.
const maxTornadoes = 1;
Limits each generation to at most 1 tornado, preventing clusters
const minSeparation = min(width, height) * 0.12;
Calculates the minimum distance tornadoes must be apart (12% of the smallest canvas dimension) to avoid overlapping markers
let safety = 0;
Initializes a counter to prevent infinite loops if tornado placement fails repeatedly
while (tornadoes.length < maxTornadoes && safety < 200 && candidates.length > 0) {
Loops while we haven't hit the maximum number of tornadoes, haven't exceeded 200 attempts, and still have candidates to try
const pick = random(candidates);
Selects a random strong echo from the candidates array as a potential tornado location
let ok = true;
Assumes the picked location is valid unless we find a conflict
for (const t of tornadoes) { if (dist(pick.x, pick.y, t.x, t.y) < minSeparation) { ok = false; break; } }
Checks whether the picked location is too close to any existing tornado. If so, marks it as invalid (ok = false) and breaks out of the check loop
if (ok) { tornadoes.push({...}); }
If the location passed validation, adds a new tornado object with position and strength to the global array

smoothstep()

smoothstep() is a classic graphics technique for smooth transitions. Unlike linear interpolation (simple map()), it creates a curve that eases in and out. In this sketch, it's used to fade echoes at the map edges — preventing hard cutoffs.

function smoothstep(edge0, edge1, x) {
  const t = constrain((x - edge0) / (edge1 - edge0), 0, 1);
  return t * t * (3 - 2 * t);
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

calculation Hermite interpolation return t * t * (3 - 2 * t);

Applies the Hermite smoothstep formula to create a smooth S-curve transition instead of linear interpolation

const t = constrain((x - edge0) / (edge1 - edge0), 0, 1);
Normalizes x to a value t between 0 and 1 based on its position between edge0 and edge1, clamped to the safe range
return t * t * (3 - 2 * t);
Applies the Hermite smoothstep formula, which creates a smooth S-curve transition instead of a linear ramp. Values near edges are dampened; values in the middle are steeper.

lonLatToScreen()

lonLatToScreen() demonstrates coordinate system conversion — essential for mapping real-world data (lon/lat) to screen pixels. The key insight is that latitude increases northward (up) but screen y increases downward, so the ranges are reversed.

function lonLatToScreen(lon, lat) {
  const x = map(lon, LON_MIN, LON_MAX, 0, width);
  const y = map(lat, LAT_MAX, LAT_MIN, 0, height); // lat up = smaller y
  return { x, y };
}
Line-by-line explanation (3 lines)
const x = map(lon, LON_MIN, LON_MAX, 0, width);
Maps longitude (-92 to -84°) to screen x-coordinates (0 to canvas width)
const y = map(lat, LAT_MAX, LAT_MIN, 0, height);
Maps latitude (39.5 to 35°) to screen y-coordinates, reversing the range because screen y increases downward but latitude increases northward
return { x, y };
Returns an object with the converted screen coordinates for easy destructuring or property access

mousePressed()

mousePressed() is p5.js's built-in callback for mouse clicks. Paired with noLoop(), it makes the sketch event-driven — nothing happens until the user acts.

function mousePressed() {
  generateWeather();
  redraw(); // re-render once
}
Line-by-line explanation (2 lines)
generateWeather();
Regenerates the entire weather field with new random echoes and tornadoes
redraw(); // re-render once
Calls draw() a single time to render the new weather. Without this, nothing would appear until the next event.

touchStarted()

touchStarted() is the mobile equivalent of mousePressed(). It ensures the sketch works on phones and tablets by handling touch events.

function touchStarted() {
  generateWeather();
  redraw(); // re-render once
  return false; // prevent scrolling on mobile
}
Line-by-line explanation (3 lines)
generateWeather();
Regenerates the weather field in response to a touch event
redraw();
Renders the new weather once
return false; // prevent scrolling on mobile
Returning false prevents the default touch behavior (page scroll), keeping the touch focused on the sketch

windowResized()

windowResized() is a p5.js callback triggered when the browser window is resized. It keeps the sketch responsive — the radar scales and regenerates automatically on resize.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  generateWeather();
  redraw();
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
Adjusts the canvas size to match the new window dimensions when the browser is resized
generateWeather();
Regenerates the weather field to match the new canvas size
redraw();
Renders the resized radar once

drawHUD()

drawHUD() is a placeholder function, intentionally empty. It's a great example of defensive coding — it leaves room to add UI elements (title, warnings, data readouts) in the future without reorganizing the draw() layer stack.

function drawHUD() {
  // Intentionally left blank — no extra HUD text or bars
}

📦 Key Variables

echoes array of objects

Stores all rain/hail radar echoes as {x, y, intensity}. Each entry represents one pulse of reflectivity on the radar.

let echoes = [];
tornadoes array of objects

Stores tornado marker locations and strength values as {x, y, strength}. Updated dynamically during weather generation.

let tornadoes = [];
gridCols number

Number of grid columns for the radar field resolution. Calculated based on canvas width and target cell size.

let gridCols;
gridRows number

Number of grid rows for the radar field resolution. Calculated based on canvas height and target cell size.

let gridRows;
cellW number

Width of a single grid cell in pixels. Calculated as width / gridCols.

let cellW;
cellH number

Height of a single grid cell in pixels. Calculated as height / gridRows.

let cellH;
LON_MIN number

Western boundary (minimum longitude) of the map region in degrees. Used for coordinate conversion.

const LON_MIN = -92.0;
LON_MAX number

Eastern boundary (maximum longitude) of the map region in degrees.

const LON_MAX = -84.0;
LAT_MIN number

Southern boundary (minimum latitude) of the map region in degrees.

const LAT_MIN = 35.0;
LAT_MAX number

Northern boundary (maximum latitude) of the map region in degrees.

const LAT_MAX = 39.5;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG generateWeather()

Drizzle loop has typo: `for (let i = 0; i < drizzleCount; i++) ++)` has an extra ++

💡 Remove the extra `+)` — should be `for (let i = 0; i < drizzleCount; i++)`. This may prevent the loop from running.

PERFORMANCE generateWeather() grid loop

Every grid cell computes cos/sin per cell, but they're already precomputed as cosF/sinF and never change

💡 The current code is actually optimized (cos/sin are precomputed), but could benefit from using typed arrays (Float32Array) for the echoes list if dealing with very large datasets

STYLE generateTornadoesFromEchoes()

The safety counter and candidates array copying pattern is verbose and could be simplified

💡 Consider using p5.random() directly on the candidates array with a counter, or shuffle the array once instead of checking distances repeatedly

FEATURE drawEchoes()

Hail cores are only magenta at intensity > 0.9, but real radars often use multiple hail signatures (e.g., probability of hail)

💡 Add another visual indicator (size variation, texture) for moderate hail likelihood (intensity 0.8–0.9) to match real NWS radar presentations

🔄 Code Flow

Code flow showing setup, draw, generateweather, drawbasemap, drawtornados, drawechoes, generatetornadoesfromechoes, smoothstep, lonlattoscreen, mousepressed, touchstarted, windowresized, drawhud

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> drawbasemap[drawBaseMap] draw --> drawechoes[drawEchoes] draw --> drawtornados[drawTornados] draw --> drawhud[drawHUD] click setup href "#fn-setup" click draw href "#fn-draw" click drawbasemap href "#fn-drawbasemap" click drawechoes href "#fn-drawechoes" click drawtornados href "#fn-drawtornados" click drawhud href "#fn-drawhud" setup --> canvas-creation[canvas-creation] setup --> hsb-mode[hsb-mode] setup --> no-loop-init[no-loop-init] setup --> background-color[background-color] click canvas-creation href "#sub-canvas-creation" click hsb-mode href "#sub-hsb-mode" click no-loop-init href "#sub-no-loop-init" click background-color href "#sub-background-color" draw --> layer-calls[layer-calls] layer-calls --> grid-init[grid-init] layer-calls --> main-grid-loop[main-grid-loop] layer-calls --> drizzle-loop[drizzle-loop] click layer-calls href "#sub-layer-calls" click grid-init href "#sub-grid-init" click main-grid-loop href "#sub-main-grid-loop" click drizzle-loop href "#sub-drizzle-loop" main-grid-loop --> rotation-math[rotation-math] main-grid-loop --> candidate-filter[candidate-filter] candidate-filter --> rarity-check[rarity-check] rarity-check --> separation-loop[separation-loop] click rotation-math href "#sub-rotation-math" click candidate-filter href "#sub-candidate-filter" click rarity-check href "#sub-rarity-check" click separation-loop href "#sub-separation-loop" drawbasemap --> grid-v-loop[grid-v-loop] drawbasemap --> grid-h-loop[grid-h-loop] drawbasemap --> prov-marker[prov-marker] click grid-v-loop href "#sub-grid-v-loop" click grid-h-loop href "#sub-grid-h-loop" click prov-marker href "#sub-prov-marker" drawechoes --> color-ramp[color-ramp] drawechoes --> saturation-brightness-map[saturation-brightness-map] drawechoes --> hail-marker[hail-marker] click color-ramp href "#sub-color-ramp" click saturation-brightness-map href "#sub-saturation-brightness-map" click hail-marker href "#sub-hail-marker" drawtornados --> strength-map[strength-map] drawtornados --> warning-ring[warning-ring] drawtornados --> rotating-triangle[rotating-triangle] click strength-map href "#sub-strength-map" click warning-ring href "#sub-warning-ring" click rotating-triangle href "#sub-rotating-triangle" mousepressed --> draw touchstarted --> draw windowresized --> draw click mousepressed href "#fn-mousepressed" click touchstarted href "#fn-touchstarted" click windowresized href "#fn-windowresized"

❓ Frequently Asked Questions

What visual elements are present in the p5.js storm chasing project?

The sketch features a full-screen radar map with a dark bluish background, a squall line of storms oriented SW to NE, and markers indicating tornadoes and hail.

How can users interact with the storm chasing sketch?

Users can tap or click anywhere on the canvas to regenerate the weather patterns, creating a new radar visualization each time.

What creative coding concepts are showcased in the p5.js radar map project?

The project demonstrates techniques such as procedural generation for weather patterns and dynamic rendering of visual elements based on user input.

Preview

p5 storm chasing project - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of p5 storm chasing project - Code flow showing setup, draw, generateweather, drawbasemap, drawtornados, drawechoes, generatetornadoesfromechoes, smoothstep, lonlattoscreen, mousepressed, touchstarted, windowresized, drawhud
Code Flow Diagram