<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ canvas - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ canvas - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 29 Oct 2026 15:37:02 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/canvas/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Disaster Simulations on a Real Map: Animated Canvas Layers and Collapsing 3D Buildings with Mapbox GL JS and Three.js ]]>
                </title>
                <description>
                    <![CDATA[ At some point, a map project needs more than markers and polygons. You want something to happen on the map: water moving up a street, smoke drifting with the wind, or a shockwave spreading out from a  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/disaster-simulation-real-map-mapbox-three-js/</link>
                <guid isPermaLink="false">6ad05935fb11435ce8281161</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mapbox ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Oliinyk ]]>
                </dc:creator>
                <pubDate>Thu, 15 Oct 2026 04:40:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/db541c6a-aba6-4a25-b0f8-2a4657c485d3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>At some point, a map project needs more than markers and polygons. You want something to happen on the map: water moving up a street, smoke drifting with the wind, or a shockwave spreading out from a point.</p>
<p>The first time I tried this, I did what seemed obvious to me. I put a <code>&lt;canvas&gt;</code> on top of the map and drew my animation on it.</p>
<p>But it looked wrong in a way I couldn't fix with more polish. The effect had edges. It floated above the ground. And as soon as I tilted or rotated the map, it stopped lining up with anything underneath.</p>
<p>I hit this while building <a href="https://hazardsim.com/">HazardSim</a>, a browser simulator that runs natural disasters on a real map. The tsunami scenario went through about a dozen rewrites before it stopped looking like a cut-out pasted over the city.</p>
<p>Much of the testing, and a good share of the bug reports, came from my 12-year-old son Tim, who turned out to be a more patient tester than I am. Most of those rewrites taught me the same thing from a different direction: <strong>draw your simulation in map coordinates, not screen coordinates.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/a7c17a37-4c30-48a9-be44-89da78f6c14f.jpg" alt="Left: a video clip of a breaking wave drawn on a canvas overlaid on the map. It is a rectangle floating over Odesa with visible soft edges. Right: the same second of the simulation with the water painted into a canvas that the map treats as a raster source. The water follows the coastline and the tilt of the camera." style="display: block;" width="2238" height="878" loading="lazy">

<p>That rule carried me through the tsunami. Then I started on the earthquake and ran into the opposite problem. Mapbox GL JS will draw a city in 3D for you: turn on a <code>fill-extrusion</code> layer and every footprint in the vector tiles rises to its real height.</p>
<p>It looks good until you try to make one of those buildings fall down, and you find out that the map's 3D is a rendering trick, not a scene. There are no objects to grab, rotate or break. Things that lie on the ground and things that have height turned out to need two different techniques, and this guide covers both.</p>
<p><strong>Part 1</strong> builds the ground layer. You'll set up a grid aligned to the map's own tiles, load real elevation data into it, run a flood fill that respects hills and valleys, hand the canvas to the map as a raster source, write pixels into it every frame, layer video textures on top with canvas compositing modes, and move the camera into the scene.</p>
<p><strong>Part 2</strong> adds height. You'll pull building footprints and heights out of the map's tiles, make the extrusions look like buildings, decide which ones fall from the magnitude and distance, animate the collapse without rebuilding any layers, set up a <code>three.js</code> custom layer inside the map's own WebGL context, turn a footprint into a box that tips over, throw debris and dust, and shake the camera.</p>
<p>At the end we'll go through the mistakes from both halves, several of which produce no error message and cost me a day each.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with JavaScript, <code>requestAnimationFrame</code>, and the basics of the 2D canvas API: <code>getContext("2d")</code>, <code>drawImage</code>, and <code>ImageData</code>.</p>
<p>You don't need any background in web maps. Everything map-specific is explained where it comes up.</p>
<p>Part 2 also assumes you've used <code>three.js</code> at least once: meshes, materials, a scene and a camera. If you haven't, the three.js fundamentals are enough to follow along, and the parts that interact with the map are explained in full.</p>
<p>The examples use <a href="https://docs.mapbox.com/mapbox-gl-js/">Mapbox GL JS</a>, which is what runs on HazardSim. You'll need a free Mapbox access token for the base map and the vector tiles. Every API used here also exists in <a href="https://maplibre.org/">MapLibre GL JS</a>, the open-source fork, under the same names. The one place the two differ is called out in Part 2.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-part-1-drawing-on-the-ground">Part 1: Drawing on the Ground</a></p>
</li>
<li><p><a href="#heading-the-core-idea-map-space-not-screen-space">The Core Idea: Map Space, Not Screen Space</a></p>
</li>
<li><p><a href="#heading-step-1-choose-a-grid-aligned-to-map-tiles">Step 1: Choose a Grid Aligned to Map Tiles</a></p>
</li>
<li><p><a href="#heading-step-2-load-real-elevation-into-the-grid">Step 2: Load Real Elevation Into the Grid</a></p>
</li>
<li><p><a href="#heading-step-3-attach-a-canvas-as-a-map-source">Step 3: Attach a Canvas as a Map Source</a></p>
</li>
<li><p><a href="#heading-step-4-simulate-a-flood-that-respects-the-terrain">Step 4: Simulate a Flood That Respects the Terrain</a></p>
</li>
<li><p><a href="#heading-step-5-animate-by-writing-pixels">Step 5: Animate by Writing Pixels</a></p>
</li>
<li><p><a href="#heading-step-6-layer-video-textures-with-canvas-compositing">Step 6: Layer Video Textures With Canvas Compositing</a></p>
</li>
<li><p><a href="#heading-step-7-move-the-camera-into-the-scene">Step 7: Move the Camera Into the Scene</a></p>
</li>
<li><p><a href="#heading-where-the-ground-layer-stops">Where the Ground Layer Stops</a></p>
</li>
<li><p><a href="#heading-part-2-things-with-height">Part 2: Things with Height</a></p>
</li>
<li><p><a href="#heading-two-kinds-of-3d-and-why-you-need-both">Two Kinds of 3D, and Why You Need Both</a></p>
</li>
<li><p><a href="#heading-step-8-get-buildings-out-of-the-maps-own-tiles">Step 8: Get Buildings Out of the Map's Own Tiles</a></p>
</li>
<li><p><a href="#heading-step-9-make-extrusions-look-like-buildings">Step 9: Make Extrusions Look Like Buildings</a></p>
</li>
<li><p><a href="#heading-step-10-decide-what-falls">Step 10: Decide What Falls</a></p>
</li>
<li><p><a href="#heading-step-11-animate-collapse-without-rebuilding-layers">Step 11: Animate Collapse Without Rebuilding Layers</a></p>
</li>
<li><p><a href="#heading-step-12-a-threejs-scene-inside-the-map">Step 12: A three.js Scene Inside the Map</a></p>
</li>
<li><p><a href="#heading-step-13-from-footprint-to-a-box-that-tips-over">Step 13: From Footprint to a Box That Tips Over</a></p>
</li>
<li><p><a href="#heading-step-14-debris-and-dust">Step 14: Debris and Dust</a></p>
</li>
<li><p><a href="#heading-step-15-the-camera-has-to-shake">Step 15: The Camera Has to Shake</a></p>
</li>
<li><p><a href="#heading-keeping-it-fast">Keeping It Fast</a></p>
</li>
<li><p><a href="#heading-what-went-wrong-and-why">What Went Wrong, and Why</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-part-1-drawing-on-the-ground">Part 1: Drawing on the Ground</h2>
<p>The first half of this guide is about anything that belongs to the surface of the map: water, fire spread, a smoke stain, or a shockwave. The technique is one idea applied carefully, and the next seven steps build it up from a grid to a camera move.</p>
<h2 id="heading-the-core-idea-map-space-not-screen-space">The Core Idea: Map Space, Not Screen Space</h2>
<p>A web map is a camera looking at a surface. When the user drags, zooms, tilts or rotates, the camera moves and the surface stays where it is. Roads, buildings, and your flood all belong to the surface, so the camera has to transform them together.</p>
<p>A canvas placed over the map's DOM element is attached to the camera, not to the surface. Think of it as a sheet of glass in front of the lens. Whatever you draw on it stays where you drew it while the world moves underneath.</p>
<p>At a top-down zoom with no rotation, you can fake alignment by reprojecting your points every frame, and it almost works. Add pitch and it falls apart: a flat sprite has no perspective, no foreshortening, and nothing in front of or behind it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/fbcc5755-b979-4e21-86f4-79f16e27bbc9.png" alt="Two diagrams side by side. Left, titled &quot;Canvas on top of the map&quot;: a tilted map surface with a flat blue rectangle standing upright over it, labelled &quot;flat sprite&quot;, with the note &quot;no perspective, visible edges, doesn't rotate with the map&quot;. Right, titled &quot;Canvas as a map source&quot;: the same tilted surface with the blue area lying flat on it as a trapezoid, labelled &quot;same pixels, foreshortened&quot;, with the note &quot;perspective for free, no edges, rotates, tilts, follows terrain&quot;." style="display: block;" width="2960" height="1240" loading="lazy">

<p>The fix is to give your canvas to the map as a data source. Mapbox GL JS has a <code>canvas</code> source type. You pass it a canvas element and four geographic corners, and from then on the map drapes that canvas over the rectangle the same way it drapes satellite imagery. Rotate the map and the canvas rotates. Tilt it and the canvas foreshortens. Turn on 3D terrain and the canvas follows the hills.</p>
<p>Once the drawing surface lives in map space, several hard problems disappear. The rest of this handbook is about what you can do with that.</p>
<h2 id="heading-step-1-choose-a-grid-aligned-to-map-tiles">Step 1: Choose a Grid Aligned to Map Tiles</h2>
<p>Your simulation needs a grid: a 2D array of cells, each holding elevation, water depth, or whatever your model tracks. The decision that makes everything downstream simpler is to align that grid to the map's own tile pyramid.</p>
<p>Web maps use the Web Mercator projection, cut into 256×256-pixel tiles at zoom levels 0 to 22. At zoom <code>z</code> the world is <code>2^z</code> tiles wide. If one grid cell is exactly one tile pixel at some zoom, converting between grid indices and longitude/latitude takes four short functions, and every dataset that ships as tiles (elevation, population, imagery) drops into your grid without resampling.</p>
<p>Here are the four conversions. <code>lon2x</code> and <code>lat2y</code> return fractional tile coordinates. Multiply by 256 to get pixels.</p>
<pre><code class="language-javascript">function lon2x(lon, z) {
  return (lon + 180) / 360 * Math.pow(2, z);
}

function lat2y(lat, z) {
  const rad = lat * Math.PI / 180;
  return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, z);
}

function x2lon(x, z) {
  return x / Math.pow(2, z) * 360 - 180;
}

function y2lat(y, z) {
  const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
  return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
</code></pre>
<p>Next, pick the zoom. You want the area of interest to fit in a grid that's fine enough to look smooth but small enough to iterate over sixty times a second. In HazardSim, I start at zoom 15 and step down until the bounding box fits in roughly 780 pixels across and under about 620,000 cells:</p>
<pre><code class="language-javascript">function chooseZoom(bbox) {
  for (let z = 15; z &gt;= 8; z--) {
    const pw = (lon2x(bbox.e, z) - lon2x(bbox.w, z)) * 256;
    const ph = (lat2y(bbox.s, z) - lat2y(bbox.n, z)) * 256;
    if (pw &lt;= 780 &amp;&amp; pw * ph &lt;= 620000) return z;
  }
  return 8;
}
</code></pre>
<p>With the zoom chosen, snap the bounding box outward to whole tiles. That rectangle is what your canvas will cover, and those are the tiles you'll download:</p>
<pre><code class="language-javascript">function gridFor(bbox) {
  const z = chooseZoom(bbox);
  const x0 = Math.floor(lon2x(bbox.w, z)), x1 = Math.floor(lon2x(bbox.e, z));
  const y0 = Math.floor(lat2y(bbox.n, z)), y1 = Math.floor(lat2y(bbox.s, z));
  const nx = x1 - x0 + 1, ny = y1 - y0 + 1;
  const latMid = (bbox.n + bbox.s) / 2;

  return {
    z, x0, y0, nx, ny,
    W: nx * 256,
    H: ny * 256,
    // metres per cell at this latitude and zoom
    cellM: 156543.03392 * Math.cos(latMid * Math.PI / 180) / Math.pow(2, z),
    // exact geographic corners of the snapped rectangle
    corners: {
      w: x2lon(x0, z), e: x2lon(x1 + 1, z),
      n: y2lat(y0, z), s: y2lat(y1 + 1, z),
    },
  };
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/2b47daac-24cc-42e9-b6f0-fb110bb13aa8.png" alt="Diagram of a 4 by 3 arrangement of map tiles with orange tile borders. A dashed blue rectangle inside marks the city bounding box. One tile is subdivided into small cells to show that one grid cell is one tile pixel. A side panel lists why aligning to tiles helps and shows the conversion formulas." style="display: block;" width="2800" height="1400" loading="lazy">

<p>Keep <code>cellM</code> around. You'll need it to turn "the wave has travelled 400 metres" into "the wave has travelled 3.8 cells". For Odesa, the city in most of the screenshots here, the grid ends up at zoom 10 with 105-metre cells.</p>
<h2 id="heading-step-2-load-real-elevation-into-the-grid">Step 2: Load Real Elevation Into the Grid</h2>
<p>A flood that ignores hills is a blue blob. What makes a simulation on a real map feel real is real terrain, and terrain is free.</p>
<p>The AWS Open Data program hosts the Mapzen <strong>Terrarium</strong> elevation tiles. They're ordinary PNGs with height encoded in the pixel colour:</p>
<pre><code class="language-javascript">height_m = R * 256 + G + B / 256 - 32768
</code></pre>
<p>Because the grid is tile-aligned, loading elevation means fetching the tiles, drawing them onto one big canvas in their natural positions, reading the pixels back, and decoding. Nothing is resampled. The grid cell and the elevation sample are the same pixel.</p>
<pre><code class="language-javascript">function terrariumUrl(z, x, y) {
  return `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`;
}

function loadImage(url) {
  return new Promise((resolve, reject) =&gt; {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () =&gt; resolve(img);
    img.onerror = reject;
    img.src = url;
  });
}

async function loadElevation(grid) {
  const cv = document.createElement("canvas");
  cv.width = grid.W;
  cv.height = grid.H;
  const cx = cv.getContext("2d", { willReadFrequently: true });

  const jobs = [];
  for (let iy = 0; iy &lt; grid.ny; iy++) {
    for (let ix = 0; ix &lt; grid.nx; ix++) {
      jobs.push(
        loadImage(terrariumUrl(grid.z, grid.x0 + ix, grid.y0 + iy))
          .then(img =&gt; cx.drawImage(img, ix * 256, iy * 256))
      );
    }
  }
  await Promise.all(jobs);

  const d = cx.getImageData(0, 0, grid.W, grid.H).data;
  const elev = new Float32Array(grid.W * grid.H);
  for (let i = 0; i &lt; elev.length; i++) {
    const o = i * 4;
    elev[i] = d[o] * 256 + d[o + 1] + d[o + 2] / 256 - 32768;
  }
  return elev;
}
</code></pre>
<p><code>crossOrigin = "anonymous"</code> is required. Without it the canvas becomes "tainted" and <code>getImageData</code> throws. The Terrarium bucket sends the right CORS headers, so this works as written.</p>
<p>Since you already have a Mapbox token, you can use Mapbox's own <strong>Terrain-RGB</strong> tiles instead. The URL is <code>https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=…</code> and the decode formula is different:</p>
<pre><code class="language-javascript">// Mapbox Terrain-RGB
elev[i] = -10000 + (d[o] * 65536 + d[o + 1] * 256 + d[o + 2]) * 0.1;
</code></pre>
<p>HazardSim supports both and switches with a config flag. Terrain-RGB is cleaner data with fewer spikes. Terrarium is free of per-tile billing. For a tutorial, I'd start with Terrarium.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/e8f721b6-ff2d-44b6-9bb0-3c0830ada2ba.png" alt="Decoded elevation for the coast around Odesa rendered as a greyscale heightmap, with the sea in dark blue and higher ground lighter. Orange lines show the boundaries of the 4 by 4 Terrarium tiles that were stitched together." style="display: block;" width="1024" height="1090" loading="lazy">

<p>Two practical notes. Terrarium data has occasional single-pixel spikes, and a 3×3 median pass over cells that differ from all their neighbours by more than a few metres cleans them up. And cells at or below zero are sea, except where they aren't, like in the Netherlands, New Orleans, or Death Valley. I treat <code>elev &lt;= 0</code> as sea only when the cell connects to the map edge through other sea cells. That handles the common cases.</p>
<h2 id="heading-step-3-attach-a-canvas-as-a-map-source">Step 3: Attach a Canvas as a Map Source</h2>
<p>Now the central move. Create a canvas the size of your grid and register it with the map as a <code>canvas</code> source whose four corners are the snapped rectangle from Step 1.</p>
<pre><code class="language-html">&lt;div id="map"&gt;&lt;/div&gt;
&lt;canvas id="floodcanvas" width="10" height="10" style="display:none"&gt;&lt;/canvas&gt;
</code></pre>
<pre><code class="language-javascript">mapboxgl.accessToken = "YOUR_MAPBOX_TOKEN";

const map = new mapboxgl.Map({
  container: "map",
  style: "mapbox://styles/mapbox/satellite-streets-v12",
  center: [30.73, 46.48],
  zoom: 11,
});

const fc = document.getElementById("floodcanvas");
const fctx = fc.getContext("2d");
let fimg = null;

function attachCanvas(grid) {
  fc.width = grid.W;
  fc.height = grid.H;
  fimg = fctx.createImageData(grid.W, grid.H);

  const c = grid.corners;
  const coords = [[c.w, c.n], [c.e, c.n], [c.e, c.s], [c.w, c.s]];

  if (map.getSource("flood")) {
    map.getSource("flood").setCoordinates(coords);
    return;
  }
  map.addSource("flood", {
    type: "canvas",
    canvas: "floodcanvas",
    coordinates: coords,
    animate: true,
  });
  map.addLayer({
    id: "flood",
    type: "raster",
    source: "flood",
    paint: {
      "raster-opacity": 0.94,
      "raster-resampling": "linear",
      "raster-fade-duration": 0,
    },
  });
}
</code></pre>
<p>Three options matter here:</p>
<ul>
<li><p><code>animate: true</code> tells Mapbox GL to re-upload the canvas as a texture every frame. Without it, the animation freezes on the first frame.</p>
</li>
<li><p><code>"raster-resampling": "linear"</code> blends between cells when the user zooms in past your grid resolution. With 105-metre cells at a city zoom, it's the difference between water and a pixel-art game.</p>
</li>
<li><p><code>"raster-fade-duration": 0</code> turns off the cross-fade Mapbox GL applies to raster tiles. You're redrawing every frame, and a fade only smears it.</p>
</li>
</ul>
<p>The canvas is <code>display:none</code> because the map reads it directly. It never needs to be visible in the DOM.</p>
<p>From here on, anything you write into <code>fimg</code> and push with <code>fctx.putImageData(fimg, 0, 0)</code> shows up on the map in the right place, at any pitch and bearing, draped over terrain if terrain is on. The rendering problem has turned into a pixel-array problem.</p>
<h2 id="heading-step-4-simulate-a-flood-that-respects-the-terrain">Step 4: Simulate a Flood That Respects the Terrain</h2>
<p>A tsunami doesn't flood everything below the wave height. Water has to get there. It enters from the sea, flows over low ground, and stops where the ground rises above the water level, even if there's lower ground on the far side of a ridge. That's a connectivity problem, and breadth-first search is the tool for it.</p>
<p>First, a distance field: how far is each cell from the coast? Seed the queue with every sea cell adjacent to land, then expand into the sea. You'll use this to animate the wave approaching, and the land-side equivalent to animate it spreading inland.</p>
<pre><code class="language-javascript">function seaDistance(grid, sea) {
  const { W, H, cellM } = grid;
  const N = W * H;
  const dist = new Float32Array(N).fill(-1);
  const q = new Int32Array(N);
  let head = 0, tail = 0;

  // seed: sea cells that touch land
  for (let i = 0; i &lt; N; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const touchesLand =
      (x &gt; 0 &amp;&amp; !sea[i - 1]) || (x &lt; W - 1 &amp;&amp; !sea[i + 1]) ||
      (y &gt; 0 &amp;&amp; !sea[i - W]) || (y &lt; H - 1 &amp;&amp; !sea[i + W]);
    if (touchesLand) { dist[i] = 0; q[tail++] = i; }
  }

  // expand
  while (head &lt; tail) {
    const c = q[head++];
    const cx = c % W, cy = (c / W) | 0;
    for (let dy = -1; dy &lt;= 1; dy++) {
      for (let dx = -1; dx &lt;= 1; dx++) {
        if (!dx &amp;&amp; !dy) continue;
        const nx = cx + dx, ny = cy + dy;
        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= W || ny &gt;= H) continue;
        const n = ny * W + nx;
        if (!sea[n] || dist[n] &gt;= 0) continue;
        dist[n] = dist[c] + (dx &amp;&amp; dy ? cellM * Math.SQRT2 : cellM);
        q[tail++] = n;
      }
    }
  }
  return dist;
}
</code></pre>
<p>The flood is the same search in the other direction, with one extra rule: a cell is reachable only if the water surface is above its ground. <code>waveH</code> is the wave height at the shore in metres. The water loses height as it travels inland, and a linear decay is enough to look right:</p>
<pre><code class="language-javascript">function floodFill(grid, elev, sea, waveH) {
  const { W, H, cellM } = grid;
  const N = W * H;
  const depth = new Float32Array(N);       // metres of water above ground
  const reach = new Float32Array(N).fill(-1); // distance from shore, metres
  const q = new Int32Array(N);
  let head = 0, tail = 0;

  for (let i = 0; i &lt; N; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const nb = [i - 1, i + 1, i - W, i + W];
    for (const n of nb) {
      if (n &lt; 0 || n &gt;= N || sea[n] || reach[n] &gt;= 0) continue;
      if ((n === i - 1 &amp;&amp; x === 0) || (n === i + 1 &amp;&amp; x === W - 1)) continue;
      reach[n] = 0;
      q[tail++] = n;
    }
  }

  const decayPerM = waveH / 12000; // loses its height over ~12 km

  while (head &lt; tail) {
    const c = q[head++];
    const level = waveH - reach[c] * decayPerM;
    const d = level - elev[c];
    if (d &lt;= 0) continue;               // ground is above the water here: stop
    depth[c] = d;

    const cx = c % W, cy = (c / W) | 0;
    for (let dy = -1; dy &lt;= 1; dy++) {
      for (let dx = -1; dx &lt;= 1; dx++) {
        if (!dx &amp;&amp; !dy) continue;
        const nx = cx + dx, ny = cy + dy;
        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= W || ny &gt;= H) continue;
        const n = ny * W + nx;
        if (sea[n] || reach[n] &gt;= 0) continue;
        reach[n] = reach[c] + (dx &amp;&amp; dy ? cellM * Math.SQRT2 : cellM);
        q[tail++] = n;
      }
    }
  }
  return { depth, reach };
}
</code></pre>
<p>Run this once per scenario, not per frame. On a 700×500 grid it takes a few milliseconds. The outputs, <code>depth</code> for every cell and <code>reach</code> for how far inland it is, are all the animation needs.</p>
<p>Because the fill follows real elevation, it goes up river valleys and stops at embankments. Multiply the flooded cell count by <code>cellM²</code> and you get a flooded area in square kilometres you can stand behind.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/171566ef-f912-4a90-894f-ae4adda8e252.jpg" alt="Top-down view of Odesa after the simulated tsunami. Dark brown water covers the low coastal districts and the port, following the shape of the coastline and stopping at higher ground inland. The open sea stays its normal dark blue." style="display: block;" width="1095" height="790" loading="lazy">

<h2 id="heading-step-5-animate-by-writing-pixels">Step 5: Animate by Writing Pixels</h2>
<p>The frame loop does one thing. For each flooded land cell, it decides whether the water has arrived yet, and if so, what colour it is.</p>
<p>"Arrived" is a comparison of the cell's <code>reach</code> against a front position <code>F</code> that grows with time. Add some per-cell noise to <code>reach</code> so the front isn't a perfect contour line, and the water starts to look like water:</p>
<pre><code class="language-javascript">let t0 = null;
const T_LANDFALL = 3.9;   // seconds until the wave reaches the shore
const T_FULL = 10.9;      // seconds until it reaches its maximum extent

function ease(k) {
  return k &lt;= 0 ? 0 : k &gt;= 1 ? 1 : 1 - Math.pow(1 - k, 2.2);
}

function frame(now) {
  if (t0 === null) t0 = now;
  const t = (now - t0) / 1000;
  const d = fimg.data;
  const { W, H } = grid;

  // how far inland the front has got, in metres
  const F = t &lt; T_LANDFALL
    ? -1e9
    : maxReach * ease((t - T_LANDFALL) / (T_FULL - T_LANDFALL));

  for (let i = 0; i &lt; W * H; i++) {
    const o = i * 4;
    if (sea[i] || depth[i] &lt;= 0) { d[o + 3] = 0; continue; }

    const arrival = reach[i] + noise[i] * 180;   // ±90 m of jitter
    if (arrival &gt; F) { d[o + 3] = 0; continue; }

    const age = (F - arrival) / 150;             // how long the cell has been under
    const dp = depth[i];

    // base colour: shallow is lighter, deep is darker
    let r = 40 + Math.max(0, 60 - dp * 10);
    let g = 70 + Math.max(0, 50 - dp * 8);
    let b = 95 + Math.max(0, 40 - dp * 6);
    let a = dp &lt; 0.5 ? 215 + dp * 60 : 245;

    // the leading edge is foam: push toward white for the first moments
    if (age &lt; 1.4) {
      const k = Math.pow(1 - age / 1.4, 2) * 0.8;
      r += (240 - r) * k; g += (246 - g) * k; b += (246 - b) * k;
    }

    d[o] = r; d[o + 1] = g; d[o + 2] = b; d[o + 3] = a;
  }

  fctx.putImageData(fimg, 0, 0);
  if (t &lt; T_FULL + 3) requestAnimationFrame(frame);
}

requestAnimationFrame(frame);
</code></pre>
<p><code>noise</code> is a <code>Float32Array</code> of random values in <code>[-0.5, 0.5]</code> generated once per run. <code>maxReach</code> is the largest <code>reach</code> value among flooded cells.</p>
<p>There are two performance points: A 700×500 grid is 350,000 cells, and this loop runs at 60 fps on my laptop and around 30 fps on a mid-range phone. If you need more, iterate only over the list of flooded land cells (collect the indices once after <code>floodFill</code>) instead of the whole grid. In a coastal city, that's usually a quarter of the cells. And keep <code>fimg</code> between frames. Calling <code>createImageData</code> every frame is the first thing a profiler will point at.</p>
<p>If you stop here, you have a working, terrain-aware simulation. The rest is about making it look like footage instead of a diagram.</p>
<h2 id="heading-step-6-layer-video-textures-with-canvas-compositing">Step 6: Layer Video Textures With Canvas Compositing</h2>
<p>Flat colour reads as a diagram. Real water has texture that moves. The cheapest way to get that is to drape a looping video of real water over the flooded area, and the restriction "over the flooded area only" comes free from two canvas compositing modes:</p>
<ul>
<li><p><code>source-atop</code> draws the new image only where the destination already has pixels. Draw a video onto the flood canvas with <code>source-atop</code> and it appears on water only. Dry land stays untouched, because dry land is transparent.</p>
</li>
<li><p><code>destination-in</code> keeps the destination only where the new image has pixels. Draw a black-and-transparent mask onto a buffer with <code>destination-in</code>, and the buffer is cropped to the mask's shape.</p>
</li>
</ul>
<p>You need a hidden <code>&lt;video&gt;</code> (muted, looping, and <code>playsInline</code>. You need all three, or mobile Safari refuses to autoplay) and one scratch canvas the size of the grid:</p>
<pre><code class="language-javascript">const vid = document.createElement("video");
vid.muted = true; vid.loop = true; vid.playsInline = true;
vid.setAttribute("muted", ""); vid.setAttribute("playsinline", "");
vid.src = "/fx/water.webm";
vid.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none";
document.body.appendChild(vid);
vid.load();

const buf = document.createElement("canvas");
buf.width = grid.W; buf.height = grid.H;
const bctx = buf.getContext("2d");

function drawWaterTexture(mud) {
  if (vid.readyState &lt; 2) return;         // not enough data yet: skip, don't block
  if (vid.paused) vid.play().catch(() =&gt; {});

  // 1. video into the scratch buffer, stretched to the grid
  bctx.globalCompositeOperation = "source-over";
  bctx.clearRect(0, 0, grid.W, grid.H);
  bctx.drawImage(vid, 0, 0, grid.W, grid.H);

  // 2. tint it, only where the video is (everywhere, at this point)
  bctx.globalCompositeOperation = "source-atop";
  bctx.globalAlpha = 0.5 + 0.38 * mud;
  bctx.fillStyle = `rgb(${28 + 108 * mud | 0}, ${112 - 10 * mud | 0}, ${178 - 126 * mud | 0})`;
  bctx.fillRect(0, 0, grid.W, grid.H);
  bctx.globalAlpha = 1;

  // 3. crop the buffer to land cells, so the open sea keeps its own colour
  bctx.globalCompositeOperation = "destination-in";
  bctx.drawImage(landMask, 0, 0);

  // 4. onto the flood canvas, only where there's already water
  fctx.save();
  fctx.globalCompositeOperation = "source-atop";
  fctx.globalAlpha = 0.8;
  fctx.drawImage(buf, 0, 0);
  fctx.restore();
}
</code></pre>
<p>Call this right after <code>fctx.putImageData(fimg, 0, 0)</code> in the frame loop. Step 4 is where it pays off. Because the flood canvas is transparent wherever there's no water, <code>source-atop</code> puts the texture exactly inside the flood outline, following every bay and valley the BFS found, with no per-pixel work on your side.</p>
<p><code>landMask</code> is a canvas you build once per run: white where <code>sea[i]</code> is false, transparent elsewhere. Without it the texture also covers the open sea, and the whole gulf turns the colour of the flood. I shipped that version for about an hour before I noticed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/926abb5b-ba8a-4cf9-8eb9-2e343ad53313.jpg" alt="Pitched view from above the shore of Odesa, looking out to sea. A white foam front sweeps across the coast and into the city, following the coastline, with darker water behind it." style="display: block;" width="1095" height="790" loading="lazy">

<p>The <code>mud</code> parameter is a time ramp from 0 to 1. Tsunami water arrives as seawater and turns dark brown as it picks up sediment off the land. Interpolating the tint from sea blue to mud brown over a few seconds after landfall gives you that for free.</p>
<p>The first time I built this, I had baked a brown grade into the video file itself, and no amount of tinting could make the water blue at the start. Keep the source footage neutral and do colour in code.</p>
<p>The same <code>destination-in</code> trick gives you a depth-driven darkening (a mask whose alpha is proportional to <code>depth[i]</code>, filled with a dark colour) and a foam band along the moving front (a mask whose alpha is a Gaussian of <code>reach[i] - F</code>, filled with a second video of churning foam). Each is a few lines, and each one makes the water read a little more as a volume and a little less as a tint.</p>
<h2 id="heading-step-7-move-the-camera-into-the-scene">Step 7: Move the Camera Into the Scene</h2>
<p>A top-down map is an illustration. Tilt the camera and the same pixels become a place. The flood canvas is draped on the ground, so it foreshortens correctly. Your only job is to point the camera somewhere worth looking at.</p>
<p>For a tsunami, that means standing on the shore near the city, looking out to sea, and watching it come in. "Near the city" is the coastline cell closest to the map centre. "Out to sea" is the average direction to nearby sea cells from that point. Compute it locally rather than for the whole coastline, or a curved bay will point your camera at the wrong horizon. I found that out with Odesa, where the citywide average pointed the camera at empty fields next to the airport.</p>
<pre><code class="language-javascript">function shoreCamera(grid, sea, center) {
  const { W, H, z, x0, y0, cellM } = grid;

  // 1. nearest coastline cell to the map centre
  let best = -1, bestD = Infinity;
  for (let i = 0; i &lt; W * H; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const coast = (x &gt; 0 &amp;&amp; !sea[i - 1]) || (x &lt; W - 1 &amp;&amp; !sea[i + 1]) ||
                  (y &gt; 0 &amp;&amp; !sea[i - W]) || (y &lt; H - 1 &amp;&amp; !sea[i + W]);
    if (!coast) continue;
    const lng = x2lon(x0 + (x + 0.5) / 256, z), lat = y2lat(y0 + (y + 0.5) / 256, z);
    const d = (lng - center.lng) ** 2 + (lat - center.lat) ** 2;
    if (d &lt; bestD) { bestD = d; best = i; }
  }
  const sx = best % W, sy = (best / W) | 0;

  // 2. local seaward direction: average offset to sea cells within ~2.5 km
  const r = Math.max(6, Math.round(2500 / cellM));
  let vx = 0, vy = 0;
  for (let dy = -r; dy &lt;= r; dy++) {
    for (let dx = -r; dx &lt;= r; dx++) {
      const x = sx + dx, y = sy + dy;
      if (x &lt; 0 || y &lt; 0 || x &gt;= W || y &gt;= H || !sea[y * W + x]) continue;
      const len = Math.hypot(dx, dy);
      if (len &lt; 1 || len &gt; r) continue;
      vx += dx / len; vy += dy / len;
    }
  }
  const len = Math.hypot(vx, vy) || 1;
  const east = vx / len, north = -vy / len;   // grid y grows southward

  const shoreLng = x2lon(x0 + (sx + 0.5) / 256, z);
  const shoreLat = y2lat(y0 + (sy + 0.5) / 256, z);
  const mLng = 111320 * Math.cos(shoreLat * Math.PI / 180), mLat = 111320;

  return {
    // stand 900 m inland of the shore...
    center: [shoreLng - east * 900 / mLng, shoreLat - north * 900 / mLat],
    // ...facing the sea
    bearing: Math.atan2(east, north) * 180 / Math.PI,
  };
}
</code></pre>
<p>Then it's two <code>easeTo</code> calls: dive in before landfall, pull back out before the results appear.</p>
<pre><code class="language-javascript">const before = {
  center: map.getCenter(), zoom: map.getZoom(),
  pitch: map.getPitch(), bearing: map.getBearing(),
};
const cam = shoreCamera(grid, sea, map.getCenter());

map.easeTo({
  center: cam.center,
  zoom: Math.min(13.6, before.zoom + 2.8),
  pitch: 72,
  bearing: cam.bearing,
  duration: 1200,
});

setTimeout(() =&gt; map.easeTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000);
</code></pre>
<p>Here is what those two calls produce. The camera has dropped to a steep pitch above the shore of Odesa and turned to face the Gulf. The sea pulls back first, exposing a wide band of sand, and then the front comes in over the beach and into the port. Everything you see moving is the same flood canvas from Step 3, foreshortened by the camera and draped over the terrain.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/4ba27056-3ff4-4d5a-9472-f0d43bb101b6.gif" alt="Animated: a tilted camera view over the shore of Odesa. The sea first draws back from the beach, exposing sand, then a dark wave front rolls back in across the beach and the port while the city fills the lower half of the frame." style="display: block;" width="720" height="520" loading="lazy">

<p>Notice that nothing in the frame loop knows or cares that the camera moved. The loop keeps writing the same pixels into the same canvas, and the map does the rest. That's what Step 3 bought you.</p>
<h2 id="heading-where-the-ground-layer-stops">Where the Ground Layer Stops</h2>
<p>Everything so far lives on the ground. The flood canvas is the map's own surface, and that's exactly why it works: perspective, rotation and terrain come free. The moment you need something with height, the trick runs out. You can't drape a tower onto a raster.</p>
<p>I learned where that line is the expensive way. I built a real 3D wall of water as a <code>three.js</code> custom layer, and it worked. I also cut it, for a reason worth knowing before you spend the time.</p>
<p>A 30-metre wave seen from 250 metres away covers about 7 degrees of a 37-degree field of view, a fifth of the screen. To make it fill the frame, you have to put the camera a hundred metres from it and nearly on the ground, where satellite imagery smears into mud. The geometry was correct and the shot was bad. Draped water under a pitched camera turned out to read as more water than a correctly-scaled wall did.</p>
<p>So the ground layer stayed a ground layer. But the earthquake scenario had no such escape: a building that collapses has to be an object. That is Part 2.</p>
<h2 id="heading-part-2-things-with-height">Part 2: Things with Height</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/ee831f6b-7f05-4b1b-ac4b-f47152fad5d5.jpg" alt="Street-level view of central Kyiv during a simulated magnitude 7.8 earthquake. Textured high-rise towers stand in the foreground, several of them leaning, and grey dust clouds drift across the scene over the Olympic stadium." style="display: block;" width="1095" height="790" loading="lazy">

<p>For the earthquake scenario, I needed buildings to fall. This half is what I ended up with after roughly ten rounds of rebuilding it: a mix of map-native extrusions for the thousands of buildings that only need to stand there, and a <code>three.js</code> scene rendered into the map's own WebGL context for the ones that come down.</p>
<p>Both paths read the same vector tiles, share the same depth buffer and move together under every camera move.</p>
<h2 id="heading-two-kinds-of-3d-and-why-you-need-both">Two Kinds of 3D, and Why You Need Both</h2>
<p>A <code>fill-extrusion</code> layer is the map engine's own 3D. It's fast, it handles tens of thousands of buildings, it's lit consistently with the rest of the map, and it's driven by data expressions: height from a property, colour from a property, and, the part that matters here, height multiplied by a per-feature <em>state</em> you can change at runtime. What it can't do is move. An extrusion is always a vertical prism standing on the ground.</p>
<p>A <code>custom</code> layer gives you the map's WebGL context and projection matrix every frame and lets you draw whatever you want into it. With <code>three.js</code> on the other end, that means real objects: meshes with position, rotation, and scale, instanced by the thousand, writing into the same depth buffer as the map so that a falling block disappears correctly behind a building that's still standing.</p>
<p>The split that works is simple. <strong>Every building is an extrusion.</strong> When one is damaged, its extrusion shrinks (that's the per-feature state). When one collapses close enough to the camera that you'd see the difference, the extrusion shrinks to nothing and a <code>three.js</code> object takes its place for the two seconds of the fall. Then the object is replaced with a pile of rubble, also <code>three.js</code>, and the frame rate never notices.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/de9747f7-1e42-4923-862b-eb8b2a7d3708.jpg" alt="Pitched view of central Kyiv before the shaking starts. Thousands of buildings are extruded from the map's vector tiles with window-pattern textures on their walls; a 3D model of the Olympic stadium stands in the middle distance." style="display: block;" width="1095" height="790" loading="lazy">

<h2 id="heading-step-8-get-buildings-out-of-the-maps-own-tiles">Step 8: Get Buildings Out of the Map's Own Tiles</h2>
<p>You don't need a building dataset. The map is already downloading one. Mapbox Streets vector tiles carry a <code>building</code> source layer with a polygon footprint, a <code>height</code>, a <code>min_height</code> for buildings that start above ground, and an <code>extrude</code> flag. <code>querySourceFeatures</code> reads whatever is currently loaded:</p>
<pre><code class="language-javascript">function buildingSource(map) {
  const sources = map.getStyle().sources;
  if (sources.composite) return "composite";         // Mapbox styles
  if (sources.openmaptiles) return "openmaptiles";   // MapLibre + OpenMapTiles
  for (const id in sources) if (sources[id].type === "vector") return id;
  return null;
}

function collectBuildings(map, center, kx, ky) {
  const src = buildingSource(map);
  if (!src) return [];
  const feats = map.querySourceFeatures(src, { sourceLayer: "building" });
  const out = [], seen = new Set();

  for (const f of feats) {
    const p = f.properties || {}, g = f.geometry;
    if (!g || p.extrude === "false" || p.underground === "true") continue;

    const polys = g.type === "Polygon" ? [g.coordinates]
                : g.type === "MultiPolygon" ? g.coordinates : null;
    if (!polys || !polys[0] || polys[0][0].length &lt; 4) continue;

    // centroid and bounds of the outer ring
    const ring = polys[0][0], n = ring.length - 1;
    let sx = 0, sy = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
    for (let j = 0; j &lt; n; j++) {
      const [x, y] = ring[j];
      sx += x; sy += y;
      if (x &lt; x0) x0 = x; if (x &gt; x1) x1 = x;
      if (y &lt; y0) y0 = y; if (y &gt; y1) y1 = y;
    }
    sx /= n; sy /= n;

    // the same building appears once per tile it touches
    const key = sx.toFixed(6) + "," + sy.toFixed(6) + "," + n;
    if (seen.has(key)) continue;
    seen.add(key);

    let h = +(p.height ?? p.render_height);
    if (!(h &gt; 0)) h = 8;                              // no data: assume two storeys
    const mh = Math.min(h, +(p.min_height ?? p.render_min_height) || 0);

    out.push({
      geometry: g, polys, lng: sx, lat: sy, h, mh,
      d: Math.hypot((sx - center.lng) * kx, (sy - center.lat) * ky),   // metres from epicentre
      rf: 0.5 * Math.sqrt(Math.max(4, (x1 - x0) * kx * (y1 - y0) * ky)), // rough radius, metres
    });
  }
  out.sort((a, b) =&gt; a.d - b.d);
  return out.slice(0, 9000);
}
</code></pre>
<p><code>kx</code> and <code>ky</code> are metres per degree of longitude and latitude at the city's latitude: <code>111320 * cos(lat)</code> and <code>110574</code>. Everything downstream works in metres from the epicentre.</p>
<p>Three things to know about this data. First, <code>querySourceFeatures</code> returns a building once for each tile it intersects, so you have to deduplicate. A rounded centroid plus vertex count makes a good key.</p>
<p>Second, the features <strong>have no stable IDs</strong> in Mapbox Streets. That matters enormously in Step 11.</p>
<p>Third, you only get what's loaded, which means you have to zoom in to where the buildings are before you collect them, and wait for the tiles to arrive.</p>
<p>In HazardSim, the camera dives in two steps, holds until <code>map.areTilesLoaded()</code> is true and the feature count stops changing between polls, and only then runs <code>collectBuildings</code>. If you collect too early, you get the ten buildings that happened to be in the first tile.</p>
<h2 id="heading-step-9-make-extrusions-look-like-buildings">Step 9: Make Extrusions Look Like Buildings</h2>
<p>The default extrusion is a flat-shaded box in one colour. It reads as a diagram. The trick that fixes it costs almost nothing: <code>fill-extrusion-pattern</code> takes an image from the style's sprite and tiles it over the walls, and you can add images to the sprite at runtime with <code>map.addImage</code>.</p>
<p>Draw a few façade textures on a canvas (rows of windows on a wall colour, with slight noise so they don't look computer-generated), register them, and pick one per building from its height:</p>
<pre><code class="language-javascript">function makeFacade(map, id, wall, glass) {
  const cv = document.createElement("canvas");
  cv.width = 128; cv.height = 64;
  const c = cv.getContext("2d");
  c.fillStyle = wall; c.fillRect(0, 0, 128, 64);

  // four floors of windows, 16px per floor
  for (let f = 0; f &lt; 4; f++) {
    for (let x = 3; x + 6 &lt;= 128; x += 12) {
      const lit = Math.random() &lt; 0.07;
      c.fillStyle = lit ? "#c6b68c" : glass;
      c.fillRect(x, f * 16 + 3, 6, 9);
    }
  }
  // film grain
  const im = c.getImageData(0, 0, 128, 64), d = im.data;
  for (let i = 0; i &lt; d.length; i += 4) {
    const n = (Math.random() - 0.5) * 12;
    d[i] += n; d[i + 1] += n; d[i + 2] += n;
  }
  map.addImage(id, im);
}

makeFacade(map, "fac-glass",  "#566a78", "#3c4e5c");
makeFacade(map, "fac-light",  "#cdc8be", "#364048");
makeFacade(map, "fac-brick",  "#8a5a44", "#2e2a26");
</code></pre>
<p>Then the layer. Height and base are expressions, and both are multiplied by a feature-state value <code>k</code> that defaults to 1. That multiplier is the whole collapse mechanism, so it goes in now:</p>
<pre><code class="language-javascript">const H  = ["coalesce", ["get", "height"], ["get", "render_height"], 8];
const MH = ["coalesce", ["get", "min_height"], ["get", "render_min_height"], 0];
const K  = ["coalesce", ["feature-state", "k"], 1];

function addBuildingLayers(map, source, sourceLayer, before) {
  const common = { type: "fill-extrusion", source, minzoom: 13 };
  if (sourceLayer) {
    common["source-layer"] = sourceLayer;
    common.filter = ["all", ["!=", ["get", "extrude"], "false"], ["!=", ["get", "underground"], "true"]];
  }
  map.addLayer({
    ...common, id: "bld-walls",
    paint: {
      "fill-extrusion-pattern": ["case",
        ["&gt;=", H, 45], "fac-glass",
        ["&gt;=", H, 14], "fac-light",
        "fac-brick"],
      "fill-extrusion-height": ["*", H, K],
      "fill-extrusion-base":   ["*", MH, K],
      "fill-extrusion-opacity": 1,
    },
  }, before);
  // a thin second extrusion on top gives the roof its own texture
  map.addLayer({
    ...common, id: "bld-roof",
    paint: {
      "fill-extrusion-pattern": "fac-roof",
      "fill-extrusion-height": ["+", ["*", H, K], 0.05],
      "fill-extrusion-base":   ["max", ["*", MH, K], ["-", ["*", H, K], 0.45]],
      "fill-extrusion-opacity": 1,
    },
  }, before);
}
</code></pre>
<p><code>before</code> should be the ID of the first <code>symbol</code> layer in the style, so the buildings go under the labels. Find it with a loop over <code>map.getStyle().layers</code>.</p>
<p>A pattern can't be rotated or scaled per feature, so windows don't line up with floors exactly. At the camera heights this runs at, nobody can tell.</p>
<h2 id="heading-step-10-decide-what-falls">Step 10: Decide What Falls</h2>
<p>Now the simulation part, which is short. For each building, you need the shaking intensity at its location and the probability that a building of that type collapses at that intensity.</p>
<p>Shaking is expressed on the Modified Mercalli scale, I to XII. A standard attenuation relation gives intensity from magnitude <code>M</code> and distance <code>r</code> in kilometres (<code>depth</code> is the hypocentre depth, 10 km for a shallow crustal quake):</p>
<pre><code class="language-javascript">const DEPTH_KM = 10;

function mmiAt(M, rKm) {
  const R = Math.sqrt(rKm * rKm + DEPTH_KM * DEPTH_KM);
  const v = 1.5 * M - 3.0 * Math.log10(R) - 0.0018 * R + 1.6;
  return Math.max(0, Math.min(12, v));
}
</code></pre>
<p>Collapse probability as a function of intensity is a fragility curve. The published ones are per building class and far more detailed than a simulation needs. This piecewise-linear version is tuned to look right and to give sane casualty numbers. Multiply it by a factor for construction quality and another for height. Mid-rise buildings fare worst, very tall ones are engineered and fare better:</p>
<pre><code class="language-javascript">const FRAG = [[6.5, 0], [7, 0.004], [8, 0.025], [9, 0.08], [10, 0.2], [11, 0.38], [12, 0.55]];
const QUALITY = { strict: 0.2, avg: 1, poor: 3 };

function collapseProbability(mmi) {
  if (mmi &lt;= FRAG[0][0]) return 0;
  for (let i = 1; i &lt; FRAG.length; i++) {
    if (mmi &lt;= FRAG[i][0]) {
      const [m0, p0] = FRAG[i - 1], [m1, p1] = FRAG[i];
      return p0 + (p1 - p0) * (mmi - m0) / (m1 - m0);
    }
  }
  return FRAG[FRAG.length - 1][1];
}

function assignDamage(buildings, M, epicentre, quality) {
  const kx = 111320 * Math.cos(epicentre.lat * Math.PI / 180), ky = 110574;
  const q = QUALITY[quality] || 1;

  for (const b of buildings) {
    const rKm = Math.hypot((b.lng - epicentre.lng) * kx, (b.lat - epicentre.lat) * ky) / 1000;
    const mmi = mmiAt(M, rKm);
    const hf = b.h &lt; 5 ? 0.7 : b.h &lt; 35 ? 1.15 : b.h &lt; 70 ? 0.8 : 0.45;
    const pCollapse = Math.min(0.9, collapseProbability(mmi) * q * hf);
    const pDamage = mmi &gt;= 6 ? Math.min(0.6, pCollapse * 2.5 + 0.004) : 0;

    const r = Math.random();
    b.cat = r &lt; pCollapse ? 2 : r &lt; pCollapse + pDamage ? 1 : 0;   // 2 collapse, 1 damaged, 0 fine
    b.start = 0.8 + Math.random() * 2.8;                            // seconds after the shock
  }
}
</code></pre>
<p>The per-building randomness is what sells it. Two identical towers next to each other, one falls and one stands. That's also how real earthquakes look.</p>
<h2 id="heading-step-11-animate-collapse-without-rebuilding-layers">Step 11: Animate Collapse Without Rebuilding Layers</h2>
<p>Here's the mechanism from Step 9 paying off. Each frame, for every building that's falling, set its feature-state <code>k</code> to a number between 1 and its final rubble height. The extrusion shrinks. No layer is rebuilt, no GeoJSON is re-set, and no tiles are re-parsed.</p>
<pre><code class="language-javascript">map.setFeatureState({ source: "bld", id: b.id }, { k: 0.62 });
</code></pre>
<p>There's one catch, and it's the one that cost me the most time in this whole project: <code>setFeatureState</code> <strong>needs a feature ID, and Mapbox Streets buildings don't have one.</strong> Calling it against the vector source silently does nothing.</p>
<p>The fix is to copy the buildings you collected into your own GeoJSON source, with an <code>id</code> you control, and tell the source to promote that property to the feature ID:</p>
<pre><code class="language-javascript">const feats = buildings.map((b, i) =&gt; {
  b.id = i;
  return { type: "Feature", properties: { i, height: b.h, min_height: b.mh }, geometry: b.geometry };
});

map.addSource("bld", {
  type: "geojson",
  data: { type: "FeatureCollection", features: feats },
  promoteId: "i",
  maxzoom: 16,
});
addBuildingLayers(map, "bld", null, firstSymbolLayer);
</code></pre>
<p>Now <code>setFeatureState({ source: "bld", id: 17 }, ...)</code> reaches building 17. Keep the original tile-backed layers around until the GeoJSON source reports <code>isSourceLoaded</code>, then remove them, so there's no frame where the city blinks.</p>
<p>The shape of <code>k</code> over time is what makes a collapse look like a collapse rather than a building sliding into the ground. I ended up with five modes, chosen at random per building and constrained by height:</p>
<pre><code class="language-javascript">// k0 = starting height multiplier (1), kf = final rubble height, u = progress 0..1
function collapseK(b, u) {
  const k0 = b.k0, sp = k0 - b.kf;
  switch (b.mode) {
    case 1: {   // floor by floor: ns steps, each with a fast drop then a pause
      const s = u * b.ns, i = Math.min(b.ns - 1, Math.floor(s));
      let f = s - i;
      f = f &lt; 0.45 ? f * f / 0.45 * 1.1 : 0.55 + (f - 0.45) * 0.45 / 0.55;
      return k0 - sp * Math.min(1, (i + Math.min(1, f)) / b.ns);
    }
    case 2: {   // soft storey: ground floor gives, building drops one floor, pauses, then goes
      const sd = Math.min(sp * 0.5, 3.2 / b.h);
      if (u &lt; 0.12) return k0 - sd * (u / 0.12) ** 2;
      if (u &lt; 0.5)  return k0 - sd;
      const v = (u - 0.5) / 0.5;
      return k0 - sd - (sp - sd) * v * v;
    }
    case 3: {   // crushed top: upper part fails first
      const pt = sp * 0.45;
      if (u &lt; 0.3)  return k0 - pt * (u / 0.3) ** 2;
      if (u &lt; 0.55) return k0 - pt;
      const v = (u - 0.55) / 0.45;
      return k0 - pt - (sp - pt) * v * v;
    }
    case 4:     // sideways: steady, slightly accelerating
      return k0 - sp * Math.pow(u, 1.6);
    default:    // pancake: accelerating free fall
      return k0 - sp * u * u;
  }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/090f37e2-11d6-41cd-ae23-0affa8357364.png" alt="Line chart of remaining height against progress for the five collapse modes. Pancake is a smooth accelerating curve. Floor-by-floor is a staircase of drops and pauses. Soft storey drops a little, holds flat for a long time, then falls. Crushed top drops early, holds, then falls. Sideways is a gentle curve. All five meet at the same final height." style="display: block;" width="1400" height="700" loading="lazy">

<p>The frame loop is then a few lines per building:</p>
<pre><code class="language-javascript">function frame(now) {
  const el = (now - T0) / 1000, dt = Math.min(0.04, (now - last) / 1000);
  last = now;

  for (const b of active) {
    if (b.done || el &lt; b.start) continue;
    const u = Math.min(1, (el - b.start) / b.dur);
    const k = collapseK(b, u);
    map.setFeatureState({ source: "bld", id: b.id }, { k });
    if (u &gt;= 1) { b.done = true; onLanded(b); }
  }
  requestAnimationFrame(frame);
}
</code></pre>
<p>For a damaged-but-standing building (<code>cat === 1</code>), <code>kf</code> is close to 1 and the mode is a short settle: the building visibly drops half a metre and stops. It's a small detail, and it's the one that makes the whole street look hit rather than just the buildings that fell.</p>
<p>Clamp <code>dt</code> to 40 ms. If the tab stutters and a frame takes a quarter of a second, a building that was mid-fall would jump instead of fall, and the eye catches that instantly.</p>
<h2 id="heading-step-12-a-threejs-scene-inside-the-map">Step 12: A Three.js Scene Inside the Map</h2>
<p>Shrinking extrusions handle the buildings in the distance. For the ones near the camera, you want to see the structure break: a tower tipping over and snapping into blocks, debris arcing out from the base, or rubble left on the street. That needs objects, so this is where <code>three.js</code> comes in.</p>
<p>The custom layer gets two callbacks: <code>onAdd</code> once, with the map and its GL context, and <code>render</code> every frame, with the context and the map's projection matrix. The non-obvious part is coordinates. You want to work in metres from an origin, not in Mercator units, so build a matrix that converts, and multiply it into the map's projection each frame:</p>
<pre><code class="language-javascript">function makeScene(map, origin, before) {
  const mc = mapboxgl.MercatorCoordinate.fromLngLat([origin.lng, origin.lat], 0);
  const s = mc.meterInMercatorCoordinateUnits();
  // metres east / north / up at the origin  -&gt;  Mercator.  Mercator y grows southward, hence -s.
  const toMercator = new THREE.Matrix4()
    .makeTranslation(mc.x, mc.y, mc.z)
    .scale(new THREE.Vector3(s, -s, s));

  const scene = new THREE.Scene();
  const camera = new THREE.Camera();
  scene.add(new THREE.HemisphereLight(0xfff4e4, 0x3c342d, 0.55));
  scene.add(new THREE.AmbientLight(0xffffff, 0.1));
  const sun = new THREE.DirectionalLight(0xfff8ee, 0.8);
  sun.position.set(-0.45, -0.6, 1);
  scene.add(sun);

  let renderer = null;
  const layer = {
    id: "debris3d",
    type: "custom",
    renderingMode: "3d",
    onAdd(m, gl) {
      renderer = new THREE.WebGLRenderer({ canvas: m.getCanvas(), context: gl, antialias: true });
      renderer.autoClear = false;             // the map has already drawn this frame
      renderer.localClippingEnabled = true;   // used to sink buildings below ground
    },
    render(gl, matrix) {
      camera.projectionMatrix.fromArray(matrix).multiply(toMercator);
      renderer.resetState();                  // Mapbox changed GL state since our last draw
      renderer.render(scene, camera);
    },
  };
  map.addLayer(layer, before);
  return { scene, camera, layer };
}
</code></pre>
<p>On MapLibre, the second argument of <code>render</code> is an object, and the matrix is at <code>args.defaultProjectionData.mainMatrix</code>. Everything else is the same.</p>
<p><code>renderingMode: "3d"</code> is what makes the map share its depth buffer with you. A block of concrete that lands behind a standing extrusion is hidden by it, which is the entire reason to render into the map's context rather than in a separate canvas on top.</p>
<p>Thousands of debris pieces means instancing. HazardSim keeps a pool of <code>InstancedMesh</code> objects, one per kind of piece (slabs, wall fragments, rebar, rocks, roof prisms), each with a fixed capacity, and hands out instance indices as pieces are created:</p>
<pre><code class="language-javascript">function makePool(scene, geometry, material, capacity) {
  const mesh = new THREE.InstancedMesh(geometry, material, capacity);
  // allocate the colour buffer for the full capacity up front (see Gotchas)
  mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
  mesh.count = 0;
  mesh.frustumCulled = false;                 // the map camera is not a three.js camera
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  scene.add(mesh);

  const dummy = new THREE.Object3D(), color = new THREE.Color();
  dummy.rotation.order = "ZXY";
  return {
    n: 0, capacity,
    add(p) {
      if (this.n &gt;= capacity) return -1;
      const i = this.n++;
      this.set(i, p);
      color.setRGB(p.rgb[0], p.rgb[1], p.rgb[2]);
      mesh.setColorAt(i, color);
      mesh.instanceColor.needsUpdate = true;
      mesh.count = this.n;
      return i;
    },
    set(i, p) {
      dummy.position.set(p.x, p.y, p.z);
      dummy.rotation.set(p.tx, p.ty, p.yaw);
      dummy.scale.set(p.sx, p.sy, p.sz);
      dummy.updateMatrix();
      mesh.setMatrixAt(i, dummy.matrix);
      mesh.instanceMatrix.needsUpdate = true;
    },
  };
}

const concrete = new THREE.MeshStandardMaterial({ map: concreteTexture, roughness: 1 });
const pools = {
  slab: makePool(scene, new THREE.BoxGeometry(1, 1, 1), concrete, 30000),
  wall: makePool(scene, new THREE.BoxGeometry(1, 1, 1), facadeMaterial, 1500),
  bar:  makePool(scene, new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x6e4c38, metalness: 0.25 }), 20000),
  rock: makePool(scene, rockGeometry(), concrete, 9000),
};
</code></pre>
<p>Thirty thousand slabs sounds like a lot, but it's one draw call.</p>
<p>One more thing the map needs from you: it only redraws when something changes. Your scene changing is not, from the map's point of view, something changing. Call <code>map.triggerRepaint()</code> at the end of every frame in which you moved anything, or your meshes will update in memory and never on screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/8481687c-e9b9-48f7-846b-0520afbda6ed.jpg" alt="Pitched close-up of a Kyiv street. Several textured high-rise towers rebuilt as three.js models stand among the extruded buildings, ready for the shaking to start; the Olympic stadium model is in the background." style="display: block;" width="1095" height="790" loading="lazy">

<h2 id="heading-step-13-from-footprint-to-a-box-that-tips-over">Step 13: From Footprint to a Box That Tips Over</h2>
<p>The vector tile gives you a footprint polygon. To tip a building over, you need a box: a centre, an orientation, and two side lengths. The oriented bounding box is a few lines:</p>
<pre><code class="language-javascript">function orientedBox(ring, kx, ky, origin) {
  // ring in lng/lat -&gt; local metres
  const loc = ring.map(([lng, lat]) =&gt; [(lng - origin.lng) * kx, (lat - origin.lat) * ky]);

  // the longest edge sets the orientation
  let ang = 0, best = 0;
  for (let i = 1; i &lt; loc.length; i++) {
    const ex = loc[i][0] - loc[i - 1][0], ey = loc[i][1] - loc[i - 1][1];
    if (ex * ex + ey * ey &gt; best) { best = ex * ex + ey * ey; ang = Math.atan2(ey, ex); }
  }

  // project every vertex onto that axis and its normal, take the extents
  const ca = Math.cos(ang), sa = Math.sin(ang);
  let u0 = Infinity, u1 = -Infinity, v0 = Infinity, v1 = -Infinity;
  for (const [x, y] of loc) {
    const u = x * ca + y * sa, v = -x * sa + y * ca;
    if (u &lt; u0) u0 = u; if (u &gt; u1) u1 = u;
    if (v &lt; v0) v0 = v; if (v &gt; v1) v1 = v;
  }
  const cu = (u0 + u1) / 2, cv = (v0 + v1) / 2;
  return {
    x: cu * ca - cv * sa, y: cu * sa + cv * ca,    // centre, metres from origin
    ang,
    L1: Math.max(2, u1 - u0), L2: Math.max(2, v1 - v0),
  };
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/28644ba8-6900-4885-98f6-ca163d50aa6c.png" alt="Three-panel diagram. First: a building footprint polygon with its longest edge highlighted, labelled &quot;longest edge gives the angle&quot;. Second: the polygon rotated to that angle with an orange bounding rectangle around it, labelled &quot;L1 by L2, centre, angle&quot;. Third: a stack of four blocks hinged at the bottom edge, with the formula theta double-prime equals 1.5 g over h times sine theta." style="display: block;" width="2800" height="1120" loading="lazy">

<p>The box becomes a stack of two to four blocks, one <code>Mesh</code> each, in a <code>Group</code> whose origin is on the bottom edge the building will rotate around. The physics is a rigid body falling over: angular acceleration proportional to <code>g / h · sin θ</code>, with a small initial nudge so it starts moving. When the lean passes a threshold, the upper blocks detach and fly ballistically with the velocity they had at that instant:</p>
<pre><code class="language-javascript">function startTip(scene, box, h, onLand) {
  const n = Math.max(2, Math.min(4, Math.round(h / 9))), hs = h / n;
  const geo = new THREE.BoxGeometry(box.L1, box.L2, hs);
  const group = new THREE.Group();
  group.position.set(box.x + Math.cos(box.ang) * box.L1 / 2, box.y + Math.sin(box.ang) * box.L1 / 2, 0);
  group.rotation.z = box.ang;
  scene.add(group);

  const segs = [];
  for (let i = 0; i &lt; n; i++) {
    const m = new THREE.Mesh(geo, wallMaterial);
    m.frustumCulled = false;
    group.add(m);
    segs.push({ m, free: false, gone: false, x: -box.L1 / 2, z: hs * (i + 0.5), th: 0, px: 0, pz: 0 });
  }

  const tip = { th: 0.03, w: 0.22, broke: false, bend: 0.1 + Math.random() * 0.14, brk: 0.4 + Math.random() * 0.3 };

  function pose() {
    let bx = -box.L1 / 2, bz = 0;
    for (let j = 0; j &lt; n; j++) {
      const s = segs[j];
      if (s.free) continue;
      const th = tip.th * (1 + tip.bend * j);        // upper blocks bend a little further
      const sn = Math.sin(th), cs = Math.cos(th);
      if (j === 0) { bx = -box.L1 / 2 * cs; bz = box.L1 / 2 * sn; }
      s.px = s.x; s.pz = s.z;
      s.x = bx + sn * hs / 2; s.z = bz + cs * hs / 2; s.th = th;
      bx += sn * hs; bz += cs * hs;
      s.m.position.set(s.x, 0, s.z);
      s.m.rotation.y = th;
    }
  }

  return function tick(dt) {
    tip.w = Math.min(2.2, tip.w + 1.5 * 9.8 / h * Math.sin(tip.th) * dt);
    tip.th += Math.min(0.09, tip.w * dt);          // never more than ~5 degrees per frame
    pose();

    if (!tip.broke &amp;&amp; tip.th * (1 + tip.bend * (n - 1)) &gt; tip.brk &amp;&amp; n &gt; 1) {
      tip.broke = true;
      for (let j = 1; j &lt; n; j++) {
        const s = segs[j];
        s.free = true;
        s.vx = Math.max(-18, Math.min(18, (s.x - s.px) / Math.max(dt, 0.008)));
        s.vz = Math.max(-18, Math.min(18, (s.z - s.pz) / Math.max(dt, 0.008)));
        s.w = tip.w * (1 + tip.bend * j) * (1.05 + Math.random() * 0.25);
      }
    }

    let left = 0;
    for (const s of segs) {
      if (s.gone) continue;
      if (s.free) {
        s.vz -= 9.8 * dt; s.x += s.vx * dt; s.z += s.vz * dt; s.th += s.w * dt;
        s.m.position.set(s.x, 0, s.z);
        s.m.rotation.y = s.th;
      }
      const landed = s.free ? s.z &lt; Math.min(hs, box.L1) * 0.5 + 0.3 : tip.th &gt;= 1.25;
      if (landed) {
        s.gone = true;
        group.remove(s.m);
        onLand(group.position.x + Math.cos(box.ang) * s.x, group.position.y + Math.sin(box.ang) * s.x);
      } else left++;
    }
    if (!left) { scene.remove(group); geo.dispose(); return true; }   // finished
    return false;
  };
}
</code></pre>
<p>The clamps aren't decoration. Velocity at detachment is limited to 18 m/s and the frame step <code>dt</code> to a minimum of 8 ms, because the velocity is computed from a position difference, and a hiccup frame would otherwise launch a block into orbit. Angular velocity is limited to 2.5 rad/s and the lean to 0.09 rad per frame for the same reason. Every one of those numbers was added after watching a building do something a building can't do.</p>
<p><code>onLand</code> is where a collapse starts to look like one: each block that hits the ground spawns a burst of debris and dust at that point, and leaves a pile.</p>
<h2 id="heading-step-14-debris-and-dust">Step 14: Debris and Dust</h2>
<p>Debris is the instanced pools from Step 12 plus gravity. When a collapsing building passes a milestone (the first drop, each floor in floor-by-floor mode, a block landing), throw a handful of pieces out from the edges of the footprint, with velocity pointing away from the centre:</p>
<pre><code class="language-javascript">function throwDebris(pools, b, origin, kx, ky, top, n) {
  const ring = b.polys[0][0];
  const cx = (b.lng - origin.lng) * kx, cy = (b.lat - origin.lat) * ky;
  for (let i = 0; i &lt; n; i++) {
    // a random point on a random edge of the footprint
    const e = 1 + Math.floor(Math.random() * (ring.length - 1)), t = Math.random();
    const a = ring[e - 1], z = ring[e];
    const x = ((a[0] + (z[0] - a[0]) * t) - origin.lng) * kx;
    const y = ((a[1] + (z[1] - a[1]) * t) - origin.lat) * ky;
    // outward, with a little spread
    const ox = x - cx, oy = y - cy, ol = Math.hypot(ox, oy) || 1, v = 2 + Math.random() * 6;
    const size = 0.3 + Math.random() * 1.1;
    const idx = pools.rock.add({
      x, y, z: top * (0.35 + Math.random() * 0.65),
      yaw: Math.random() * 6, tx: Math.random() * 6, ty: Math.random() * 6,
      sx: size, sy: size * 0.8, sz: size * 0.6, rgb: concreteColour(),
    });
    if (idx &gt;= 0) flying.push({ idx, x, y, z: top, vx: ox / ol * v, vy: oy / ol * v, vz: Math.random() * 3, spin: (Math.random() - 0.5) * 8 });
  }
}

function tickDebris(pools, dt) {
  for (let i = flying.length - 1; i &gt;= 0; i--) {
    const p = flying[i];
    p.vz -= 9.8 * dt;
    p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
    p.tx += p.spin * dt;
    if (p.z &lt;= p.sz * 0.35) { p.z = p.sz * 0.35; flying.splice(i, 1); }   // landed: stays as rubble
    pools.rock.set(p.idx, p);
  }
}
</code></pre>
<p>Pieces that land simply stop being updated and stay in the pool. That's your rubble, for free.</p>
<p>HazardSim adds a few more things on top: stacked floor slabs on a 9-metre grid where a building stood, wall fragments with windows, bent rebar, a low fill-extrusion mound under the pile so the ground isn't flat, and for the closest buildings, a set of photogrammetry-scanned rubble models. Those are polish, and the pool plus gravity gets you most of the way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/1544d432-3f78-480b-9412-fa4d0fa73e82.jpg" alt="Close-up at street level after the collapse. Dust hangs over a block of towers, the ground between them is strewn with grey rubble, and the sky is a haze of smoke lit from the side." style="display: block;" width="1095" height="790" loading="lazy">

<p>Dust is the one place where I draw in screen space, and it's worth saying why that's fine here when it was fatal for water in Part 1. Dust particles are small, there are hundreds of them, and each lives three to eight seconds. They're also drawn as soft sprites with per-particle position, size, and rotation projected from a map point each frame. There are no edges to see and nothing to line up with. The screen-space rule is about large, persistent things with shape.</p>
<p>The important decision is <strong>where dust comes from</strong>. Early versions had a constant haze over the city and burning fires, and it looked like a screensaver. What reads as real is dust that comes only from events: a puff when a floor drops, a surge along the ground when a building lands, a jet from the ground floor of a soft-storey collapse. Each event emits a burst with a size proportional to the building's footprint and a vertical speed proportional to the drop, then nothing until the next event.</p>
<pre><code class="language-javascript">function emitDust(b, level, n, surge) {
  for (let i = 0; i &lt; n &amp;&amp; dust.length &lt; MAX_DUST; i++) {
    const a = Math.random() * Math.PI * 2;
    const speed = surge ? 5 + Math.random() * 8 : 1 + Math.random() * 3;
    dust.push({
      lng: b.lng, lat: b.lat,                                        // anchor on the map
      x: Math.cos(a) * b.rf * 0.6, y: Math.sin(a) * b.rf * 0.6,     // offset, metres
      z: surge ? Math.random() * 5 : level * (0.2 + Math.random() * 0.8),
      vx: Math.cos(a) * speed, vy: Math.sin(a) * speed,
      vz: surge ? 0.3 + Math.random() * 1.2 : 1.5 + Math.random() * 3,
      size: (surge ? b.rf * 0.5 + 6 : b.rf * 0.6 + 4) * (0.7 + Math.random() * 0.6),
      grow: 3 + Math.random() * 5,
      t: 0, life: (surge ? 5 : 3.5) + Math.random() * 2.5,
      sprite: sprites[Math.floor(Math.random() * sprites.length)],
      alpha: 0.5 + Math.random() * 0.3,
    });
  }
}
</code></pre>
<p>Per frame, each particle decelerates, drifts with the wind, grows, and is projected: <code>map.project([lng + x / kx, lat + y / ky])</code> gives the screen position of its ground point, and the height is applied as <code>y -= z * pixelsPerMetre * sin(pitch)</code>. Sort by screen depth, draw back to front with <code>drawImage</code>, and fade with age. It's a few hundred <code>drawImage</code> calls per frame on a 2D canvas over the map, and it costs almost nothing.</p>
<h2 id="heading-step-15-the-camera-has-to-shake">Step 15: The Camera Has to Shake</h2>
<p>Everything so far could be running silently in a corner of the screen. What makes a viewer feel the earthquake is the camera.</p>
<p>Two things. First, the dive: from the overview, ease to zoom 14.6 at 45 degrees, hold until tiles are loaded, then ease to zoom 16.2 at 62 degrees over the epicentre. The hold isn't optional. If you dive straight to zoom 16, the vector tiles for that zoom haven't arrived, <code>querySourceFeatures</code> returns almost nothing, and the earthquake hits an empty city.</p>
<pre><code class="language-javascript">function diveToCity(map, center, then) {
  const bearing = map.getBearing();
  map.easeTo({ center, zoom: 14.6, pitch: 45, bearing: bearing - 10, duration: 1100, essential: true });
  const t1 = Date.now();
  (function hold() {
    const loaded = !map.isMoving() &amp;&amp; map.areTilesLoaded();
    if (Date.now() - t1 &gt; 1000 &amp;&amp; (loaded || Date.now() - t1 &gt; 2000)) {
      map.easeTo({ center, zoom: 16.2, pitch: 62, bearing: bearing - 25, duration: 1500, essential: true });
      map.once("moveend", then);
    } else setTimeout(hold, 150);
  })();
}
</code></pre>
<p>Second, the shake. Don't move the map camera for this. A camera that jitters through <code>easeTo</code> fights the animation system and loads tiles for every jitter. Shake the <em>container</em> with a CSS transform. The map doesn't know it moved:</p>
<pre><code class="language-javascript">function shake(durationMs, magnitude) {
  const el = map.getContainer();
  const w = window.innerWidth, h = window.innerHeight;
  const amp = Math.min(28, w * 0.02, Math.max(6, (magnitude - 4) * 4.2));
  const dir = Math.random() * Math.PI;
  const t0 = performance.now();
  let last = t0, phase = 0;

  function tick(now) {
    const k = (now - t0) / durationMs;
    if (k &gt;= 1) { el.style.transform = ""; return; }
    const dt = Math.min(0.05, (now - last) / 1000); last = now;

    // builds up over the first third, then decays; frequency rises with it
    let env, omega;
    if (k &lt; 0.35) { const u = k / 0.35; env = 0.15 + 0.85 * u * u * (3 - 2 * u); omega = 7 + 39 * u * u; }
    else          { const v = (k - 0.35) / 0.65; env = Math.pow(1 - v, 1.4); omega = 46 - 18 * v; }
    phase += omega * dt;

    const a = amp * env;
    const osc = Math.sin(phase) * 0.78 + (Math.random() - 0.5) * 0.44 * Math.min(1, omega / 30);
    const x = Math.cos(dir) * osc * a, y = Math.sin(dir) * osc * a * 0.7;
    el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(osc * a * 0.012).toFixed(3)}deg) scale(${(1 + 2.4 * amp / Math.min(w, h) * env).toFixed(4)})`;
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
}
</code></pre>
<p>The envelope matters more than the amplitude. Real shaking builds for a second or two, peaks, and dies off over several more, with the frequency rising as it builds. A constant jitter reads as a broken video. An aftershock at about half the amplitude, three or four seconds after the main shock, finishes the effect.</p>
<p>When the shaking stops, ease the camera down to the centre of the nearest collapses and hold for a few seconds so the rubble gets its moment, then pull back up to the intensity rings and the casualty figures.</p>
<h2 id="heading-keeping-it-fast">Keeping It Fast</h2>
<p>A few decisions kept this at 60 fps on a laptop and usable on phones.</p>
<h3 id="heading-1-pools-with-fixed-capacity">1. Pools with Fixed Capacity</h3>
<p>Every instanced pool has a cap, and <code>add</code> returns <code>-1</code> when it's full. The caps scale down for low-power devices: 9,000 slabs instead of 30,000, 3,000 rocks instead of 9,000. The scene degrades to fewer pieces rather than to a slideshow.</p>
<h3 id="heading-2-a-device-tier-decided-once">2. A Device Tier, Decided Once</h3>
<p>Mobile user agent, <code>navigator.deviceMemory &lt; 6</code>, <code>hardwareConcurrency &lt;= 4</code>, or a short screen side under 700 px puts the device in the low tier. The low tier skips the <code>three.js</code> tipping entirely and uses only shrinking extrusions, caps dust at a third, and drops the hero models.</p>
<h3 id="heading-3-rebuild-the-rubble-mound-at-most-every-12-seconds">3. Rebuild the Rubble Mound at Most Every 1.2 Seconds</h3>
<p>The low <code>fill-extrusion</code> mound under each pile is a GeoJSON source, and <code>setData</code> on a GeoJSON source re-parses it. Doing that once per collapsed building was the main cause of stutter in early versions. Now landed buildings go into a pending list that's flushed on a timer.</p>
<h3 id="heading-4-never-remove-a-collapsed-building-from-the-source">4. Never Remove a Collapsed Building from the Source</h3>
<p>It stays in <code>bld</code> with <code>k</code> set to 0.001. Removing features means <code>setData</code>, and <code>setData</code> means a stall.</p>
<h3 id="heading-5-load-assets-on-intent">5. Load Assets on Intent</h3>
<p>The textures and models for the full-detail mode add up to 24 MB. They start downloading when the visitor clicks the earthquake tab, not on page load, and only in the full tier.</p>
<h2 id="heading-what-went-wrong-and-why">What Went Wrong, and Why</h2>
<p>Each of these cost me real time, and the first two are why Part 1 exists at all.</p>
<h3 id="heading-the-sticker-problem">The Sticker Problem</h3>
<p>My first version of a "wall of water" was a video clip drawn on a screen-space canvas, positioned by projecting a map point each frame. On a 13-inch laptop it almost worked. On a 4K monitor it was a rectangle with soft edges hovering over the city, obviously not part of the scene.</p>
<p>No amount of tuning opacity, feathering, or size fixes this, because the problem isn't the sprite. A sprite has no place in a 3D world. Everything in Steps 3 to 6 is the alternative.</p>
<p>If you do need screen-space elements, say a splash sprite anchored to a point, keep them small, short-lived, and numerous, and accept that they'll never carry the shot.</p>
<h3 id="heading-i-couldnt-find-my-layer-twice">I Couldn't Find My Layer, Twice</h3>
<p>The 3D water wall from the end of Part 1 rendered without an error, the mesh moved exactly where my debug probe said it should, and nothing appeared on screen. I'd added it with <code>map.addLayer(layer, "flood")</code>, which inserts it <em>below</em> the flood raster, and the opaque water was hiding my wall. Check <code>map.getStyle().layers</code> before you check anything else.</p>
<p>The second time I lost a layer, it was the opposite problem: it wasn't there at all. <code>map.getStyle().layers</code> doesn't list custom layers. Check with <code>map.getLayer("debris3d")</code> instead. I spent a while convinced the layer wasn't being added.</p>
<h3 id="heading-24-levels-of-transparency">24 Levels of Transparency</h3>
<p>I built sprite sheets with soft alpha edges and ran them through <code>PIL.Image.quantize()</code> to shrink the PNG. The palette compression quantized the alpha channel along with the colours. 256 levels became 24, the smooth falloff became a staircase, and every step was a hard straight edge that looked exactly like a tile boundary. I spent two rounds hunting for a tiling bug that didn't exist.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/bba497b2-fe0e-4398-a7af-40a4245674f1.png" alt="Two versions of the same smoke sprite on a dark background. Left, after palette quantization: the zoomed edge is made of blocky pixels in only a few shades, and the alpha histogram shows eight isolated spikes. Right, full 8-bit alpha: the zoomed edge fades smoothly, and the histogram is a continuous spread of 253 levels." style="display: block;" width="1400" height="1000" loading="lazy">

<p>The fix was to stop quantizing and make the RGB channels constant (the sprites were white anyway), so PNG compression had nothing left to do. The file got three times smaller. I now check alpha with a histogram instead of trusting my eyes.</p>
<h3 id="heading-same-filename-different-file">Same Filename, Different File</h3>
<p>I re-encoded <code>flood.webm</code> with a different grade and redeployed. Visitors kept getting the old one for hours. The CDN cached by URL, and a <code>?v=2</code> query string didn't bust it for a file that already existed.</p>
<p>Renaming the file to <code>flood2.webm</code> fixed it in one deploy. Since then I rename every binary asset I update instead of versioning the URL.</p>
<h3 id="heading-headless-screenshots-lied-twice">Headless Screenshots Lied, Twice</h3>
<p>I tested under headless Chrome with a software GL renderer. Each screenshot took a second or two to capture, so a "sequence" of frames was nothing of the sort, and I spent an afternoon tuning timings against noise.</p>
<p>Two things fixed it: record the browser session as video and cut frames with ffmpeg, and, for colour, read the simulation canvas directly with <code>getImageData</code> instead of measuring the composited screen. The second one sounds obvious written down, but it took me an afternoon to arrive at it.</p>
<p>The same headless setup fooled me again on the earthquake. Satellite tiles load slowly there, and for a long time I thought my building layer was broken because screenshots showed a black city. It was the base map that hadn't arrived. If a layer looks wrong in headless tests, wait for <code>map.areTilesLoaded()</code> before trusting the screenshot.</p>
<h3 id="heading-nothing-happened-when-i-set-feature-state">Nothing Happened When I Set Feature State</h3>
<p><code>setFeatureState</code> against the tile source did nothing and reported nothing, because the features have no IDs. The fix is the GeoJSON copy with <code>promoteId</code> from Step 11. It took me an embarrassingly long time to find, because the API doesn't fail. It just doesn't do anything.</p>
<h3 id="heading-every-instance-was-black">Every Instance Was Black</h3>
<p><code>InstancedMesh.setColorAt</code> in three r128 lazily creates the colour buffer the first time you call it, sized to the mesh's <strong>current</strong> <code>count</code>. My pools started with <code>count = 0</code>, so the buffer was created with zero entries, and every piece rendered black. Allocate <code>instanceColor</code> yourself, for the full capacity, before the first <code>setColorAt</code>. That's the line in <code>makePool</code>.</p>
<h3 id="heading-half-the-debris-vanished-when-i-panned">Half the Debris Vanished When I Panned</h3>
<p><code>three.js</code> frustum-culls meshes against its own camera. Our camera is a bare <code>THREE.Camera</code> with a projection matrix stuffed into it each frame and no idea where the map is looking, so its idea of the frustum is nonsense. Set <code>frustumCulled = false</code> on every mesh in the custom layer.</p>
<h3 id="heading-the-scene-updated-but-the-screen-didnt">The Scene Updated But the Screen Didn't</h3>
<p>The map only repaints when it thinks something changed. Call <code>map.triggerRepaint()</code> after you move your objects. Without it the animation runs at whatever rate the map happens to repaint for other reasons, which in a still scene is zero.</p>
<h3 id="heading-a-sky-made-everything-disappear">A Sky Made Everything Disappear</h3>
<p>On a different project with the same setup, <code>map.setSky(...)</code> hid the whole <code>three.js</code> scene. The sky writes to the depth buffer, and everything the custom layer draws afterwards fails the depth test. If you render 3D into the map, don't enable the sky layer.</p>
<h3 id="heading-the-idle-event-never-fired-in-a-background-tab">The <code>idle</code> Event Never Fired in a Background Tab</h3>
<p>I preloaded models on <code>map.on("idle")</code>. In a background tab the map can go a very long time without becoming idle, and visitors who opened the site in a tab and came back later got an earthquake with no models. The preload moved to the moment the hazard type is selected.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The whole guide reduces to two structures.</p>
<p>On the ground:</p>
<ol>
<li><p>Align a grid to the map's tile pyramid, so every tiled dataset drops in without resampling.</p>
</li>
<li><p>Decode free elevation tiles into that grid.</p>
</li>
<li><p>Register a canvas as a map source over the grid's rectangle, with <code>animate: true</code>.</p>
</li>
<li><p>Run the simulation on the grid once. Animate by writing pixels every frame.</p>
</li>
<li><p>Use <code>source-atop</code> and <code>destination-in</code> to drape textures exactly inside the simulated shape.</p>
</li>
<li><p>Move the camera. The drawing comes along.</p>
</li>
</ol>
<p>With height:</p>
<ol>
<li><p>Pull buildings from the vector tiles the map already loaded. Deduplicate, as they have no IDs.</p>
</li>
<li><p>Draw them all as <code>fill-extrusion</code> with a per-feature height multiplier, through a GeoJSON copy with <code>promoteId</code> so feature state works.</p>
</li>
<li><p>Decide damage from an attenuation relation and a fragility curve, with randomness per building.</p>
</li>
<li><p>Animate collapse by writing the multiplier each frame.</p>
</li>
<li><p>For the buildings near the camera, render <code>three.js</code> into the map's own GL context with <code>renderingMode: "3d"</code>, so the depth buffer is shared.</p>
</li>
<li><p>Turn footprints into oriented boxes, tip them over, snap them into blocks, and throw instanced debris. Emit dust only from events. Shake the container, not the camera.</p>
</li>
</ol>
<p>The idea under all of it is the one I kept relearning: anything that belongs to the world has to be drawn in the world's coordinates. A canvas on top of the map is a sheet of glass. A canvas inside the map is the ground, and a <code>three.js</code> scene inside the map's GL context is the air above it.</p>
<p>Every improvement that made either simulation look real came from moving something out of the first category and into the second.</p>
<p>The other thing I'd tell myself at the start: the map engine and <code>three.js</code> are not competitors. The map is better at ten thousand static buildings than anything you'll write, and <code>three.js</code> is better at ten moving ones than any expression language.</p>
<p>Give each the job it's good at, and spend your effort on the seam between them, which is almost entirely the coordinate transform in Step 12 and the feature-state trick in Step 11.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/3ed70355-abc0-4a98-8285-8012ad46aff9.jpg" alt="The finished scenario: a 30-metre tsunami in Odesa seen from above, with brown floodwater covering the coastal districts and a results panel on the left listing people killed, area flooded, inland reach and maximum depth." style="display: block;" width="1440" height="790" loading="lazy">

<p>You can see both scenarios at <a href="https://hazardsim.com/">hazardsim.com</a>. Pick a coastal city and launch a tsunami, or pick any city, set a magnitude, and press the button. The code in this guide is a cleaned-up version of what runs there. Thanks to Tim for breaking it in ways I never would have.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ High-Frequency Real-Time Data in React: From Ring Buffers to OffscreenCanvas ]]>
                </title>
                <description>
                    <![CDATA[ React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose. Try forc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/high-frequency-real-time-data-in-react-from-ring-buffers-to-offscreencanvas/</link>
                <guid isPermaLink="false">6a84d268fa401f6597cd200e</guid>
                
                    <category>
                        <![CDATA[ react js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ realtime ]]>
                    </category>
                
                    <category>
                        <![CDATA[ workers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ concurrency ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 21:45:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c3ffaae8-51d5-4add-9a9e-e49443e49746.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose.</p>
<p>Try forcing too much through it, and either the lawn floods (your DOM) or the pipe bursts (your app).</p>
<p>There's a second observation that pairs with the first. Your laptop has 8 to 16 CPU cores. Your React app uses 1 of them, almost always. The main thread handles JavaScript, the DOM, layout, and paint setup. The other cores sit idle while the main thread struggles to keep a 60fps frame budget.</p>
<p>Both problems have the same shape: you need to keep React out of the hot path, and you need to use more than one thread. The patterns that get you there also happen to be the patterns behind Figma's canvas engine, Bloomberg's trading dashboards, and every biosignal viewer you've seen.</p>
<p>In one project, I had to visualise 19 EEG (brainwave) channels, each sending about 1,000 data points per second. That's almost 19,000 updates per second. If you feed all of that directly into React, the UI doesn't just slow down. It faints dramatically.</p>
<p>This article is the end-to-end architecture I landed on: the ring buffers, workers, shared memory, off-main rendering, and specific patterns that hold up under sustained multi-hour load.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-whos-already-doing-this">Who's Already Doing This?</a></p>
</li>
<li><p><a href="#heading-the-1khz-math">The 1kHz Math</a></p>
</li>
<li><p><a href="#heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</a></p>
</li>
<li><p><a href="#heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</a></p>
</li>
<li><p><a href="#heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</a></p>
</li>
<li><p><a href="#heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</a></p>
</li>
<li><p><a href="#heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</a></p>
</li>
<li><p><a href="#heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with OffscreenCanvas</a></p>
</li>
<li><p><a href="#heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</a></p>
</li>
<li><p><a href="#heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</a></p>
</li>
<li><p><a href="#heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</a></p>
</li>
<li><p><a href="#heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</a></p>
</li>
<li><p><a href="#heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</a></p>
</li>
<li><p><a href="#heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</a></p>
</li>
<li><p><a href="#heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</a></p>
</li>
<li><p><a href="#heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</a></p>
</li>
<li><p><a href="#heading-the-coopcoep-catch">The COOP/COEP Catch</a></p>
</li>
<li><p><a href="#heading-production-tradeoffs">Production Tradeoffs</a></p>
</li>
<li><p><a href="#heading-should-you-build-like-this">Should You Build Like This?</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you'll want:</p>
<ul>
<li><p><strong>Working knowledge of React 18 or 19.</strong> You should be comfortable with <code>useState</code>, <code>useEffect</code>, <code>useRef</code>, and the difference between mounting and re-rendering.</p>
</li>
<li><p><strong>TypeScript basics.</strong> Most examples are in TypeScript. You should be able to read type annotations without stopping.</p>
</li>
<li><p><strong>A rough sense of the browser main thread and event loop.</strong> You don't need to have written a Web Worker, but knowing what "blocking the main thread" means will make Step 3 easier.</p>
</li>
<li><p><strong>Familiarity with Canvas 2D or a chart library</strong> is a plus, not a requirement. If you've drawn anything on a canvas, you're ready.</p>
</li>
<li><p><strong>A laptop that can run modern Chrome or Edge.</strong> The examples rely on <code>SharedArrayBuffer</code>, <code>OffscreenCanvas</code>, and Atomics, which need a Chromium-based browser and cross-origin isolation (covered later in the article).</p>
</li>
</ul>
<p>You don't need prior experience with Web Workers, ring buffers, or WebGL. This article introduces each in the context of a real problem.</p>
<h2 id="heading-whos-already-doing-this">Who's Already Doing This?</h2>
<p>The patterns in this article aren't experimental. They're the architecture behind production apps that ingest and render high-frequency data:</p>
<ul>
<li><p><strong>Trading and finance dashboards</strong> (Bloomberg, Hyperliquid, dYdX, every serious market viewer) push thousands of price ticks per second through canvas-rendered grids.</p>
</li>
<li><p><strong>Figma</strong> runs its entire canvas engine in WebAssembly inside a worker. The main thread renders React for the chrome only.</p>
</li>
<li><p><strong>Google Docs and Microsoft Loop</strong> run their document models in workers, with the DOM as the projection.</p>
</li>
<li><p><strong>Charting libraries</strong> like LightningChart, uPlot, Plotly, and ECharts draw on Canvas or WebGL and treat React as a wrapper.</p>
</li>
<li><p><strong>Biosignal, ECG, EEG, and motion-capture apps</strong> routinely process samples at 1kHz or higher and stream them to live plots.</p>
</li>
<li><p><strong>Observability and APM tools</strong> (Datadog live tail, Grafana real-time panels) decouple ingestion from render to keep tabs responsive.</p>
</li>
<li><p><strong>Audio editors and visualisers</strong>, plus anything using the Web Audio API with a waveform display.</p>
</li>
<li><p><strong>transformers.js and ONNX Runtime Web</strong> place ML inference in workers by default.</p>
</li>
</ul>
<p>Different domains, same trick: React owns what changes rarely, something else owns what changes at refresh rate, and heavy work happens on threads that aren't the main one.</p>
<h2 id="heading-the-1khz-math">The 1kHz Math</h2>
<p>Some numbers to make the problem concrete.</p>
<ul>
<li><p>A sample arrives every 1ms.</p>
</li>
<li><p>A 60Hz display refreshes every 16.67ms.</p>
</li>
<li><p>So in one frame, you'll receive roughly <strong>16 to 17 samples per stream</strong>.</p>
</li>
<li><p>With 19 active streams (the EEG case), that's <strong>300 to 320 samples per frame</strong>.</p>
</li>
</ul>
<p>If you <code>setState</code> on each sample, React tries to do around 19,000 renders per second. It can't, so it skips frames. The UI stutters and your laptop fans take off.</p>
<p>If you <code>setState</code> once per frame with the batch of ~320 samples, React does 60 renders per second, which is easy.</p>
<p>That single reframe is the entire trick, and it will echo through every step below.</p>
<h2 id="heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</h2>
<p>Here's the version of the code I see in most real-time React apps the first time they try this. It looks reasonable. It's also the source of every bit of jank the team will spend the next two weeks tracking down.</p>
<pre><code class="language-tsx">import { useEffect, useState } from "react";

export default function NaiveChart({ socket }) {
  const [data, setData] = useState&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      setData((prev) =&gt; [...prev, point]); // re-renders every time
    });
  }, [socket]);

  return &lt;div&gt;{data.length} points&lt;/div&gt;;
}
</code></pre>
<p>Three problems baked into seven lines:</p>
<ol>
<li><p><strong>Every incoming sample triggers a re-render:</strong> At 1kHz that's 1,000 renders per second. React was never going to be happy about that.</p>
</li>
<li><p><code>[...prev, point]</code> <strong>allocates a new array on every push:</strong> At 1kHz that's a new array per millisecond, all of which the garbage collector has to clean up. The heap climbs, GC pauses lengthen, the fan kicks in.</p>
</li>
<li><p><strong>There's no upper bound on the array:</strong> Run this for an hour and you have 3.6 million numbers in memory, all of which React has to consider on every render.</p>
</li>
</ol>
<p>The fix isn't one trick. It's a stack of small ones, each addressing one of those failure modes and, eventually, the deeper problem of the main thread being the only thread.</p>
<h2 id="heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</h2>
<p>Before the code, two metaphors that will keep the pieces straight.</p>
<p><strong>First, air traffic control:</strong> Three roles, one airport.</p>
<ul>
<li><p>The <strong>control tower</strong> (your store) sees every plane (every sample) and tracks where it is.</p>
</li>
<li><p>The <strong>ground crew</strong> (React) sets up the runways and gates: the layout that planes use.</p>
</li>
<li><p>The <strong>pilots</strong> (the draw loop) actually fly. They look at the tower for clearance and act every few seconds.</p>
</li>
</ul>
<p>The control tower doesn't pull a gate from the ground crew every time a plane moves. It just holds the data. The ground crew rearranges gates when the schedule changes, which is rare. The pilots act constantly, at their own rate, off the tower's data.</p>
<p><strong>Second, a kitchen brigade:</strong> A restaurant kitchen at peak hour. One head chef can't make every dish. The classical brigade has stations: sauce, fish, pastry, garde manger, plating, and service. Each station owns a slice of the meal. The expediter coordinates timing.</p>
<ul>
<li><p>The <strong>expediter</strong> is your main thread.</p>
</li>
<li><p>The <strong>stations</strong> are your workers.</p>
</li>
<li><p>The <strong>plating window</strong> is your shared memory.</p>
</li>
<li><p>The <strong>dishes going to tables</strong> are your rendered frames.</p>
</li>
</ul>
<p>A single chef trying to do everything serially is your main-thread-only frontend. A brigade is your worker-based one. The brigade is faster because cuts, sauces, and sears happen in parallel, not because any one cook is faster than the soloist. The expediter doesn't cook, they orchestrate.</p>
<p>Everything below is a specific application of these two ideas.</p>
<h2 id="heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</h2>
<p>The single biggest mistake in real-time React apps is treating every sample as state, which it isn't. State is what determines <em>which components exist and how they're arranged</em>. A live plot is one component. The 60,000 samples scrolling across it aren't 60,000 pieces of state. They're one buffer.</p>
<p>You want a store that lives outside React. A ring buffer over a typed array gives you constant-time inserts and a bounded heap:</p>
<pre><code class="language-ts">type Listener = () =&gt; void;

export function createSampleStore(capacity: number) {
  const buf = new Float32Array(capacity);
  let head = 0;
  let size = 0;
  const listeners = new Set&lt;Listener&gt;();

  return {
    push(sample: number) {
      buf[head] = sample;
      head = (head + 1) % capacity;
      if (size &lt; capacity) size++;
    },
    pushBatch(samples: Float32Array) {
      for (let i = 0; i &lt; samples.length; i++) {
        buf[head] = samples[i];
        head = (head + 1) % capacity;
        if (size &lt; capacity) size++;
      }
    },
    read(): Float32Array {
      if (size &lt; capacity) return buf.subarray(0, size);
      const out = new Float32Array(capacity);
      out.set(buf.subarray(head));
      out.set(buf.subarray(0, head), capacity - head);
      return out;
    },
    subscribe(l: Listener) {
      listeners.add(l);
      return () =&gt; listeners.delete(l);
    },
  };
}
</code></pre>
<p>Notice what's not here: no <code>useState</code>, no setter, no React anything. It's just plain JavaScript. The store can accept a million pushes per second and React won't care, because React isn't subscribed.</p>
<p>Here are the ring buffer mechanics, visualised:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361162753/2374fc6e-429c-4a8a-8b31-30fb51c6052c.png" alt="Ring buffer with eight indexed slots, a head pointer advancing each push and wrapping around when it reaches the end, providing constant-time inserts and a bounded heap" style="display: block;" width="1600" height="463" loading="lazy">

<p>We have head advances on every push and wraps at capacity. Reads pull a window of the last N samples in chronological order. The result is constant time and a bounded heap.</p>
<p>A simpler stop-gap, when you don't want a typed buffer yet, is to put the rolling window in a ref instead of state:</p>
<pre><code class="language-tsx">import { useEffect, useRef } from "react";

export default function RefChart({ socket }) {
  const bufferRef = useRef&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      bufferRef.current.push(point);
      if (bufferRef.current.length &gt; 1000) {
        bufferRef.current.shift();
      }
    });
  }, [socket]);

  return &lt;div&gt;Streaming {bufferRef.current.length} points&lt;/div&gt;;
}
</code></pre>
<p>This is the "10x faster than <code>NaiveChart</code>, still not great" version. The render doesn't trigger on every push, but you also won't see updates unless something else re-renders. For real plotting, pair the ref with a <code>requestAnimationFrame</code> draw loop (see Step 2).</p>
<h2 id="heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</h2>
<p>React renders when the <em>shape</em> of the UI changes. New plot? Re-render. Removed plot? Re-render. Switched from line to bar? Re-render. None of those happen at 1000Hz. They happen a few times a minute, when the user clicks something.</p>
<p>The <em>values</em> inside each plot change at the data rate. Those should never touch React.</p>
<p>Concretely:</p>
<pre><code class="language-tsx">function LivePlot({ store }: { store: SampleStore }) {
  const canvasRef = useRef&lt;HTMLCanvasElement&gt;(null);

  useEffect(() =&gt; {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;

    let raf = 0;
    const draw = () =&gt; {
      const samples = store.read();
      drawSeries(ctx, samples);
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () =&gt; cancelAnimationFrame(raf);
  }, [store]);

  return &lt;canvas ref={canvasRef} width={1200} height={300} /&gt;;
}
</code></pre>
<p>React renders this component once. The <code>useEffect</code> runs once. The <code>requestAnimationFrame</code> loop reads from the store on every frame.</p>
<p>The store can be pushed to at any rate the source can manage. The user sees a smooth 60fps line whether the source is sending 100 samples per second or 100,000. The instinct to wire <code>samples</code> into <code>useState</code> is wrong, so resist it.</p>
<h3 id="heading-selective-subscriptions-when-you-do-need-react-in-the-loop">Selective Subscriptions When You Do Need React in the Loop</h3>
<p>Sometimes a component genuinely depends on the data (summary stats, axis labels, or a live value badge). For that, use a store with selective subscriptions so only the components that care re-render. Zustand makes this trivial:</p>
<pre><code class="language-ts">import { create } from "zustand";

const useDataStore = create&lt;{ latest: number | null; setLatest: (v: number) =&gt; void }&gt;((set) =&gt; ({
  latest: null,
  setLatest: (v) =&gt; set({ latest: v }),
}));

function LatestBadge() {
  // Only re-renders when latest changes, not when other store fields do.
  const latest = useDataStore((state) =&gt; state.latest);
  return &lt;div&gt;Latest: {latest?.toFixed(2)}&lt;/div&gt;;
}
</code></pre>
<p>Pair this with RAF coalescing on the writes (call <code>setLatest</code> once per frame, not once per sample) and you get a React component that updates smoothly at 60fps no matter what the data rate is.</p>
<p><code>useSyncExternalStore</code> is the native equivalent and works against any pub-sub store, including the ring buffer above. Use whichever feels lighter for your team.</p>
<h2 id="heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</h2>
<p>The store and imperative draw loop handle React's contribution to the bottleneck. The main thread itself is still doing all the ingest, parsing, and math. Everything past ~50,000 samples per second per stream needs more.</p>
<p>The browser gives you four escape hatches: Web Workers, transferable objects, <code>SharedArrayBuffer</code> + Atomics, and <code>OffscreenCanvas</code>. Each solves a specific problem.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369113008/d1d608c9-8078-4339-91e1-c9ee221bf014.png" alt="Browser process model with the renderer process containing the main thread, dedicated workers, shared workers, a service worker, and a compositor thread; the GPU process containing the GPU thread for WebGL and WebGPU; and the network process containing the network thread for fetch and WebSocket, with communication between main and worker types happening via postMessage" style="display: block;" width="1600" height="1006" loading="lazy">

<p>The main thread runs JavaScript, the DOM, layout, and paint setup. Workers are isolated JavaScript contexts with their own event loops. They can't touch the DOM, they can't share memory by default, and every cross-thread message is async and copied unless you transfer it.</p>
<p>Three properties matter as you design against this model:</p>
<ul>
<li><p><strong>Workers can't touch the DOM:</strong> That's the point. They run pure JavaScript. Perfect for data work, network parsing, math, codecs, ML inference.</p>
</li>
<li><p><strong>Communication is async:</strong> No shared variable to read in the middle of a function. Plan APIs around requests and events.</p>
</li>
<li><p><strong>Data copies, unless you transfer or share it:</strong> Structured clone is the default, transferable objects skip the copy, and <code>SharedArrayBuffer</code> skips it permanently.</p>
</li>
</ul>
<h3 id="heading-dedicated-workers-in-practice">Dedicated Workers in Practice</h3>
<p>Here's a minimal worker for ingest:</p>
<pre><code class="language-ts">// worker.ts
self.onmessage = (event) =&gt; {
  const { samples } = event.data;
  // Decode, filter, decimate. The main thread doesn't see any of this.
  const summary = computeStats(samples);
  postMessage(summary);
};
</code></pre>
<pre><code class="language-tsx">// main thread
import { useEffect, useRef } from "react";

export default function WorkerChart({ socket, store }) {
  const workerRef = useRef&lt;Worker&gt;();

  useEffect(() =&gt; {
    workerRef.current = new Worker(new URL("./worker.ts", import.meta.url), {
      type: "module",
    });

    workerRef.current.onmessage = (event) =&gt; {
      store.pushBatch(event.data); // pre-processed, cheap to ingest
    };

    socket.on("newPoint", (point: number) =&gt; {
      workerRef.current?.postMessage({ point });
    });

    return () =&gt; workerRef.current?.terminate();
  }, [socket, store]);

  return &lt;LivePlot store={store} /&gt;;
}
</code></pre>
<p>Three things to internalise about workers.</p>
<p>First, workers are real processes from the runtime's perspective. Each has its own heap, event loop, and <code>globalThis</code>. Starting one costs about 1 to 5ms. Don't spin them up inside hot paths.</p>
<p>Second, module workers are the modern default. <code>type: "module"</code> enables ES modules inside the worker, including <code>import</code>. The legacy <code>importScripts</code> is for classic workers, so avoid it for new code.</p>
<p>Finally, bundlers know about workers. Vite, Webpack, esbuild, and Rspack all detect the <code>new Worker(new URL("./x.ts", import.meta.url))</code> pattern and produce a separate chunk for the worker.</p>
<h3 id="heading-a-worker-pool-that-scales-with-cores">A Worker Pool That Scales with Cores</h3>
<p>For CPU-bound work (parsing binary frames, decoding audio, computing FFTs), a pool spreads jobs across all available cores:</p>
<pre><code class="language-ts">class WorkerPool {
  private workers: Worker[];
  private next = 0;
  private pending = new Map&lt;string, (result: unknown) =&gt; void&gt;();

  constructor(scriptUrl: URL, size = Math.max(1, navigator.hardwareConcurrency - 1)) {
    this.workers = Array.from({ length: size }, () =&gt; {
      const w = new Worker(scriptUrl, { type: "module" });
      w.onmessage = (event) =&gt; {
        const cb = this.pending.get(event.data.id);
        if (!cb) return;
        this.pending.delete(event.data.id);
        cb(event.data.result);
      };
      return w;
    });
  }

  async run&lt;T&gt;(kind: string, payload: unknown, transferables: Transferable[] = []): Promise&lt;T&gt; {
    const id = crypto.randomUUID();
    const result = new Promise&lt;T&gt;((resolve) =&gt; this.pending.set(id, resolve as (r: unknown) =&gt; void));
    const worker = this.workers[this.next];
    this.next = (this.next + 1) % this.workers.length;
    worker.postMessage({ id, kind, payload }, transferables);
    return result;
  }
}

export const pool = new WorkerPool(new URL("./decoder.worker.ts", import.meta.url));
</code></pre>
<p>Round-robin distribution with one worker per core minus one. Pending requests resolve when the matching response arrives. It's cheap, predictable, and scales linearly until you hit memory bandwidth limits.</p>
<h3 id="heading-transferable-objects-the-no-copy-path">Transferable Objects, the No-copy Path</h3>
<p><code>postMessage</code> clones the payload by default. Cloning a 10MB buffer takes milliseconds and doubles your memory use. The fix is to <strong>transfer</strong> the buffer instead.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369124743/0a53c521-618c-48ec-a5c5-935e1acdbf1a.png" alt="Side-by-side comparison of postMessage modes: default postMessage clones the buffer with a copy landing in both the main heap and the worker heap via structured clone, while Transferable transfers ownership so the same buffer exists once with ownership moving from main to worker" style="display: block;" width="1600" height="4546" loading="lazy">

<p>The semantics: when you transfer a buffer, the sender loses access. The receiver gains it, with no copy and an O(1) handoff.</p>
<pre><code class="language-ts">const buf = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
new Uint8Array(buf).set(somePayload);

worker.postMessage({ buf }, [buf]); // second arg = list of transferables
// `buf` is now detached on this side. Accessing it throws.
</code></pre>
<p>The list of transferable types in 2026 includes <code>ArrayBuffer</code> (and any typed-array view backed by one), <code>MessagePort</code>, <code>ImageBitmap</code>, <code>OffscreenCanvas</code>, the stream types (<code>ReadableStream</code>, <code>WritableStream</code>, <code>TransformStream</code>), <code>RTCDataChannel</code>, <code>VideoFrame</code>, <code>AudioData</code>, and the WebTransport streams. The most useful for React plus real-time work are <code>ArrayBuffer</code>, <code>OffscreenCanvas</code>, and <code>MessagePort</code>.</p>
<p>A common pitfall is that forgetting to transfer creates silent slowness. The app works, but it copies every message. Profile worker <code>postMessage</code> calls. If they're showing milliseconds for "small" payloads, you're cloning when you should be transferring.</p>
<pre><code class="language-ts">// Bad: copies every frame.
worker.postMessage({ samples: float32Array });

// Good: transfers the underlying buffer.
worker.postMessage({ samples: float32Array }, [float32Array.buffer]);
</code></pre>
<p>The buffer is detached after transfer, so the sender needs to re-allocate (or pull from a pool of pre-allocated buffers) if it wants to keep producing.</p>
<h3 id="heading-sharedarraybuffer-and-atomics">SharedArrayBuffer and Atomics</h3>
<p>Transfer hands a buffer off. <strong>Sharing</strong> lets both threads see the same memory simultaneously.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(1024 * 1024); // 1 MB shared
worker.postMessage({ sab });

// Both main and worker now hold references to the same memory.
const viewMain = new Int32Array(sab);
// Inside worker:
// const viewWorker = new Int32Array(event.data.sab);
</code></pre>
<p>Three properties matter.</p>
<p><code>SharedArrayBuffer</code> needs cross-origin isolation. Your page must be served with <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>. Without these headers, <code>SharedArrayBuffer</code> is undefined in the browser. Covered in more detail later in the article.</p>
<p>You also need <code>Atomics</code> for synchronisation. Multiple threads writing to the same memory without coordination produces undefined results. <code>Atomics</code> gives you compare-and-swap, load, store, add, sub, wait, and notify operations on typed array views.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab);

// Main thread: wake any worker that's waiting on slot 0.
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1);

// Worker: block until slot 0 changes from 0.
const result = Atomics.wait(view, 0, 0); // "ok", "not-equal", or "timed-out"
</code></pre>
<p>And lock-free ring buffers are the killer app. A producer-consumer queue between two threads with no locking, no <code>postMessage</code> round-trip, and no GC pressure. The data sits in the shared buffer. Atomics coordinate read/write positions.</p>
<p>Here's a minimal SPSC (single-producer, single-consumer) ring buffer:</p>
<pre><code class="language-ts">type SharedRing = {
  data: Float32Array;          // payload
  control: Int32Array;         // [head, tail]
};

function createSharedRing(capacity: number): SharedRing {
  const sab = new SharedArrayBuffer(capacity * 4 + 16);
  const control = new Int32Array(sab, 0, 4);   // [head, tail, ...]
  const data = new Float32Array(sab, 16, capacity);
  return { data, control };
}

function push(ring: SharedRing, value: number): boolean {
  const head = Atomics.load(ring.control, 0);
  const tail = Atomics.load(ring.control, 1);
  const next = (head + 1) % ring.data.length;
  if (next === tail) return false; // full
  ring.data[head] = value;
  Atomics.store(ring.control, 0, next);
  return true;
}

function pop(ring: SharedRing): number | null {
  const tail = Atomics.load(ring.control, 1);
  const head = Atomics.load(ring.control, 0);
  if (tail === head) return null; // empty
  const value = ring.data[tail];
  Atomics.store(ring.control, 1, (tail + 1) % ring.data.length);
  return value;
}
</code></pre>
<p>The producer writes from one thread. The consumer reads from another, neither blocks, and there's no <code>postMessage</code> between them. At signal rates, this is the only architecture that scales.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369132294/fcefe558-040e-41d3-b759-a8c10c5d5502.png" alt="Single-producer single-consumer ring buffer over SharedArrayBuffer: the producer (ingest worker) writes at the head pointer and increments it, the consumer (render thread) reads from the tail pointer and increments it, both pointers stored in the shared buffer and updated with atomic operations" style="display: block;" width="1600" height="308" loading="lazy">

<p>For multi-producer or multi-consumer queues, you need compare-and-swap loops (<code>Atomics.compareExchange</code>). They get fiddly fast. Most production setups use SPSC where they can and fall back to message-passing where they can't.</p>
<h3 id="heading-in-electron-ingest-in-the-main-process">In Electron, Ingest in the Main Process</h3>
<p>The same idea applies one level up. With Electron and a native SDK, you can ingest samples in the <strong>main process</strong>, buffer there, and forward batches across IPC at the renderer's refresh cadence. One IPC message per sample at 1kHz will saturate IPC. One IPC message per frame with a batch of 16 samples is trivial.</p>
<pre><code class="language-ts">// electron main: buffer in Node, flush at 60Hz
let pending: number[] = [];

device.on("sample", (value) =&gt; pending.push(value));

setInterval(() =&gt; {
  if (pending.length === 0) return;
  const batch = new Float32Array(pending);
  pending = [];
  // Transferable to avoid the structured-clone copy.
  mainWindow.webContents.send("device:samples", batch.buffer, [batch.buffer]);
}, 1000 / 60);
</code></pre>
<pre><code class="language-ts">// preload: expose a thin subscription API to the renderer
contextBridge.exposeInMainWorld("device", {
  onSamples: (cb: (samples: Float32Array) =&gt; void) =&gt; {
    const handler = (_: unknown, buf: ArrayBuffer) =&gt; cb(new Float32Array(buf));
    ipcRenderer.on("device:samples", handler);
    return () =&gt; ipcRenderer.removeListener("device:samples", handler);
  },
});
</code></pre>
<pre><code class="language-tsx">// renderer: feed the store from the bridge
useEffect(() =&gt; {
  return window.device.onSamples((batch) =&gt; store.pushBatch(batch));
}, []);
</code></pre>
<p>The renderer thread of the renderer touches only one batch per frame, no matter how fast the device is. The data work happens upstream.</p>
<h2 id="heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with <code>OffscreenCanvas</code></h2>
<p>The DOM is single-threaded. The Canvas API used to be too. <code>OffscreenCanvas</code> breaks that: a canvas you can transfer to a worker, where it draws independently of main.</p>
<pre><code class="language-ts">// Main thread
const canvas = canvasRef.current!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
</code></pre>
<pre><code class="language-ts">// Worker
let ctx: OffscreenCanvasRenderingContext2D | null = null;

self.onmessage = (event) =&gt; {
  if (event.data.canvas) {
    ctx = event.data.canvas.getContext("2d");
    return;
  }
  if (event.data.samples &amp;&amp; ctx) {
    drawSeries(ctx, event.data.samples);
  }
};
</code></pre>
<p>The main thread is now free to handle clicks, hover, and other interaction without competing with the draw loop. The worker draws at its own rate, against whatever data it has.</p>
<p>Combined with <code>SharedArrayBuffer</code>, you get the cleanest real-time rendering pipeline available in the browser:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369135331/1a205236-981d-4df7-8b21-698d15b84a63.png" alt="Rendering pipeline off the main thread: a data source feeds an ingest worker which writes into a SharedArrayBuffer, which a render worker owning an OffscreenCanvas reads to issue GPU commands that update the DOM, while the main thread reads a summary from the SharedArrayBuffer for React UI" style="display: block;" width="1600" height="260" loading="lazy">

<p>The ingest worker writes samples into the shared buffer. The render worker reads them and draws. Main only reads summaries (FPS, latest value, channel labels) and renders chrome. No data ever crosses through main's event loop.</p>
<p>For 19-channel signal at 1kHz, this is the only architecture that keeps a guaranteed 60fps on a laptop, with headroom.</p>
<h2 id="heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</h2>
<p>A 1200-pixel-wide canvas can show, at best, 1200 distinct X positions. If you have 60,000 samples in your window and you draw all of them, you do 50 times more work than the user can see.</p>
<p>Pick the right point per pixel column. The "min-max" pattern works well for signals: for each pixel column, find the minimum and maximum value in that range and draw a vertical line between them. This is visually identical to drawing every point, but it's much cheaper.</p>
<pre><code class="language-ts">function drawDecimated(
  ctx: CanvasRenderingContext2D,
  samples: Float32Array,
  width: number,
) {
  const samplesPerPixel = samples.length / width;
  ctx.beginPath();
  for (let x = 0; x &lt; width; x++) {
    const start = Math.floor(x * samplesPerPixel);
    const end = Math.floor((x + 1) * samplesPerPixel);
    let min = Infinity;
    let max = -Infinity;
    for (let i = start; i &lt; end; i++) {
      const v = samples[i];
      if (v &lt; min) min = v;
      if (v &gt; max) max = v;
    }
    ctx.moveTo(x, scale(min));
    ctx.lineTo(x, scale(max));
  }
  ctx.stroke();
}
</code></pre>
<p><a href="https://en.wikipedia.org/wiki/Downsampling_(signal_processing)">Decimation</a> is the single biggest CPU win in real-time visualisation, and almost nobody does it.</p>
<p>A more visually faithful variant is <strong>LTTB (Largest Triangle Three Buckets)</strong>, an algorithm that picks one representative sample per bucket while preserving the visual shape better than min-max. It's worth the read if you're plotting non-signal data like stock charts where peaks and dips matter individually. Most good chart libraries (uPlot, Plotly, ECharts) include decimation out of the box.</p>
<h2 id="heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</h2>
<p>You don't always need to write the draw loop yourself. React is great at lifecycle management. Imperative chart libraries are great at raw performance. The right move is often to let React mount and unmount the chart while the library handles the fast inner loop.</p>
<pre><code class="language-tsx">import Uplot from "uplot";
import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";

export default function UPlotChart({ data }: { data: AlignedData }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);
  const plotRef = useRef&lt;Uplot | null&gt;(null);

  useEffect(() =&gt; {
    const opts = {
      title: "Realtime Chart",
      width: 600,
      height: 300,
      series: [{}, { label: "Signal" }],
    };
    plotRef.current = new Uplot(opts, data, ref.current!);
    return () =&gt; plotRef.current?.destroy();
  }, []);

  useEffect(() =&gt; {
    plotRef.current?.setData(data); // imperative update, no React render
  }, [data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>uPlot, TimeChart, ECharts, Plotly, LightningChart, and the others all follow this shape: instantiate inside <code>useEffect</code>, call <code>setData</code> imperatively, and destroy on unmount. React orchestrates and the library renders.</p>
<p>The general pattern, for any imperative renderer, looks like this:</p>
<pre><code class="language-tsx">function ChartWrapper({ config, data }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    const chart = new SomeFastChartLib(ref.current, config);
    chart.setData(data);
    return () =&gt; chart.destroy();
  }, [config, data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>This is the highest-ROI move if you're not in a domain where you need pixel-level control over the drawing. Reach for a library first, and only write your own draw loop when no library fits.</p>
<h2 id="heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</h2>
<p>Canvas 2D handles a few thousand line segments per frame comfortably. Past that, you start spending milliseconds in <code>stroke()</code> itself.</p>
<p>WebGL (or its higher-level wrappers like <code>regl</code>, <code>twgl</code>, <code>pixi.js</code>, <code>deck.gl</code>) moves the rendering to the GPU. You pay an upfront cost (writing shaders and managing buffers) for the ability to draw millions of points without breaking a sweat.</p>
<p>Here's a minimal WebGL "line strip with a single vertex shader" sketch:</p>
<pre><code class="language-ts">const gl = canvas.getContext("webgl2")!;

const program = gl.createProgram()!;
// ... compile vertex + fragment shaders, link, get attribute location ...

const samplesBuffer = gl.createBuffer();
function drawWebGL(samples: Float32Array) {
  gl.bindBuffer(gl.ARRAY_BUFFER, samplesBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, samples, gl.STREAM_DRAW);
  gl.useProgram(program);
  gl.drawArrays(gl.LINE_STRIP, 0, samples.length);
}
</code></pre>
<p>The pattern is the same as Canvas 2D: an imperative draw call inside the RAF loop. The difference is the GPU does the actual rasterising. Most real-time charting libraries that claim "millions of points" are doing exactly this under the hood.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361166770/22241c5c-446b-4026-88fb-33e3caad3831.png" alt="Decision tree comparing rendering options: Canvas 2D for up to a few thousand points, WebGL for tens of thousands or more, and WebGPU for 3D, custom shaders, or compute once stable" style="display: block;" width="1600" height="1351" loading="lazy">

<p>Default to Canvas 2D, and escalate to WebGL only when you can prove Canvas is the bottleneck on the Profiler.</p>
<h2 id="heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</h2>
<p>Real-time apps die slowly. They run smoothly for an hour and then the laptop fan kicks in. The cause is almost always memory.</p>
<p>There are three rules that keep the heap calm.</p>
<p>First, use typed arrays for numeric data. A <code>Float32Array</code> of 60,000 floats is 240KB. A plain JavaScript array of the same size is around 1.4MB and creates GC pressure on every push.</p>
<p>Second, bound everything. Use ring buffers, not growing arrays. Put caps on history, queues, and pending work. Anything that <em>can</em> grow unbounded eventually will.</p>
<p>Third, reuse buffers. Allocate once in setup, and reuse across frames. Allocating a fresh array per frame at 60fps is 60 allocations per second per plot. Multiply by plot count.</p>
<pre><code class="language-ts">// Bad: fresh array every frame.
function drawFrame() {
  const snapshot = store.read(); // returns a new Float32Array
  drawDecimated(ctx, snapshot, width);
}

// Better: reuse a draw buffer.
const drawBuf = new Float32Array(WINDOW_SIZE);

function drawFrame() {
  store.readInto(drawBuf); // writes into the existing buffer
  drawDecimated(ctx, drawBuf, width);
}
</code></pre>
<p>These rules aren't React-specific. They're the rules any real-time system follows. The reason they need stating is that React-shaped thinking ("derive a new array each render") is the opposite of what real-time wants.</p>
<h2 id="heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</h2>
<p>A worker pool with no scheduling is just a queue. Sometimes you want priority: a user-driven action should jump ahead of a background sweep.</p>
<h3 id="heading-priority-queues">Priority Queues</h3>
<p>A small priority scheduler:</p>
<pre><code class="language-ts">type Priority = "high" | "normal" | "low";

class PriorityPool {
  private queues: Record&lt;Priority, Job[]&gt; = { high: [], normal: [], low: [] };
  private idle: Worker[];

  enqueue(job: Job, priority: Priority = "normal") {
    if (this.idle.length &gt; 0) {
      const worker = this.idle.pop()!;
      this.dispatch(worker, job);
    } else {
      this.queues[priority].push(job);
    }
  }

  private next(): Job | null {
    for (const p of ["high", "normal", "low"] as const) {
      const j = this.queues[p].shift();
      if (j) return j;
    }
    return null;
  }

  private dispatch(worker: Worker, job: Job) {
    worker.postMessage(job.payload, job.transferables);
    worker.onmessage = (event) =&gt; {
      job.resolve(event.data);
      const next = this.next();
      if (next) this.dispatch(worker, next);
      else this.idle.push(worker);
    };
  }
}
</code></pre>
<p>Three buckets are usually enough: high (user-initiated), normal (steady-state work), low (background sweeps, prefetch, telemetry flushing).</p>
<h3 id="heading-chunkable-work">Chunkable Work</h3>
<p>A job that takes 2 seconds blocks a worker for 2 seconds. If you want to keep workers responsive to higher-priority jobs, the job has to be chunkable.</p>
<pre><code class="language-ts">// In the worker:
self.onmessage = async (event) =&gt; {
  const { id, kind, payload } = event.data;
  if (kind === "decode_large") {
    const total = payload.byteLength;
    for (let i = 0; i &lt; total; i += CHUNK) {
      const chunk = decodeChunk(payload, i, Math.min(i + CHUNK, total));
      self.postMessage({ id, kind: "progress", chunk, offset: i });
      // Yield so the worker can check its message queue.
      await new Promise((r) =&gt; setTimeout(r, 0));
    }
    self.postMessage({ id, kind: "done" });
  }
  if (kind === "cancel") {
    // ... abort the current job
  }
};
</code></pre>
<p>Yielding inside a worker isn't free, but it lets the worker process cancellation messages or higher-priority jobs interleaved with the big task. For long jobs (like a firmware flash, large file decode, or big render), this is essential.</p>
<h3 id="heading-fan-out-and-fan-in">Fan-out and Fan-in</h3>
<p>Split a big job into N pieces, dispatch each to a different worker, and gather the results.</p>
<pre><code class="language-ts">async function decodeFile(buffer: ArrayBuffer): Promise&lt;DecodedFrame[]&gt; {
  const chunks = splitBuffer(buffer, 8);
  const results = await Promise.all(
    chunks.map((chunk) =&gt; pool.run&lt;DecodedFrame[]&gt;("decode", chunk, [chunk])),
  );
  return results.flat();
}
</code></pre>
<p>This gives you linear speedup on parallelisable workloads, up to the worker count. It's critical for batch operations like decoding a recording, summarising a long document, or computing embeddings for a folder.</p>
<h2 id="heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</h2>
<p>A spike on the Profiler is one thing. A slow heap creep over an hour is another. For real-time apps, you measure two things.</p>
<p>First, frame consistency. Are you holding 60fps consistently, or dropping occasional frames? A simple FPS meter:</p>
<pre><code class="language-ts">let lastTime = performance.now();
let frames = 0;

function rafLoop(now: number) {
  frames++;
  if (now - lastTime &gt;= 1000) {
    const fps = (frames * 1000) / (now - lastTime);
    frames = 0;
    lastTime = now;
    console.log(`fps: ${fps.toFixed(1)}`);
  }
  requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
</code></pre>
<p>A more honest measure is the worst frame in a window. Average frame time hides the jank.</p>
<p>Second, heap stability. Open DevTools Memory tab, take a heap snapshot, run the app for ten minutes, take another snapshot, and then compare. The diff should be flat. If it's growing, you have a leak: retained listeners, growing arrays, or closures holding onto large objects.</p>
<p>For real apps, wire heap usage and FPS into your analytics so you spot regressions before users complain.</p>
<h2 id="heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</h2>
<p>Back to the project that started this article: we have nineteen channels, each at 1kHz, all rendered simultaneously, and all needing to stay smooth across multi-hour recording sessions.</p>
<p>The first build used <strong>LightningChart</strong>. It's powerful, capable, and beautiful. But it's also heavy. Memory usage climbed noticeably, the chart's own internals were doing a lot of work for our use case (which was 19 simple line plots, not full multi-axis financial charts), and the licensing was a friction point.</p>
<p>We switched to <strong>uPlot</strong>. It's tiny, fast, and written specifically for time-series. Memory usage dropped, render time per frame went from "occasionally over budget" to "always under," and my machine stopped sounding like it was about to take off. The chart library change alone bought us most of the headroom we needed.</p>
<p>The architecture around the chart did the rest. The pipeline runs on four threads:</p>
<ul>
<li><p><strong>One ingest worker</strong> reads from the device SDK over IPC (Electron main process to renderer).</p>
</li>
<li><p><strong>One</strong> <code>SharedArrayBuffer</code> holds the rolling window for all 19 channels.</p>
</li>
<li><p><strong>One render worker</strong> reads the SAB and draws to an <code>OffscreenCanvas</code>.</p>
</li>
<li><p><strong>Main thread</strong> renders chrome: channel labels, controls, and the FPS meter.</p>
</li>
</ul>
<p>The shared buffer layout is one big <code>Float32Array</code> indexed as <code>[channel * samplesPerChannel + sampleIndex]</code>. The ingest worker writes new samples and advances per-channel head pointers (stored in an <code>Int32Array</code> slice of the SAB). The render worker reads the latest window each frame.</p>
<pre><code class="language-ts">const CHANNELS = 19;
const WINDOW_SAMPLES = 60_000;

const sab = new SharedArrayBuffer(CHANNELS * WINDOW_SAMPLES * 4 + CHANNELS * 4);
const heads = new Int32Array(sab, 0, CHANNELS);
const samples = new Float32Array(sab, CHANNELS * 4, CHANNELS * WINDOW_SAMPLES);

function writeSample(channel: number, value: number) {
  const head = Atomics.load(heads, channel);
  samples[channel * WINDOW_SAMPLES + head] = value;
  Atomics.store(heads, channel, (head + 1) % WINDOW_SAMPLES);
}
</code></pre>
<p>The renderer reads the channel buffers, decimates per pixel column, and draws:</p>
<pre><code class="language-ts">// render.worker.ts
function drawFrame() {
  const ctx = offscreenCtx;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (let ch = 0; ch &lt; CHANNELS; ch++) {
    const start = ch * WINDOW_SAMPLES;
    drawDecimatedRow(ctx, samples.subarray(start, start + WINDOW_SAMPLES), ch);
  }
  requestAnimationFrame(drawFrame);
}
</code></pre>
<p>On a 2024 M3 MacBook Pro, this holds 60fps with 19 channels at 1kHz, and 144fps if the display supports it. The main thread stays under 1% utilisation. Workers consume the spare cores. The user feels something that used to require a native app.</p>
<p>The lesson, in short: most of the work is choosing the right tool for the inner loop, and getting out of its way.</p>
<h2 id="heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</h2>
<p>Here are numbers from running comparable workloads on a 2024 M3 MacBook Pro. They're indicative, not promissory.</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>Main thread only</th>
<th>Worker pool</th>
<th>Workers + SAB</th>
<th>Workers + SAB + OffscreenCanvas</th>
</tr>
</thead>
<tbody><tr>
<td>Parse 100MB binary file</td>
<td>4.2s (UI frozen)</td>
<td>1.1s</td>
<td>1.0s</td>
<td>1.0s</td>
</tr>
<tr>
<td>Decode 1,000 frames</td>
<td>920ms</td>
<td>280ms</td>
<td>240ms</td>
<td>240ms</td>
</tr>
<tr>
<td>Render 1M-point chart</td>
<td>24fps</td>
<td>24fps</td>
<td>28fps</td>
<td>60fps</td>
</tr>
<tr>
<td>Telemetry: 4 streams x 1000Hz</td>
<td>22fps</td>
<td>38fps</td>
<td>55fps</td>
<td>60fps</td>
</tr>
<tr>
<td>EEG: 19 channels x 1kHz</td>
<td>12fps</td>
<td>25fps</td>
<td>48fps</td>
<td>60fps (144fps possible)</td>
</tr>
<tr>
<td>Main-thread JS time per frame</td>
<td>22ms</td>
<td>8ms</td>
<td>4ms</td>
<td>&lt; 1ms</td>
</tr>
<tr>
<td>Memory overhead</td>
<td>baseline</td>
<td>+50MB</td>
<td>+20MB</td>
<td>+20MB</td>
</tr>
<tr>
<td>Worker spin-up latency (first call)</td>
<td>0</td>
<td>2-5ms</td>
<td>2-5ms</td>
<td>5-10ms</td>
</tr>
</tbody></table>
<p>The pattern: workers alone help, and workers plus shared memory help more. Workers plus shared memory plus <code>OffscreenCanvas</code> is what gets you to "the main thread is doing nothing and the chart is still smooth."</p>
<p>For chart-heavy apps, the leap from "workers" to "workers plus <code>OffscreenCanvas</code>" is the biggest single architectural improvement available without leaving the browser.</p>
<h2 id="heading-the-coopcoep-catch">The COOP/COEP Catch</h2>
<p><code>SharedArrayBuffer</code> and high-resolution timers were tightened in 2020 after Spectre/Meltdown. To use them, your page must be served with two HTTP headers:</p>
<pre><code class="language-plaintext">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>This opts your page into "cross-origin isolation." Within an isolated context, <code>SharedArrayBuffer</code> exists, <code>performance.now()</code> is high-resolution, and various other restricted APIs work.</p>
<p>Outside an isolated context, <code>SharedArrayBuffer</code> is undefined, <code>performance.now()</code> is throttled to about 1ms precision, and Atomics throw.</p>
<p>The cost is significant. <code>require-corp</code> means every cross-origin resource (images from a CDN, embedded YouTube videos, third-party fonts, analytics scripts) must explicitly opt in by setting <code>Cross-Origin-Resource-Policy: cross-origin</code> or <code>Cross-Origin-Embedder-Policy: credentialless</code>. Many third-party services don't, which breaks their embedding.</p>
<p>There are two practical options:</p>
<ul>
<li><p><strong>For an Electron app:</strong> the renderer process can be configured to use these headers easily. Most production Electron apps that want real-time visualisation enable them by default.</p>
</li>
<li><p><strong>For a browser app:</strong> weigh the embeds you'd lose against the performance you'd gain. If your app is the main attraction (Figma, Google Docs), opt in. If you depend on third-party widgets, the cost is real.</p>
</li>
</ul>
<p>For chart-heavy or signal-heavy apps that need SAB, the Electron path is usually cleaner.</p>
<h2 id="heading-production-tradeoffs">Production Tradeoffs</h2>
<p>Here are the five real costs of everything above.</p>
<ul>
<li><p><strong>Code complexity:</strong> A worker-driven app has 2 to 4 times the source files of a single-threaded one (main + workers + shared types). It's worth it for the right scale, but painful for a trivial app.</p>
</li>
<li><p><strong>Debugging:</strong> Stack traces split across threads. Chrome DevTools handles this well in 2026 (each worker has its own debugger panel), but it's still more work than a single-thread bug.</p>
</li>
<li><p><strong>Bundle size:</strong> Each worker is a separate chunk. Tree-shaking inside workers is sometimes worse than in main (less mature). Audit worker bundles separately.</p>
</li>
<li><p><strong>Startup latency:</strong> Spinning up workers at app start adds 50 to 200ms. Pre-warm them during the splash screen, or accept the first-frame delay.</p>
</li>
<li><p><strong>Browser API gaps:</strong> <code>localStorage</code>, <code>document</code>, and most DOM APIs aren't available in workers. Some libraries silently rely on them and break. Test in a worker context before bundling a library you haven't tried there.</p>
</li>
</ul>
<p>There are three trade-offs specific to the imperative rendering pattern:</p>
<ul>
<li><p><strong>Declarative animation of the data:</strong> The chart frame, labels, and controls all stay declarative. The data inside the chart becomes imperative.</p>
</li>
<li><p><strong>Easy snapshot testing of the rendered output:</strong> A canvas has no DOM you can query. Test the data path separately from the draw path. Snapshot the store output, not the pixels.</p>
</li>
<li><p><strong>React's component story for the inner loop:</strong> The draw loop is a closure. Composing draw loops is harder than composing components. Pick your component boundary carefully so each canvas does one thing.</p>
</li>
</ul>
<p>For most apps these costs aren't worth paying. For an app that has to render 1kHz data smoothly, they're the price of admission.</p>
<h2 id="heading-should-you-build-like-this">Should You Build Like This?</h2>
<p>If your data rate is below 30 updates per second per stream, none of this is needed. Naïve <code>setState</code> per batch will work. Profile first, optimise second.</p>
<p>This architecture earns its keep when:</p>
<ul>
<li><p>You have many streams or sensors</p>
</li>
<li><p>Ingestion is sustained, not bursty</p>
</li>
<li><p>The UX promise is smooth motion for hours, not seconds</p>
</li>
<li><p>You'd rather not rewrite the UI in a native language to get there</p>
</li>
</ul>
<p>The boring rule: start with <code>requestAnimationFrame</code> coalescing and external stores. Promote to workers when those aren't enough. Promote to shared memory when worker <code>postMessage</code> is the bottleneck. Promote to <code>OffscreenCanvas</code> when the render loop itself becomes the bottleneck. Each step is a real architectural investment. Take them in order.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>React can handle real-time visualisation if you use it the right way. Instead of pushing React to do everything, use it as the conductor. Let specialised libraries and workers handle the heavy lifting.</p>
<p>These are the three rules that hold up across every high-frequency React app I've built:</p>
<ol>
<li><p><strong>Workers do the work, while main does the UI.</strong> If main is doing math, you've put the math in the wrong place.</p>
</li>
<li><p><strong>Transfer if you can, share if you must.</strong> Both beat cloning. Sharing is more complex than transferring.</p>
</li>
<li><p><strong>Let React orchestrate. Let specialised tools render.</strong> The store owns the data, the draw loop owns the values, and React owns the shape.</p>
</li>
</ol>
<p>Get those right and your React app stops being a one-core system that flinches at high-frequency data. It becomes a real multi-core system that scales with the hardware, ingests without dropping, and renders without stuttering.</p>
<p>The cores are right there. Use them.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://github.com/leeoniya/uPlot">uPlot</a>: tiny, fast, focused on time-series.</p>
</li>
<li><p><a href="https://github.com/huww98/TimeChart">TimeChart</a>: high-performance real-time chart on WebGL.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">Using Web Workers in React</a>: MDN reference.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">SharedArrayBuffer on MDN</a>: the shared-memory primitive.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas">OffscreenCanvas on MDN</a>: rendering off the main thread.</p>
</li>
<li><p><a href="https://web.dev/articles/coop-coep">COOP and COEP explainer</a>: what cross-origin isolation buys you.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to use the Fullscreen API in JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ How do you run a game created for the web in fullscreen? In this quick tutorial, you'll see how to display a game or any other HTML element in fullscreen, how to exit fullscreen, and how to make a nice fullscreen toggle button in SVG. Recently I publ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-use-full-screen-api-in-js/</link>
                <guid isPermaLink="false">66c4c81129f446a67a4197ee</guid>
                
                    <category>
                        <![CDATA[ Fullscreen API ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SVG ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hunor Márton Borbély ]]>
                </dc:creator>
                <pubDate>Thu, 22 Feb 2024 14:17:32 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/02/Untitled.022.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>How do you run a game created for the web in fullscreen? In this quick tutorial, you'll see how to display a game or any other HTML element in fullscreen, how to exit fullscreen, and how to make a nice fullscreen toggle button in SVG.</p>
<p>Recently I published a long <a target="_blank" href="https://www.freecodecamp.org/news/how-to-draw-a-gorilla-with-javascript-on-html-canvas/">JavaScript game tutorial</a>. While it was a very packed guide, there were still a few things we could not cover in it: how to display the game in fullscreen.</p>
<p>When you watch a video on YouTube, you have the option to also watch it on fullscreen. But did you know that the fullscreen feature isn't only for video elements?</p>
<p>In JavaScript, there’s a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">Fullscreen API</a>. And it’s surprisingly simple to use. Here's a quick demo of what we're about to implement. Let's see how it works.</p>
<div class="embed-wrapper">
        <iframe width="100%" height="350" src="https://codepen.io/HunorMarton/embed/QWoRLXM" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>
<p>You can also <a target="_blank" href="https://www.youtube.com/watch?v=jX3mIQdQQ2w&amp;t=15s">watch this article as a video</a> on YouTube.</p>
<h2 id="heading-table-of-contents">Table of Contents:</h2>
<ul>
<li><a class="post-section-overview" href="#heading-how-to-enter-fullscreen-mode">How to Enter Fullscreen Mode</a></li>
<li><a class="post-section-overview" href="#heading-how-to-style-the-fullscreen">How to Style the Fullscreen</a></li>
<li><a class="post-section-overview" href="#heading-how-to-display-games-with-the-canvas-element-in-fullscreen">How to Display Games with the Canvas Element in Fullscreen</a></li>
<li><a class="post-section-overview" href="#heading-how-to-exit-fullscreen">How to Exit Fullscreen</a></li>
<li><a class="post-section-overview" href="#heading-how-to-code-a-fullscreen-icon-with-svg">How to Code a Fullscreen Icon with SVG</a></li>
<li><a class="post-section-overview" href="#heading-learn-more">Learn More</a></li>
</ul>
<h2 id="heading-how-to-enter-fullscreen-mode">How to Enter Fullscreen Mode</h2>
<p>Let’s say we have a simple website with some text. And at the bottom, we have a button that will display the text in full screen. We are going to refine the look of this button, but first, let’s get work on the main logic.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-21-at-18.20.51.png" alt="Image" width="600" height="400" loading="lazy">
<em>A simple website with some text and a Toggle Fullscreen button</em></p>
<pre><code class="lang-css"><span class="hljs-selector-tag">body</span> {
  <span class="hljs-attribute">font-family</span>: Montserrat;
  <span class="hljs-attribute">margin</span>: <span class="hljs-number">50px</span>;
  <span class="hljs-attribute">max-width</span>: <span class="hljs-number">500px</span>;
}
</code></pre>
<p>In the code above, we attached an event handler to the button in HTML. We can then implement the <code>toggleFullscreen</code> function logic in JavaScript.</p>
<p>In this function, all we have to do is call the <code>requestFullScreen</code> method on the <code>document</code>’s <code>documentElement</code> property. And that’s it:</p>
<pre><code class="lang-js"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">toggleFullscreen</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">document</span>.documentElement.requestFullscreen();
}
</code></pre>
<p>If you click the button, your website will pop into fullscreen.</p>
<h2 id="heading-how-to-style-the-fullscreen">How to Style the Fullscreen</h2>
<p>Before we cover how to exit full screen and create a nice-looking toggle button, let’s see a few other things.</p>
<p>What you might notice right away is that, with more space, your content might get a bit lost on a full screen. Make sure you have a responsive styling that looks good on every screen size.</p>
<p>You can even style the layout specifically for fullscreen. In CSS you can set a media query that only applies the styling in case the <code>display-mode</code> is <code>fullscreen</code>. </p>
<p>For instance, you can change the font-size, or change the <code>background-color</code> to have a distinct look on full screen.</p>
<pre><code class="lang-css"><span class="hljs-keyword">@media</span> (<span class="hljs-attribute">display-mode:</span> fullscreen) {
  <span class="hljs-selector-tag">body</span> {
    <span class="hljs-attribute">background-color</span>: <span class="hljs-number">#f9bb86</span>;
    <span class="hljs-attribute">font-size</span>: <span class="hljs-number">1.2em</span>;
  }
}
</code></pre>
<h2 id="heading-how-to-display-games-with-the-canvas-element-in-fullscreen">How to Display Games with the Canvas Element in Fullscreen</h2>
<p>In this case, we want to make a game that uses the <code>canvas</code> element to be fullscreen – like the <a target="_blank" href="https://www.freecodecamp.org/news/how-to-draw-a-gorilla-with-javascript-on-html-canvas/">Gorillas</a> game – we also need to resize the <code>canvas</code> element to fit the whole screen.</p>
<p>In this case, we can use the <code>windows</code>’s <code>resize</code> event. The event is triggered both when we simply resize the browser window, and when we enter or exit fullscreen mode. </p>
<p>With the <code>resize</code> event, we can resize the <code>canvas</code> element to fit the whole screen, update the scaling, adjust any other properties we need to change on resize and redraw the whole scene.</p>
<pre><code class="lang-js"><span class="hljs-built_in">window</span>.addEventListener(<span class="hljs-string">"resize"</span>, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-comment">// Resize canvas element</span>
  canvas.width = <span class="hljs-built_in">window</span>.innerWidth;
  canvas.height = <span class="hljs-built_in">window</span>.innerHeight;

  <span class="hljs-comment">// Update scaling</span>
  <span class="hljs-comment">// . . .</span>

  <span class="hljs-comment">// Adjust size dependent properties</span>
  <span class="hljs-comment">// . . .</span>

  <span class="hljs-comment">// Redraw canvas</span>
  draw();
});
</code></pre>
<p>If you check the source code of the <a target="_blank" href="https://codepen.io/HunorMarton/pen/jOJZqvp">Gorillas game on CodePen</a>, you'll find similar steps.</p>
<h2 id="heading-how-to-exit-fullscreen">How to Exit Fullscreen</h2>
<p>Now that we know how to enter full screen, how do we exit from it?</p>
<p>By default, if you press the <code>Escape</code> key, the browser switches back to the normal view. In Google Chrome, you even get a notification at the top of the screen about this when you enter fullscreen mode.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-21-at-17.47.03-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Google Chrome shows a notification on top of the screen once you enter full screen</em></p>
<p>What if you want to exit fullscreen mode when you click the HTML button? Let’s change our button’s behavior to toggle fullscreen on or off.</p>
<pre><code class="lang-js"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">toggleFullscreen</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">document</span>.fullscreenElement) {
    <span class="hljs-built_in">document</span>.documentElement.requestFullscreen();
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-built_in">document</span>.exitFullscreen();
  }
}
</code></pre>
<p>First, we start by checking if we are in fullscreen mode already. We can do this by checking the <code>document</code>’s <code>fullscreenElement</code> property. If it is undefined, then we enter fullscreen mode the same way we did before. And if we are already in fullscreen mode, then we can exit by calling the document’s <code>exitFullscreen</code> method. </p>
<p>It is really that simple. With a few lines of code, we can implement the logic for a fullscreen toggle button.</p>
<h2 id="heading-how-to-code-a-fullscreen-icon-with-svg">How to Code a Fullscreen Icon with SVG</h2>
<p>If you follow <a target="_blank" href="https://www.freecodecamp.org/news/author/hunor/">my tutorials</a>, you know I love creative coding, and <a target="_blank" href="https://www.freecodecamp.org/news/svg-tutorial-learn-to-code-images/">drawing from code</a>. So let’s update the look of our button, to make it look similar to what we have on YouTube.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-21-at-18.06.07.png" alt="Image" width="600" height="400" loading="lazy">
<em>Fullscreen icon</em></p>
<p>Let’s create an SVG image within our button. If you check the source code of YouTube, you will see that they also use an SVG.</p>
<p>Let’s define an SVG element within HTML. We'll set its size to 30 x 30 and define a <code>path</code> element:</p>
<pre><code class="lang-html">. . .

<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"toggleFullscreen()"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">svg</span> <span class="hljs-attr">width</span>=<span class="hljs-string">"30"</span> <span class="hljs-attr">height</span>=<span class="hljs-string">"30"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">path</span>
      <span class="hljs-attr">stroke</span>=<span class="hljs-string">"black"</span>
      <span class="hljs-attr">stroke-width</span>=<span class="hljs-string">"3"</span>
      <span class="hljs-attr">fill</span>=<span class="hljs-string">"none"</span>
      <span class="hljs-attr">d</span>=<span class="hljs-string">"
        M 10, 2 L 2,2 L 2, 10
        M 20, 2 L 28,2 L 28, 10
        M 28, 20 L 28,28 L 20, 28
        M 10, 28 L 2,28 L 2, 20"</span>
    /&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">svg</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>

. . .
</code></pre>
<p>To style the path, we set its color with the <code>stroke</code> property, set its <code>stroke-width</code>, and made sure that we didn't end up with a filled shape. SVG paths by default are filled, so we need to set explicitly that we don’t want to <code>fill</code> this shape.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-21-at-18.02.10.png" alt="Image" width="600" height="400" loading="lazy">
<em>Using the move-to and line-to commands within an SVG path</em></p>
<p>Then we defined the path with a few move-to and line-to commands. We can set these commands as a string in the <code>d</code> attribute of the path element.</p>
<p>We started with a move-to command: <code>M 10, 2</code>. The letter <code>M</code> signifies that we have a move-to command, and the 10 and 2 are the <code>x</code> and <code>y</code> coordinates of this command. We moved to the start of one of the four lines.</p>
<p>Then we continued the path with a line-to command that moves to the corner, and then with another line-to command. The line-to command works in a similar way. It starts with the letter <code>L</code>, then we set an <code>x, y</code> coordinates where the line should go to.</p>
<p>Then we did the same with the other corners. We move to the next line segment with another move-to command and draw a line with two more line-to commands.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Untitled.021.png" alt="Image" width="600" height="400" loading="lazy">
<em>Drawing a path on a Canvas element with JavaScript has some similarities to defining a path in SVG</em></p>
<p><strong>Note</strong>: If you read my <a target="_blank" href="https://www.freecodecamp.org/news/how-to-draw-a-gorilla-with-javascript-on-html-canvas/">previous tutorial</a> on how to make the gorillas game, then you might have noticed that we had something similar there. We also drew paths with move to and line to. Except that there we were drawn on a <code>canvas</code> element with JavaScript, and now we have the commands as a string within the HTML file.</p>
<h3 id="heading-how-to-toggle-the-icons-appearance">How to Toggle the Icon's Appearance</h3>
<p>Now the SVG is looking great, but what if we want to have a different look when we are in fullscreen mode? On YouTube, when we enter fullscreen mode, the button switches to a different icon.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-21-at-18.06.33.png" alt="Image" width="600" height="400" loading="lazy">
<em>The two faces of the fullscreen icon</em></p>
<p>You can do this in different ways. The easiest way is probably to define another path, within the same SVG element with a different look. Then make this path transparent by default. We are going to toggle the visibility of these two paths in JavaScript.</p>
<pre><code class="lang-html">. . .

<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"toggleFullscreen()"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">svg</span> <span class="hljs-attr">width</span>=<span class="hljs-string">"30"</span> <span class="hljs-attr">height</span>=<span class="hljs-string">"30"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">path</span>
      <span class="hljs-attr">id</span>=<span class="hljs-string">"enter-fullscreen"</span>
      <span class="hljs-attr">stroke</span>=<span class="hljs-string">"black"</span>
      <span class="hljs-attr">stroke-width</span>=<span class="hljs-string">"3"</span>
      <span class="hljs-attr">fill</span>=<span class="hljs-string">"none"</span>
      <span class="hljs-attr">d</span>=<span class="hljs-string">"
        M 10, 2 L 2,2 L 2, 10
        M 20, 2 L 28,2 L 28, 10
        M 28, 20 L 28,28 L 20, 28
        M 10, 28 L 2,28 L 2, 20"</span>
    /&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">path</span>
      <span class="hljs-attr">id</span>=<span class="hljs-string">"exit-fullscreen"</span>
      <span class="hljs-attr">stroke</span>=<span class="hljs-string">"transparent"</span>
      <span class="hljs-attr">stroke-width</span>=<span class="hljs-string">"3"</span>
      <span class="hljs-attr">fill</span>=<span class="hljs-string">"none"</span>
      <span class="hljs-attr">d</span>=<span class="hljs-string">"
        M 10, 2 L 10,10 L 2, 10
        M 20, 2 L 20,10 L 28, 10
        M 28, 20 L 20,20 L 20, 28
        M 10, 28 L 10,20 L 2, 20"</span>
    /&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">svg</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>

. . .
</code></pre>
<p>This second path is very similar to the previous one. Except that we used different coordinates for some of the line-to commands.</p>
<p>Then we set unique IDs for both of these paths, and we update our toggle function in JavaScript. In JavaScript, we get a reference to these paths by ID, and then in the toggle button’s event handler, we can switch the visibility of these elements back and forth.</p>
<pre><code class="lang-js"><span class="hljs-keyword">const</span> enterFullscreen = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"enter-fullscreen"</span>);
<span class="hljs-keyword">const</span> exitFullscreen = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"exit-fullscreen"</span>);

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">toggleFullscreen</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">document</span>.fullscreenElement) {
    <span class="hljs-built_in">document</span>.documentElement.requestFullscreen();
    enterFullscreen.setAttribute(<span class="hljs-string">"stroke"</span>, <span class="hljs-string">"transparent"</span>);
    exitFullscreen.setAttribute(<span class="hljs-string">"stroke"</span>, <span class="hljs-string">"black"</span>);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-built_in">document</span>.exitFullscreen();
    enterFullscreen.setAttribute(<span class="hljs-string">"stroke"</span>, <span class="hljs-string">"black"</span>);
    exitFullscreen.setAttribute(<span class="hljs-string">"stroke"</span>, <span class="hljs-string">"transparent"</span>);
  }
}
</code></pre>
<p>Now if you click this button, it toggles the fullscreen mode and changes its own appearance.</p>
<h2 id="heading-learn-more">Learn More</h2>
<p>If you want to learn more about SVGs, check out <a target="_blank" href="http://SVG-Tutorial.com">SVG-Tutorial.com</a> where you can find a lot of examples from the basics to more advanced levels. It’s a free site and you can also find the example that we discussed in this article.</p>
<p>To use the button to run a JavaScript game in full screen, check out the whole JavaScript Game Tutorial on how to remake the classic Gorillas game here on <a target="_blank" href="https://www.freecodecamp.org/news/how-to-draw-a-gorilla-with-javascript-on-html-canvas/">freeCodeCamp</a> or on <a target="_blank" href="https://www.youtube.com/watch?v=2q5EufbUEQk&amp;t=2337s">YouTube</a>. It’s a massive tutorial that covers things from drawing on an HTML Canvas element, to the entire game logic, from event handling, through the animation loop, hit detection, and even AI logic, for the enemy gorilla.</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/2q5EufbUEQk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p>You can subscribe to my channel for more JavaScript game development tutorials:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/undefined" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Draw with JavaScript on an HTML Canvas Element – Gorilla Example ]]>
                </title>
                <description>
                    <![CDATA[ Drawing from code can be fun for many reasons. You can generate art that follows a certain logic. You can create animations by moving only parts of an image. And you can even build up a whole game as I covered in this tutorial. In my last article, we... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-draw-a-gorilla-with-javascript-on-html-canvas/</link>
                <guid isPermaLink="false">66c4c80829f446a67a4197ec</guid>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTML ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hunor Márton Borbély ]]>
                </dc:creator>
                <pubDate>Thu, 15 Feb 2024 16:30:12 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/02/Thumbnail.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Drawing from code can be fun for many reasons. You can generate art that follows a certain logic. You can create animations by moving only parts of an image. And you can even build up a whole game as I covered in <a target="_blank" href="https://www.freecodecamp.org/news/gorillas-game-in-javascript/">this tutorial</a>.</p>
<p>In my last article, we focused on the <a target="_blank" href="https://www.freecodecamp.org/news/drawing-on-a-canvas-element-with-javascript/">basics of drawing</a>. Now, let's see a concrete example and explore how to use JavaScript to draw a Gorilla. </p>
<p>You don't need to have any prerequisites for this tutorial. Even if you missed the basics, you can start right away. We are only going to have some simple HTML and a plain JavaScript file that you can run directly in the browser.</p>
<p>By the end, you'll know how to draw a gorilla with JS.‌</p>
<h2 id="heading-table-of-contents">Table of Contents:</h2>
<ol>
<li><a class="post-section-overview" href="#heading-how-to-define-a-canvas">How to Define a Canvas</a></li>
<li><a class="post-section-overview" href="#heading-how-to-turn-the-coordinate-system-upside-down">How to Turn the Coordinate System Upside Down</a></li>
<li><a class="post-section-overview" href="#heading-how-to-draw-the-legs-of-the-gorilla">How to Draw the Body of the Gorilla</a></li>
<li><a class="post-section-overview" href="#heading-how-to-draw-the-arms-of-the-gorilla">How to Draw the Legs of the gorilla</a></li>
<li><a class="post-section-overview" href="#heading-how-to-draw-the-face-of-the-gorilla">How to Draw the Arms of the Gorilla</a></li>
<li><a class="post-section-overview" href="#heading-how-to-draw-the-face-of-the-gorilla">How to Draw the Face of the Gorilla</a></li>
<li><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></li>
</ol>
<h2 id="heading-how-to-define-a-canvas">How to Define a Canvas</h2>
<p>To draw our gorilla, first let's define a simple HTML file with a Canvas element. Then we'll see how to access it from JavaScript. </p>
<p>In the HTML file, in the header, we'll add our JavaScript file. Note that I’m using the <code>defer</code> keyword to make sure the script only executes once the rest of the document is parsed.</p>
<p>In the body, we'll add a <code>canvas</code> element. We set its size to 500 x 500 and set an ID.</p>
<pre><code class="lang-html"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span> /&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Gorilla<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"index.js"</span> <span class="hljs-attr">defer</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">canvas</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"gorilla"</span> <span class="hljs-attr">width</span>=<span class="hljs-string">"500"</span> <span class="hljs-attr">height</span>=<span class="hljs-string">"500"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">canvas</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>Then, let's create a JavaScript file. In this file, we'll first get the canvas element by ID. Then, we'll get the rendering context of the canvas element. This is a built-in API with many methods and properties that we can use to draw on the canvas.</p>
<pre><code class="lang-js"><span class="hljs-comment">// The canvas element and its drawing context </span>
<span class="hljs-keyword">const</span> canvas = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"gorilla"</span>); 
<span class="hljs-keyword">const</span> ctx = canvas.getContext(<span class="hljs-string">"2d"</span>);

. . .
</code></pre>
<p>Before we get to drawing, first let's make our life easier by turning the coordinate system upside down.</p>
<h2 id="heading-how-to-turn-the-coordinate-system-upside-down">How to Turn the Coordinate System Upside Down</h2>
<p>When we use canvas, we have a coordinate system with the origin at the top-left corner of the canvas that grows to the right and downwards. This is aligned with how websites work in general. Things go from left to right and top to bottom. </p>
<p>This is the default, but we can change it.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/08/gorilla-with-and-without-transforming-and-scaling.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>The gorilla with and without transforming and scaling the coordinate system</em></p>
<p>In our case, it is more convenient to go from the bottom to the top. Then the gorilla can stand at the bottom, and we don’t have to figure out where the bottom of the canvas is.</p>
<p>We can use the <code>translate</code> method to shift the entire coordinate system to the bottom-middle of the canvas. We'll move the coordinate system down along the Y-axis by the size of the canvas, and to the right along the X-axis by half the size of the canvas.</p>
<p>Once we do this, the Y-coordinate is still growing downwards. We can flip it using the <code>scale</code> method. Setting a negative number for the vertical direction will flip the entire coordinate system upside down.</p>
<pre><code class="lang-js"><span class="hljs-comment">// The canvas element and its drawing context </span>
<span class="hljs-keyword">const</span> canvas = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"gorilla"</span>); 
<span class="hljs-keyword">const</span> ctx = canvas.getContext(<span class="hljs-string">"2d"</span>);

ctx.translate(<span class="hljs-number">250</span>, <span class="hljs-number">500</span>);
ctx.scale(<span class="hljs-number">1</span>, <span class="hljs-number">-1</span>);

. . .
</code></pre>
<p>‌We have to do this before we paint anything on the canvas because the <code>translate</code> and <code>scale</code> methods do not actually move anything that's already on the canvas. But anything we paint after these method calls will be painted according to this new coordinate system.</p>
<h2 id="heading-how-to-draw-the-gorilla">How to Draw the Gorilla</h2>
<p>Now let's look into drawing the gorilla. We'll break this down into multiple steps. First, we'll draw the body, then the legs, the arms, and finally the face of the gorilla.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/08/drawing-gorilla-steps.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>We'll draw the gorilla in multiple steps</em></p>
<h3 id="heading-how-to-draw-the-body-of-the-gorilla">How to draw the body of the gorilla</h3>
<p>We'll draw the body of the gorilla as a path. Paths start with the <code>beginPath</code> method and end with either calling the <code>fill</code> or the <code>stroke</code> method – or both. </p>
<p>In between, we'll build the path by calling path-building methods. In this case, to build up the body of the gorilla we'll use the <code>moveTo</code> and a bunch of <code>lineTo</code> methods.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-12-at-23.53.57.png" alt="Image" width="600" height="400" loading="lazy">
<em>The body of the gorilla. The image shows the path we're filling.</em></p>
<p>We'll set the fill style to black, and then we'll begin a path. Move to a starting position and then draw straight lines to draw the silhouette of the gorilla. Once we're finished, we'll fill the shape with the <code>fill</code> method.</p>
<pre><code class="lang-js">. . .

ctx.fillStyle = <span class="hljs-string">"black"</span>;

<span class="hljs-comment">// Draw the Body of the Gorilla</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">-68</span>, <span class="hljs-number">72</span>);
ctx.lineTo(<span class="hljs-number">-80</span>, <span class="hljs-number">176</span>);

ctx.lineTo(<span class="hljs-number">-44</span>, <span class="hljs-number">308</span>);
ctx.lineTo(<span class="hljs-number">0</span>, <span class="hljs-number">336</span>);
ctx.lineTo(+<span class="hljs-number">44</span>, <span class="hljs-number">308</span>);

ctx.lineTo(+<span class="hljs-number">80</span>, <span class="hljs-number">176</span>);
ctx.lineTo(+<span class="hljs-number">68</span>, <span class="hljs-number">72</span>);
ctx.fill();

. . .
</code></pre>
<p>In case you are wondering how I came up with these coordinates, I actually started with an initial sketch with pen and paper. I tried to estimate the coordinates, tried them with code, and then adjusted them until they started getting the right shape. Of course, you might have other methods as well.</p>
<h3 id="heading-how-to-draw-the-legs-of-the-gorilla">How to draw the legs of the gorilla</h3>
<p>We'll draw the legs the same way. We could even continue the same path we used before, but it might be easier to see what's happening if we break this down into separate paths.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-12-at-23.09.59.png" alt="Image" width="600" height="400" loading="lazy">
<em>Drawing the legs as two separate paths</em></p>
<pre><code class="lang-js">. . .

<span class="hljs-comment">// Draw the Left Leg</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">0</span>, <span class="hljs-number">72</span>);
ctx.lineTo(<span class="hljs-number">-28</span>, <span class="hljs-number">0</span>);
ctx.lineTo(<span class="hljs-number">-80</span>, <span class="hljs-number">0</span>);
ctx.lineTo(<span class="hljs-number">-68</span>, <span class="hljs-number">72</span>);
ctx.fill();

<span class="hljs-comment">// Draw the Right Leg</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">0</span>, <span class="hljs-number">72</span>);
ctx.lineTo(+<span class="hljs-number">28</span>, <span class="hljs-number">0</span>);
ctx.lineTo(+<span class="hljs-number">80</span>, <span class="hljs-number">0</span>);
ctx.lineTo(+<span class="hljs-number">68</span>, <span class="hljs-number">72</span>);
ctx.fill();

. . .
</code></pre>
<p>The fill color in this case is also going to be black. Why? Because that's what we set the <code>fillStyle</code> property to, the last time we set its value. Every path that follows this statement will use this color until we change its value.</p>
<h3 id="heading-how-to-draw-the-arms-of-the-gorilla">How to draw the arms of the gorilla</h3>
<p>While the body and the legs are relatively simple parts of the gorilla, the arms are a bit more complicated. We'll draw them as a curve.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-12-at-23.12.03.png" alt="Image" width="600" height="400" loading="lazy">
<em>Drawing the arms as a curve</em></p>
<p>Let’s start with the left arm. The main part of this is actually only two lines of code. We'll use the <code>moveTo</code> method to move to the shoulder of the gorilla, then from there, we'll draw the arm as a quadratic curve with the <code>quadraticCurveTo</code> method.</p>
<p>A quadratic curve is a simple curve with one control point. As the curve goes from the starting point (which we'll set with <code>moveTo</code>), the curve bends towards this control point (set as the first two arguments of the <code>quadraticCurveTo</code> method) as it reaches its end position (set as the last two arguments).</p>
<pre><code class="lang-js">. . .

ctx.strokeStyle = <span class="hljs-string">"black"</span>;
ctx.lineWidth = <span class="hljs-number">70</span>;

<span class="hljs-comment">// Draw the Left Arm</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">-56</span>, <span class="hljs-number">200</span>);
ctx.quadraticCurveTo(<span class="hljs-number">-176</span>, <span class="hljs-number">180</span>, <span class="hljs-number">-112</span>, <span class="hljs-number">48</span>);
ctx.stroke();

<span class="hljs-comment">// Draw the Right Arm</span>
ctx.beginPath();
ctx.moveTo(+<span class="hljs-number">56</span>, <span class="hljs-number">200</span>);
ctx.quadraticCurveTo(+<span class="hljs-number">176</span>, <span class="hljs-number">180</span>, +<span class="hljs-number">112</span>, <span class="hljs-number">48</span>);
ctx.stroke();

. . .
</code></pre>
<p>We'll draw the hands as strokes. Instead of ending the path with the <code>fill</code> method, we'll use the <code>stroke</code> method.</p>
<p>We'll also set up the styling differently. Instead of using the <code>fillStyle</code> property, here we'll set the color with <code>strokeStyle</code> and give thickness to the arms with the <code>lineWidth</code> property.</p>
<p>Drawing the right arm is the same, except the horizontal coordinates are flipped. The negative numbers have a positive sign now.</p>
<p>As a result, our gorillas should start to gain shape. They still don’t have a face, but we have the whole silhouette now.</p>
<h3 id="heading-how-to-draw-the-face-of-the-gorilla">How to draw the face of the gorilla</h3>
<p>The face of the gorilla comes together from multiple different parts. First, we'll draw the facial mask with three circles.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/08/gorilla-facial-mask.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>We draw the facial mask as three circles</em></p>
<p>Unfortunately, we don’t have a simple fill circle method, as we have in the case of rectangles. We have to draw an <code>arc</code> instead.</p>
<p>An <code>arc</code> method can be called as part of a path. We'll start each circle with the <code>beginPath</code> method and end with the <code>fill</code> method.</p>
<pre><code class="lang-js">. . .

ctx.fillStyle = <span class="hljs-string">"lightgray"</span>;

<span class="hljs-comment">// Draw the Facial Mask</span>
ctx.beginPath();
ctx.arc(<span class="hljs-number">0</span>, <span class="hljs-number">252</span>, <span class="hljs-number">36</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span> * <span class="hljs-built_in">Math</span>.PI);
ctx.fill();

ctx.beginPath();
ctx.arc(<span class="hljs-number">-14</span>, <span class="hljs-number">280</span>, <span class="hljs-number">16</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span> * <span class="hljs-built_in">Math</span>.PI);
ctx.fill();

ctx.beginPath();
ctx.arc(+<span class="hljs-number">14</span>, <span class="hljs-number">280</span>, <span class="hljs-number">16</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span> * <span class="hljs-built_in">Math</span>.PI);
ctx.fill();

. . .
</code></pre>
<p>The <code>arc</code> method has a lot of properties. This might look a bit scary, but we only need to focus on the first 3 when drawing circles:</p>
<ul>
<li>The first two arguments are <code>x</code> and <code>y</code>, the center coordinates of the arc.</li>
<li>The third argument is the <code>radius</code>.</li>
<li>Then the last two arguments are the <code>startAngle</code> and the <code>endAngle</code> of the arc in radians. Because here we want to have a full circle and not an arc, we'll start with 0 and end at a full circle. A full circle in radians is two times Pi.</li>
</ul>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-02-12-at-23.17.08.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>If these last two properties are confusing, don't worry about it. What's important is that when we draw circles, they are always <code>0</code> and <code>2 * Math.Pi</code>.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/08/gorilla-final-steps.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>We draw the eyes, the nostrils, and the mouth</em></p>
<p>Then we draw the eyes of the gorilla as two other circles. Here the center coordinates of the circles are the same as the bigger gray circles around them. Only their radius and their fill color are different.</p>
<pre><code class="lang-js">. . .

ctx.fillStyle = <span class="hljs-string">"black"</span>;

<span class="hljs-comment">// Draw the Left Eye</span>
ctx.beginPath();
ctx.arc(<span class="hljs-number">-14</span>, <span class="hljs-number">280</span>, <span class="hljs-number">6</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span> * <span class="hljs-built_in">Math</span>.PI);
ctx.fill();

<span class="hljs-comment">// Draw the Right Eye</span>
ctx.beginPath();
ctx.arc(+<span class="hljs-number">14</span>, <span class="hljs-number">280</span>, <span class="hljs-number">6</span>, <span class="hljs-number">0</span>, <span class="hljs-number">2</span> * <span class="hljs-built_in">Math</span>.PI);
ctx.fill();

. . .
</code></pre>
<p>Then for the nose, we'll draw two short lines as nostrils. They are part of the same path, and we'll call the <code>moveTo</code> method in between to get from one side to the other. Before calling stroke at the end of this path, we'll update the <code>lineWidth</code> property. </p>
<pre><code class="lang-js">. . .

ctx.lineWidth = <span class="hljs-number">6</span>;

<span class="hljs-comment">// Draw the Nostrils</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">-14</span>, <span class="hljs-number">266</span>);
ctx.lineTo(<span class="hljs-number">-6</span>, <span class="hljs-number">260</span>);

ctx.moveTo(<span class="hljs-number">14</span>, <span class="hljs-number">266</span>);
ctx.lineTo(+<span class="hljs-number">6</span>, <span class="hljs-number">260</span>);
ctx.stroke();

. . .
</code></pre>
<p>And finally, we'll also add a path for the mouth. This could be part of the same path as the nose, because it has the same line width and color, but might be clearer to have them separate.</p>
<pre><code class="lang-js">. . .

<span class="hljs-comment">// Draw the Mouth</span>
ctx.beginPath();
ctx.moveTo(<span class="hljs-number">-20</span>, <span class="hljs-number">230</span>);
ctx.quadraticCurveTo(<span class="hljs-number">0</span>, <span class="hljs-number">245</span>, <span class="hljs-number">20</span>, <span class="hljs-number">230</span>);
ctx.stroke();
</code></pre>
<p>Let's draw another quadratic curve. This is similar to the one we used for the arms. The start and endpoint of this curve are on the same level, but the control point is a bit higher so the middle of the mouth is higher than the two sides.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Now that we have a basic gorilla, what can we do with it? We can build a whole game around it. In this <a target="_blank" href="https://www.freecodecamp.org/news/gorillas-game-in-javascript/">JavaScript Game Tutorial</a> we rebuild the 1991 classic game, Gorillas.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/02/Screenshot-2024-01-19-at-23.49.34.png" alt="Image" width="600" height="400" loading="lazy">
<em>Screenshot from the JavaScript Game Tutorial</em></p>
<p>For a deep dive, read the <a target="_blank" href="https://www.freecodecamp.org/news/gorillas-game-in-javascript/">full tutorial</a> where we build up a complete game with plain JavaScript. In this tutorial, we not only cover how to draw the gorillas and the city skyline but also implement the whole game logic. From event handling, through the animation loop, to hit detection.</p>
<p>For even more, you can also watch the <a target="_blank" href="https://www.youtube.com/watch?v=2q5EufbUEQk">extended tutorial on YouTube</a>. In the YouTube version, we also cover how to make the buildings destructible, how to animate the hand of the gorilla to follow the drag movement while aiming, have nicer graphics, and we add AI logic, so you can play against the computer.</p>
<p>Check it out to learn more:</p>
<p><a target="_blank" href="https://www.youtube.com/embed/2q5EufbUEQk?feature=oembed">Embedded content</a></p>
<p>You can subscribe to my channel for more JavaScript game development tutorials:</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/undefined" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to lock an angle when drawing on canvas in JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ By Thang Minh Vu In many drawing tools (Adobe Photoshop, Sketch, and so on), if we hold the SHIFT button when drawing a line, we can create perfectly straight lines horizontally or vertically. Recently, I tried implementing this feature in canvas by ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-lock-an-angle-when-drawing-on-canvas-in-javascript-51938b5abc7c/</link>
                <guid isPermaLink="false">66c35328d58e4fdd567d51ae</guid>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ geometry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ technology ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Mon, 18 Mar 2019 16:16:31 +0000</pubDate>
                <media:content url="https://cdn-media-1.freecodecamp.org/images/1*cWcey5rf6AkuNtkVZ7ywwg.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Thang Minh Vu</p>
<p>In many drawing tools (<a target="_blank" href="https://www.adobe.com/products/photoshop.html">Adobe Photoshop</a>, <a target="_blank" href="https://www.sketchapp.com/">Sketch</a>, and so on), if we hold the SHIFT button when drawing a line, we can create perfectly straight lines horizontally or vertically.</p>
<p>Recently, I tried implementing this feature in canvas by JavaScript. The process is really interesting. I would like to share the progress of how I approach it.</p>
<p><strong>Demo</strong>: To easier understand the idea, you can check a demo version at the <a target="_blank" href="https://ittus.github.io/draw-lock-angle/">demo page</a>.</p>
<h3 id="heading-requirements">Requirements</h3>
<p><strong>Input</strong></p>
<ul>
<li>A base point (B)</li>
<li>Current mouse position (M)</li>
</ul>
<p><strong>Output</strong></p>
<ul>
<li>Projection of current mouse position on x-axis or y-axis (P)</li>
</ul>
<p>For convenience, in all graphs, we will mark the base point by a red circle and the current mouse point by a green circle.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/zvt4t9MmiO6Uxc3zAyLDxUOta3S-j4Nl7JvE" alt="Image" width="488" height="458" loading="lazy">
<em>Problem: Decide which projection is better</em></p>
<h3 id="heading-simple-solution">Simple solution</h3>
<p>As I tackle the problem, it’s intuitive to see we can calculate the distance between the current mouse position with the horizontal line and vertical line. If the mouse position is nearer the horizontal line than the vertical line, we will take the projection on the horizontal line, and vice versa.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/F-mQyWvLknInihDnYTgeS2CYyiHljKRB-P1R" alt="Image" width="632" height="495" loading="lazy"></p>
<p>The calculation is quite simple — here is the Javascript code:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/AmQiLZ6chh1YF30QhI6MXs0Qrpwq1SkXAdti" alt="Image" width="800" height="475" loading="lazy"></p>
<h3 id="heading-extended-problem">Extended Problem</h3>
<p>How about if we want to project on the bisector line between the horizontal line and vertical line (similar with <a target="_blank" href="https://www.sketchapp.com/">Sketch</a>)? That means users can project the mouse position on the horizontal line, vertical line, 45-degree angle line, or 135-degree angle line.</p>
<p>The approach is similar. This time we need to calculate the distance between the mouse position to 4 lines: horizontal line, vertical line, and 2 bisector lines (45-degree line and 135-degree line). But the calculation is more complex.</p>
<p>We still can divide it into 2 steps:</p>
<ol>
<li>Determine which line is nearest with mouse position</li>
<li>Calculate the projection of mouse position on the nearest line</li>
</ol>
<p><img src="https://cdn-media-1.freecodecamp.org/images/yRRlpXpZg16PFuGjohkEwMBRTQ074yN4UmvH" alt="Image" width="489" height="460" loading="lazy"></p>
<h4 id="heading-step-1-determine-which-line-is-nearest-with-mouse-position">Step 1: Determine which line is nearest with mouse position</h4>
<p>First, we need to determine line formulation of 4 lines above. Because we already know the base point (x0, y0) and the line angle, it’s easy to figure out the formulation of each line.</p>
<blockquote>
<p>Example: To calculate the formula of the 45-degree bisector, we already know that the line will go through the base point (x0, y0) and (x0 + 1, y0 + 1). Using the <a target="_blank" href="https://www.wikihow.com/Find-the-Equation-of-a-Line">Find-the-Equation-of-a-Line</a> method, we can figure out the line formula.</p>
</blockquote>
<p>Finally, we will have 4 lines’ formulas:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/ngaxjDK6zWtgP74BOXPwMU2jZY014c7L-lu6" alt="Image" width="187" height="82" loading="lazy"></p>
<p>To calculate the distance between the base mouse position to each line, we can use a popular math formula:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/C4Tk7kqRbwGbPtYFwK-HDSRlmVnGTRunxAso" alt="Image" width="359" height="27" loading="lazy">
<em>Distance from a point to a line</em></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Adk5BQAv15Jy4IuGUBSyKiSxkU68gEgsdh7z" alt="Image" width="800" height="561" loading="lazy">
<em>Finding the nearest line</em></p>
<h4 id="heading-step-2-calculate-the-orthogonal-projection-of-mouse-position-on-the-nearest-line">Step 2: Calculate the orthogonal projection of mouse position on the nearest line</h4>
<p>Now the problem becomes calculating the orthogonal projection of the mouse position (M) to the nearest line with the formula: ax + by + c = 0 (L)</p>
<p>There are multiple ways to solve this problem. I took a simple way: First, calculate the formula of the line which contains mouse position M and perpendicular to line L, called L'. Then, solve the system of equations to get the intersection point between line L and L', which is the projection point which we are finding.</p>
<p>After some calculation, I figured out the formula of L’, which goes through M (x0, y0) and perpendicular to L (ax + by + c = 0):</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/BwlHT2VPl2baMMHQtQYI2vGkCUq0jl0ZpiLz" alt="Image" width="198" height="17" loading="lazy"></p>
<p>Now to find the intersection, we need to solve the system of equations:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/4nJ1rFj7FrXEwZ0KK3MLEGo10DMJDUrchUxB" alt="Image" width="211" height="54" loading="lazy"></p>
<p>Using <a target="_blank" href="https://en.wikipedia.org/wiki/Cramer%27s_rule">Cramer’s rule</a> and matrix determinant, we can solve this equation easily:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/YeHWX9l70xghLPvDkqTZA19RSbRGyRIZq4WJ" alt="Image" width="800" height="526" loading="lazy">
<em>Solve simultaneous equations</em></p>
<h3 id="heading-boundary">Boundary</h3>
<p>There is a situation when we want to limit the boundary of projection.</p>
<p>Example:</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/3WNRAcorUIwF9lwihmXCjAna8DovCrhgmUxi" alt="Image" width="379" height="404" loading="lazy">
<em>The projection point is outside of the boundary</em></p>
<p>In this case, we want to limit the projection in the white rectangle area, but using the discussed method, the projection point can be outside of the boundary area.</p>
<p>In this situation, we can simply get the intersection point of line L’ to the boundary (called P’).</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Zl5D-QVbgmra2XCyD6khtLSFE1sF9VzPz49c" alt="Image" width="800" height="1049" loading="lazy">
<em>Support boundary of the projection</em></p>
<h3 id="heading-full-source-code"><strong>Full source code</strong></h3>
<p>You can check out the demo and source code on <a target="_blank" href="https://github.com/ittus/draw-lock-angle">Github</a>.</p>
<p>Happy Coding!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
