aero and wave
This commit is contained in:
@@ -0,0 +1,707 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>awesome johnruina site</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f0f0f;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 60px rgba(74,74,138,0.15);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar > * { pointer-events: auto; }
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: color 0.2s;
|
||||||
|
background: rgba(15,15,15,0.7);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover { color: #fff; border-color: #555; }
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
background: rgba(20,20,35,0.85);
|
||||||
|
border: 1px solid #2a2a4a;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings::-webkit-scrollbar { width: 4px; }
|
||||||
|
.settings::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group label span { color: #bbb; font-weight: 800; }
|
||||||
|
|
||||||
|
.s-group input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: #2a2a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #4a4a8a;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group input[type="range"]::-webkit-slider-thumb:hover { background: #6a6aaa; }
|
||||||
|
.s-group input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
background: #4a4a8a;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-swatch {
|
||||||
|
width: 28px; height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-swatch:hover { border-color: #666; transform: scale(1.1); }
|
||||||
|
.palette-swatch.active { border-color: #4a4a8a; box-shadow: 0 0 8px rgba(74,74,138,0.4); }
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row strong { color: #777; }
|
||||||
|
|
||||||
|
.s-btn {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn:hover { color: #ccc; border-color: #555; background: rgba(255,255,255,0.03); }
|
||||||
|
.s-btn.active { color: #fff; border-color: #4a4a8a; background: #1a1a2e; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.layout { flex-direction: column; padding: 1rem; padding-top: 3rem; }
|
||||||
|
.settings { width: 100%; max-height: 200px; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: 0.6rem; padding: 0.8rem; }
|
||||||
|
.settings .settings-title { width: 100%; }
|
||||||
|
.settings .s-group { flex: 1; min-width: 100px; }
|
||||||
|
.settings .palette-row { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top-bar">
|
||||||
|
<a class="back-btn" href="index.html">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<canvas id="aeroCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="settings">
|
||||||
|
<div class="settings-title">Aerodynamics</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Viscosity <span id="viscVal">1.0e-6</span></label>
|
||||||
|
<input type="range" id="viscSlider" min="0" max="10" step="0.1" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Wind Speed <span id="windVal">3.0</span></label>
|
||||||
|
<input type="range" id="windSlider" min="0.5" max="15" step="0.5" value="3">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Particles <span id="partVal">600</span></label>
|
||||||
|
<input type="range" id="partSlider" min="100" max="2000" step="50" value="600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="subtitle">Display</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:4px">
|
||||||
|
<button class="s-btn" style="flex:1" id="showSpeedBtn">Speed</button>
|
||||||
|
<button class="s-btn" style="flex:1" id="showDyeBtn">Dye</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="subtitle">Obstacle</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:4px">
|
||||||
|
<button class="s-btn active" style="flex:1" id="obsOnBtn">On</button>
|
||||||
|
<button class="s-btn" style="flex:1" id="obsOffBtn">Off</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="subtitle">Palette</div>
|
||||||
|
<div class="palette-row" id="paletteRow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
Particles: <strong id="particleCount">0</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="s-btn" id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─── palette ───
|
||||||
|
const palettes = [
|
||||||
|
{ name: 'neon', stops: [
|
||||||
|
[0,0,0], [10,10,50], [20,40,120], [60,120,200],
|
||||||
|
[100,200,220], [200,220,80], [240,140,40], [200,40,80], [100,10,60]
|
||||||
|
]},
|
||||||
|
{ name: 'fire', stops: [
|
||||||
|
[0,0,0], [40,10,0], [100,20,0], [180,60,0],
|
||||||
|
[240,140,20], [255,220,80], [255,255,200]
|
||||||
|
]},
|
||||||
|
{ name: 'ocean', stops: [
|
||||||
|
[0,0,10], [5,20,60], [10,60,120], [40,120,180],
|
||||||
|
[80,180,200], [160,220,220], [200,240,240]
|
||||||
|
]},
|
||||||
|
{ name: 'psych', stops: [
|
||||||
|
[0,0,0], [80,0,80], [200,0,100], [255,100,0],
|
||||||
|
[255,255,0], [0,200,100], [0,100,200], [80,0,120]
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPalette = 0;
|
||||||
|
function buildPalette(name) {
|
||||||
|
const p = palettes.find(x => x.name === name) || palettes[0];
|
||||||
|
const arr = [];
|
||||||
|
const segs = p.stops.length - 1;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const t = i / 255;
|
||||||
|
const seg = Math.min(segs - 1, Math.floor(t * segs));
|
||||||
|
const f = (t * segs) - seg;
|
||||||
|
const a = p.stops[seg];
|
||||||
|
const b = p.stops[seg + 1];
|
||||||
|
arr.push([
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * f),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * f),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * f),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let palette = buildPalette('neon');
|
||||||
|
function getPaletteColor(t) {
|
||||||
|
const i = Math.max(0, Math.min(255, Math.floor(t * 255)));
|
||||||
|
return palette[i];
|
||||||
|
}
|
||||||
|
function setPalette(name) {
|
||||||
|
const idx = palettes.findIndex(x => x.name === name);
|
||||||
|
if (idx >= 0) currentPalette = idx;
|
||||||
|
palette = buildPalette(name);
|
||||||
|
document.querySelectorAll('#paletteRow .palette-swatch').forEach(s => {
|
||||||
|
s.classList.toggle('active', s.dataset.name === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── key state ───
|
||||||
|
const keyState = {};
|
||||||
|
document.addEventListener('keydown', (e) => { keyState[e.key.toLowerCase()] = true; });
|
||||||
|
document.addEventListener('keyup', (e) => { keyState[e.key.toLowerCase()] = false; });
|
||||||
|
|
||||||
|
// ─── aero sim ───
|
||||||
|
const canvas = document.getElementById('aeroCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
|
||||||
|
const N = 160;
|
||||||
|
const S = N + 2;
|
||||||
|
const IX = (i, j) => i + j * S;
|
||||||
|
|
||||||
|
let u = new Float32Array(S * S);
|
||||||
|
let v = new Float32Array(S * S);
|
||||||
|
let d = new Float32Array(S * S);
|
||||||
|
let u0 = new Float32Array(S * S);
|
||||||
|
let v0 = new Float32Array(S * S);
|
||||||
|
let d0 = new Float32Array(S * S);
|
||||||
|
|
||||||
|
let visc = 1e-6;
|
||||||
|
let windSpeed = 3;
|
||||||
|
let targetParticles = 600;
|
||||||
|
|
||||||
|
let showSpeed = true;
|
||||||
|
let showDye = false;
|
||||||
|
let obstacleOn = true;
|
||||||
|
|
||||||
|
const particles = [];
|
||||||
|
let running = true;
|
||||||
|
let animId = null;
|
||||||
|
let startTime = 0;
|
||||||
|
const dt = 0.12;
|
||||||
|
|
||||||
|
// ─── slider refs ───
|
||||||
|
const viscSlider = document.getElementById('viscSlider');
|
||||||
|
const windSlider = document.getElementById('windSlider');
|
||||||
|
const partSlider = document.getElementById('partSlider');
|
||||||
|
const viscVal = document.getElementById('viscVal');
|
||||||
|
const windVal = document.getElementById('windVal');
|
||||||
|
const partVal = document.getElementById('partVal');
|
||||||
|
const particleCount = document.getElementById('particleCount');
|
||||||
|
|
||||||
|
function updateSliders() {
|
||||||
|
visc = parseFloat(viscSlider.value) * 1e-6;
|
||||||
|
windSpeed = parseFloat(windSlider.value);
|
||||||
|
targetParticles = parseInt(partSlider.value);
|
||||||
|
viscVal.textContent = visc.toExponential(1);
|
||||||
|
windVal.textContent = windSpeed.toFixed(1);
|
||||||
|
partVal.textContent = targetParticles;
|
||||||
|
}
|
||||||
|
|
||||||
|
viscSlider.addEventListener('input', updateSliders);
|
||||||
|
windSlider.addEventListener('input', updateSliders);
|
||||||
|
partSlider.addEventListener('input', updateSliders);
|
||||||
|
|
||||||
|
// ─── palette swatches ───
|
||||||
|
(function buildPaletteSwatches() {
|
||||||
|
const row = document.getElementById('paletteRow');
|
||||||
|
palettes.forEach((p) => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'palette-swatch' + (p.name === 'neon' ? ' active' : '');
|
||||||
|
const col = buildPalette(p.name);
|
||||||
|
const avg = col[Math.floor(col.length * 0.6)];
|
||||||
|
swatch.style.background = `rgb(${avg[0]},${avg[1]},${avg[2]})`;
|
||||||
|
swatch.title = p.name;
|
||||||
|
swatch.dataset.name = p.name;
|
||||||
|
swatch.addEventListener('click', () => setPalette(p.name));
|
||||||
|
row.appendChild(swatch);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── display mode ───
|
||||||
|
document.getElementById('showSpeedBtn').addEventListener('click', () => {
|
||||||
|
showSpeed = true; showDye = false;
|
||||||
|
document.getElementById('showSpeedBtn').classList.add('active');
|
||||||
|
document.getElementById('showDyeBtn').classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById('showDyeBtn').addEventListener('click', () => {
|
||||||
|
showSpeed = false; showDye = true;
|
||||||
|
document.getElementById('showDyeBtn').classList.add('active');
|
||||||
|
document.getElementById('showSpeedBtn').classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── obstacle toggle ───
|
||||||
|
document.getElementById('obsOnBtn').addEventListener('click', () => { obstacleOn = true;
|
||||||
|
document.getElementById('obsOnBtn').classList.add('active');
|
||||||
|
document.getElementById('obsOffBtn').classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById('obsOffBtn').addEventListener('click', () => { obstacleOn = false;
|
||||||
|
document.getElementById('obsOffBtn').classList.add('active');
|
||||||
|
document.getElementById('obsOnBtn').classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── fluid core ───
|
||||||
|
function setBnd(b, x) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
x[IX(0, i)] = b === 1 ? -x[IX(1, i)] : x[IX(1, i)];
|
||||||
|
x[IX(N+1, i)] = b === 1 ? -x[IX(N, i)] : x[IX(N, i)];
|
||||||
|
x[IX(i, 0)] = b === 2 ? -x[IX(i, 1)] : x[IX(i, 1)];
|
||||||
|
x[IX(i, N+1)] = b === 2 ? -x[IX(i, N)] : x[IX(i, N)];
|
||||||
|
}
|
||||||
|
x[IX(0, 0)] = 0.5 * (x[IX(1, 0)] + x[IX(0, 1)]);
|
||||||
|
x[IX(0, N+1)] = 0.5 * (x[IX(1, N+1)] + x[IX(0, N)]);
|
||||||
|
x[IX(N+1, 0)] = 0.5 * (x[IX(N, 0)] + x[IX(N+1, 1)]);
|
||||||
|
x[IX(N+1, N+1)] = 0.5 * (x[IX(N, N+1)] + x[IX(N+1, N)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linSolve(b, x, x0, a, c, iter) {
|
||||||
|
const cRecip = 1 / c;
|
||||||
|
for (let k = 0; k < iter; k++) {
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
x[IX(i,j)] = (x0[IX(i,j)] + a * (
|
||||||
|
x[IX(i-1,j)] + x[IX(i+1,j)] + x[IX(i,j-1)] + x[IX(i,j+1)]
|
||||||
|
)) * cRecip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBnd(b, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffuse(b, x, x0, vsc) {
|
||||||
|
const a = dt * vsc * N * N;
|
||||||
|
linSolve(b, x, x0, a, 1 + 4 * a, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advect(b, d, d0, u, v) {
|
||||||
|
const dt0 = dt * N;
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
let x = i - dt0 * u[IX(i,j)];
|
||||||
|
let y = j - dt0 * v[IX(i,j)];
|
||||||
|
if (x < 0.5) x = 0.5; if (x > N + 0.5) x = N + 0.5;
|
||||||
|
if (y < 0.5) y = 0.5; if (y > N + 0.5) y = N + 0.5;
|
||||||
|
const i0 = Math.floor(x); const i1 = i0 + 1;
|
||||||
|
const j0 = Math.floor(y); const j1 = j0 + 1;
|
||||||
|
const s1 = x - i0; const s0 = 1 - s1;
|
||||||
|
const t1 = y - j0; const t0 = 1 - t1;
|
||||||
|
d[IX(i,j)] = s0 * (t0 * d0[IX(i0,j0)] + t1 * d0[IX(i0,j1)])
|
||||||
|
+ s1 * (t0 * d0[IX(i1,j0)] + t1 * d0[IX(i1,j1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBnd(b, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(u, v, p, div) {
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
div[IX(i,j)] = -0.5 * (
|
||||||
|
u[IX(i+1,j)] - u[IX(i-1,j)] + v[IX(i,j+1)] - v[IX(i,j-1)]
|
||||||
|
) / N;
|
||||||
|
p[IX(i,j)] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBnd(0, div); setBnd(0, p);
|
||||||
|
linSolve(0, p, div, 1, 4, 20);
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
u[IX(i,j)] -= 0.5 * N * (p[IX(i+1,j)] - p[IX(i-1,j)]);
|
||||||
|
v[IX(i,j)] -= 0.5 * N * (p[IX(i,j+1)] - p[IX(i,j-1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBnd(1, u); setBnd(2, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
diffuse(1, u0, u, visc);
|
||||||
|
diffuse(2, v0, v, visc);
|
||||||
|
project(u0, v0, u, v);
|
||||||
|
advect(1, u, u0, u0, v0);
|
||||||
|
advect(2, v, v0, u0, v0);
|
||||||
|
project(u, v, u0, v0);
|
||||||
|
|
||||||
|
d0.set(d);
|
||||||
|
diffuse(0, d0, d, 1e-7);
|
||||||
|
const tmp = d; d = d0; d0 = tmp;
|
||||||
|
advect(0, d, d0, u, v);
|
||||||
|
|
||||||
|
for (let i = 0; i < S * S; i++) d[i] *= 0.995;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInflow() {
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
u[IX(1, j)] = windSpeed;
|
||||||
|
d[IX(1, j)] = Math.max(d[IX(1, j)], 0.3 + Math.random() * 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyObstacle() {
|
||||||
|
if (!obstacleOn) return;
|
||||||
|
const cx = N / 2;
|
||||||
|
const cy = N / 2;
|
||||||
|
const r = N * 0.08;
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const dist = Math.sqrt((i - cx) ** 2 + (j - cy) ** 2);
|
||||||
|
if (dist <= r) {
|
||||||
|
u[IX(i, j)] = 0;
|
||||||
|
v[IX(i, j)] = 0;
|
||||||
|
d[IX(i, j)] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── particles ───
|
||||||
|
function spawnParticle() {
|
||||||
|
return {
|
||||||
|
x: Math.random() * 0.1,
|
||||||
|
y: 0.1 + Math.random() * 0.8,
|
||||||
|
age: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticles() {
|
||||||
|
while (particles.length < targetParticles) {
|
||||||
|
particles.push(spawnParticle());
|
||||||
|
}
|
||||||
|
while (particles.length > targetParticles) {
|
||||||
|
particles.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i];
|
||||||
|
p.age++;
|
||||||
|
|
||||||
|
const gi = Math.floor(p.x * N) + 1;
|
||||||
|
const gj = Math.floor(p.y * N) + 1;
|
||||||
|
const ig = Math.min(N, Math.max(1, gi));
|
||||||
|
const jg = Math.min(N, Math.max(1, gj));
|
||||||
|
|
||||||
|
p.x += u[IX(ig, jg)] * dt / N;
|
||||||
|
p.y += v[IX(ig, jg)] * dt / N;
|
||||||
|
|
||||||
|
if (p.x > 1 || p.x < 0 || p.y > 1 || p.y < 0 || p.age > 500) {
|
||||||
|
particles.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── render ───
|
||||||
|
function render() {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
|
const paletteShift = (performance.now() - startTime) * 0.0001;
|
||||||
|
|
||||||
|
const imgData = offCtx.createImageData(N, N);
|
||||||
|
const data = imgData.data;
|
||||||
|
|
||||||
|
let maxVel = 0.001;
|
||||||
|
if (showSpeed) {
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const vel = Math.sqrt(u[IX(i,j)] ** 2 + v[IX(i,j)] ** 2);
|
||||||
|
if (vel > maxVel) maxVel = vel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < N; j++) {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const idx = (j * N + i) * 4;
|
||||||
|
let val;
|
||||||
|
if (showSpeed) {
|
||||||
|
const vel = Math.sqrt(u[IX(i+1, j+1)] ** 2 + v[IX(i+1, j+1)] ** 2);
|
||||||
|
val = Math.min(vel / maxVel, 1);
|
||||||
|
} else {
|
||||||
|
val = Math.min(d[IX(i+1, j+1)] * 0.5, 0.95);
|
||||||
|
}
|
||||||
|
const t = (val + paletteShift) % 1.0;
|
||||||
|
const c = getPaletteColor(t);
|
||||||
|
data[idx] = c[0];
|
||||||
|
data[idx + 1] = c[1];
|
||||||
|
data[idx + 2] = c[2];
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obstacleOn) {
|
||||||
|
const cx = N / 2;
|
||||||
|
const cy = N / 2;
|
||||||
|
const r = N * 0.08;
|
||||||
|
for (let j = 0; j < N; j++) {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const dist = Math.sqrt((i - cx) ** 2 + (j - cy) ** 2);
|
||||||
|
if (dist <= r + 0.5) {
|
||||||
|
const idx = (j * N + i) * 4;
|
||||||
|
data[idx] = 30; data[idx+1] = 30; data[idx+2] = 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offCtx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = '#05050a';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.drawImage(offscreen, 0, 0, w, h);
|
||||||
|
|
||||||
|
// draw particles
|
||||||
|
ctx.fillStyle = 'rgba(200,220,255,0.6)';
|
||||||
|
const sx = w / N;
|
||||||
|
const sy = h / N;
|
||||||
|
for (const p of particles) {
|
||||||
|
const px = p.x * N * sx;
|
||||||
|
const py = p.y * N * sy;
|
||||||
|
const alpha = Math.min(1, 1 - p.age / 500);
|
||||||
|
ctx.globalAlpha = alpha * 0.7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
particleCount.textContent = particles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeSim() {
|
||||||
|
const wrap = document.querySelector('.canvas-wrap');
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
let size = Math.floor(Math.min(rect.width, rect.height, 800));
|
||||||
|
if (size < 50) size = 400;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
offscreen.width = N;
|
||||||
|
offscreen.height = N;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (!running) return;
|
||||||
|
applyInflow();
|
||||||
|
step();
|
||||||
|
applyObstacle();
|
||||||
|
updateParticles();
|
||||||
|
render();
|
||||||
|
animId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
u.fill(0); v.fill(0); d.fill(0);
|
||||||
|
u0.fill(0); v0.fill(0); d0.fill(0);
|
||||||
|
particles.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── events ───
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const my = (e.clientY - rect.top) / rect.height;
|
||||||
|
const gi = Math.floor(mx * N) + 1;
|
||||||
|
const gj = Math.floor(my * N) + 1;
|
||||||
|
const ig = Math.min(N, Math.max(1, gi));
|
||||||
|
const jg = Math.min(N, Math.max(1, gj));
|
||||||
|
u[IX(ig, jg)] += 5;
|
||||||
|
v[IX(ig, jg)] += 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', reset);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') window.location.href = 'index.html';
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', resizeSim);
|
||||||
|
|
||||||
|
// ─── start ───
|
||||||
|
updateSliders();
|
||||||
|
resizeSim();
|
||||||
|
startTime = performance.now();
|
||||||
|
loop();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -210,6 +210,26 @@
|
|||||||
|
|
||||||
.brush-btn:hover { color: #ccc; border-color: #555; }
|
.brush-btn:hover { color: #ccc; border-color: #555; }
|
||||||
.brush-btn.active { color: #fff; border-color: #4a4a8a; background: #1a1a2e; }
|
.brush-btn.active { color: #fff; border-color: #4a4a8a; background: #1a1a2e; }
|
||||||
|
.brush-btn.eraser-active { color: #f66; border-color: #a44; background: #2e1a1a; }
|
||||||
|
|
||||||
|
.recent-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-swatch {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-swatch:hover { border-color: #888; }
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -257,6 +277,8 @@
|
|||||||
|
|
||||||
<div class="color-preview" id="colorPreview"></div>
|
<div class="color-preview" id="colorPreview"></div>
|
||||||
|
|
||||||
|
<div class="recent-row" id="recentColors"></div>
|
||||||
|
|
||||||
<div class="slider-group">
|
<div class="slider-group">
|
||||||
<label>Lightness <span id="lightVal">50%</span></label>
|
<label>Lightness <span id="lightVal">50%</span></label>
|
||||||
<input type="range" id="lightSlider" min="0" max="100" step="1" value="50">
|
<input type="range" id="lightSlider" min="0" max="100" step="1" value="50">
|
||||||
@@ -277,6 +299,7 @@
|
|||||||
<button class="brush-btn" data-brush="soft">Soft</button>
|
<button class="brush-btn" data-brush="soft">Soft</button>
|
||||||
<button class="brush-btn" data-brush="square">Square</button>
|
<button class="brush-btn" data-brush="square">Square</button>
|
||||||
<button class="brush-btn" data-brush="spray">Spray</button>
|
<button class="brush-btn" data-brush="spray">Spray</button>
|
||||||
|
<button class="brush-btn" data-brush="eraser">Eraser</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="settings-btn" id="clearCanvas">Clear Canvas</button>
|
<button class="settings-btn" id="clearCanvas">Clear Canvas</button>
|
||||||
@@ -460,32 +483,93 @@
|
|||||||
opacityVal.textContent = opacitySlider.value + '%';
|
opacityVal.textContent = opacitySlider.value + '%';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── recent colors ───
|
||||||
|
const recentColors = [];
|
||||||
|
|
||||||
|
function addRecentColor(color) {
|
||||||
|
const idx = recentColors.indexOf(color);
|
||||||
|
if (idx >= 0) recentColors.splice(idx, 1);
|
||||||
|
recentColors.unshift(color);
|
||||||
|
if (recentColors.length > 8) recentColors.pop();
|
||||||
|
renderRecentColors();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentColors() {
|
||||||
|
const container = document.getElementById('recentColors');
|
||||||
|
container.innerHTML = '';
|
||||||
|
recentColors.forEach(c => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'recent-swatch';
|
||||||
|
swatch.style.background = c;
|
||||||
|
swatch.addEventListener('click', () => {
|
||||||
|
isEraser = false;
|
||||||
|
document.querySelectorAll('#brushTypes .brush-btn').forEach(b => {
|
||||||
|
b.classList.toggle('active', b.dataset.brush === brushType);
|
||||||
|
b.classList.remove('eraser-active');
|
||||||
|
});
|
||||||
|
currentColor = c;
|
||||||
|
document.getElementById('colorPreview').style.background = c;
|
||||||
|
});
|
||||||
|
container.appendChild(swatch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── brush type ───
|
// ─── brush type ───
|
||||||
let brushType = 'round';
|
let brushType = 'round';
|
||||||
|
let isEraser = false;
|
||||||
const brushBtns = document.querySelectorAll('#brushTypes .brush-btn');
|
const brushBtns = document.querySelectorAll('#brushTypes .brush-btn');
|
||||||
|
|
||||||
brushBtns.forEach(btn => {
|
brushBtns.forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
brushBtns.forEach(b => b.classList.remove('active'));
|
brushBtns.forEach(b => {
|
||||||
btn.classList.add('active');
|
b.classList.remove('active');
|
||||||
|
b.classList.remove('eraser-active');
|
||||||
|
});
|
||||||
|
if (btn.dataset.brush === 'eraser') {
|
||||||
|
isEraser = true;
|
||||||
|
btn.classList.add('eraser-active');
|
||||||
|
} else {
|
||||||
|
isEraser = false;
|
||||||
brushType = btn.dataset.brush;
|
brushType = btn.dataset.brush;
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── undo ───
|
||||||
|
const undoStack = [];
|
||||||
|
const MAX_UNDO = 50;
|
||||||
|
|
||||||
|
function saveState() {
|
||||||
|
if (canvas.width === 0 || canvas.height === 0) return;
|
||||||
|
undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
|
||||||
|
if (undoStack.length > MAX_UNDO) undoStack.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
if (undoStack.length === 0) return;
|
||||||
|
const state = undoStack.pop();
|
||||||
|
ctx.putImageData(state, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── drawing ───
|
// ─── drawing ───
|
||||||
let isDown = false;
|
let isDown = false;
|
||||||
let lastX = -1, lastY = -1;
|
let lastX = -1, lastY = -1;
|
||||||
|
|
||||||
function drawBrush(x, y, size) {
|
function drawBrush(x, y, size) {
|
||||||
const opacity = parseFloat(opacitySlider.value) / 100;
|
const opacity = parseFloat(opacitySlider.value) / 100;
|
||||||
|
|
||||||
|
if (isEraser) {
|
||||||
|
ctx.globalCompositeOperation = 'destination-out';
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
} else {
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = opacity;
|
ctx.globalAlpha = opacity;
|
||||||
ctx.fillStyle = currentColor;
|
ctx.fillStyle = currentColor;
|
||||||
|
addRecentColor(currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
if (brushType === 'round') {
|
if (brushType === 'soft' && !isEraser) {
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
} else if (brushType === 'soft') {
|
|
||||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, size / 2);
|
const grad = ctx.createRadialGradient(x, y, 0, x, y, size / 2);
|
||||||
grad.addColorStop(0, currentColor);
|
grad.addColorStop(0, currentColor);
|
||||||
grad.addColorStop(1, 'transparent');
|
grad.addColorStop(1, 'transparent');
|
||||||
@@ -496,7 +580,7 @@
|
|||||||
} else if (brushType === 'square') {
|
} else if (brushType === 'square') {
|
||||||
ctx.fillRect(x - size / 2, y - size / 2, size, size);
|
ctx.fillRect(x - size / 2, y - size / 2, size, size);
|
||||||
} else if (brushType === 'spray') {
|
} else if (brushType === 'spray') {
|
||||||
const count = Math.max(5, size * 0.8);
|
const count = Math.max(5, size * 0.4);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const angle = Math.random() * Math.PI * 2;
|
||||||
const radius = Math.random() * size / 2;
|
const radius = Math.random() * size / 2;
|
||||||
@@ -507,7 +591,13 @@
|
|||||||
dotSize, dotSize
|
dotSize, dotSize
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +638,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function endDraw() {
|
function endDraw() {
|
||||||
|
if (isDown) saveState();
|
||||||
isDown = false;
|
isDown = false;
|
||||||
lastX = -1; lastY = -1;
|
lastX = -1; lastY = -1;
|
||||||
}
|
}
|
||||||
@@ -575,13 +666,23 @@
|
|||||||
|
|
||||||
// ─── clear ───
|
// ─── clear ───
|
||||||
document.getElementById('clearCanvas').addEventListener('click', () => {
|
document.getElementById('clearCanvas').addEventListener('click', () => {
|
||||||
|
saveState();
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── keyboard ───
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
undo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── init ───
|
// ─── init ───
|
||||||
drawColorWheel();
|
drawColorWheel();
|
||||||
updateCurrentColor();
|
updateCurrentColor();
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
|
saveState();
|
||||||
window.addEventListener('resize', resizeCanvas);
|
window.addEventListener('resize', resizeCanvas);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -87,6 +87,8 @@
|
|||||||
<div class="button-sidebar">
|
<div class="button-sidebar">
|
||||||
<a class="action-btn" href="fractal.html">FRACTAL</a>
|
<a class="action-btn" href="fractal.html">FRACTAL</a>
|
||||||
<a class="action-btn" href="fluid.html">FLUID SIM</a>
|
<a class="action-btn" href="fluid.html">FLUID SIM</a>
|
||||||
|
<a class="action-btn" href="wave.html">WAVE</a>
|
||||||
|
<a class="action-btn" href="aero.html">AERO</a>
|
||||||
<a class="action-btn" href="draw.html">DRAW</a>
|
<a class="action-btn" href="draw.html">DRAW</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>awesome johnruina site</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #0f0f0f;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 60px rgba(74,74,138,0.15);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar > * { pointer-events: auto; }
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #888;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: color 0.2s;
|
||||||
|
background: rgba(15,15,15,0.7);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover { color: #fff; border-color: #555; }
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
width: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
background: rgba(20,20,35,0.85);
|
||||||
|
border: 1px solid #2a2a4a;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings::-webkit-scrollbar { width: 4px; }
|
||||||
|
.settings::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group label span { color: #bbb; font-weight: 800; }
|
||||||
|
|
||||||
|
.s-group input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: #2a2a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #4a4a8a;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-group input[type="range"]::-webkit-slider-thumb:hover { background: #6a6aaa; }
|
||||||
|
.s-group input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
background: #4a4a8a;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-swatch {
|
||||||
|
width: 28px; height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-swatch:hover { border-color: #666; transform: scale(1.1); }
|
||||||
|
.palette-swatch.active { border-color: #4a4a8a; box-shadow: 0 0 8px rgba(74,74,138,0.4); }
|
||||||
|
|
||||||
|
.s-btn {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn:hover { color: #ccc; border-color: #555; background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.layout { flex-direction: column; padding: 1rem; padding-top: 3rem; }
|
||||||
|
.settings { width: 100%; max-height: 200px; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: 0.6rem; padding: 0.8rem; }
|
||||||
|
.settings .settings-title { width: 100%; }
|
||||||
|
.settings .s-group { flex: 1; min-width: 100px; }
|
||||||
|
.settings .palette-row { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top-bar">
|
||||||
|
<a class="back-btn" href="index.html">← Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<canvas id="waveCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="settings">
|
||||||
|
<div class="settings-title">Wave Simulation</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Wave Speed <span id="speedVal">0.5</span></label>
|
||||||
|
<input type="range" id="speedSlider" min="0.05" max="1.5" step="0.05" value="0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Damping <span id="dampVal">0.5%</span></label>
|
||||||
|
<input type="range" id="dampSlider" min="0" max="5" step="0.1" value="0.5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Drop Force <span id="forceVal">10</span></label>
|
||||||
|
<input type="range" id="forceSlider" min="1" max="40" step="1" value="10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Drop Radius <span id="radiusVal">5</span></label>
|
||||||
|
<input type="range" id="radiusSlider" min="1" max="20" step="1" value="5">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-group">
|
||||||
|
<label>Speed <span id="timeVal">1.0</span></label>
|
||||||
|
<input type="range" id="timeSlider" min="0.1" max="3" step="0.1" value="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="subtitle">Palette</div>
|
||||||
|
<div class="palette-row" id="paletteRow"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="s-btn" id="resetBtn">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─── palette ───
|
||||||
|
const palettes = [
|
||||||
|
{ name: 'ocean', stops: [
|
||||||
|
[0,0,10], [5,20,60], [10,60,120], [40,120,180],
|
||||||
|
[80,180,200], [160,220,220], [200,240,240]
|
||||||
|
]},
|
||||||
|
{ name: 'neon', stops: [
|
||||||
|
[0,0,0], [10,10,50], [20,40,120], [60,120,200],
|
||||||
|
[100,200,220], [200,220,80], [240,140,40], [200,40,80], [100,10,60]
|
||||||
|
]},
|
||||||
|
{ name: 'fire', stops: [
|
||||||
|
[0,0,0], [40,10,0], [100,20,0], [180,60,0],
|
||||||
|
[240,140,20], [255,220,80], [255,255,200]
|
||||||
|
]},
|
||||||
|
{ name: 'psych', stops: [
|
||||||
|
[0,0,0], [80,0,80], [200,0,100], [255,100,0],
|
||||||
|
[255,255,0], [0,200,100], [0,100,200], [80,0,120]
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPalette = 0;
|
||||||
|
function buildPalette(name) {
|
||||||
|
const p = palettes.find(x => x.name === name) || palettes[0];
|
||||||
|
const arr = [];
|
||||||
|
const segs = p.stops.length - 1;
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const t = i / 255;
|
||||||
|
const seg = Math.min(segs - 1, Math.floor(t * segs));
|
||||||
|
const f = (t * segs) - seg;
|
||||||
|
const a = p.stops[seg];
|
||||||
|
const b = p.stops[seg + 1];
|
||||||
|
arr.push([
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * f),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * f),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * f),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let palette = buildPalette('ocean');
|
||||||
|
function getPaletteColor(t) {
|
||||||
|
const i = Math.max(0, Math.min(255, Math.floor(t * 255)));
|
||||||
|
return palette[i];
|
||||||
|
}
|
||||||
|
function setPalette(name) {
|
||||||
|
const idx = palettes.findIndex(x => x.name === name);
|
||||||
|
if (idx >= 0) currentPalette = idx;
|
||||||
|
palette = buildPalette(name);
|
||||||
|
document.querySelectorAll('#paletteRow .palette-swatch').forEach(s => {
|
||||||
|
s.classList.toggle('active', s.dataset.name === name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── key state ───
|
||||||
|
const keyState = {};
|
||||||
|
document.addEventListener('keydown', (e) => { keyState[e.key.toLowerCase()] = true; });
|
||||||
|
document.addEventListener('keyup', (e) => { keyState[e.key.toLowerCase()] = false; });
|
||||||
|
|
||||||
|
// ─── wave sim ───
|
||||||
|
const canvas = document.getElementById('waveCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
|
||||||
|
const N = 200;
|
||||||
|
const S = N + 2;
|
||||||
|
const IX = (i, j) => i + j * S;
|
||||||
|
|
||||||
|
let h = new Float32Array(S * S);
|
||||||
|
let hPrev = new Float32Array(S * S);
|
||||||
|
let hTmp = new Float32Array(S * S);
|
||||||
|
|
||||||
|
let waveSpeed = 0.5;
|
||||||
|
let damping = 0.005;
|
||||||
|
let dropForce = 10;
|
||||||
|
let dropRadius = 5;
|
||||||
|
let timeScale = 1;
|
||||||
|
|
||||||
|
let mouseX = -1, mouseY = -1;
|
||||||
|
let prevMouseX = -1, prevMouseY = -1;
|
||||||
|
let isDown = false;
|
||||||
|
let running = true;
|
||||||
|
let animId = null;
|
||||||
|
let startTime = 0;
|
||||||
|
|
||||||
|
// ─── slider refs ───
|
||||||
|
const speedSlider = document.getElementById('speedSlider');
|
||||||
|
const dampSlider = document.getElementById('dampSlider');
|
||||||
|
const forceSlider = document.getElementById('forceSlider');
|
||||||
|
const radiusSlider = document.getElementById('radiusSlider');
|
||||||
|
const timeSlider = document.getElementById('timeSlider');
|
||||||
|
const speedVal = document.getElementById('speedVal');
|
||||||
|
const dampVal = document.getElementById('dampVal');
|
||||||
|
const forceVal = document.getElementById('forceVal');
|
||||||
|
const radiusVal = document.getElementById('radiusVal');
|
||||||
|
const timeVal = document.getElementById('timeVal');
|
||||||
|
|
||||||
|
function updateSliders() {
|
||||||
|
waveSpeed = parseFloat(speedSlider.value);
|
||||||
|
damping = parseFloat(dampSlider.value) * 0.01;
|
||||||
|
dropForce = parseFloat(forceSlider.value);
|
||||||
|
dropRadius = parseFloat(radiusSlider.value);
|
||||||
|
timeScale = parseFloat(timeSlider.value);
|
||||||
|
|
||||||
|
speedVal.textContent = waveSpeed.toFixed(2);
|
||||||
|
dampVal.textContent = (damping * 100).toFixed(1) + '%';
|
||||||
|
forceVal.textContent = dropForce.toFixed(0);
|
||||||
|
radiusVal.textContent = dropRadius.toFixed(0);
|
||||||
|
timeVal.textContent = timeScale.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
speedSlider.addEventListener('input', updateSliders);
|
||||||
|
dampSlider.addEventListener('input', updateSliders);
|
||||||
|
forceSlider.addEventListener('input', updateSliders);
|
||||||
|
radiusSlider.addEventListener('input', updateSliders);
|
||||||
|
timeSlider.addEventListener('input', updateSliders);
|
||||||
|
|
||||||
|
// ─── palette swatches ───
|
||||||
|
(function buildPaletteSwatches() {
|
||||||
|
const row = document.getElementById('paletteRow');
|
||||||
|
palettes.forEach((p) => {
|
||||||
|
const swatch = document.createElement('div');
|
||||||
|
swatch.className = 'palette-swatch' + (p.name === 'ocean' ? ' active' : '');
|
||||||
|
const col = buildPalette(p.name);
|
||||||
|
const avg = col[Math.floor(col.length * 0.6)];
|
||||||
|
swatch.style.background = `rgb(${avg[0]},${avg[1]},${avg[2]})`;
|
||||||
|
swatch.title = p.name;
|
||||||
|
swatch.dataset.name = p.name;
|
||||||
|
swatch.addEventListener('click', () => setPalette(p.name));
|
||||||
|
row.appendChild(swatch);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ─── core wave sim ───
|
||||||
|
function addDrop(cx, cy, force, radius) {
|
||||||
|
const px = Math.floor(cx * N);
|
||||||
|
const py = Math.floor(cy * N);
|
||||||
|
const r = Math.floor(radius);
|
||||||
|
for (let j = Math.max(1, py - r); j <= Math.min(N, py + r); j++) {
|
||||||
|
for (let i = Math.max(1, px - r); i <= Math.min(N, px + r); i++) {
|
||||||
|
const dist = Math.sqrt((i - px) ** 2 + (j - py) ** 2);
|
||||||
|
if (dist <= r) {
|
||||||
|
h[IX(i, j)] -= force * (1 - dist / r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const c2 = waveSpeed * waveSpeed;
|
||||||
|
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const laplacian =
|
||||||
|
h[IX(i + 1, j)] + h[IX(i - 1, j)] +
|
||||||
|
h[IX(i, j + 1)] + h[IX(i, j - 1)] -
|
||||||
|
4 * h[IX(i, j)];
|
||||||
|
|
||||||
|
hTmp[IX(i, j)] = 2 * h[IX(i, j)] - hPrev[IX(i, j)] + c2 * laplacian;
|
||||||
|
hTmp[IX(i, j)] *= (1 - damping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = hPrev;
|
||||||
|
hPrev = h;
|
||||||
|
h = hTmp;
|
||||||
|
hTmp = tmp;
|
||||||
|
|
||||||
|
hPrev.set(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h2 = canvas.height;
|
||||||
|
if (w === 0 || h2 === 0) return;
|
||||||
|
|
||||||
|
const imgData = offCtx.createImageData(N, N);
|
||||||
|
const data = imgData.data;
|
||||||
|
const paletteShift = (performance.now() - startTime) * 0.00005;
|
||||||
|
|
||||||
|
let maxH = 0.001;
|
||||||
|
for (let j = 1; j <= N; j++) {
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const val = Math.abs(h[IX(i, j)]);
|
||||||
|
if (val > maxH) maxH = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j < N; j++) {
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const val = h[IX(i + 1, j + 1)];
|
||||||
|
const t = ((val / maxH) * 0.5 + 0.5 + paletteShift) % 1.0;
|
||||||
|
const c = getPaletteColor(t);
|
||||||
|
const idx = (j * N + i) * 4;
|
||||||
|
data[idx] = c[0];
|
||||||
|
data[idx + 1] = c[1];
|
||||||
|
data[idx + 2] = c[2];
|
||||||
|
data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offCtx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h2);
|
||||||
|
ctx.fillStyle = '#05050a';
|
||||||
|
ctx.fillRect(0, 0, w, h2);
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.drawImage(offscreen, 0, 0, w, h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeSim() {
|
||||||
|
const wrap = document.querySelector('.canvas-wrap');
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
let size = Math.floor(Math.min(rect.width, rect.height, 800));
|
||||||
|
if (size < 50) size = 400;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
offscreen.width = N;
|
||||||
|
offscreen.height = N;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (!running) return;
|
||||||
|
|
||||||
|
if (isDown && prevMouseX >= 0) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = (mouseX / rect.width);
|
||||||
|
const my = (mouseY / rect.height);
|
||||||
|
const pmx = (prevMouseX / rect.width);
|
||||||
|
const pmy = (prevMouseY / rect.height);
|
||||||
|
const dist = Math.sqrt((mx - pmx) ** 2 + (my - pmy) ** 2);
|
||||||
|
if (dist > 0.005) {
|
||||||
|
addDrop(mx, my, dropForce * timeScale, dropRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = Math.ceil(timeScale);
|
||||||
|
for (let s = 0; s < steps; s++) step();
|
||||||
|
render();
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(loop);
|
||||||
|
prevMouseX = mouseX;
|
||||||
|
prevMouseY = mouseY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
h.fill(0);
|
||||||
|
hPrev.fill(0);
|
||||||
|
hTmp.fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── events ───
|
||||||
|
function canvasCoords(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * (canvas.width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (canvas.height / rect.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrop(e) {
|
||||||
|
const p = canvasCoords(e);
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = e.clientX - rect.left;
|
||||||
|
mouseY = e.clientY - rect.top;
|
||||||
|
prevMouseX = mouseX;
|
||||||
|
prevMouseY = mouseY;
|
||||||
|
isDown = true;
|
||||||
|
addDrop(mouseX / rect.width, mouseY / rect.height, dropForce * timeScale, dropRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrop(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = e.clientX - rect.left;
|
||||||
|
mouseY = e.clientY - rect.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', startDrop);
|
||||||
|
window.addEventListener('mouseup', () => { isDown = false; prevMouseX = -1; prevMouseY = -1; });
|
||||||
|
canvas.addEventListener('mousemove', moveDrop);
|
||||||
|
|
||||||
|
canvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0];
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = t.clientX - rect.left;
|
||||||
|
mouseY = t.clientY - rect.top;
|
||||||
|
prevMouseX = mouseX; prevMouseY = mouseY;
|
||||||
|
isDown = true;
|
||||||
|
addDrop(mouseX / rect.width, mouseY / rect.height, dropForce * timeScale, dropRadius);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0];
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseX = t.clientX - rect.left;
|
||||||
|
mouseY = t.clientY - rect.top;
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('touchend', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDown = false; prevMouseX = -1; prevMouseY = -1;
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', reset);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') window.location.href = 'index.html';
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', resizeSim);
|
||||||
|
|
||||||
|
// ─── start ───
|
||||||
|
updateSliders();
|
||||||
|
resizeSim();
|
||||||
|
startTime = performance.now();
|
||||||
|
loop();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user