phase portrait · one wave
Ghost Delay
A loop is not a function: one x would need many y. So a height wave y = f(x) can never loop. But read a single 1D wave against a delayed copy of itself, x = wave(u) and y = wave(u + τ), and the pair closes into a loop ring. The delay τ is the only extra variable, the ghost. Sweep it and the ring breathes from an ellipse into petals. Shift is on, so the closing-group wave morphs and the whole loop family transforms. The strip along the bottom is the actual wave, with the two sampling points marked.
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/p5@2.2.2/lib/p5.js"></script>
<script src="https://cdn.jsdelivr.net/gh/seb-prjcts-be/p5.waves@v3.6.0/p5.waves.min.js"
integrity="sha384-dHdUC+3z6CdPzSv5Nc0Euww+a51g/8mZsDZYSDBisghUfCsdbg4ynknr5q/f+u41"
crossorigin="anonymous"></script>
</head>
<body>
<div id="sketch-container"></div>
<script src="sketch.js"></script>
</body>
</html>
sketch.js
// Ghost Delay - one 1D wave, read against a delayed copy of itself.
// x = sample(u), y = sample(u + delay): one scalar wave closes into a loop ring
// (its phase portrait). shift morphs the wave, so the loop family keeps changing.
// The strip along the bottom is that one wave; the dots are the two read points.
let sampler;
const N = 800;
function setup() {
const cnv = createCanvas(720, 720);
// The example page docks the canvas into #sketch-container. Pasted anywhere
// else (p5 web editor, a bare index.html) that div does not exist, and the
// canvas simply lands on the page - so the paste runs as-is.
if (document.getElementById('sketch-container')) cnv.parent('sketch-container');
colorMode(HSB, 360, 100, 100, 100);
strokeJoin(ROUND);
noFill();
// 'ghost' is a built-in pool of closing waves that stay clean under this
// delay. range [-1, 1] gives unit output (the default would be amplitude 100).
sampler = Waves.createSampler({ group: 'ghost', shift: true, range: [-1, 1] });
}
function draw() {
background(230, 25, 8);
noFill(); // re-assert every frame: the strip dots and HUD text set a fill
// that would otherwise leak into next frame's ring and fill it in
const t = millis() / 1000;
const ringHue = (t * 12) % 360; // 'hue' itself is a p5 function - do not shadow it
const period = sampler.period; // ghost waves share one period
const delay = period * (0.5 + 0.35 * sin(t * 0.35)); // the ghost delay, breathing
// The ring: the wave against its own delayed self.
const rad = min(width, height) * 0.36;
stroke(ringHue, 55, 100, 90);
strokeWeight(1.4);
push();
translate(width / 2, height / 2 - 30);
beginShape();
for (let i = 0; i <= N; i++) {
const u = (i / N) * period; // one period -> the ring closes
vertex(sampler.sample(u, t) * rad, sampler.sample(u + delay, t) * rad);
}
endShape(CLOSE);
pop();
drawWaveStrip(t, delay, ringHue, period);
// shift is cycling the ghost pool; name the current wave.
noStroke();
fill(0, 0, 70);
textFont('monospace');
textSize(12);
text(sampler.waveName, 18, 26);
}
// The raw 1D wave as a height line, with the two read points (u and u + delay)
// marked. The whole trick is reading this one wave twice.
function drawWaveStrip(t, delay, ringHue, period) {
const baseY = height - 60;
const w = width - 120;
const left = 60;
const amp = 24;
stroke(ringHue, 45, 100, 70);
strokeWeight(1.2);
beginShape();
for (let i = 0; i <= N; i++) {
const u = (i / N) * period;
vertex(left + (i / N) * w, baseY - sampler.sample(u, t) * amp);
}
endShape();
noStroke();
fill(0, 0, 100);
circle(left, baseY - sampler.sample(0, t) * amp, 7);
fill(ringHue, 70, 100);
circle(left + (delay % period) / period * w, baseY - sampler.sample(delay, t) * amp, 7);
}