<!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>