<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fractal Explorer (Mandelbrot & Julia)</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background-color: #1a1a1a;
color: #e5e5e5;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
canvas {
cursor: crosshair;
image-rendering: pixelated;
}
.controls {
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(10px);
}
input[type="range"] {
accent-color: #3b82f6;
}
/* Pseudo-fullscreen styling (Theater Mode) */
.pseudo-fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 50;
background: black;
}
</style>
</head>
<body class="flex flex-col h-screen" id="appContainer">
<!-- Header / UI -->
<div id="uiPanel" class="controls fixed top-4 left-4 z-[100] p-5 rounded-xl shadow-2xl border border-gray-700 w-80 transition-opacity duration-300">
<h1 class="text-xl font-bold mb-4 border-b border-gray-600 pb-2 text-blue-400">Fractal Explorer</h1>
<div class="space-y-4 text-sm">
<div>
<label class="block mb-1 text-gray-400">Mode (モード選択)</label>
<div class="flex gap-2">
<button id="mandelModeBtn" class="flex-1 bg-blue-600 py-1 rounded font-bold text-white transition">Mandelbrot</button>
<button id="juliaModeBtn" class="flex-1 bg-gray-700 py-1 rounded font-bold text-white transition">Julia</button>
</div>
</div>
<div id="juliaParamContainer" class="hidden animate-pulse">
<label class="block mb-1 text-blue-300">Julia Constant (c)</label>
<div id="juliaCValue" class="font-mono text-xs bg-black p-2 rounded border border-blue-900">
c = 0 + 0i
</div>
</div>
<div>
<label class="block mb-1 text-gray-400">Iterations (精度: <span id="iterValue">250</span>)</label>
<input type="range" id="iterRange" min="50" max="2000" value="250" class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer">
</div>
<div>
<label class="block mb-1 text-gray-400">Color Palette</label>
<select id="paletteSelect" class="w-full bg-gray-800 border border-gray-600 rounded p-1">
<option value="electric">Electric Blue</option>
<option value="fire">Solar Flare</option>
<option value="magma">Magma</option>
<option value="grayscale">High Contrast</option>
</select>
</div>
<div class="pt-2 border-t border-gray-700 text-[11px] space-y-1">
<p class="text-gray-400">🖱️ 左クリック: 拡大 / 右クリック: 縮小</p>
<p class="text-blue-400 font-semibold">✨ ダブルクリックでジュリア集合を表示</p>
<div class="flex gap-2 mt-3">
<button id="resetBtn" class="flex-1 bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 rounded transition">
リセット
</button>
<button id="fullscreenBtn" class="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2 rounded transition">
フルスクリーン
</button>
</div>
<p class="text-[9px] text-gray-500 text-center mt-1">※ 'H' キーでUIを隠せます</p>
</div>
<div id="stats" class="text-[10px] text-gray-500 font-mono mt-2">
Center: 0, 0<br>
Zoom: x1.0
</div>
</div>
</div>
<!-- Main Canvas -->
<canvas id="mandelCanvas" class="w-full h-full"></canvas>
<script>
// --- Worker Implementation ---
const workerCode = `
self.onmessage = function(e) {
const { width, height, xMin, xMax, yMin, yMax, maxIter, palette, mode, juliaC } = e.data;
const data = new Uint8ClampedArray(width * height * 4);
function getColor(iter, maxIter, palette) {
if (iter === maxIter) return [0, 0, 0];
const t = iter / maxIter;
if (palette === 'electric') {
return [Math.sin(t * 10) * 128 + 127, Math.sin(t * 20) * 128 + 127, Math.sin(t * 5) * 128 + 127];
} else if (palette === 'fire') {
return [Math.min(255, t * 500), Math.min(255, t * 100), Math.min(255, t * 50)];
} else if (palette === 'magma') {
return [Math.sin(t * 3 + 4) * 127 + 128, Math.sin(t * 3 + 2) * 127 + 128, Math.sin(t * 3 + 1) * 127 + 128];
}
const v = (iter % 256);
return [v, v, v];
}
for (let py = 0; py < height; py++) {
for (let px = 0; px < width; px++) {
let x = xMin + (px / width) * (xMax - xMin);
let y = yMin + (py / height) * (yMax - yMin);
let cx, cy;
if (mode === 'mandelbrot') {
cx = x;
cy = y;
x = 0;
y = 0;
} else {
cx = juliaC.re;
cy = juliaC.im;
}
let iteration = 0;
while (x*x + y*y <= 4 && iteration < maxIter) {
let xtemp = x*x - y*y + cx;
y = 2*x*y + cy;
x = xtemp;
iteration++;
}
const color = getColor(iteration, maxIter, palette);
const idx = (py * width + px) * 4;
data[idx] = color[0];
data[idx + 1] = color[1];
data[idx + 2] = color[2];
data[idx + 3] = 255;
}
}
self.postMessage({ data }, [data.buffer]);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
// --- Application Logic ---
const appContainer = document.getElementById('appContainer');
const canvas = document.getElementById('mandelCanvas');
const ctx = canvas.getContext('2d');
const iterRange = document.getElementById('iterRange');
const iterValue = document.getElementById('iterValue');
const paletteSelect = document.getElementById('paletteSelect');
const resetBtn = document.getElementById('resetBtn');
const fullscreenBtn = document.getElementById('fullscreenBtn');
const stats = document.getElementById('stats');
const mandelBtn = document.getElementById('mandelModeBtn');
const juliaBtn = document.getElementById('juliaModeBtn');
const juliaContainer = document.getElementById('juliaParamContainer');
const juliaCDisplay = document.getElementById('juliaCValue');
const uiPanel = document.getElementById('uiPanel');
let width, height;
let xMin = -2.0, xMax = 1.0;
let yMin = -1.2, yMax = 1.2;
let maxIter = 250;
let currentPalette = 'electric';
let isRendering = false;
let isPseudoFullscreen = false;
let mode = 'mandelbrot';
let juliaC = { re: -0.8, im: 0.156 };
function updateUI() {
mandelBtn.className = mode === 'mandelbrot' ? "flex-1 bg-blue-600 py-1 rounded font-bold text-white transition" : "flex-1 bg-gray-700 py-1 rounded font-bold text-white transition";
juliaBtn.className = mode === 'julia' ? "flex-1 bg-blue-600 py-1 rounded font-bold text-white transition" : "flex-1 bg-gray-700 py-1 rounded font-bold text-white transition";
if (mode === 'julia') {
juliaContainer.classList.remove('hidden');
juliaCDisplay.textContent = `c = ${juliaC.re.toFixed(4)} + ${juliaC.im.toFixed(4)}i`;
} else {
juliaContainer.classList.add('hidden');
}
}
function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
const ratio = width / height;
const xCenter = (xMin + xMax) / 2;
const yCenter = (yMin + yMax) / 2;
const xRange = xMax - xMin;
const yRange = xRange / ratio;
yMin = yCenter - yRange / 2;
yMax = yCenter + yRange / 2;
render();
}
function render() {
if (isRendering) return;
isRendering = true;
worker.postMessage({
width, height, xMin, xMax, yMin, yMax, maxIter,
palette: currentPalette,
mode: mode,
juliaC: juliaC
});
const zoom = (3.0 / (xMax - xMin)).toFixed(1);
stats.innerHTML = `Center: ${((xMin+xMax)/2).toFixed(4)}, ${((yMin+yMax)/2).toFixed(4)}<br>Zoom: x${zoom}`;
}
worker.onmessage = function(e) {
const imageData = new ImageData(e.data.data, width, height);
ctx.putImageData(imageData, 0, 0);
isRendering = false;
};
function zoom(px, py, factor) {
const xClick = xMin + (px / width) * (xMax - xMin);
const yClick = yMin + (py / height) * (yMax - yMin);
const newXRange = (xMax - xMin) * factor;
const newYRange = (yMax - yMin) * factor;
xMin = xClick - (px / width) * newXRange;
xMax = xMin + newXRange;
yMin = yClick - (py / height) * newYRange;
yMax = yMin + newYRange;
render();
}
function toggleFullscreen() {
isPseudoFullscreen = !isPseudoFullscreen;
if (isPseudoFullscreen) {
appContainer.classList.add('pseudo-fullscreen');
fullscreenBtn.textContent = "終了";
fullscreenBtn.classList.replace('bg-indigo-600', 'bg-red-600');
} else {
appContainer.classList.remove('pseudo-fullscreen');
fullscreenBtn.textContent = "フルスクリーン";
fullscreenBtn.classList.replace('bg-red-600', 'bg-indigo-600');
}
setTimeout(resize, 50);
}
// --- Event Listeners ---
window.addEventListener('resize', resize);
canvas.addEventListener('mousedown', (e) => {
if (e.button === 0) zoom(e.clientX, e.clientY, 0.4);
else if (e.button === 2) zoom(e.clientX, e.clientY, 2.5);
});
canvas.addEventListener('dblclick', (e) => {
const cx = xMin + (e.clientX / width) * (xMax - xMin);
const cy = yMin + (e.clientY / height) * (yMax - yMin);
juliaC = { re: cx, im: cy };
mode = 'julia';
xMin = -1.8; xMax = 1.8;
yMin = -1.2; yMax = 1.2;
updateUI();
resize();
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
iterRange.addEventListener('input', (e) => {
maxIter = parseInt(e.target.value);
iterValue.textContent = maxIter;
render();
});
paletteSelect.addEventListener('change', (e) => {
currentPalette = e.target.value;
render();
});
mandelBtn.addEventListener('click', () => {
mode = 'mandelbrot';
xMin = -2.0; xMax = 1.0;
yMin = -1.2; yMax = 1.2;
updateUI();
resize();
});
juliaBtn.addEventListener('click', () => {
mode = 'julia';
xMin = -1.8; xMax = 1.8;
yMin = -1.2; yMax = 1.2;
updateUI();
resize();
});
resetBtn.addEventListener('click', () => {
if (mode === 'mandelbrot') {
xMin = -2.0; xMax = 1.0;
} else {
xMin = -1.8; xMax = 1.8;
}
resize();
});
fullscreenBtn.addEventListener('click', toggleFullscreen);
// キーボードショートカット
window.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') toggleFullscreen();
if (e.key === 'h' || e.key === 'H') {
uiPanel.style.opacity = uiPanel.style.opacity === '0' ? '1' : '0';
uiPanel.style.pointerEvents = uiPanel.style.opacity === '0' ? 'none' : 'auto';
}
if (e.key === 'Escape' && isPseudoFullscreen) toggleFullscreen();
});
// Initialize
updateUI();
resize();
</script>
</body>
</html>