The interactive canvas on my homepage isn't decoration. It's a working model of how I think about my work — built from real physics, real projects, and a lot of patient tuning. This document explains how it was made, starting from the very beginning. You don't need any coding background to start reading. Stop wherever you want.
When you visit atmarcus.net, just below the introduction, there's a quiet canvas — warm, off-white, still. It looks like nothing is happening. But when you click anywhere on it, a ripple expands outward from that spot. A project label appears at the center. The ripple slowly spreads, brightening the areas it reaches, fading at its edges, and eventually disappearing.
Click again somewhere else. Another ripple. A different project. When the two ripples overlap, the places where they meet light up — and the places where they cancel each other go darker. This is the same thing that happens when you drop two pebbles into still water.
Each ripple is a real project I've worked on. Clicking the dot at its center, or the text label next to it, takes you to that project's page. Most ripples fade after a while — smaller projects fade faster, larger ones last longer. A few never fade at all.
This is a direct expression of how I think about my body of work. Disciplines and projects don't sit in separate buckets — they radiate outward and amplify each other where they meet. The tagline at the bottom of the field says it plainly: Where disciplines meet, they amplify.
Every time the page loads, the order in which projects appear is shuffled randomly — so no single project always dominates. The field resets itself over time. There's a "Reset field" button if you want to start over immediately.
Wave interference is a phenomenon from physics. When two waves travel through the same space — whether that's water, air, or light — they add together wherever they meet. In some places, the crests of both waves arrive at the same moment: the combined wave is taller. In other places, a crest from one wave arrives at the same moment as a trough from the other: they cancel out. The water goes flat.
Physicists call the first case constructive interference and the second destructive interference. The name of my Substack newsletter — Constructive Interference — is taken directly from this concept.
Every project has a weight — a number from 1 to 10 that reflects how central it is to the body of work. Weight determines:
A weight of 10 is special: that node never fades. It's permanent. Sageframe — the knowledge architecture system at the center of everything I build — has a weight of 10.
The chip label in the top corner reads Constructive Interference · click to add a project. That's your invitation. The field is interactive — not a passive decoration.
Before getting into the code itself, it helps to understand the surface it draws on — and how animation actually works in a browser.
A normal web page is made of text, images, and boxes — things the browser arranges and displays automatically. But HTML includes a special element called a canvas. A canvas is a blank rectangle that the browser hands over to JavaScript with a single implicit instruction: you draw whatever you want in here.
The canvas is fundamentally a grid of pixels — tiny colored squares. JavaScript draws by issuing commands: draw a circle at this position with this radius, fill this rectangle with this color, write this text here. After each set of commands, the screen updates.
Animation works the same way it always has — by drawing the same scene over and over, slightly changed each time. Film is 24 frames per second. A browser can do 60.
The Resonance field uses a browser function called requestAnimationFrame.
It works like this: you tell the browser, call my draw function just before
you repaint the screen. The browser does that, your code draws whatever
the current state of the waves looks like, and then at the very end it calls
requestAnimationFrame again — registering itself for the next frame.
This loop runs indefinitely.
speed scales how fast t advances. trail controls how many ghost dots get drawn on each frame. orbit sets how wide the path is. The last line — requestAnimationFrame(draw) — is what keeps the loop going.To draw the wave field realistically, the code needs to calculate the wave value at every point on screen. A typical laptop screen is around 1400×800 pixels — that's over a million calculations per frame, 60 times per second.
To keep this fast, the code works at a coarser resolution: one calculation per 3×3 block of pixels, rather than per pixel. That reduces the math by a factor of 9. The result is then scaled back up — the browser blurs the edges in the process, which actually makes the waves look smoother, not blockier. It's a deliberate and effective tradeoff.
Modern screens, especially on Apple devices, have more physical pixels than CSS coordinates suggest. A canvas that appears to be 700×400 in the page layout might be 1400×800 in actual device pixels. The code tracks this ratio (called the device pixel ratio) and adjusts so the canvas stays sharp on high-density screens.
Every program needs a way to represent the things it knows about. In this case: what the projects are, where they've been placed on the canvas, and what the physics parameters look like. In JavaScript, a structured collection of information is called an object.
Before anything appears on screen, each project exists purely as data — four labeled values bundled together:
All 12 projects are stored in a numbered list called an array. When the page loads, that list is shuffled into a random order using an algorithm called Fisher-Yates. Each click on the canvas places the next project from the shuffled list.
When a project is actually placed by a click, a richer object is created. It links back to the original project data, but also records where it landed and when:
All placed nodes live in a single array called placed.
The draw loop reads this array every frame to know what to render.
When a node exceeds its lifespan, it's removed from the array.
Separately, a set of named numbers controls how waves behave overall.
These come from an external file — hero-config.js —
which keeps the tunable parameters separate from the logic:
You don't need to know mathematics to understand this. But the wave calculation is the heart of the whole system — everything visual comes from it — so it's worth understanding the idea.
A wave is something that oscillates — moves up and down — as it travels through space. Think of a single point on the surface of a pond after a stone is dropped. That point rises and falls in a rhythm, and so does every other point nearby, each slightly delayed depending on how far from the stone it is.
For any point on the canvas, the code asks: what is the height of the wave at this exact location, right now? The answer depends on four things:
The formula looks like this:
height = amplitude × cos( (2π / λ) × d − (2π / λ) × speed × time )
lam to change ring spacing — the value you're dragging is the exact number passed to the wave equation as λ. Drag spd to change how fast the wavefront expands. Watch both values update in the code block above.The cosine function produces the alternating rings: it swings smoothly from +1 (peak) to -1 (trough) and back, over and over. The expression inside the parentheses — involving distance and time — determines where we are in that cycle at any given point and moment.
When two nodes are placed, the code calculates the wave value from each source at every pixel cell, then adds the results together. That's it. That's interference.
Where both waves are at their peak simultaneously: the sum is large and positive. The pixel brightens toward the node's color. Where one is at a peak and the other at a trough: they partially or fully cancel. The pixel stays near the background tone or darkens slightly.
lam — a smaller wavelength packs more rings and creates a tighter interference pattern. Drag gap to move the two source positions and watch the pattern re-form around the new geometry. Every value you change maps directly to what appears in the code block above.If you keep adding waves, the total value could get very large — especially where many nodes are close together. That would produce colors far outside any visible range. The code passes the total through a function called tanh (hyperbolic tangent), which compresses any value — no matter how large — into the range -1 to +1. It's a soft ceiling and floor. Colors never blow out to pure white or pure black.
The background is the warm off-white of the rest of the site. A positive wave value blends that pixel toward the project's color. A negative value darkens it slightly toward near-black. Pixels far from any source stay the background color. The blend amounts are modest — the effect is subtle, not saturated.
The code has four distinct responsibilities. Understanding how they relate makes the rest of the document much easier to follow.
The placed array is the single source of truth for what's happening on the canvas.
Everything else is derived from it. The draw loop doesn't remember anything between frames —
it reads placed, calculates what the screen should look like right now,
draws it, and starts fresh on the next frame.
When you click the canvas, a click handler adds a new entry to placed.
When you click Reset, a handler empties it.
The draw loop runs on its own schedule — 60 times per second — completely independent of events.
It just looks at whatever state exists at that moment and renders it.
This separation is one of the cleanest and most important patterns in interactive programming. Events modify state. Rendering reads state. They never directly touch each other. It's the same fundamental model used by modern UI frameworks like React.
A function is a named, reusable block of code that does one specific job. Think of it as a machine: you put something in, and get something back out. Here are all the functions in this file, in plain language, with their code.
In the annotated code below, each line is paired with a plain-English explanation on the right. The colored text in the code is called syntax highlighting — it doesn't change what the code does, it just makes it easier to read at a glance.
Converts a hex color code (like "#c45a2d") and an opacity value (0 is invisible, 1 is solid)
into a CSS color string with transparency: rgba(196,90,45,0.7).
Used everywhere a node or label needs to fade in or out over time.
The same conversion as hexWithAlpha, but returns the three color values
as raw numbers in an array instead of a string. The pixel-painting loop works
directly with numbers, so this avoids building and parsing strings inside
the innermost loop — a meaningful performance saving when it runs millions of times per second.
Configures the canvas to exactly match the pixel dimensions of its container, and scales everything correctly for high-density displays. Called once on page load and again whenever the window is resized.
Returns how far apart the wave rings should be for a given project weight. Higher weight means tighter rings — a more intricate, dense pattern. Lower weight means wider, more spacious rings. The result is in pixels.
Returns how far a node's wave spreads across the canvas before it becomes invisible. This is expressed as a multiple of that node's wavelength — heavier nodes get a much larger multiple, so their waves reach proportionally farther. A weight-10 node's reach can span the entire canvas; a weight-1 node barely extends beyond its immediate area.
Returns how long, in seconds, a node stays visible before fading out completely.
Weight-10 nodes return Infinity — they never fade.
All others follow a smooth curve: a weight-5 node lasts fadeBase seconds;
weight-9 lasts about 3× longer; weight-1 lasts less than a third as long.
Infinity is a real JavaScript value — not a placeholder. The node will never be culled.Returns a number between 0 and 1 representing how strong a node's wave should be at any point in its life. It rises smoothly from zero to full strength in the first 0.8 seconds (no jarring pop-in), holds at full strength through most of the node's life, then fades out gently over the final 25% of its lifespan. This shape — rise, hold, fade — is called an amplitude envelope, borrowed from audio synthesis.
riseTime is the denominator in age / riseTime — bigger = slower attack. fadeStart is the multiplier on lifespan — 0.75 means the fade begins at 75% of life. lifespan is passed in directly as the second argument.
Checks whether a mouse position (mx, my) is close enough to any placed node's
center dot to count as clicking on it. Returns the node if there's a hit,
or null if the cursor isn't over any node. The companion function
hitLabel does the same check for the text labels.
Math.hypot computes straight-line distance using the Pythagorean theorem: √(dx² + dy²). If the cursor is within 18 pixels of the node center, it's a hit — return the node immediately.
hitLabel is nearly identical but checks against a rectangle — the bounding box
of each node's text label. Every frame, the draw loop stores each label's position and dimensions
in p._labelRect, which hitLabel reads. Clicking either the dot or
the label opens the project's URL.
Makes sure the pixel buffer — the working grid the physics calculation writes into — is the right size. If the canvas has been resized since the last frame, it allocates a fresh buffer. Otherwise it reuses the existing one. Buffer allocation is expensive, so this is called every frame but only does real work when something has actually changed.
Clears everything. Called when the user clicks "Reset field." Three things happen: the list of active wave sources is emptied, the project rotation counter goes back to zero, and the running clock resets to zero.
The draw() function is where everything converges.
It runs roughly 60 times per second. Here's every step in order,
explained as if we could slow time down to watch it.
Drift makes placed nodes slowly wander, as if they have a small, organic restlessness. It's disabled by default (drift=0) and only runs if the tuning config enables it.
drift to zero and the nodes freeze in place — that's the default in production. Drag settle to a small value and nodes will start wandering right away; at a large value they hold position for several seconds first. Click the canvas to reset positions.This is the innermost loop and the most computationally intensive step. For each cell in the downsampled pixel grid:
After the wave field is computed, each pixel is blended with the same pixel from the previous frame. This is the single most visible quality enhancement: it creates the soft, almost filmic quality of the animation.
| 0 at the end truncates to an integer — pixel values must be whole numbers.
After the wave field is painted, the code draws each placed node on top:
a translucent halo (larger when hovered), a solid central dot, and the project name
as text in the node's color. The label's bounding rectangle is calculated and stored
in p._labelRect for hit-testing on the next click.
Node size and label font size both scale with the project's weight.
There's a hidden control panel built into the hero. It's only visible when you open
the page with ?edit appended to the URL —
like /hero/resonance.html?edit.
In normal production use, it's invisible.
Wave physics have many parameters that interact in non-obvious ways. Rather than changing a number in the code, deploying to a live server, and looking at it — the author panel lets you move sliders and watch the field respond in real time. When it looks right, one button generates the complete config file, ready to paste in and deploy. This is the tool I used to find the values currently live on the site.
body.edit .tweaks { display: block } makes it visible. A single class toggle — zero JavaScript beyond that.
Each slider is a standard HTML range input. When any slider moves, a function called
syncTune() reads all slider values, updates the RES_TUNE object,
and saves the state to the browser's local storage. Because draw()
reads from RES_TUNE on every frame, the wave field responds to slider
changes instantly — no reload, no delay.
The author panel also shows every project node as an editable row. You can rename nodes, adjust their weight, change their color, edit their URL, or delete them. Each change is saved to local storage immediately. A "Reset" button restores the default list.
The Export Config button reads the current state of the colors, projects, and physics,
and assembles them into a complete JavaScript file — formatted exactly as
hero-config.js expects. It then copies this to the clipboard.
You paste it over hero-config.js, save the file, commit to git,
push to GitHub, and the Cloudflare deployment picks it up within about a minute.
The full authoring loop: open /hero/resonance.html?edit while the local dev server runs → move sliders → Export Config → paste into hero-config.js → save → commit → push → trigger the deploy hook. The live site updates in about a minute.