1394 lines
49 KiB
JavaScript
1394 lines
49 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 = 420, steps = 10) => {
|
|
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 -4%", threshold: 0.2 },
|
|
);
|
|
|
|
scrambleHeadings.forEach((heading) => scrambleObserver.observe(heading));
|
|
}
|
|
|
|
const siteHeader = document.querySelector(".site-header");
|
|
const navToggle = document.querySelector(".nav-toggle");
|
|
const primaryNav = document.querySelector("#primary-navigation");
|
|
|
|
if (siteHeader && navToggle && primaryNav) {
|
|
const mobileNavQuery = window.matchMedia("(max-width: 52rem)");
|
|
|
|
const setNavOpen = (open) => {
|
|
siteHeader.classList.toggle("is-nav-open", open);
|
|
navToggle.setAttribute("aria-expanded", String(open));
|
|
primaryNav.toggleAttribute("inert", mobileNavQuery.matches && !open);
|
|
};
|
|
|
|
const syncNavMode = () => {
|
|
siteHeader.classList.remove("is-nav-open");
|
|
navToggle.setAttribute("aria-expanded", "false");
|
|
primaryNav.toggleAttribute("inert", mobileNavQuery.matches);
|
|
};
|
|
|
|
navToggle.addEventListener("click", () => {
|
|
setNavOpen(!siteHeader.classList.contains("is-nav-open"));
|
|
});
|
|
|
|
primaryNav.querySelectorAll("a").forEach((link) => {
|
|
link.addEventListener("click", () => {
|
|
if (mobileNavQuery.matches) setNavOpen(false);
|
|
});
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && siteHeader.classList.contains("is-nav-open")) {
|
|
setNavOpen(false);
|
|
navToggle.focus();
|
|
}
|
|
});
|
|
|
|
mobileNavQuery.addEventListener("change", syncNavMode);
|
|
syncNavMode();
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|