fiws-page/js/app.js
2026-08-11 16:26:50 +02:00

1354 lines
47 KiB
JavaScript

const root = document.documentElement;
root.classList.remove("no-js");
root.classList.add("js");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const revealItems = document.querySelectorAll(".reveal");
if (reducedMotion || !("IntersectionObserver" in window)) {
revealItems.forEach((item) => item.classList.add("is-visible"));
} else {
const revealObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{ rootMargin: "0px 0px -8%", threshold: 0.08 },
);
revealItems.forEach((item) => revealObserver.observe(item));
}
const year = document.querySelector("#year");
if (year) year.textContent = new Date().getFullYear().toString();
const glitchName = document.querySelector("#glitch-name");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (glitchName && finePointer && !reducedMotion) {
const glyphField = document.createElement("div");
const glyphs = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ{}[]<>/\\#$%&*+?=;:";
const colorClasses = ["", "is-cyan", "is-lime", "is-paper"];
let active = false;
let intervalId;
let lastSpawn = 0;
let pointerX = window.innerWidth * 0.5;
let pointerY = window.innerHeight * 0.5;
glyphField.className = "glitch-field";
glyphField.setAttribute("aria-hidden", "true");
document.body.append(glyphField);
const randomBetween = (min, max) => min + Math.random() * (max - min);
const spawnGlyph = (origin) => {
if (glyphField.childElementCount >= 64) glyphField.firstElementChild?.remove();
const glyph = document.createElement("span");
const nearPointer = Boolean(origin);
const x = nearPointer ? origin.x + randomBetween(-44, 44) : randomBetween(20, window.innerWidth - 20);
const y = nearPointer ? origin.y + randomBetween(-30, 30) : randomBetween(40, window.innerHeight - 30);
const travel = nearPointer ? 150 : 90;
glyph.className = `glitch-glyph ${colorClasses[Math.floor(Math.random() * colorClasses.length)]}`.trim();
glyph.textContent = glyphs[Math.floor(Math.random() * glyphs.length)];
glyph.style.setProperty("--x", `${x}px`);
glyph.style.setProperty("--y", `${y}px`);
glyph.style.setProperty("--dx", `${randomBetween(-travel, travel)}px`);
glyph.style.setProperty("--dy", `${randomBetween(-travel, travel)}px`);
glyph.style.setProperty("--rot", `${randomBetween(-80, 80)}deg`);
glyph.style.setProperty("--size", `${randomBetween(0.65, 1.45)}rem`);
glyph.style.setProperty("--life", `${randomBetween(650, 1350)}ms`);
glyph.addEventListener("animationend", () => glyph.remove(), { once: true });
glyphField.append(glyph);
};
const burst = (count) => {
const rect = glitchName.getBoundingClientRect();
const origin = { x: rect.left + rect.width * 0.5, y: rect.top + rect.height * 0.55 };
for (let index = 0; index < count; index += 1) spawnGlyph(origin);
};
const startGlitch = (event) => {
if (active) return;
active = true;
pointerX = event.clientX;
pointerY = event.clientY;
document.body.classList.add("glitch-active");
burst(18);
intervalId = window.setInterval(() => {
if (!active) return;
spawnGlyph({ x: pointerX, y: pointerY });
spawnGlyph();
}, 115);
};
const moveGlitch = (event) => {
pointerX = event.clientX;
pointerY = event.clientY;
const now = performance.now();
if (now - lastSpawn < 42) return;
lastSpawn = now;
spawnGlyph({ x: pointerX, y: pointerY });
};
const stopGlitch = () => {
active = false;
document.body.classList.remove("glitch-active");
window.clearInterval(intervalId);
};
glitchName.addEventListener("pointerenter", startGlitch);
glitchName.addEventListener("pointermove", moveGlitch);
glitchName.addEventListener("pointerleave", stopGlitch);
glitchName.addEventListener("pointercancel", stopGlitch);
document.addEventListener("visibilitychange", () => {
if (document.hidden) stopGlitch();
});
}
const scrambleHeadings = document.querySelectorAll(".section-heading h2, #contact-title");
const scrambleGlyphs = "01ABCDEFGHIJKLMNOPQRSTUVWXYZ{}[]<>/\\#$%&*+?=;:";
if (scrambleHeadings.length && !reducedMotion && "IntersectionObserver" in window) {
scrambleHeadings.forEach((heading) => {
heading.dataset.finalText = heading.textContent ?? "";
});
const decryptHeading = (heading, duration = 600, steps = 14) => {
const finalText = heading.dataset.finalText ?? "";
const totalChars = finalText.length;
let frame = 0;
const timer = window.setInterval(() => {
frame += 1;
const revealCount = Math.floor((frame / steps) * totalChars);
heading.textContent = finalText
.split("")
.map((char, index) => {
if (char === " " || index < revealCount) return char;
return scrambleGlyphs[Math.floor(Math.random() * scrambleGlyphs.length)];
})
.join("");
if (frame >= steps) {
heading.textContent = finalText;
window.clearInterval(timer);
}
}, duration / steps);
};
const scrambleObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
decryptHeading(entry.target);
observer.unobserve(entry.target);
});
},
{ rootMargin: "0px 0px -10%", threshold: 0.4 },
);
scrambleHeadings.forEach((heading) => scrambleObserver.observe(heading));
}
const gooeyNav = document.querySelector(".nav-gooey");
const gooeyLinks = document.querySelectorAll(".site-nav > a:not(.nav-pill)");
if (gooeyNav && gooeyLinks.length && !reducedMotion) {
const navEl = gooeyNav.parentElement;
const blob = gooeyNav.querySelector(".nav-gooey-blob--lead");
const pad = 10;
let litLink = null;
let jumpTimeout;
const moveBlob = (target) => {
const navRect = navEl.getBoundingClientRect();
const rect = target.getBoundingClientRect();
blob.style.left = `${rect.left - navRect.left - pad}px`;
blob.style.top = `${rect.top - navRect.top - pad}px`;
blob.style.width = `${rect.width + pad * 2}px`;
blob.style.height = `${rect.height + pad * 2}px`;
gooeyNav.classList.add("is-active", "is-jumping");
window.clearTimeout(jumpTimeout);
jumpTimeout = window.setTimeout(() => gooeyNav.classList.remove("is-jumping"), 220);
litLink?.classList.remove("is-lit");
litLink = target;
litLink.classList.add("is-lit");
};
gooeyLinks.forEach((link) => {
link.addEventListener("pointerenter", () => moveBlob(link));
link.addEventListener("focus", () => moveBlob(link));
});
const clear = () => {
gooeyNav.classList.remove("is-active");
litLink?.classList.remove("is-lit");
litLink = null;
};
navEl.addEventListener("pointerleave", clear);
navEl.addEventListener("focusout", (event) => {
if (!navEl.contains(event.relatedTarget)) clear();
});
}
// --- ASCIIText, ported from React Bits (vanilla JS, three.js loaded on
// demand). Replaces the flat "TIDE" watermark on the TideWM card with a
// rippling, wave-distorted ASCII render once the card scrolls into view.
// The plain-text watermark stays in the DOM as the fallback if WebGL or the
// three.js CDN load ever fails. ---
(() => {
const targets = [
{
host: document.querySelector("#tide-ascii"),
ancestorSelector: ".current-project",
readyClass: "has-tide-ascii",
text: "TIDE",
enableWaves: true,
asciiFontSize: 11,
textFontSize: 220,
},
{
// Flux is a media-editing tool, not a water compositor — keep its
// watermark shape still and crisp (no mesh ripple), but let the
// glyphs themselves flicker/decode for a "processing feed" feel.
host: document.querySelector("#flux-ascii"),
ancestorSelector: ".project-visual-flux",
readyClass: "has-flux-ascii",
text: "FLUX",
enableWaves: false,
charJitter: true,
asciiFontSize: 8,
textFontSize: 240,
},
]
.map((t) => ({ ...t, ancestor: t.host?.closest(t.ancestorSelector) }))
.filter((t) => t.host && t.ancestor);
if (!targets.length || reducedMotion) return;
let hasWebGL = false;
try {
const testCanvas = document.createElement("canvas");
hasWebGL = Boolean(testCanvas.getContext("webgl") || testCanvas.getContext("experimental-webgl"));
} catch {
hasWebGL = false;
}
if (!hasWebGL) return;
const THREE_CDN_URL = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js";
let threeLoadPromise = null;
const loadThree = () => {
if (window.THREE) return Promise.resolve();
if (!threeLoadPromise) {
threeLoadPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = THREE_CDN_URL;
script.onload = () => resolve();
script.onerror = () => reject(new Error("three.js failed to load"));
document.head.append(script);
});
}
return threeLoadPromise;
};
const mapRange = (n, start, stop, start2, stop2) => ((n - start) / (stop - start)) * (stop2 - start2) + start2;
const vertexShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uEnableWaves;
void main() {
vUv = uv;
float time = uTime * 5.0;
float waveFactor = uEnableWaves;
vec3 transformed = position;
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
transformed.z += sin(time + position.x) * waveFactor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uEnableWaves;
uniform sampler2D uTexture;
void main() {
float time = uTime;
vec2 pos = vUv;
float amount = mix(0.006, 0.01, uEnableWaves);
float r = texture2D(uTexture, pos + cos(time * 2.0 - time + pos.x) * amount).r;
float g = texture2D(uTexture, pos + tan(time * 0.5 + pos.x - time) * amount).g;
float b = texture2D(uTexture, pos - cos(time * 2.0 + time + pos.y) * amount).b;
float a = texture2D(uTexture, pos).a;
gl_FragColor = vec4(r, g, b, a);
}
`;
class AsciiFilter {
constructor(renderer, { fontSize, fontFamily, charset, charJitter } = {}) {
this.renderer = renderer;
this.domElement = document.createElement("div");
this.domElement.className = "tide-ascii-inner";
this.pre = document.createElement("pre");
this.pre.className = "tide-ascii-pre";
this.domElement.append(this.pre);
this.canvas = document.createElement("canvas");
this.canvas.className = "tide-ascii-canvas";
this.context = this.canvas.getContext("2d");
this.domElement.append(this.canvas);
this.fontSize = fontSize ?? 8;
this.fontFamily = fontFamily ?? "'IBM Plex Mono', monospace";
this.charset = charset ?? " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
// Optional per-cell character flicker — a subtle "decoding" glitch
// where a handful of glyphs jump to a denser/lighter char and settle
// back, independent of any mesh/wave motion.
this.charJitter = charJitter ?? false;
this.context.imageSmoothingEnabled = false;
}
setSize(width, height) {
if (!width || !height) return;
this.width = width;
this.height = height;
this.renderer.setSize(width, height);
this.reset();
}
reset() {
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
const charWidth = this.context.measureText("A").width;
this.cols = Math.max(1, Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize))));
this.rows = Math.max(1, Math.floor(this.height / this.fontSize));
this.canvas.width = this.cols;
this.canvas.height = this.rows;
this.pre.style.fontFamily = this.fontFamily;
this.pre.style.fontSize = `${this.fontSize}px`;
}
render(scene, camera, time) {
this.renderer.render(scene, camera);
const w = this.canvas.width;
const h = this.canvas.height;
this.context.clearRect(0, 0, w, h);
if (this.context && w && h) this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
this.asciify(this.context, w, h, time ?? 0);
}
asciify(ctx, w, h, time = 0) {
if (!w || !h) return;
const imgData = ctx.getImageData(0, 0, w, h).data;
const jitter = this.charJitter;
const frameBucket = Math.floor(time * 7);
const last = this.charset.length - 1;
let str = "";
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
const i = x * 4 + y * 4 * w;
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
if (a === 0) {
str += " ";
continue;
}
const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
let idx = this.charset.length - Math.floor((1 - gray) * last) - 1;
if (jitter) {
const seed = Math.sin(x * 12.9898 + y * 78.233 + frameBucket * 37.719) * 43758.5453;
const rnd = seed - Math.floor(seed);
if (rnd > 0.85) {
const swing = Math.floor(((rnd - 0.85) / 0.15) * 8) - 4;
idx = Math.min(last, Math.max(0, idx + swing));
}
}
str += this.charset[idx];
}
str += "\n";
}
this.pre.textContent = str;
}
dispose() {}
}
class CanvasTxt {
constructor(txt, { fontSize = 200, fontFamily = "Arial", color = "#fdf9f3" } = {}) {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.txt = txt;
this.font = `600 ${fontSize}px ${fontFamily}`;
this.color = color;
}
resize() {
this.context.font = this.font;
const metrics = this.context.measureText(this.txt);
this.canvas.width = Math.ceil(metrics.width) + 20;
this.canvas.height = Math.ceil((metrics.actualBoundingBoxAscent || 0) + (metrics.actualBoundingBoxDescent || 0)) + 20;
}
render() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.color;
this.context.font = this.font;
const metrics = this.context.measureText(this.txt);
this.context.fillText(this.txt, 10, 10 + metrics.actualBoundingBoxAscent);
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
get texture() {
return this.canvas;
}
}
class CanvAscii {
constructor({ text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, charJitter }, container, width, height) {
this.textString = text;
this.asciiFontSize = asciiFontSize;
this.textFontSize = textFontSize;
this.textColor = textColor;
this.planeBaseHeight = planeBaseHeight;
this.container = container;
this.width = width;
this.height = height;
this.enableWaves = enableWaves;
this.charJitter = charJitter;
this.camera = new window.THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
this.camera.position.z = 30;
this.scene = new window.THREE.Scene();
}
async init() {
try {
await document.fonts.load(`600 200px "IBM Plex Mono"`);
} catch {
// fall back to default font metrics if the webfont isn't ready yet
}
this.setMesh();
this.setRenderer();
}
setMesh() {
const THREE = window.THREE;
this.textCanvas = new CanvasTxt(this.textString, {
fontSize: this.textFontSize,
fontFamily: "IBM Plex Mono",
color: this.textColor,
});
this.textCanvas.resize();
this.textCanvas.render();
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
this.texture.minFilter = THREE.NearestFilter;
const textAspect = this.textCanvas.width / this.textCanvas.height;
const planeW = this.planeBaseHeight * textAspect;
this.geometry = new THREE.PlaneGeometry(planeW, this.planeBaseHeight, 36, 36);
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
uniforms: {
uTime: { value: 0 },
uTexture: { value: this.texture },
uEnableWaves: { value: this.enableWaves ? 1 : 0 },
},
});
this.mesh = new THREE.Mesh(this.geometry, this.material);
this.scene.add(this.mesh);
}
setRenderer() {
const THREE = window.THREE;
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
this.renderer.setPixelRatio(1);
this.renderer.setClearColor(0x000000, 0);
this.filter = new AsciiFilter(this.renderer, { fontSize: this.asciiFontSize, charJitter: this.charJitter });
this.container.append(this.filter.domElement);
this.setSize(this.width, this.height);
}
setSize(w, h) {
if (!w || !h) return;
this.width = w;
this.height = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.filter.setSize(w, h);
}
animate() {
const frame = () => {
this.animationFrameId = requestAnimationFrame(frame);
this.renderFrame();
};
frame();
}
renderFrame() {
const time = Date.now() * 0.001;
this.mesh.material.uniforms.uTime.value = Math.sin(time);
this.filter.render(this.scene, this.camera, time);
}
dispose() {
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
this.filter?.dispose();
this.filter?.domElement?.remove();
this.renderer?.dispose();
}
}
const mount = async ({ host, ancestor, readyClass, text, enableWaves, charJitter, asciiFontSize, textFontSize }) => {
try {
await loadThree();
} catch {
return;
}
if (!window.THREE) return;
const rect = host.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
let instance;
try {
instance = new CanvAscii(
{
text,
asciiFontSize: asciiFontSize ?? 11,
textFontSize: textFontSize ?? 220,
textColor: "#eaf9f7",
planeBaseHeight: 8,
enableWaves: enableWaves ?? true,
charJitter: charJitter ?? false,
},
host,
rect.width,
rect.height,
);
await instance.init();
instance.animate();
} catch {
instance?.dispose();
return;
}
host.classList.add("is-ready");
ancestor.classList.add(readyClass);
if ("ResizeObserver" in window) {
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 20 && height > 20) instance.setSize(width, height);
});
resizeObserver.observe(host);
}
};
targets.forEach((target) => {
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
obs.disconnect();
mount(target);
});
},
{ rootMargin: "200px" },
);
observer.observe(target.host);
});
})();
// --- Liquid ASCII: a real FLIP/PIC fluid simulation (grid + particles,
// incompressible pressure solve, particle-particle separation) rendered as
// ASCII. No React, no registry, no CDN. Ported by hand from
// javierbyte/fluid-triangle (MIT), whose solver is itself ported from
// Matthias Müller's "Ten Minute Physics" FLIP fluid tutorial (MIT) — see
// license text below, preserved per the MIT terms of both sources. The
// obstacle's particle collider was a triangle in that fork; it's a circle
// here (matches the original Ten Minute Physics demo, and is what actually
// runs — the fork's grid-level triangle carving never mutated the fluid).
//
// Copyright 2022 Matthias Müller - Ten Minute Physics,
// www.youtube.com/c/TenMinutePhysics, www.matthiasMueller.info/tenMinutePhysics
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions: the above copyright notice and this permission
// notice shall be included in all copies or substantial portions of the
// Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
(() => {
const host = document.querySelector("#hero-liquid");
const pre = host?.querySelector(".hero-liquid-pre");
const heroSection = host?.closest(".hero") || host;
if (!host || !pre || reducedMotion) return;
const FLUID_CELL = 0;
const AIR_CELL = 1;
const SOLID_CELL = 2;
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
// Letter ramps spelling "FLUID" across brightness bands, cycling
// diagonally across the grid — the fluid-triangle demo's signature
// touch, kept as-is since it's a genuinely nice detail for a water sim.
const BASE = [
["~", 12198],
[":", 6921],
["-", 5589],
["·", 3267],
[" ", 0],
[" ", 0],
];
const RENDER_CHARS = [
[["F", 26574], ["F", 26574], ["f", 17490], ...BASE],
[["L", 21327], ["L", 21327], ["l", 14019], ...BASE],
[["U", 32973], ["U", 32973], ["u", 24093], ...BASE],
[["I", 14883], ["I", 14883], ["i", 13638], ...BASE],
[["D", 36198], ["D", 36198], ["d", 30762], ...BASE],
].map((set) =>
set
.slice()
.sort((a, b) => a[1] - b[1])
.map(([char]) => char)
.join(""),
);
class FlipFluid {
constructor(density, width, height, spacing, particleRadius, maxParticles) {
this.density = density;
this.fNumX = Math.floor(width / spacing);
this.fNumY = Math.floor(height / spacing);
this.h = Math.max(width / this.fNumX, height / this.fNumY);
this.fInvSpacing = 1.0 / this.h;
this.fNumCells = this.fNumX * this.fNumY;
this.u = new Float32Array(this.fNumCells);
this.v = new Float32Array(this.fNumCells);
this.du = new Float32Array(this.fNumCells);
this.dv = new Float32Array(this.fNumCells);
this.prevU = new Float32Array(this.fNumCells);
this.prevV = new Float32Array(this.fNumCells);
this.p = new Float32Array(this.fNumCells);
this.s = new Float32Array(this.fNumCells);
this.cellType = new Int32Array(this.fNumCells);
this.cellColor = new Float32Array(3 * this.fNumCells);
this.maxParticles = maxParticles;
this.particlePos = new Float32Array(2 * this.maxParticles);
this.particleVel = new Float32Array(2 * this.maxParticles);
this.particleDensity = new Float32Array(this.fNumCells);
this.particleRestDensity = 0.0;
this.particleRadius = particleRadius;
this.pInvSpacing = 1.0 / (2.2 * particleRadius);
this.pNumX = Math.floor(width * this.pInvSpacing) + 1;
this.pNumY = Math.floor(height * this.pInvSpacing) + 1;
this.pNumCells = this.pNumX * this.pNumY;
this.numCellParticles = new Int32Array(this.pNumCells);
this.firstCellParticle = new Int32Array(this.pNumCells + 1);
this.cellParticleIds = new Int32Array(maxParticles);
this.numParticles = 0;
}
integrateParticles(dt, gravity) {
for (let i = 0; i < this.numParticles; i += 1) {
this.particleVel[2 * i + 1] += dt * gravity;
this.particlePos[2 * i] += this.particleVel[2 * i] * dt;
this.particlePos[2 * i + 1] += this.particleVel[2 * i + 1] * dt;
}
}
pushParticlesApart(numIters) {
this.numCellParticles.fill(0);
for (let i = 0; i < this.numParticles; i += 1) {
const xi = clamp(Math.floor(this.particlePos[2 * i] * this.pInvSpacing), 0, this.pNumX - 1);
const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * this.pInvSpacing), 0, this.pNumY - 1);
this.numCellParticles[xi * this.pNumY + yi] += 1;
}
let first = 0;
for (let i = 0; i < this.pNumCells; i += 1) {
first += this.numCellParticles[i];
this.firstCellParticle[i] = first;
}
this.firstCellParticle[this.pNumCells] = first;
for (let i = 0; i < this.numParticles; i += 1) {
const xi = clamp(Math.floor(this.particlePos[2 * i] * this.pInvSpacing), 0, this.pNumX - 1);
const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * this.pInvSpacing), 0, this.pNumY - 1);
const cellNr = xi * this.pNumY + yi;
this.firstCellParticle[cellNr] -= 1;
this.cellParticleIds[this.firstCellParticle[cellNr]] = i;
}
const minDist = 2.0 * this.particleRadius;
const minDist2 = minDist * minDist;
for (let iter = 0; iter < numIters; iter += 1) {
for (let i = 0; i < this.numParticles; i += 1) {
const px = this.particlePos[2 * i];
const py = this.particlePos[2 * i + 1];
const pxi = Math.floor(px * this.pInvSpacing);
const pyi = Math.floor(py * this.pInvSpacing);
const x0 = Math.max(pxi - 1, 0);
const y0 = Math.max(pyi - 1, 0);
const x1 = Math.min(pxi + 1, this.pNumX - 1);
const y1 = Math.min(pyi + 1, this.pNumY - 1);
for (let xi = x0; xi <= x1; xi += 1) {
for (let yi = y0; yi <= y1; yi += 1) {
const cellNr = xi * this.pNumY + yi;
const first = this.firstCellParticle[cellNr];
const last = this.firstCellParticle[cellNr + 1];
for (let j = first; j < last; j += 1) {
const id = this.cellParticleIds[j];
if (id === i) continue;
let dx = this.particlePos[2 * id] - px;
let dy = this.particlePos[2 * id + 1] - py;
const d2 = dx * dx + dy * dy;
if (d2 > minDist2 || d2 === 0.0) continue;
const d = Math.sqrt(d2);
const s = (0.5 * (minDist - d)) / d;
dx *= s;
dy *= s;
this.particlePos[2 * i] -= dx;
this.particlePos[2 * i + 1] -= dy;
this.particlePos[2 * id] += dx;
this.particlePos[2 * id + 1] += dy;
}
}
}
}
}
}
// Circular obstacle collider — the fork's triangle, deleted, restored
// to a plain circle (and given the obstacle's own velocity on contact
// for a real "push" instead of just clamping to zero).
handleParticleCollisions(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) {
const h = this.h;
const r = this.particleRadius;
const minDist = obstacleRadius + r;
const minDist2 = minDist * minDist;
const minX = h + r;
const maxX = (this.fNumX - 1) * h - r;
const minY = h + r;
const maxY = (this.fNumY - 1) * h - r;
for (let i = 0; i < this.numParticles; i += 1) {
let x = this.particlePos[2 * i];
let y = this.particlePos[2 * i + 1];
if (obstacleRadius > 0) {
const dx = x - obstacleX;
const dy = y - obstacleY;
const d2 = dx * dx + dy * dy;
if (d2 < minDist2) {
const d = Math.sqrt(d2) || 0.0001;
x = obstacleX + (dx / d) * minDist;
y = obstacleY + (dy / d) * minDist;
this.particleVel[2 * i] = obstacleVelX;
this.particleVel[2 * i + 1] = obstacleVelY;
}
}
if (x < minX) {
x = minX;
this.particleVel[2 * i] = 0.0;
}
if (x > maxX) {
x = maxX;
this.particleVel[2 * i] = 0.0;
}
if (y < minY) {
y = minY;
this.particleVel[2 * i + 1] = 0.0;
}
if (y > maxY) {
y = maxY;
this.particleVel[2 * i + 1] = 0.0;
}
this.particlePos[2 * i] = x;
this.particlePos[2 * i + 1] = y;
}
}
updateParticleDensity() {
const n = this.fNumY;
const h = this.h;
const h1 = this.fInvSpacing;
const h2 = 0.5 * h;
const d = this.particleDensity;
d.fill(0.0);
for (let i = 0; i < this.numParticles; i += 1) {
let x = clamp(this.particlePos[2 * i], h, (this.fNumX - 1) * h);
let y = clamp(this.particlePos[2 * i + 1], h, (this.fNumY - 1) * h);
const x0 = Math.floor((x - h2) * h1);
const tx = (x - h2 - x0 * h) * h1;
const x1 = Math.min(x0 + 1, this.fNumX - 2);
const y0 = Math.floor((y - h2) * h1);
const ty = (y - h2 - y0 * h) * h1;
const y1 = Math.min(y0 + 1, this.fNumY - 2);
const sx = 1.0 - tx;
const sy = 1.0 - ty;
if (x0 < this.fNumX && y0 < this.fNumY) d[x0 * n + y0] += sx * sy;
if (x1 < this.fNumX && y0 < this.fNumY) d[x1 * n + y0] += tx * sy;
if (x1 < this.fNumX && y1 < this.fNumY) d[x1 * n + y1] += tx * ty;
if (x0 < this.fNumX && y1 < this.fNumY) d[x0 * n + y1] += sx * ty;
}
if (this.particleRestDensity === 0.0) {
let sum = 0.0;
let numFluidCells = 0;
for (let i = 0; i < this.fNumCells; i += 1) {
if (this.cellType[i] === FLUID_CELL) {
sum += d[i];
numFluidCells += 1;
}
}
if (numFluidCells > 0) this.particleRestDensity = sum / numFluidCells;
}
}
transferVelocities(toGrid, flipRatio) {
const n = this.fNumY;
const h = this.h;
const h1 = this.fInvSpacing;
const h2 = 0.5 * h;
if (toGrid) {
this.prevU.set(this.u);
this.prevV.set(this.v);
this.du.fill(0.0);
this.dv.fill(0.0);
this.u.fill(0.0);
this.v.fill(0.0);
for (let i = 0; i < this.fNumCells; i += 1) {
this.cellType[i] = this.s[i] === 0.0 ? SOLID_CELL : AIR_CELL;
}
for (let i = 0; i < this.numParticles; i += 1) {
const xi = clamp(Math.floor(this.particlePos[2 * i] * h1), 0, this.fNumX - 1);
const yi = clamp(Math.floor(this.particlePos[2 * i + 1] * h1), 0, this.fNumY - 1);
const cellNr = xi * n + yi;
if (this.cellType[cellNr] === AIR_CELL) this.cellType[cellNr] = FLUID_CELL;
}
}
for (let component = 0; component < 2; component += 1) {
const dx = component === 0 ? 0.0 : h2;
const dy = component === 0 ? h2 : 0.0;
const f = component === 0 ? this.u : this.v;
const prevF = component === 0 ? this.prevU : this.prevV;
const d = component === 0 ? this.du : this.dv;
for (let i = 0; i < this.numParticles; i += 1) {
let x = clamp(this.particlePos[2 * i], h, (this.fNumX - 1) * h);
let y = clamp(this.particlePos[2 * i + 1], h, (this.fNumY - 1) * h);
const x0 = Math.min(Math.floor((x - dx) * h1), this.fNumX - 2);
const tx = (x - dx - x0 * h) * h1;
const x1 = Math.min(x0 + 1, this.fNumX - 2);
const y0 = Math.min(Math.floor((y - dy) * h1), this.fNumY - 2);
const ty = (y - dy - y0 * h) * h1;
const y1 = Math.min(y0 + 1, this.fNumY - 2);
const sx = 1.0 - tx;
const sy = 1.0 - ty;
const d0 = sx * sy;
const d1 = tx * sy;
const d2 = tx * ty;
const d3 = sx * ty;
const nr0 = x0 * n + y0;
const nr1 = x1 * n + y0;
const nr2 = x1 * n + y1;
const nr3 = x0 * n + y1;
if (toGrid) {
const pv = this.particleVel[2 * i + component];
f[nr0] += pv * d0;
d[nr0] += d0;
f[nr1] += pv * d1;
d[nr1] += d1;
f[nr2] += pv * d2;
d[nr2] += d2;
f[nr3] += pv * d3;
d[nr3] += d3;
} else {
const offset = component === 0 ? n : 1;
const valid0 = this.cellType[nr0] !== AIR_CELL || this.cellType[nr0 - offset] !== AIR_CELL ? 1.0 : 0.0;
const valid1 = this.cellType[nr1] !== AIR_CELL || this.cellType[nr1 - offset] !== AIR_CELL ? 1.0 : 0.0;
const valid2 = this.cellType[nr2] !== AIR_CELL || this.cellType[nr2 - offset] !== AIR_CELL ? 1.0 : 0.0;
const valid3 = this.cellType[nr3] !== AIR_CELL || this.cellType[nr3 - offset] !== AIR_CELL ? 1.0 : 0.0;
const v = this.particleVel[2 * i + component];
const dSum = valid0 * d0 + valid1 * d1 + valid2 * d2 + valid3 * d3;
if (dSum > 0.0) {
const picV = (valid0 * d0 * f[nr0] + valid1 * d1 * f[nr1] + valid2 * d2 * f[nr2] + valid3 * d3 * f[nr3]) / dSum;
const corr =
(valid0 * d0 * (f[nr0] - prevF[nr0]) +
valid1 * d1 * (f[nr1] - prevF[nr1]) +
valid2 * d2 * (f[nr2] - prevF[nr2]) +
valid3 * d3 * (f[nr3] - prevF[nr3])) /
dSum;
const flipV = v + corr;
this.particleVel[2 * i + component] = (1.0 - flipRatio) * picV + flipRatio * flipV;
}
}
}
if (toGrid) {
for (let i = 0; i < f.length; i += 1) {
if (d[i] > 0.0) f[i] /= d[i];
}
for (let i = 0; i < this.fNumX; i += 1) {
for (let j = 0; j < this.fNumY; j += 1) {
const solid = this.cellType[i * n + j] === SOLID_CELL;
if (solid || (i > 0 && this.cellType[(i - 1) * n + j] === SOLID_CELL)) this.u[i * n + j] = this.prevU[i * n + j];
if (solid || (j > 0 && this.cellType[i * n + j - 1] === SOLID_CELL)) this.v[i * n + j] = this.prevV[i * n + j];
}
}
}
}
}
solveIncompressibility(numIters, dt, overRelaxation, compensateDrift) {
this.p.fill(0.0);
this.prevU.set(this.u);
this.prevV.set(this.v);
const n = this.fNumY;
const cp = (this.density * this.h) / dt;
for (let iter = 0; iter < numIters; iter += 1) {
for (let i = 1; i < this.fNumX - 1; i += 1) {
for (let j = 1; j < this.fNumY - 1; j += 1) {
if (this.cellType[i * n + j] !== FLUID_CELL) continue;
const center = i * n + j;
const left = (i - 1) * n + j;
const right = (i + 1) * n + j;
const bottom = i * n + j - 1;
const top = i * n + j + 1;
const sx0 = this.s[left];
const sx1 = this.s[right];
const sy0 = this.s[bottom];
const sy1 = this.s[top];
const s = sx0 + sx1 + sy0 + sy1;
if (s === 0.0) continue;
let div = this.u[right] - this.u[center] + this.v[top] - this.v[center];
if (this.particleRestDensity > 0.0 && compensateDrift) {
const compression = this.particleDensity[center] - this.particleRestDensity;
if (compression > 0.0) div -= compression;
}
let p = (-div / s) * overRelaxation;
this.p[center] += cp * p;
this.u[center] -= sx0 * p;
this.u[right] += sx1 * p;
this.v[center] -= sy0 * p;
this.v[top] += sy1 * p;
}
}
}
}
setSciColor(cellNr, val, minVal, maxVal) {
val = Math.min(Math.max(val, minVal), maxVal - 0.0001);
const d = maxVal - minVal;
val = d === 0.0 ? 0.5 : (val - minVal) / d;
const m = 0.25;
const num = Math.floor(val / m);
const s = (val - num * m) / m;
const shade = num === 0 || num === 2 ? s : 1.0 - s;
this.cellColor[3 * cellNr] = shade;
this.cellColor[3 * cellNr + 1] = shade;
this.cellColor[3 * cellNr + 2] = shade;
}
updateCellColors() {
this.cellColor.fill(0.0);
for (let i = 0; i < this.fNumCells; i += 1) {
if (this.cellType[i] === FLUID_CELL) {
let d = this.particleDensity[i];
if (this.particleRestDensity > 0.0) d /= this.particleRestDensity;
this.setSciColor(i, d, 0.0, 2.0);
}
}
}
// Carves the circular obstacle into the pressure grid (s + solid-face
// velocities) — this is the part the fork's numX/numY typo silently
// disabled; done properly here so the fluid actually parts around it.
//
// Only touches a small box around the obstacle (this frame's + last
// frame's), instead of every interior cell in the grid — cells far
// from the obstacle are always s=1 and were being redundantly reset to
// that same value every single frame. Same numeric result, far less
// work: this was the single biggest avoidable per-frame cost.
applyObstacle(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) {
const n = this.fNumY;
const h = this.h;
const r2 = obstacleRadius * obstacleRadius;
const rCells = Math.max(1, Math.ceil(obstacleRadius / h) + 1);
const ci = clamp(Math.round(obstacleX / h), 1, this.fNumX - 2);
const cj = clamp(Math.round(obstacleY / h), 1, this.fNumY - 2);
const i0 = Math.max(1, ci - rCells);
const i1 = Math.min(this.fNumX - 2, ci + rCells);
const j0 = Math.max(1, cj - rCells);
const j1 = Math.min(this.fNumY - 2, cj + rCells);
const prev = this._obstacleBox;
if (prev) {
for (let i = prev.i0; i <= prev.i1; i += 1) {
for (let j = prev.j0; j <= prev.j1; j += 1) {
this.s[i * n + j] = 1.0;
}
}
}
for (let i = i0; i <= i1; i += 1) {
for (let j = j0; j <= j1; j += 1) {
const idx = i * n + j;
if (obstacleRadius <= 0) {
this.s[idx] = 1.0;
continue;
}
const dx = (i + 0.5) * h - obstacleX;
const dy = (j + 0.5) * h - obstacleY;
if (dx * dx + dy * dy < r2) {
this.s[idx] = 0.0;
this.u[idx] = obstacleVelX;
this.u[(i + 1) * n + j] = obstacleVelX;
this.v[idx] = obstacleVelY;
this.v[i * n + j + 1] = obstacleVelY;
} else {
this.s[idx] = 1.0;
}
}
}
if (this._obstacleBox) {
this._obstacleBox.i0 = i0;
this._obstacleBox.i1 = i1;
this._obstacleBox.j0 = j0;
this._obstacleBox.j1 = j1;
} else {
this._obstacleBox = { i0, i1, j0, j1 };
}
}
simulate(dt, gravity, flipRatio, numPressureIters, numParticleIters, overRelaxation, compensateDrift, separateParticles, obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY) {
this.integrateParticles(dt, gravity);
if (separateParticles) this.pushParticlesApart(numParticleIters);
this.handleParticleCollisions(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY);
this.applyObstacle(obstacleX, obstacleY, obstacleRadius, obstacleVelX, obstacleVelY);
this.transferVelocities(true);
this.updateParticleDensity();
this.solveIncompressibility(numPressureIters, dt, overRelaxation, compensateDrift);
this.transferVelocities(false, flipRatio);
this.updateCellColors();
}
}
const CELL = 10; // px per ascii cell
const CROP_X = 1;
const CROP_Y = 1;
const SIM_HEIGHT = 2.0;
const DT_START = 1.0 / 60 / 16; // very slow the instant the obstacle appears/jumps — keeps the solve stable
const DT_BASE = 1.0 / 60 / 3; // resting speed
const DT_RELEASE = 1.0 / 60 / 1.25; // a bit livelier while settling after a drag
const scene = {
gravity: -9.81,
dt: DT_BASE,
flipRatio: 0.9,
numPressureIters: 24,
numParticleIters: 2,
overRelaxation: 1.9,
compensateDrift: true,
separateParticles: true,
obstacleX: 0,
obstacleY: 0,
obstacleRadius: 0,
obstacleTargetRadius: 0,
obstacleVelX: 0,
obstacleVelY: 0,
fluid: null,
};
let fCols = 0;
let fRows = 0;
let maxRadius = 0.26;
let rafId = null;
let visible = false;
let isDown = false;
let renderTick = 0;
// Monospace glyph *advance width* is narrower than the font-size (for
// IBM Plex Mono, roughly 0.6x) — assuming 1 character == CELL px wide
// under-filled the row by the same margin, which is why the water
// stopped a good way short of the right edge even though the box itself
// was full width. Measure the real rendered glyph box instead of
// guessing.
const measureGlyph = () => {
const probe = document.createElement("span");
probe.style.whiteSpace = "pre";
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.textContent = "MMMMMMMMMM";
pre.appendChild(probe);
const box = probe.getBoundingClientRect();
pre.removeChild(probe);
return { w: box.width / 10, h: box.height || CELL };
};
const setupScene = () => {
const rect = host.getBoundingClientRect();
pre.style.fontFamily = "var(--mono)";
pre.style.fontSize = `${CELL}px`;
pre.style.lineHeight = "1em";
const glyph = measureGlyph();
fCols = clamp(Math.floor(rect.width / glyph.w), 40, 520);
fRows = clamp(Math.floor(rect.height / glyph.h), 16, 160);
maxRadius = rect.width > rect.height ? 0.26 : 0.34;
// The physics grid uses square cells, but ASCII glyphs are much
// narrower than they are tall — so the box's raw pixel aspect ratio
// (rect.width/rect.height) is the WRONG ratio to build the sim on. On
// a wide screen that under-sized the grid's width by roughly half,
// which is exactly the "water stops partway across" line: the fluid
// grid was square-cell-correct but never had enough columns to reach
// the right edge of a wide, short box. Building simWidth off the
// character-grid ratio (fCols/fRows) instead makes fNumX land on the
// actual column count the box needs.
const simWidth = SIM_HEIGHT * (fCols / fRows);
const h = SIM_HEIGHT / fRows;
const density = 1000.0;
const relWaterHeight = 0.62;
const relWaterWidth = 1.0;
const r = 0.3 * h;
const dx = 2.0 * r;
const dy = (Math.sqrt(3.0) / 2.0) * dx;
const numX = Math.max(1, Math.floor((relWaterWidth * simWidth - 2.0 * h - 2.0 * r) / dx));
const numY = Math.max(1, Math.floor((relWaterHeight * SIM_HEIGHT - 2.0 * h - 2.0 * r) / dy));
const maxParticles = numX * numY;
const f = new FlipFluid(density, simWidth, SIM_HEIGHT, h, r, maxParticles);
f.numParticles = numX * numY;
const xOffset = (simWidth - numX * dx) / 2;
const yOffset = (SIM_HEIGHT - numY * dy) * -0.5;
let p = 0;
for (let i = 0; i < numX; i += 1) {
for (let j = 0; j < numY; j += 1) {
f.particlePos[p] = h + r + dx * i + (j % 2 === 0 ? 0.0 : r) + xOffset;
f.particlePos[p + 1] = h + r + dy * j + yOffset;
p += 2;
}
}
const n = f.fNumY;
for (let i = 0; i < f.fNumX; i += 1) {
for (let j = 0; j < f.fNumY; j += 1) {
f.s[i * n + j] = i === 0 || i === f.fNumX - 1 || j === 0 ? 0.0 : 1.0;
}
}
scene.fluid = f;
scene.obstacleX = simWidth / 2;
scene.obstacleY = SIM_HEIGHT * 0.55;
scene.obstacleRadius = 0;
scene.obstacleTargetRadius = 0;
};
const simCoordsFromEvent = (event) => {
const rect = host.getBoundingClientRect();
const point = event.touches ? event.touches[0] : event;
const px = point.clientX - rect.left;
const py = point.clientY - rect.top;
const cScale = rect.height / SIM_HEIGHT;
return { x: px / cScale, y: (rect.height - py) / cScale };
};
const onPointerDown = (event) => {
isDown = true;
scene.dt = DT_START;
scene.obstacleTargetRadius = maxRadius;
const p = simCoordsFromEvent(event);
scene.obstacleX = p.x;
scene.obstacleY = p.y;
scene.obstacleVelX = 0;
scene.obstacleVelY = 0;
};
const onPointerMove = (event) => {
if (!isDown) return;
const p = simCoordsFromEvent(event);
scene.obstacleVelX = (p.x - scene.obstacleX) / scene.dt;
scene.obstacleVelY = (p.y - scene.obstacleY) / scene.dt;
scene.obstacleX = p.x;
scene.obstacleY = p.y;
};
const onPointerUp = () => {
if (!isDown) return;
isDown = false;
scene.dt = DT_RELEASE;
scene.obstacleTargetRadius = 0;
scene.obstacleVelX = 0;
scene.obstacleVelY = 0;
};
host.addEventListener("pointerdown", onPointerDown, { passive: true });
host.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
host.addEventListener("pointerleave", onPointerUp, { passive: true });
const render = () => {
const f = scene.fluid;
const dictLen = RENDER_CHARS[0].length;
const n = f.fNumY;
const rows = [];
for (let i = f.fNumY - CROP_Y; i > CROP_Y; i -= 1) {
const chars = new Array(f.fNumX - 2 * CROP_X);
let k = 0;
for (let j = CROP_X; j < f.fNumX - CROP_X; j += 1) {
const dict = RENDER_CHARS[(i + j + 1) % RENDER_CHARS.length];
const shade = f.cellColor[3 * (j * n + i)];
chars[k] = dict[Math.min(dictLen - 1, Math.floor(shade * dictLen))];
k += 1;
}
rows.push(chars.join(""));
}
pre.textContent = rows.join("\n");
};
const frame = () => {
rafId = null;
if (!visible) return;
scene.obstacleRadius = (scene.obstacleRadius * 3 + scene.obstacleTargetRadius) / 4;
scene.fluid.simulate(
scene.dt,
scene.gravity,
scene.flipRatio,
scene.numPressureIters,
scene.numParticleIters,
scene.overRelaxation,
scene.compensateDrift,
scene.separateParticles,
scene.obstacleX,
scene.obstacleY,
scene.obstacleRadius,
scene.obstacleVelX,
scene.obstacleVelY,
);
renderTick += 1;
if (renderTick % 2 === 0) render();
rafId = requestAnimationFrame(frame);
};
setupScene();
render();
// The glyph measurement above can happen before the real @font-face has
// loaded (using a fallback font's metrics instead) — redo it once the
// real font is active so the column count settles at the right value.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
setupScene();
render();
});
}
if ("ResizeObserver" in window) {
let resizeTimer = null;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
setupScene();
render();
}, 200);
});
resizeObserver.observe(host);
}
// Only real "free" saving: don't run the sim at all while it's scrolled
// out of view — costs nothing visually since it can't be seen anyway.
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
visible = entry.isIntersecting;
if (visible && !rafId) rafId = requestAnimationFrame(frame);
});
},
{ threshold: 0.05 },
);
observer.observe(host);
} else {
visible = true;
rafId = requestAnimationFrame(frame);
}
})();
const copyButton = document.querySelector("#copy-discord");
const copyLabel = document.querySelector("#discord-label");
const copyStatus = document.querySelector("#copy-status");
if (copyButton && copyLabel && copyStatus) {
copyButton.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText("fi3w0");
copyLabel.textContent = "copied: fi3w0";
copyStatus.textContent = "Discord username copied to clipboard.";
} catch {
copyLabel.textContent = "username: fi3w0";
copyStatus.textContent = "Clipboard access was blocked. The username is fi3w0.";
}
window.setTimeout(() => {
copyLabel.textContent = "fi3w0 - click to copy";
copyStatus.textContent = "";
}, 2600);
});
}