draw and fixes
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.draw-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.draw-canvas-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.draw-canvas-wrap canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 60px rgba(74,74,138,0.15);
|
||||
cursor: crosshair;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* ─── sidebar ─── */
|
||||
|
||||
.draw-settings {
|
||||
width: 180px;
|
||||
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;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.draw-settings::-webkit-scrollbar { width: 4px; }
|
||||
.draw-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;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wheelCanvas {
|
||||
border-radius: 50%;
|
||||
cursor: crosshair;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slider-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.slider-group label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #888;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.slider-group label span {
|
||||
color: #bbb;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #2a2a4a;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-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;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #6a6aaa;
|
||||
}
|
||||
|
||||
.slider-group input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #4a4a8a;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brush-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brush-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brush-btn:hover { color: #ccc; border-color: #555; }
|
||||
.brush-btn.active { color: #fff; border-color: #4a4a8a; background: #1a1a2e; }
|
||||
|
||||
.settings-btn {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.settings-btn:hover { color: #ccc; border-color: #555; background: rgba(255,255,255,0.03); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.draw-layout { flex-direction: column; padding: 1rem; padding-top: 3rem; }
|
||||
.draw-settings { width: 100%; max-height: 200px; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.8rem; }
|
||||
.draw-settings .settings-title { width: 100%; }
|
||||
#wheelCanvas { width: 80px; height: 80px; }
|
||||
.color-preview { width: 40px; height: 40px; }
|
||||
.slider-group { flex: 1; min-width: 80px; }
|
||||
.brush-row { width: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<a class="back-btn" href="index.html">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="draw-layout">
|
||||
<div class="draw-canvas-wrap">
|
||||
<canvas id="drawCanvas"></canvas>
|
||||
</div>
|
||||
<div class="draw-settings">
|
||||
<div class="settings-title">Drawing Tools</div>
|
||||
|
||||
<canvas id="wheelCanvas" width="300" height="300"></canvas>
|
||||
|
||||
<div class="color-preview" id="colorPreview"></div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label>Lightness <span id="lightVal">50%</span></label>
|
||||
<input type="range" id="lightSlider" min="0" max="100" step="1" value="50">
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label>Size <span id="sizeVal">10</span></label>
|
||||
<input type="range" id="sizeSlider" min="1" max="100" step="1" value="10">
|
||||
</div>
|
||||
|
||||
<div class="slider-group">
|
||||
<label>Opacity <span id="opacityVal">100%</span></label>
|
||||
<input type="range" id="opacitySlider" min="5" max="100" step="1" value="100">
|
||||
</div>
|
||||
|
||||
<div class="brush-row" id="brushTypes">
|
||||
<button class="brush-btn active" data-brush="round">Round</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="spray">Spray</button>
|
||||
</div>
|
||||
|
||||
<button class="settings-btn" id="clearCanvas">Clear Canvas</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── color utilities ───
|
||||
function hslToRgb(h, s, l) {
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1/3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// ─── drawing canvas ───
|
||||
const canvas = document.getElementById('drawCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
function resizeCanvas() {
|
||||
const wrap = document.querySelector('.draw-canvas-wrap');
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
let size = Math.floor(Math.min(rect.width, rect.height, 900));
|
||||
if (size < 50) size = 600;
|
||||
if (canvas.width !== size || canvas.height !== size) {
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = canvas.width;
|
||||
temp.height = canvas.height;
|
||||
const tctx = temp.getContext('2d');
|
||||
tctx.drawImage(canvas, 0, 0);
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
ctx.drawImage(temp, 0, 0, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── color wheel ───
|
||||
const wheelCanvas = document.getElementById('wheelCanvas');
|
||||
const wheelCtx = wheelCanvas.getContext('2d');
|
||||
const wheelSize = 300;
|
||||
const wheelRadius = wheelSize / 2 - 6;
|
||||
|
||||
let currentHue = 0;
|
||||
let currentSat = 1;
|
||||
let currentLight = 0.5;
|
||||
let currentColor = 'rgb(255,0,0)';
|
||||
|
||||
function drawColorWheel() {
|
||||
const cx = wheelSize / 2;
|
||||
const cy = wheelSize / 2;
|
||||
const r = wheelRadius;
|
||||
const imgData = wheelCtx.createImageData(wheelSize, wheelSize);
|
||||
const data = imgData.data;
|
||||
|
||||
for (let y = 0; y < wheelSize; y++) {
|
||||
for (let x = 0; x < wheelSize; x++) {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist <= r) {
|
||||
const hue = (Math.atan2(dy, dx) / Math.PI * 180 + 180) / 360;
|
||||
const sat = Math.min(1, dist / r);
|
||||
const [r2, g, b] = hslToRgb(hue, sat, currentLight);
|
||||
const idx = (y * wheelSize + x) * 4;
|
||||
data[idx] = Math.round(r2 * 255);
|
||||
data[idx + 1] = Math.round(g * 255);
|
||||
data[idx + 2] = Math.round(b * 255);
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
wheelCtx.putImageData(imgData, 0, 0);
|
||||
wheelCtx.beginPath();
|
||||
wheelCtx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
wheelCtx.strokeStyle = '#333';
|
||||
wheelCtx.lineWidth = 2;
|
||||
wheelCtx.stroke();
|
||||
}
|
||||
|
||||
function colorFromWheel(clientX, clientY) {
|
||||
const rect = wheelCanvas.getBoundingClientRect();
|
||||
const x = (clientX - rect.left) * (wheelSize / rect.width);
|
||||
const y = (clientY - rect.top) * (wheelSize / rect.height);
|
||||
const cx = wheelSize / 2;
|
||||
const cy = wheelSize / 2;
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > wheelRadius) return null;
|
||||
const hue = (Math.atan2(dy, dx) / Math.PI * 180 + 180) / 360;
|
||||
const sat = dist / wheelRadius;
|
||||
return { hue, sat, dist };
|
||||
}
|
||||
|
||||
function pickColor(clientX, clientY) {
|
||||
const c = colorFromWheel(clientX, clientY);
|
||||
if (!c) return;
|
||||
currentHue = c.hue;
|
||||
currentSat = c.sat;
|
||||
updateCurrentColor();
|
||||
}
|
||||
|
||||
function updateCurrentColor() {
|
||||
const [r, g, b] = hslToRgb(currentHue, currentSat, currentLight);
|
||||
currentColor = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`;
|
||||
document.getElementById('colorPreview').style.background = currentColor;
|
||||
}
|
||||
|
||||
let wheelDown = false;
|
||||
|
||||
wheelCanvas.addEventListener('mousedown', (e) => {
|
||||
pickColor(e.clientX, e.clientY);
|
||||
wheelDown = true;
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (wheelDown) pickColor(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => { wheelDown = false; });
|
||||
|
||||
wheelCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
pickColor(t.clientX, t.clientY);
|
||||
wheelDown = true;
|
||||
}, { passive: false });
|
||||
|
||||
wheelCanvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
if (wheelDown) {
|
||||
const t = e.touches[0];
|
||||
pickColor(t.clientX, t.clientY);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
wheelCanvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
wheelDown = false;
|
||||
}, { passive: false });
|
||||
|
||||
// ─── lightness ───
|
||||
const lightSlider = document.getElementById('lightSlider');
|
||||
const lightVal = document.getElementById('lightVal');
|
||||
|
||||
lightSlider.addEventListener('input', () => {
|
||||
currentLight = parseFloat(lightSlider.value) / 100;
|
||||
lightVal.textContent = Math.round(currentLight * 100) + '%';
|
||||
drawColorWheel();
|
||||
updateCurrentColor();
|
||||
});
|
||||
|
||||
// ─── brush size ───
|
||||
const sizeSlider = document.getElementById('sizeSlider');
|
||||
const sizeVal = document.getElementById('sizeVal');
|
||||
|
||||
sizeSlider.addEventListener('input', () => {
|
||||
sizeVal.textContent = sizeSlider.value;
|
||||
});
|
||||
|
||||
// ─── opacity ───
|
||||
const opacitySlider = document.getElementById('opacitySlider');
|
||||
const opacityVal = document.getElementById('opacityVal');
|
||||
|
||||
opacitySlider.addEventListener('input', () => {
|
||||
opacityVal.textContent = opacitySlider.value + '%';
|
||||
});
|
||||
|
||||
// ─── brush type ───
|
||||
let brushType = 'round';
|
||||
const brushBtns = document.querySelectorAll('#brushTypes .brush-btn');
|
||||
|
||||
brushBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
brushBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
brushType = btn.dataset.brush;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── drawing ───
|
||||
let isDown = false;
|
||||
let lastX = -1, lastY = -1;
|
||||
|
||||
function drawBrush(x, y, size) {
|
||||
const opacity = parseFloat(opacitySlider.value) / 100;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = currentColor;
|
||||
|
||||
if (brushType === 'round') {
|
||||
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);
|
||||
grad.addColorStop(0, currentColor);
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (brushType === 'square') {
|
||||
ctx.fillRect(x - size / 2, y - size / 2, size, size);
|
||||
} else if (brushType === 'spray') {
|
||||
const count = Math.max(5, size * 0.8);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const radius = Math.random() * size / 2;
|
||||
const dotSize = Math.max(1, 1 + Math.random() * 2);
|
||||
ctx.fillRect(
|
||||
x + Math.cos(angle) * radius,
|
||||
y + Math.sin(angle) * radius,
|
||||
dotSize, dotSize
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function drawLine(x1, y1, x2, y2) {
|
||||
const size = parseFloat(sizeSlider.value);
|
||||
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
const steps = Math.max(1, Math.floor(dist / 1.5));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
drawBrush(
|
||||
x1 + (x2 - x1) * t,
|
||||
y1 + (y2 - y1) * t,
|
||||
size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 startDraw(e) {
|
||||
const p = canvasCoords(e);
|
||||
lastX = p.x; lastY = p.y;
|
||||
isDown = true;
|
||||
drawBrush(lastX, lastY, parseFloat(sizeSlider.value));
|
||||
}
|
||||
|
||||
function moveDraw(e) {
|
||||
if (!isDown) return;
|
||||
const p = canvasCoords(e);
|
||||
drawLine(lastX, lastY, p.x, p.y);
|
||||
lastX = p.x; lastY = p.y;
|
||||
}
|
||||
|
||||
function endDraw() {
|
||||
isDown = false;
|
||||
lastX = -1; lastY = -1;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', startDraw);
|
||||
canvas.addEventListener('mousemove', moveDraw);
|
||||
window.addEventListener('mouseup', endDraw);
|
||||
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
startDraw(t);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
moveDraw(t);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
endDraw();
|
||||
}, { passive: false });
|
||||
|
||||
// ─── clear ───
|
||||
document.getElementById('clearCanvas').addEventListener('click', () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
});
|
||||
|
||||
// ─── init ───
|
||||
drawColorWheel();
|
||||
updateCurrentColor();
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+773
@@ -0,0 +1,773 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.fluid-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.fluid-canvas-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.fluid-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; }
|
||||
|
||||
.fluid-label {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #444;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* ─── fluid settings panel ─── */
|
||||
|
||||
.fluid-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;
|
||||
}
|
||||
|
||||
.fluid-settings::-webkit-scrollbar { width: 4px; }
|
||||
.fluid-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;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #888;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-group label span {
|
||||
color: #bbb;
|
||||
font-weight: 800;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.settings-group input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #2a2a4a;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-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;
|
||||
}
|
||||
|
||||
.settings-group input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #6a6aaa;
|
||||
}
|
||||
|
||||
.settings-group input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #4a4a8a;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-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); }
|
||||
|
||||
.settings-info {
|
||||
color: #555;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-info strong { color: #777; }
|
||||
|
||||
.settings-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;
|
||||
}
|
||||
|
||||
.settings-btn:hover { color: #ccc; border-color: #555; background: rgba(255,255,255,0.03); }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.fluid-layout { flex-direction: column; padding: 1rem; padding-top: 3rem; }
|
||||
.fluid-settings { width: 100%; max-height: 200px; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: 0.6rem; padding: 0.8rem; }
|
||||
.fluid-settings .settings-title { width: 100%; }
|
||||
.fluid-settings .settings-group { flex: 1; min-width: 100px; }
|
||||
.fluid-settings .palette-row { width: 100%; }
|
||||
.fluid-settings .settings-btn { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<a class="back-btn" href="index.html">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="fluid-layout">
|
||||
<div class="fluid-canvas-wrap">
|
||||
<canvas id="fluidCanvas"></canvas>
|
||||
</div>
|
||||
<div class="fluid-settings">
|
||||
<div class="settings-title">Fluid Settings</div>
|
||||
|
||||
<div class="settings-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="settings-group">
|
||||
<label>Diffusion <span id="diffVal">5.0e-7</span></label>
|
||||
<input type="range" id="diffSlider" min="0" max="10" step="0.1" value="1">
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label>Force <span id="forceVal">8.0</span></label>
|
||||
<input type="range" id="forceSlider" min="0.5" max="50" step="0.5" value="8">
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label>Speed <span id="speedVal">0.15</span></label>
|
||||
<input type="range" id="speedSlider" min="0.01" max="0.5" step="0.01" value="0.15">
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label>Fade <span id="dissipVal">0.5%</span></label>
|
||||
<input type="range" id="dissipSlider" min="0" max="5" step="0.1" value="0.5">
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label>Damping <span id="dampVal">0.2%</span></label>
|
||||
<input type="range" id="dampSlider" min="0" max="2" step="0.05" value="0.2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="settings-subtitle">Palette</div>
|
||||
<div class="palette-row" id="fluidPaletteRow"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-info">
|
||||
Zoom: <strong id="fluidZoomDisplay">1.00x</strong>
|
||||
</div>
|
||||
|
||||
<button class="settings-btn" id="resetFluidView">Reset View</button>
|
||||
<button class="settings-btn" id="resetFluid">Reset Fluid</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fluid-label">drag to disturb · WASD to pan · scroll to zoom</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: 'forest', stops: [
|
||||
[5,15,5], [10,40,15], [20,80,30], [60,140,50],
|
||||
[120,200,80], [200,220,140], [240,240,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('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('#fluidPaletteRow .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; });
|
||||
|
||||
// ─── fluid sim ───
|
||||
const canvas = document.getElementById('fluidCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const offscreen = document.createElement('canvas');
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
|
||||
const N = 160;
|
||||
const IX = (i, j) => i + j * (N + 2);
|
||||
const S = N + 2;
|
||||
|
||||
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 mouseX = -1, mouseY = -1;
|
||||
let prevMouseX = -1, prevMouseY = -1;
|
||||
let isDown = false;
|
||||
let running = true;
|
||||
let animId = null;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
let startTime = 0;
|
||||
|
||||
let dt = 0.15;
|
||||
let visc = 1e-6;
|
||||
let diff = 5e-7;
|
||||
let forceAmt = 8;
|
||||
let dissipation = 0.005;
|
||||
let damping = 0.002;
|
||||
|
||||
let fluidZoom = 1;
|
||||
let fluidPanX = 0;
|
||||
let fluidPanY = 0;
|
||||
|
||||
// ─── slider refs ───
|
||||
const viscSlider = document.getElementById('viscSlider');
|
||||
const diffSlider = document.getElementById('diffSlider');
|
||||
const forceSlider = document.getElementById('forceSlider');
|
||||
const speedSlider = document.getElementById('speedSlider');
|
||||
const dissipSlider = document.getElementById('dissipSlider');
|
||||
const dampSlider = document.getElementById('dampSlider');
|
||||
const viscVal = document.getElementById('viscVal');
|
||||
const diffVal = document.getElementById('diffVal');
|
||||
const forceVal = document.getElementById('forceVal');
|
||||
const speedVal = document.getElementById('speedVal');
|
||||
const dissipVal = document.getElementById('dissipVal');
|
||||
const dampVal = document.getElementById('dampVal');
|
||||
const fluidZoomDisplay = document.getElementById('fluidZoomDisplay');
|
||||
const paletteRow = document.getElementById('fluidPaletteRow');
|
||||
|
||||
function updateSliders() {
|
||||
visc = parseFloat(viscSlider.value) * 1e-6;
|
||||
diff = parseFloat(diffSlider.value) * 5e-7;
|
||||
forceAmt = parseFloat(forceSlider.value);
|
||||
dt = parseFloat(speedSlider.value);
|
||||
dissipation = parseFloat(dissipSlider.value) * 0.01;
|
||||
damping = parseFloat(dampSlider.value) * 0.01;
|
||||
|
||||
const f = (v) => v.toExponential(1);
|
||||
viscVal.textContent = f(visc);
|
||||
diffVal.textContent = f(diff);
|
||||
forceVal.textContent = forceAmt.toFixed(1);
|
||||
speedVal.textContent = dt.toFixed(2);
|
||||
dissipVal.textContent = (dissipation * 100).toFixed(1) + '%';
|
||||
dampVal.textContent = (damping * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
viscSlider.addEventListener('input', updateSliders);
|
||||
diffSlider.addEventListener('input', updateSliders);
|
||||
forceSlider.addEventListener('input', updateSliders);
|
||||
speedSlider.addEventListener('input', updateSliders);
|
||||
dissipSlider.addEventListener('input', updateSliders);
|
||||
dampSlider.addEventListener('input', updateSliders);
|
||||
|
||||
// ─── palette swatches ───
|
||||
(function buildPaletteSwatches() {
|
||||
paletteRow.innerHTML = '';
|
||||
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);
|
||||
});
|
||||
paletteRow.appendChild(swatch);
|
||||
});
|
||||
})();
|
||||
|
||||
// ─── simulation 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, dt) {
|
||||
const a = dt * vsc * N * N;
|
||||
linSolve(b, x, x0, a, 1 + 4 * a, 20);
|
||||
}
|
||||
|
||||
function advect(b, d, d0, u, v, dt) {
|
||||
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 densStep(x, x0, u, v, diff, dt) {
|
||||
diffuse(0, x, x0, diff, dt);
|
||||
x0.set(x);
|
||||
advect(0, x, x0, u, v, dt);
|
||||
}
|
||||
|
||||
function velStep(u, v, u0, v0, visc, dt) {
|
||||
diffuse(1, u0, u, visc, dt);
|
||||
diffuse(2, v0, v, visc, dt);
|
||||
project(u0, v0, u, v);
|
||||
advect(1, u, u0, u0, v0, dt);
|
||||
advect(2, v, v0, u0, v0, dt);
|
||||
project(u, v, u0, v0);
|
||||
}
|
||||
|
||||
function addForce(fx, fy, cx, cy, radius) {
|
||||
const px = Math.floor(cx * N);
|
||||
const py = Math.floor(cy * N);
|
||||
const r = Math.floor(radius * N);
|
||||
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) {
|
||||
const strength = (1 - dist / r) * forceAmt;
|
||||
u[IX(i,j)] += fx * strength;
|
||||
v[IX(i,j)] += fy * strength;
|
||||
d[IX(i,j)] += (1 - dist / r) * 3.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function burstCenter() {
|
||||
const px = Math.floor(0.5 * N);
|
||||
const py = Math.floor(0.5 * N);
|
||||
const r = Math.floor(0.1 * N);
|
||||
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) d[IX(i,j)] += (1 - dist / r) * 5.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step() {
|
||||
velStep(u, v, u0, v0, visc, dt);
|
||||
d0.set(d);
|
||||
densStep(d, d0, u, v, diff, dt);
|
||||
|
||||
for (let j = 1; j <= N; j++) {
|
||||
for (let i = 1; i <= N; i++) {
|
||||
d[IX(i,j)] *= (1 - dissipation);
|
||||
u[IX(i,j)] *= (1 - damping);
|
||||
v[IX(i,j)] *= (1 - damping);
|
||||
}
|
||||
}
|
||||
|
||||
d0.set(d);
|
||||
u0.set(u);
|
||||
v0.set(v);
|
||||
}
|
||||
|
||||
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;
|
||||
for (let j = 0; j < N; j++) {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const dens = d[IX(i + 1, j + 1)];
|
||||
const idx = (j * N + i) * 4;
|
||||
if (dens > 0.01) {
|
||||
const t = (Math.min(dens * 0.4, 0.95) + paletteShift) % 1.0;
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
} else {
|
||||
data[idx] = 5; data[idx + 1] = 5; data[idx + 2] = 10;
|
||||
}
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
offCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#05050a';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const srcSize = N / fluidZoom;
|
||||
const srcX = (0.5 - fluidPanX) * N - srcSize / 2;
|
||||
const srcY = (0.5 - fluidPanY) * N - srcSize / 2;
|
||||
const scale = Math.min(w / srcSize, h / srcSize);
|
||||
const dstW = srcSize * scale;
|
||||
const dstH = srcSize * scale;
|
||||
const dstX = (w - dstW) / 2;
|
||||
const dstY = (h - dstH) / 2;
|
||||
|
||||
const sX = Math.max(0, srcX);
|
||||
const sY = Math.max(0, srcY);
|
||||
const sW = Math.min(N - sX, srcX + srcSize - sX);
|
||||
const sH = Math.min(N - sY, srcY + srcSize - sY);
|
||||
|
||||
if (sW > 0 && sH > 0) {
|
||||
const ratioX = (sX - srcX) / srcSize;
|
||||
const ratioY = (sY - srcY) / srcSize;
|
||||
const ratioW = sW / srcSize;
|
||||
const ratioH = sH / srcSize;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(offscreen, sX, sY, sW, sH,
|
||||
dstX + ratioX * dstW, dstY + ratioY * dstH,
|
||||
ratioW * dstW, ratioH * dstH);
|
||||
}
|
||||
}
|
||||
|
||||
function resizeFluid() {
|
||||
const wrap = document.querySelector('.fluid-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 fluidLoop() {
|
||||
if (!running) return;
|
||||
|
||||
if (keyState['w']) fluidPanY += 0.01 / fluidZoom;
|
||||
if (keyState['s']) fluidPanY -= 0.01 / fluidZoom;
|
||||
if (keyState['a']) fluidPanX += 0.01 / fluidZoom;
|
||||
if (keyState['d']) fluidPanX -= 0.01 / fluidZoom;
|
||||
|
||||
if (isDown && prevMouseX >= 0) {
|
||||
const mx = (mouseX - canvas.width / 2) / canvas.width / fluidZoom + fluidPanX + 0.5;
|
||||
const my = (mouseY - canvas.height / 2) / canvas.height / fluidZoom + fluidPanY + 0.5;
|
||||
const pmx = (prevMouseX - canvas.width / 2) / canvas.width / fluidZoom + fluidPanX + 0.5;
|
||||
const pmy = (prevMouseY - canvas.height / 2) / canvas.height / fluidZoom + fluidPanY + 0.5;
|
||||
const dx = mx - pmx;
|
||||
const dy = my - pmy;
|
||||
addForce(dx * 3, dy * 3, mx, my, 0.04 / fluidZoom);
|
||||
}
|
||||
|
||||
step();
|
||||
render();
|
||||
fluidZoomDisplay.textContent = fluidZoom.toFixed(2) + 'x';
|
||||
|
||||
animId = requestAnimationFrame(fluidLoop);
|
||||
prevMouseX = mouseX;
|
||||
prevMouseY = mouseY;
|
||||
}
|
||||
|
||||
function resetFluid() {
|
||||
u.fill(0); v.fill(0); d.fill(0);
|
||||
u0.fill(0); v0.fill(0); d0.fill(0);
|
||||
mouseX = -1; mouseY = -1;
|
||||
prevMouseX = -1; prevMouseY = -1;
|
||||
isDown = false;
|
||||
burstCenter();
|
||||
}
|
||||
|
||||
// ─── events ───
|
||||
function mouseDown(e) {
|
||||
const p = canvasCoords(e);
|
||||
mouseX = p.x; mouseY = p.y;
|
||||
prevMouseX = mouseX; prevMouseY = mouseY;
|
||||
isDown = true;
|
||||
}
|
||||
|
||||
function mouseMove(e) {
|
||||
const p = canvasCoords(e);
|
||||
mouseX = p.x; mouseY = p.y;
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', mouseDown);
|
||||
window.addEventListener('mouseup', () => { isDown = false; prevMouseX = -1; prevMouseY = -1; });
|
||||
canvas.addEventListener('mousemove', mouseMove);
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) fluidZoom *= 1.15;
|
||||
else fluidZoom /= 1.15;
|
||||
fluidZoom = Math.max(0.1, Math.min(100, fluidZoom));
|
||||
}, { passive: false });
|
||||
|
||||
function touchStart(e) {
|
||||
e.preventDefault();
|
||||
const p = canvasCoords(e.touches[0]);
|
||||
mouseX = p.x; mouseY = p.y;
|
||||
prevMouseX = mouseX; prevMouseY = mouseY;
|
||||
isDown = true;
|
||||
}
|
||||
|
||||
function touchMove(e) {
|
||||
e.preventDefault();
|
||||
const p = canvasCoords(e.touches[0]);
|
||||
mouseX = p.x; mouseY = p.y;
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', touchStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', touchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
isDown = false;
|
||||
prevMouseX = -1;
|
||||
prevMouseY = -1;
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById('resetFluidView').addEventListener('click', () => {
|
||||
fluidZoom = 1;
|
||||
fluidPanX = 0;
|
||||
fluidPanY = 0;
|
||||
});
|
||||
|
||||
document.getElementById('resetFluid').addEventListener('click', resetFluid);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') window.location.href = 'index.html';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', resizeFluid);
|
||||
|
||||
// ─── start ───
|
||||
updateSliders();
|
||||
resizeFluid();
|
||||
burstCenter();
|
||||
startTime = performance.now();
|
||||
fluidLoop();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.fractal-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.fractal-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fractal-sidebar button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fractal-sidebar button:hover { color: #ccc; border-color: #555; }
|
||||
|
||||
.fractal-sidebar button.active {
|
||||
color: #fff;
|
||||
border-color: #4a4a8a;
|
||||
background: #1a1a2e;
|
||||
box-shadow: 0 0 12px rgba(74,74,138,0.2);
|
||||
}
|
||||
|
||||
.fractal-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fractal-main canvas {
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 60px rgba(74,74,138,0.2);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.fractal-label {
|
||||
color: #666;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fractal-hint {
|
||||
color: #444;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.zoom-badge {
|
||||
color: #555;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: rgba(15,15,15,0.7);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.fractal-layout { flex-direction: column; padding: 1rem; padding-top: 3rem; }
|
||||
.fractal-sidebar { flex-direction: row; flex-wrap: wrap; justify-content: center; max-height: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<a class="back-btn" href="index.html">← Back</a>
|
||||
<span class="zoom-badge" id="fractalZoomBadge">1.00x</span>
|
||||
</div>
|
||||
|
||||
<div class="fractal-layout">
|
||||
<div class="fractal-sidebar" id="fractalTypes">
|
||||
<button data-type="julia" class="active">Julia</button>
|
||||
<button data-type="mandelbrot">Mandelbrot</button>
|
||||
<button data-type="burning">Burning Ship</button>
|
||||
<button data-type="tricorn">Tricorn</button>
|
||||
<button data-type="newton">Newton</button>
|
||||
<button data-type="celtic">Celtic</button>
|
||||
<button data-type="cubic_mandel">Cubic M</button>
|
||||
<button data-type="cubic_julia">Cubic J</button>
|
||||
<button data-type="phoenix">Phoenix</button>
|
||||
<button data-type="z4_mandel">z⁴ M</button>
|
||||
</div>
|
||||
<div class="fractal-main">
|
||||
<canvas id="fractalCanvas"></canvas>
|
||||
<div class="fractal-label" id="fractalLabel">∞ julia set</div>
|
||||
<div class="fractal-hint">WASD to pan · Scroll to zoom · P to cycle palette · Esc to go back</div>
|
||||
</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: 'forest', stops: [
|
||||
[5,15,5], [10,40,15], [20,80,30], [60,140,50],
|
||||
[120,200,80], [200,220,140], [240,240,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('neon');
|
||||
function getPaletteColor(t) {
|
||||
const i = Math.max(0, Math.min(255, Math.floor(t * 255)));
|
||||
return palette[i];
|
||||
}
|
||||
function cyclePalette() {
|
||||
currentPalette = (currentPalette + 1) % palettes.length;
|
||||
palette = buildPalette(palettes[currentPalette].name);
|
||||
}
|
||||
|
||||
// ─── key state ───
|
||||
const keyState = {};
|
||||
document.addEventListener('keydown', (e) => { keyState[e.key.toLowerCase()] = true; });
|
||||
document.addEventListener('keyup', (e) => { keyState[e.key.toLowerCase()] = false; });
|
||||
|
||||
// ─── fractal ───
|
||||
const canvas = document.getElementById('fractalCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const label = document.getElementById('fractalLabel');
|
||||
const typeBtns = document.querySelectorAll('#fractalTypes button');
|
||||
const zoomBadge = document.getElementById('fractalZoomBadge');
|
||||
|
||||
let running = true;
|
||||
let animId = null;
|
||||
let startTime = 0;
|
||||
let currentType = 'julia';
|
||||
let fractalZoom = 1;
|
||||
let fractalPanX = 0;
|
||||
let fractalPanY = 0;
|
||||
|
||||
const renderers = {
|
||||
julia(px, py, maxIter, cx, cy) {
|
||||
let zx = px, zy = py, iter = 0;
|
||||
while (zx * zx + zy * zy < 4 && iter < maxIter) {
|
||||
const tmp = zx * zx - zy * zy + cx;
|
||||
zy = 2 * zx * zy + cy;
|
||||
zx = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx, zy, maxIter };
|
||||
},
|
||||
mandelbrot(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = 2 * rz * iz + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
burning(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = Math.abs(2 * rz * iz) + py;
|
||||
rz = Math.abs(tmp);
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
tricorn(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = -2 * rz * iz + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
newton(px, py, maxIter) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
const tol = 1e-6;
|
||||
while (iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
const denomR = 3 * r2;
|
||||
const denomI = 3 * i2;
|
||||
const denomMag = denomR * denomR + denomI * denomI;
|
||||
if (denomMag < 1e-20) break;
|
||||
const fR = r3 - 1;
|
||||
const fI = i3;
|
||||
const nR = rz - (fR * denomR + fI * denomI) / denomMag;
|
||||
const nI = iz - (fI * denomR - fR * denomI) / denomMag;
|
||||
const dr = nR - rz, di = nI - iz;
|
||||
rz = nR; iz = nI; iter++;
|
||||
if (dr * dr + di * di < tol) break;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
celtic(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = Math.abs(rz * rz - iz * iz) + px;
|
||||
iz = 2 * Math.abs(rz * iz) + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
cubic_mandel(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
rz = r3 + px;
|
||||
iz = i3 + py;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
cubic_julia(px, py, maxIter, cx, cy) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
rz = r3 + cx;
|
||||
iz = i3 + cy;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
phoenix(px, py, maxIter, cx, cy) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
let prevR = 0, prevI = 0;
|
||||
const h = 0.4 + 0.2 * Math.sin(startTime * 0.05);
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + cx + h * prevR;
|
||||
prevI = iz;
|
||||
iz = 2 * rz * iz + cy + h * prevI;
|
||||
prevR = rz;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
z4_mandel(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r4 = r2 * r2 - i2 * i2;
|
||||
const i4 = 2 * r2 * i2;
|
||||
rz = r4 + px;
|
||||
iz = i4 + py;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
};
|
||||
|
||||
function renderFractal(time) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
let cx, cy, zoom, maxIter;
|
||||
if (currentType === 'julia') {
|
||||
cx = -0.7269 + 0.15 * Math.sin(time * 0.08);
|
||||
cy = 0.1889 + 0.15 * Math.cos(time * 0.11);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 100 + Math.floor(40 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'cubic_julia') {
|
||||
cx = 0.4 + 0.3 * Math.sin(time * 0.07);
|
||||
cy = 0.4 + 0.3 * Math.cos(time * 0.09);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'phoenix') {
|
||||
cx = -0.5 + 0.2 * Math.sin(time * 0.06);
|
||||
cy = 0.2 + 0.2 * Math.cos(time * 0.08);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'newton') {
|
||||
cx = 0; cy = 0;
|
||||
zoom = 2.0 + 0.4 * Math.sin(time * 0.03);
|
||||
maxIter = 40 + Math.floor(20 * Math.sin(time * 0.05));
|
||||
} else {
|
||||
cx = -0.5 + 0.1 * Math.sin(time * 0.05);
|
||||
cy = 0;
|
||||
zoom = 1.8 + 0.4 * Math.sin(time * 0.03);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.07));
|
||||
}
|
||||
|
||||
const effZoom = zoom / fractalZoom;
|
||||
const aspect = w / h;
|
||||
const xMin = -effZoom * aspect + fractalPanX;
|
||||
const xMax = effZoom * aspect + fractalPanX;
|
||||
const yMin = -effZoom + fractalPanY;
|
||||
const yMax = effZoom + fractalPanY;
|
||||
const renderFn = renderers[currentType];
|
||||
const paletteCycle = time * 0.015;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const px = xMin + (x / w) * (xMax - xMin);
|
||||
const py = yMin + (y / h) * (yMax - yMin);
|
||||
|
||||
const result = renderFn(px, py, maxIter, cx, cy);
|
||||
const idx = (y * w + x) * 4;
|
||||
|
||||
if (currentType === 'newton') {
|
||||
if (result.iter >= maxIter) {
|
||||
data[idx] = 0; data[idx + 1] = 0; data[idx + 2] = 0;
|
||||
} else {
|
||||
const t = ((result.iter / maxIter + paletteCycle) % 1.0);
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
}
|
||||
} else if (result.iter === maxIter) {
|
||||
data[idx] = 0; data[idx + 1] = 0; data[idx + 2] = 0;
|
||||
} else {
|
||||
const smooth = result.iter + 1 - Math.log(Math.log2(Math.sqrt(result.zx * result.zx + result.zy * result.zy)));
|
||||
const t = ((smooth / maxIter) + paletteCycle) % 1.0;
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
}
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const sidebarW = document.querySelector('.fractal-sidebar').offsetWidth;
|
||||
const availW = window.innerWidth * 0.92 - sidebarW - 48;
|
||||
const availH = window.innerHeight * 0.75;
|
||||
let size = Math.min(availW, availH, 900);
|
||||
size = Math.floor(size / 2) * 2;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (!running) return;
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
|
||||
if (keyState['w']) fractalPanY -= 0.02 * (1.5 / fractalZoom);
|
||||
if (keyState['s']) fractalPanY += 0.02 * (1.5 / fractalZoom);
|
||||
if (keyState['a']) fractalPanX += 0.02 * (1.5 / fractalZoom);
|
||||
if (keyState['d']) fractalPanX -= 0.02 * (1.5 / fractalZoom);
|
||||
|
||||
renderFractal(elapsed);
|
||||
zoomBadge.textContent = fractalZoom.toFixed(2) + 'x';
|
||||
animId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function startFractal() {
|
||||
fractalZoom = 1;
|
||||
fractalPanX = 0;
|
||||
fractalPanY = 0;
|
||||
startTime = performance.now();
|
||||
resizeCanvas();
|
||||
animate();
|
||||
}
|
||||
|
||||
function setType(type) {
|
||||
currentType = type;
|
||||
typeBtns.forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||||
const names = {
|
||||
julia: 'julia set', mandelbrot: 'mandelbrot set', burning: 'burning ship',
|
||||
tricorn: 'tricorn', newton: 'newton', celtic: 'celtic mandelbrot',
|
||||
cubic_mandel: 'cubic mandelbrot', cubic_julia: 'cubic julia',
|
||||
phoenix: 'phoenix julia', z4_mandel: 'z⁴ mandelbrot',
|
||||
};
|
||||
label.textContent = `∞ ${names[type] || type}`;
|
||||
}
|
||||
|
||||
typeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setType(btn.dataset.type);
|
||||
fractalZoom = 1;
|
||||
fractalPanX = 0;
|
||||
fractalPanY = 0;
|
||||
});
|
||||
});
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) fractalZoom *= 1.15;
|
||||
else fractalZoom /= 1.15;
|
||||
fractalZoom = Math.max(0.05, Math.min(1000, fractalZoom));
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'p' || e.key === 'P') cyclePalette();
|
||||
if (e.key === 'Escape') window.location.href = 'index.html';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
setType('julia');
|
||||
startFractal();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+7
-798
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Coming Soon</title>
|
||||
<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">
|
||||
@@ -46,6 +46,8 @@
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: 1rem 2rem;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
@@ -59,6 +61,7 @@
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
@@ -68,130 +71,9 @@
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.92);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.overlay.open { display: block; }
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 1.5rem; right: 2rem;
|
||||
background: none; border: none;
|
||||
color: #888;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover { color: #fff; }
|
||||
|
||||
.fractal-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1.5rem;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.fractal-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fractal-sidebar button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fractal-sidebar button:hover { color: #ccc; border-color: #555; }
|
||||
|
||||
.fractal-sidebar button.active {
|
||||
color: #fff;
|
||||
border-color: #4a4a8a;
|
||||
background: #1a1a2e;
|
||||
box-shadow: 0 0 12px rgba(74,74,138,0.2);
|
||||
}
|
||||
|
||||
.fractal-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.fractal-main canvas {
|
||||
max-width: 75vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 60px rgba(74,74,138,0.2);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.fractal-label {
|
||||
color: #666;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fluid-canvas-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fluid-canvas-wrap canvas {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 60px rgba(74,74,138,0.15);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.fluid-label {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #444;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.main-content { flex-direction: column; align-items: center; }
|
||||
.button-sidebar { flex-direction: row; }
|
||||
.fractal-layout { flex-direction: column; }
|
||||
.fractal-sidebar { flex-direction: row; flex-wrap: wrap; justify-content: center; max-height: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -203,686 +85,13 @@
|
||||
</video>
|
||||
</div>
|
||||
<div class="button-sidebar">
|
||||
<button class="action-btn" id="fractalBtn">FRACTAL</button>
|
||||
<button class="action-btn" id="fluidBtn">FLUID SIM</button>
|
||||
<a class="action-btn" href="fractal.html">FRACTAL</a>
|
||||
<a class="action-btn" href="fluid.html">FLUID SIM</a>
|
||||
<a class="action-btn" href="draw.html">DRAW</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="fractalOverlay">
|
||||
<button class="close-btn" id="fractalClose">✕</button>
|
||||
<div class="fractal-layout">
|
||||
<div class="fractal-sidebar" id="fractalTypes">
|
||||
<button data-type="julia" class="active">Julia</button>
|
||||
<button data-type="mandelbrot">Mandelbrot</button>
|
||||
<button data-type="burning">Burning Ship</button>
|
||||
<button data-type="tricorn">Tricorn</button>
|
||||
<button data-type="newton">Newton</button>
|
||||
<button data-type="celtic">Celtic</button>
|
||||
<button data-type="cubic_mandel">Cubic M</button>
|
||||
<button data-type="cubic_julia">Cubic J</button>
|
||||
<button data-type="phoenix">Phoenix</button>
|
||||
<button data-type="z4_mandel">z⁴ M</button>
|
||||
</div>
|
||||
<div class="fractal-main">
|
||||
<canvas id="fractalCanvas"></canvas>
|
||||
<div class="fractal-label" id="fractalLabel">∞ julia set</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="fluidOverlay">
|
||||
<button class="close-btn" id="fluidClose">✕</button>
|
||||
<div class="fluid-canvas-wrap">
|
||||
<canvas id="fluidCanvas"></canvas>
|
||||
</div>
|
||||
<div class="fluid-label">drag to disturb the fluid</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── procedural 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: 'forest', stops: [
|
||||
[5,15,5], [10,40,15], [20,80,30], [60,140,50],
|
||||
[120,200,80], [200,220,140], [240,240,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('neon');
|
||||
|
||||
function getPaletteColor(t) {
|
||||
const i = Math.max(0, Math.min(255, Math.floor(t * 255)));
|
||||
return palette[i];
|
||||
}
|
||||
|
||||
function cyclePalette() {
|
||||
currentPalette = (currentPalette + 1) % palettes.length;
|
||||
palette = buildPalette(palettes[currentPalette].name);
|
||||
}
|
||||
|
||||
// ─── overlay helpers ───
|
||||
function openOverlay(id) { document.getElementById(id).classList.add('open'); }
|
||||
function closeOverlay(id) { document.getElementById(id).classList.remove('open'); }
|
||||
|
||||
// ─── FRACTAL ───
|
||||
(function() {
|
||||
const canvas = document.getElementById('fractalCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const overlay = document.getElementById('fractalOverlay');
|
||||
const closeBtn = document.getElementById('fractalClose');
|
||||
const label = document.getElementById('fractalLabel');
|
||||
const typeBtns = document.querySelectorAll('#fractalTypes button');
|
||||
|
||||
let running = false;
|
||||
let animId = null;
|
||||
let startTime = 0;
|
||||
let currentType = 'julia';
|
||||
|
||||
function fractalRender(type, px, py, maxIter, cx, cy) {
|
||||
const render = renderers[type];
|
||||
if (!render) return { iter: maxIter, zx: 0, zy: 0, maxIter };
|
||||
return render(px, py, maxIter, cx, cy);
|
||||
}
|
||||
|
||||
const renderers = {
|
||||
julia(px, py, maxIter, cx, cy) {
|
||||
let zx = px, zy = py, iter = 0;
|
||||
while (zx * zx + zy * zy < 4 && iter < maxIter) {
|
||||
const tmp = zx * zx - zy * zy + cx;
|
||||
zy = 2 * zx * zy + cy;
|
||||
zx = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx, zy, maxIter };
|
||||
},
|
||||
mandelbrot(px, py, maxIter, cx, cy) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = 2 * rz * iz + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
burning(px, py, maxIter, cx, cy) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = Math.abs(2 * rz * iz) + py;
|
||||
rz = Math.abs(tmp);
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
tricorn(px, py, maxIter, cx, cy) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + px;
|
||||
iz = -2 * rz * iz + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
newton(px, py, maxIter) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
const tol = 1e-6;
|
||||
while (iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
const denomR = 3 * r2;
|
||||
const denomI = 3 * i2;
|
||||
const denomMag = denomR * denomR + denomI * denomI;
|
||||
if (denomMag < 1e-20) break;
|
||||
const fR = r3 - 1;
|
||||
const fI = i3;
|
||||
const nR = rz - (fR * denomR + fI * denomI) / denomMag;
|
||||
const nI = iz - (fI * denomR - fR * denomI) / denomMag;
|
||||
const dr = nR - rz, di = nI - iz;
|
||||
rz = nR; iz = nI; iter++;
|
||||
if (dr * dr + di * di < tol) break;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
celtic(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = Math.abs(rz * rz - iz * iz) + px;
|
||||
iz = 2 * Math.abs(rz * iz) + py;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
cubic_mandel(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
rz = r3 + px;
|
||||
iz = i3 + py;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
cubic_julia(px, py, maxIter, cx, cy) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r3 = r2 * rz - i2 * iz;
|
||||
const i3 = r2 * iz + i2 * rz;
|
||||
rz = r3 + cx;
|
||||
iz = i3 + cy;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
phoenix(px, py, maxIter, cx, cy) {
|
||||
let rz = px, iz = py, iter = 0;
|
||||
let prevR = 0, prevI = 0;
|
||||
const h = 0.4 + 0.2 * Math.sin(startTime * 0.05);
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const tmp = rz * rz - iz * iz + cx + h * prevR;
|
||||
prevI = iz;
|
||||
iz = 2 * rz * iz + cy + h * prevI;
|
||||
prevR = rz;
|
||||
rz = tmp;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
z4_mandel(px, py, maxIter) {
|
||||
let rz = 0, iz = 0, iter = 0;
|
||||
while (rz * rz + iz * iz < 4 && iter < maxIter) {
|
||||
const r2 = rz * rz - iz * iz;
|
||||
const i2 = 2 * rz * iz;
|
||||
const r4 = r2 * r2 - i2 * i2;
|
||||
const i4 = 2 * r2 * i2;
|
||||
rz = r4 + px;
|
||||
iz = i4 + py;
|
||||
iter++;
|
||||
}
|
||||
return { iter, zx: rz, zy: iz, maxIter };
|
||||
},
|
||||
};
|
||||
|
||||
function renderFractal(time) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
const data = imageData.data;
|
||||
|
||||
const isJulia = currentType === 'julia' || currentType === 'cubic_julia' || currentType === 'phoenix';
|
||||
|
||||
let cx, cy, zoom, maxIter;
|
||||
if (currentType === 'julia') {
|
||||
cx = -0.7269 + 0.15 * Math.sin(time * 0.08);
|
||||
cy = 0.1889 + 0.15 * Math.cos(time * 0.11);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 100 + Math.floor(40 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'cubic_julia') {
|
||||
cx = 0.4 + 0.3 * Math.sin(time * 0.07);
|
||||
cy = 0.4 + 0.3 * Math.cos(time * 0.09);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'phoenix') {
|
||||
cx = -0.5 + 0.2 * Math.sin(time * 0.06);
|
||||
cy = 0.2 + 0.2 * Math.cos(time * 0.08);
|
||||
zoom = 1.5 + 0.3 * Math.sin(time * 0.04);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.06));
|
||||
} else if (currentType === 'newton') {
|
||||
cx = 0; cy = 0;
|
||||
zoom = 2.0 + 0.4 * Math.sin(time * 0.03);
|
||||
maxIter = 40 + Math.floor(20 * Math.sin(time * 0.05));
|
||||
} else {
|
||||
cx = -0.5 + 0.1 * Math.sin(time * 0.05);
|
||||
cy = 0;
|
||||
zoom = 1.8 + 0.4 * Math.sin(time * 0.03);
|
||||
maxIter = 80 + Math.floor(30 * Math.sin(time * 0.07));
|
||||
}
|
||||
|
||||
const aspect = w / h;
|
||||
const xMin = -zoom * aspect;
|
||||
const xMax = zoom * aspect;
|
||||
const yMin = -zoom;
|
||||
const yMax = zoom;
|
||||
const renderFn = renderers[currentType];
|
||||
|
||||
const paletteCycle = time * 0.015;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const px = xMin + (x / w) * (xMax - xMin);
|
||||
const py = yMin + (y / h) * (yMax - yMin);
|
||||
|
||||
const result = renderFn(px, py, maxIter, cx, cy);
|
||||
const idx = (y * w + x) * 4;
|
||||
|
||||
if (currentType === 'newton') {
|
||||
if (result.iter >= maxIter) {
|
||||
data[idx] = 0; data[idx + 1] = 0; data[idx + 2] = 0;
|
||||
} else {
|
||||
const t = ((result.iter / maxIter + paletteCycle) % 1.0);
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
}
|
||||
} else if (result.iter === maxIter) {
|
||||
data[idx] = 0; data[idx + 1] = 0; data[idx + 2] = 0;
|
||||
} else {
|
||||
const smooth = result.iter + 1 - Math.log(Math.log2(Math.sqrt(result.zx * result.zx + result.zy * result.zy)));
|
||||
const t = ((smooth / maxIter) + paletteCycle) % 1.0;
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
}
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const sidebarW = document.querySelector('.fractal-sidebar').offsetWidth;
|
||||
const availW = window.innerWidth * 0.92 - sidebarW - 48;
|
||||
const availH = window.innerHeight * 0.85;
|
||||
let size = Math.min(availW, availH, 800);
|
||||
size = Math.floor(size / 2) * 2;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (!running) return;
|
||||
const elapsed = (performance.now() - startTime) / 1000;
|
||||
renderFractal(elapsed);
|
||||
animId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function startFractal() {
|
||||
running = true;
|
||||
startTime = performance.now();
|
||||
resizeCanvas();
|
||||
animate();
|
||||
}
|
||||
|
||||
function stopFractal() {
|
||||
running = false;
|
||||
if (animId) { cancelAnimationFrame(animId); animId = null; }
|
||||
}
|
||||
|
||||
function setType(type) {
|
||||
currentType = type;
|
||||
typeBtns.forEach(b => b.classList.toggle('active', b.dataset.type === type));
|
||||
const names = {
|
||||
julia: 'julia set', mandelbrot: 'mandelbrot set', burning: 'burning ship',
|
||||
tricorn: 'tricorn', newton: 'newton', celtic: 'celtic mandelbrot',
|
||||
cubic_mandel: 'cubic mandelbrot', cubic_julia: 'cubic julia',
|
||||
phoenix: 'phoenix julia', z4_mandel: 'z⁴ mandelbrot',
|
||||
};
|
||||
label.textContent = `∞ ${names[type] || type}`;
|
||||
}
|
||||
|
||||
typeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => setType(btn.dataset.type));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'p' || e.key === 'P') cyclePalette();
|
||||
if (e.key === 'Escape') {
|
||||
if (overlay.classList.contains('open')) {
|
||||
closeOverlay('fractalOverlay');
|
||||
stopFractal();
|
||||
}
|
||||
if (document.getElementById('fluidOverlay').classList.contains('open')) {
|
||||
closeOverlay('fluidOverlay');
|
||||
document.getElementById('fluidOverlay').__stopFluid();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('fractalBtn').addEventListener('click', () => {
|
||||
openOverlay('fractalOverlay');
|
||||
resizeCanvas();
|
||||
startFractal();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', () => { closeOverlay('fractalOverlay'); stopFractal(); });
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) { closeOverlay('fractalOverlay'); stopFractal(); }
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if (running) resizeCanvas(); });
|
||||
|
||||
setType('julia');
|
||||
})();
|
||||
|
||||
// ─── FLUID SIMULATION ───
|
||||
(function() {
|
||||
const canvas = document.getElementById('fluidCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const overlay = document.getElementById('fluidOverlay');
|
||||
const closeBtn = document.getElementById('fluidClose');
|
||||
|
||||
const N = 120;
|
||||
const IX = (i, j) => i + j * (N + 2);
|
||||
const S = N + 2;
|
||||
|
||||
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 mouseX = -1, mouseY = -1;
|
||||
let prevMouseX = -1, prevMouseY = -1;
|
||||
let isDown = false;
|
||||
let running = false;
|
||||
let animId = null;
|
||||
|
||||
const dt = 0.15;
|
||||
const visc = 0.000001;
|
||||
const diff = 0.0000005;
|
||||
const forceAmt = 8;
|
||||
|
||||
overlay.__stopFluid = () => stopFluid();
|
||||
|
||||
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, dt) {
|
||||
const a = dt * vsc * N * N;
|
||||
linSolve(b, x, x0, a, 1 + 4 * a, 20);
|
||||
}
|
||||
|
||||
function advect(b, d, d0, u, v, dt) {
|
||||
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 densStep(x, x0, u, v, diff, dt) {
|
||||
diffuse(0, x, x0, diff, dt);
|
||||
x0.set(x);
|
||||
advect(0, x, x0, u, v, dt);
|
||||
}
|
||||
|
||||
function velStep(u, v, u0, v0, visc, dt) {
|
||||
diffuse(1, u0, u, visc, dt);
|
||||
diffuse(2, v0, v, visc, dt);
|
||||
project(u0, v0, u, v);
|
||||
advect(1, u, u0, u0, v0, dt);
|
||||
advect(2, v, v0, u0, v0, dt);
|
||||
project(u, v, u0, v0);
|
||||
}
|
||||
|
||||
function addForce(fx, fy, cx, cy, radius) {
|
||||
const px = Math.floor(cx * N);
|
||||
const py = Math.floor(cy * N);
|
||||
const r = Math.floor(radius * N);
|
||||
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) {
|
||||
const strength = (1 - dist / r) * forceAmt;
|
||||
u[IX(i,j)] += fx * strength;
|
||||
v[IX(i,j)] += fy * strength;
|
||||
d[IX(i,j)] += (1 - dist / r) * 3.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function burstCenter() {
|
||||
const px = Math.floor(0.5 * N);
|
||||
const py = Math.floor(0.5 * N);
|
||||
const r = Math.floor(0.1 * N);
|
||||
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) d[IX(i,j)] += (1 - dist / r) * 5.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step() {
|
||||
velStep(u, v, u0, v0, visc, dt);
|
||||
d0.set(d);
|
||||
densStep(d, d0, u, v, diff, dt);
|
||||
d0.set(d);
|
||||
u0.set(u);
|
||||
v0.set(v);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const imageData = ctx.createImageData(w, h);
|
||||
const data = imageData.data;
|
||||
const scaleX = N / w;
|
||||
const scaleY = N / h;
|
||||
const paletteShift = (performance.now() - startTime) * 0.0001;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const gi = Math.min(N, Math.max(1, Math.floor(x * scaleX) + 1));
|
||||
const gj = Math.min(N, Math.max(1, Math.floor(y * scaleY) + 1));
|
||||
const dens = d[IX(gi, gj)];
|
||||
const idx = (y * w + x) * 4;
|
||||
if (dens > 0.01) {
|
||||
const t = (Math.min(dens * 0.4, 0.95) + paletteShift) % 1.0;
|
||||
const c = getPaletteColor(t);
|
||||
data[idx] = c[0]; data[idx + 1] = c[1]; data[idx + 2] = c[2];
|
||||
} else {
|
||||
data[idx] = 5; data[idx + 1] = 5; data[idx + 2] = 10;
|
||||
}
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
let startTime = 0;
|
||||
|
||||
function resizeFluid() {
|
||||
const maxW = window.innerWidth * 0.95;
|
||||
const maxH = window.innerHeight * 0.9;
|
||||
let size = Math.min(maxW, maxH);
|
||||
size = Math.floor(size);
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
}
|
||||
|
||||
function fluidLoop() {
|
||||
if (!running) return;
|
||||
if (isDown && prevMouseX >= 0) {
|
||||
const dx = mouseX - prevMouseX;
|
||||
const dy = mouseY - prevMouseY;
|
||||
addForce(dx * 3, dy * 3, mouseX / canvas.width, mouseY / canvas.height, 0.04);
|
||||
}
|
||||
step();
|
||||
render();
|
||||
animId = requestAnimationFrame(fluidLoop);
|
||||
prevMouseX = mouseX;
|
||||
prevMouseY = mouseY;
|
||||
}
|
||||
|
||||
function startFluid() {
|
||||
running = true;
|
||||
startTime = performance.now();
|
||||
resizeFluid();
|
||||
fluidLoop();
|
||||
}
|
||||
|
||||
function stopFluid() {
|
||||
running = false;
|
||||
if (animId) { cancelAnimationFrame(animId); animId = null; }
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
prevMouseX = mouseX;
|
||||
prevMouseY = mouseY;
|
||||
isDown = true;
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => { isDown = false; prevMouseX = -1; prevMouseY = -1; });
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
});
|
||||
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = e.touches[0];
|
||||
mouseX = t.clientX - rect.left;
|
||||
mouseY = t.clientY - rect.top;
|
||||
prevMouseX = mouseX;
|
||||
prevMouseY = mouseY;
|
||||
isDown = true;
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = e.touches[0];
|
||||
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('fluidBtn').addEventListener('click', () => {
|
||||
u.fill(0); v.fill(0); d.fill(0);
|
||||
u0.fill(0); v0.fill(0); d0.fill(0);
|
||||
mouseX = -1; mouseY = -1;
|
||||
prevMouseX = -1; prevMouseY = -1;
|
||||
isDown = false;
|
||||
burstCenter();
|
||||
openOverlay('fluidOverlay');
|
||||
startFluid();
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', () => { closeOverlay('fluidOverlay'); stopFluid(); });
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) { closeOverlay('fluidOverlay'); stopFluid(); }
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if (running) resizeFluid(); });
|
||||
})();
|
||||
|
||||
// ─── unstoppable video ───
|
||||
(function() {
|
||||
const v = document.getElementById('bgVideo');
|
||||
if (!v) return;
|
||||
|
||||
Reference in New Issue
Block a user