Pythonフラクタル生成 (マンデルブロ集合)
Pythonでピクセルごとにマンデルブロ集合を計算し、ImageDataとしてcanvasに着色描画。ボタンで見どころへズーム巡回でき、重い数値計算のブラウザ実行例になる。
外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
ライブデモ
使用例(お題: カフェ MOON BREW)
この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- MOON BREW:ラテアート模様ジェネレータ。Pythonでフラクタルを生成し泡の模様に -->
<section class="mb-latte" aria-label="MOON BREW ラテアート">
<div class="mb-latte__info">
<p class="mb-latte__eyebrow">SIGNATURE ART</p>
<h1 class="mb-latte__title">本日のラテアート</h1>
<p class="mb-latte__desc">焙煎の渦から生まれる、一杯ごとに違うフラクタル模様。お好みの柄をどうぞ。</p>
<p class="mb-latte__name" id="artName">— 渦巻きブレンド —</p>
<button id="zoom" class="mb-latte__btn" disabled>別の模様にする</button>
<p class="mb-latte__note">*模様は Python が淹れたてで計算しています</p>
</div>
<div class="mb-latte__cup">
<div class="mb-latte__foam">
<canvas id="cv" width="190" height="190" aria-label="ラテアートの模様"></canvas>
<!-- 読込/計算中オーバーレイ -->
<div class="mb-latte__overlay" id="overlay">
<span class="mb-latte__spin" aria-hidden="true"></span>
<p id="msg">模様を抽出中…</p>
</div>
</div>
<span class="mb-latte__handle" aria-hidden="true"></span>
</div>
</section>
CSS
/* MOON BREW:ラテアート模様ジェネレータ */
:root {
--cream: #f5ede1;
--brown: #2b1d12;
--brown2: #4a3422;
--amber: #c98a3b;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--cream) 0%, #ece0cf 100%);
font-family: "Hiragino Mincho ProN", "Yu Mincho", serif;
color: var(--brown);
overflow: hidden;
}
.mb-latte {
width: min(580px, 94vw);
display: grid;
grid-template-columns: 1.1fr 1fr;
align-items: center;
gap: 26px;
padding: 22px 28px;
border-radius: 20px;
background: #fffaf3;
box-shadow: 0 18px 44px rgba(43, 29, 18, 0.16);
}
/* 左:説明 */
.mb-latte__eyebrow {
margin: 0;
font-size: 10px;
letter-spacing: 0.3em;
color: var(--amber);
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
}
.mb-latte__title { margin: 8px 0 10px; font-size: 23px; font-weight: 700; }
.mb-latte__desc {
margin: 0 0 16px;
font-size: 12.5px;
line-height: 1.85;
color: #6d5b49;
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
}
.mb-latte__name {
margin: 0 0 14px;
font-size: 14px;
color: var(--brown2);
letter-spacing: 0.04em;
}
.mb-latte__btn {
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
font-size: 13px;
font-weight: 700;
padding: 11px 22px;
border: none;
border-radius: 999px;
cursor: pointer;
color: #fff;
background: linear-gradient(135deg, #d89a4c, var(--amber));
box-shadow: 0 8px 18px rgba(201, 138, 59, 0.36);
transition: transform 0.2s ease;
}
.mb-latte__btn:disabled { opacity: 0.5; cursor: default; }
.mb-latte__btn:not(:disabled):hover { transform: translateY(-2px); }
.mb-latte__note {
margin: 12px 0 0;
font-size: 10.5px;
color: #a8957f;
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
}
/* 右:カップ */
.mb-latte__cup { position: relative; justify-self: center; }
.mb-latte__foam {
position: relative;
width: 200px;
height: 200px;
border-radius: 50%;
background: #efe2cd;
padding: 5px;
box-shadow:
0 12px 28px rgba(43, 29, 18, 0.28),
inset 0 0 0 7px #fffaf3,
inset 0 0 0 9px #d8c4a6;
overflow: hidden;
}
#cv {
display: block;
width: 190px;
height: 190px;
border-radius: 50%;
}
/* カップの取っ手 */
.mb-latte__handle {
position: absolute;
top: 50%;
right: -22px;
transform: translateY(-50%);
width: 34px;
height: 54px;
border: 9px solid #d8c4a6;
border-left: none;
border-radius: 0 30px 30px 0;
}
/* オーバーレイ:読込/計算中 */
.mb-latte__overlay {
position: absolute;
inset: 5px;
border-radius: 50%;
display: grid;
place-items: center;
gap: 8px;
background: rgba(43, 29, 18, 0.78);
color: var(--cream);
font-family: "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
font-size: 11.5px;
transition: opacity 0.4s ease;
}
.mb-latte__overlay.is-hidden { opacity: 0; pointer-events: none; }
.mb-latte__overlay p { margin: 0; }
.mb-latte__spin {
width: 26px; height: 26px;
border-radius: 50%;
border: 3px solid rgba(245, 237, 225, 0.25);
border-top-color: var(--amber);
animation: mb-spin 0.8s linear infinite;
}
@keyframes mb-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.mb-latte__spin { animation: none; }
.mb-latte__btn { transition: none; }
}
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), overlay = $("overlay"), msg = $("msg"),
zoomBtn = $("zoom"), artName = $("artName");
if (cv && overlay && msg && zoomBtn && artName) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
let pyodide = null, busy = false;
// ラテアートの「柄」=マンデルブロの見どころ。名前付きで巡回
const arts = [
{ name: "— 渦巻きブレンド —", cx: -0.5, cy: 0.0, s: 2.6 },
{ name: "— 琥珀のうずまき —", cx: -0.745, cy: 0.113, s: 0.05 },
{ name: "— 双葉ロゼッタ —", cx: -0.16, cy: 1.0405, s: 0.06 },
{ name: "— ハートフォーム —", cx: 0.2515, cy: 0.0, s: 0.03 },
{ name: "— 月のしずく —", cx: -0.7436, cy: 0.1318, s: 0.012 }
];
let idx = 0;
// Pythonで模様(RGBA配列)を生成。MOON BREWのクリーム&ブラウン階調
async function render(art) {
if (!pyodide || busy) return;
busy = true;
overlay.classList.remove("is-hidden");
msg.textContent = "模様を抽出中…";
artName.textContent = art.name;
// スピナーを1フレーム見せてから重い計算へ
await new Promise((r) => requestAnimationFrame(r));
pyodide.globals.set("W", W);
pyodide.globals.set("H", H);
pyodide.globals.set("CX", art.cx);
pyodide.globals.set("CY", art.cy);
pyodide.globals.set("SCALE", art.s);
const buf = pyodide.runPython(`
def latte():
max_iter = 70
out = bytearray(W * H * 4)
for py in range(H):
y0 = CY + (py / H - 0.5) * SCALE
for px in range(W):
x0 = CX + (px / W - 0.5) * SCALE
x = y = 0.0
it = 0
while x*x + y*y <= 4.0 and it < max_iter:
x, y = x*x - y*y + x0, 2*x*y + y0
it += 1
i = (py * W + px) * 4
if it >= max_iter:
# 模様の芯:濃ブラウン
out[i], out[i+1], out[i+2] = 43, 29, 18
else:
# クリーム→琥珀→ブラウンの泡グラデーション
t = it / max_iter
out[i] = min(255, int(245 - 200 * t))
out[i+1] = min(255, int(237 - 200 * t))
out[i+2] = min(255, int(225 - 205 * t))
out[i+3] = 255
return out
latte()
`).toJs();
const img = new ImageData(new Uint8ClampedArray(buf), W, H);
ctx.putImageData(img, 0, 0);
overlay.classList.add("is-hidden");
busy = false;
}
// ボタンで次の柄へ
zoomBtn.addEventListener("click", () => {
idx = (idx + 1) % arts.length;
render(arts[idx]);
});
// Pyodide起動。失敗時はCSSの泡色のままフォールバック
(async () => {
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
zoomBtn.disabled = false;
await render(arts[0]);
} catch (e) {
msg.textContent = "本日のアートは準備中です";
}
})();
}
コード
HTML
<!-- Pythonでマンデルブロ集合を計算しImageDataで描画するデモ -->
<main class="frac" aria-label="Pythonフラクタル">
<canvas id="cv" class="frac__canvas" width="480" height="360"></canvas>
<div class="frac__hud">
<div class="frac__title">Mandelbrot</div>
<div class="frac__sub">computed pixel-by-pixel in Python</div>
<button id="zoom" class="frac__btn" disabled>ズーム巡回</button>
</div>
<!-- 進捗オーバーレイ -->
<div class="frac__overlay" id="overlay">
<div class="frac__spinner" aria-hidden="true"></div>
<p class="frac__msg" id="msg">Pyodide 起動中…</p>
</div>
</main>
CSS
:root {
--ink: #f1eaff;
--muted: #b3a6d8;
--accent: #ffb454;
--sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 14px;
font-family: var(--sans);
color: var(--ink);
background: #07050f;
}
.frac {
position: relative;
width: min(100%, 480px);
aspect-ratio: 4 / 3;
border-radius: 16px;
overflow: hidden;
border: 1px solid #2a2240;
box-shadow: 0 30px 70px -28px rgba(0,0,0,.85);
}
.frac__canvas {
width: 100%;
height: 100%;
display: block;
image-rendering: auto;
background: #000;
}
/* 左下の見出し+操作 */
.frac__hud {
position: absolute;
left: 14px;
bottom: 14px;
display: flex;
flex-direction: column;
gap: 6px;
text-shadow: 0 2px 8px rgba(0,0,0,.8);
}
.frac__title {
font-size: 20px;
font-weight: 800;
letter-spacing: .02em;
background: linear-gradient(90deg, #ffb454, #ff5e8a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.frac__sub { font-size: 11px; color: var(--muted); }
.frac__btn {
align-self: flex-start;
margin-top: 4px;
font: inherit;
font-size: 12px;
color: #1a1024;
background: linear-gradient(180deg, #ffd27a, #ffb454);
border: none;
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
font-weight: 700;
transition: transform .12s ease, box-shadow .12s ease;
}
.frac__btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 20px -6px rgba(255,180,84,.6); }
.frac__btn:disabled { opacity: .5; cursor: not-allowed; }
/* ロード/計算中オーバーレイ */
.frac__overlay {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
background: rgba(7,5,15,.78);
backdrop-filter: blur(3px);
transition: opacity .4s ease;
}
.frac__overlay[hidden] { display: none; }
.frac__overlay.is-hidden { opacity: 0; pointer-events: none; }
.frac__spinner {
width: 34px; height: 34px;
border-radius: 50%;
border: 3px solid rgba(255,180,84,.25);
border-top-color: var(--accent);
animation: spin .9s linear infinite;
}
.frac__msg { margin: 0; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.frac__spinner { animation-duration: 2.4s; }
.frac__btn { transition: none; }
}
JavaScript
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), overlay = $("overlay"), msg = $("msg"), zoomBtn = $("zoom");
if (cv && overlay && msg && zoomBtn) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
let pyodide = null, busy = false;
// ズーム先の見どころ座標(cx, cy, スケール)
const spots = [
{ cx: -0.5, cy: 0.0, s: 1.6 },
{ cx: -0.745, cy: 0.113, s: 0.02 },
{ cx: -0.16, cy: 1.0405, s: 0.03 },
{ cx: 0.2515, cy: 0.0000, s: 0.012 },
{ cx: -0.7436, cy: 0.1318, s: 0.004 }
];
let spotIndex = 0;
// Pythonでマンデルブロ集合のRGBA配列を生成
async function render(spot) {
if (!pyodide || busy) return;
busy = true;
overlay.classList.remove("is-hidden");
msg.textContent = "Python が計算中…";
// 次フレームでスピナーを見せてから重い計算へ
await new Promise((r) => requestAnimationFrame(r));
pyodide.globals.set("W", W);
pyodide.globals.set("H", H);
pyodide.globals.set("CX", spot.cx);
pyodide.globals.set("CY", spot.cy);
pyodide.globals.set("SCALE", spot.s);
const buf = pyodide.runPython(`
import math
def mandelbrot():
max_iter = 80
aspect = W / H
out = bytearray(W * H * 4)
for py in range(H):
y0 = CY + (py / H - 0.5) * SCALE
for px in range(W):
x0 = CX + (px / W - 0.5) * SCALE * aspect
x = y = 0.0
it = 0
while x*x + y*y <= 4.0 and it < max_iter:
x, y = x*x - y*y + x0, 2*x*y + y0
it += 1
i = (py * W + px) * 4
if it >= max_iter:
out[i] = out[i+1] = out[i+2] = 8 # 集合内部は暗色
else:
# 滑らかな着色(HSV風→RGB)
t = it / max_iter
r = int(9 * (1 - t) * t*t*t * 255)
g = int(15 * (1 - t)*(1 - t) * t*t * 255)
b = int(8.5 * (1 - t)**3 * t * 255)
out[i] = min(255, r); out[i+1] = min(255, g); out[i+2] = min(255, b)
out[i+3] = 255
return out
mandelbrot()
`).toJs();
const img = new ImageData(new Uint8ClampedArray(buf), W, H);
ctx.putImageData(img, 0, 0);
overlay.classList.add("is-hidden");
busy = false;
}
// ボタンで見どころを巡回ズーム
zoomBtn.addEventListener("click", () => {
spotIndex = (spotIndex + 1) % spots.length;
render(spots[spotIndex]);
});
// Pyodide起動
(async () => {
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
zoomBtn.disabled = false;
await render(spots[0]);
} catch (e) {
msg.textContent = "読込失敗: " + e.message;
}
})();
}
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「Pythonフラクタル生成 (マンデルブロ集合)」の効果を追加してください。
# 追加してほしい効果
Pythonフラクタル生成 (マンデルブロ集合)(Python (Pyodideブラウザ実行))
Pythonでピクセルごとにマンデルブロ集合を計算し、ImageDataとしてcanvasに着色描画。ボタンで見どころへズーム巡回でき、重い数値計算のブラウザ実行例になる。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- Pythonでマンデルブロ集合を計算しImageDataで描画するデモ -->
<main class="frac" aria-label="Pythonフラクタル">
<canvas id="cv" class="frac__canvas" width="480" height="360"></canvas>
<div class="frac__hud">
<div class="frac__title">Mandelbrot</div>
<div class="frac__sub">computed pixel-by-pixel in Python</div>
<button id="zoom" class="frac__btn" disabled>ズーム巡回</button>
</div>
<!-- 進捗オーバーレイ -->
<div class="frac__overlay" id="overlay">
<div class="frac__spinner" aria-hidden="true"></div>
<p class="frac__msg" id="msg">Pyodide 起動中…</p>
</div>
</main>
【CSS】
:root {
--ink: #f1eaff;
--muted: #b3a6d8;
--accent: #ffb454;
--sans: system-ui, "Segoe UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 14px;
font-family: var(--sans);
color: var(--ink);
background: #07050f;
}
.frac {
position: relative;
width: min(100%, 480px);
aspect-ratio: 4 / 3;
border-radius: 16px;
overflow: hidden;
border: 1px solid #2a2240;
box-shadow: 0 30px 70px -28px rgba(0,0,0,.85);
}
.frac__canvas {
width: 100%;
height: 100%;
display: block;
image-rendering: auto;
background: #000;
}
/* 左下の見出し+操作 */
.frac__hud {
position: absolute;
left: 14px;
bottom: 14px;
display: flex;
flex-direction: column;
gap: 6px;
text-shadow: 0 2px 8px rgba(0,0,0,.8);
}
.frac__title {
font-size: 20px;
font-weight: 800;
letter-spacing: .02em;
background: linear-gradient(90deg, #ffb454, #ff5e8a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.frac__sub { font-size: 11px; color: var(--muted); }
.frac__btn {
align-self: flex-start;
margin-top: 4px;
font: inherit;
font-size: 12px;
color: #1a1024;
background: linear-gradient(180deg, #ffd27a, #ffb454);
border: none;
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
font-weight: 700;
transition: transform .12s ease, box-shadow .12s ease;
}
.frac__btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 20px -6px rgba(255,180,84,.6); }
.frac__btn:disabled { opacity: .5; cursor: not-allowed; }
/* ロード/計算中オーバーレイ */
.frac__overlay {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
background: rgba(7,5,15,.78);
backdrop-filter: blur(3px);
transition: opacity .4s ease;
}
.frac__overlay[hidden] { display: none; }
.frac__overlay.is-hidden { opacity: 0; pointer-events: none; }
.frac__spinner {
width: 34px; height: 34px;
border-radius: 50%;
border: 3px solid rgba(255,180,84,.25);
border-top-color: var(--accent);
animation: spin .9s linear infinite;
}
.frac__msg { margin: 0; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.frac__spinner { animation-duration: 2.4s; }
.frac__btn { transition: none; }
}
【JavaScript】
// 要素取得(null安全)
const $ = (id) => document.getElementById(id);
const cv = $("cv"), overlay = $("overlay"), msg = $("msg"), zoomBtn = $("zoom");
if (cv && overlay && msg && zoomBtn) {
const ctx = cv.getContext("2d");
const W = cv.width, H = cv.height;
let pyodide = null, busy = false;
// ズーム先の見どころ座標(cx, cy, スケール)
const spots = [
{ cx: -0.5, cy: 0.0, s: 1.6 },
{ cx: -0.745, cy: 0.113, s: 0.02 },
{ cx: -0.16, cy: 1.0405, s: 0.03 },
{ cx: 0.2515, cy: 0.0000, s: 0.012 },
{ cx: -0.7436, cy: 0.1318, s: 0.004 }
];
let spotIndex = 0;
// Pythonでマンデルブロ集合のRGBA配列を生成
async function render(spot) {
if (!pyodide || busy) return;
busy = true;
overlay.classList.remove("is-hidden");
msg.textContent = "Python が計算中…";
// 次フレームでスピナーを見せてから重い計算へ
await new Promise((r) => requestAnimationFrame(r));
pyodide.globals.set("W", W);
pyodide.globals.set("H", H);
pyodide.globals.set("CX", spot.cx);
pyodide.globals.set("CY", spot.cy);
pyodide.globals.set("SCALE", spot.s);
const buf = pyodide.runPython(`
import math
def mandelbrot():
max_iter = 80
aspect = W / H
out = bytearray(W * H * 4)
for py in range(H):
y0 = CY + (py / H - 0.5) * SCALE
for px in range(W):
x0 = CX + (px / W - 0.5) * SCALE * aspect
x = y = 0.0
it = 0
while x*x + y*y <= 4.0 and it < max_iter:
x, y = x*x - y*y + x0, 2*x*y + y0
it += 1
i = (py * W + px) * 4
if it >= max_iter:
out[i] = out[i+1] = out[i+2] = 8 # 集合内部は暗色
else:
# 滑らかな着色(HSV風→RGB)
t = it / max_iter
r = int(9 * (1 - t) * t*t*t * 255)
g = int(15 * (1 - t)*(1 - t) * t*t * 255)
b = int(8.5 * (1 - t)**3 * t * 255)
out[i] = min(255, r); out[i+1] = min(255, g); out[i+2] = min(255, b)
out[i+3] = 255
return out
mandelbrot()
`).toJs();
const img = new ImageData(new Uint8ClampedArray(buf), W, H);
ctx.putImageData(img, 0, 0);
overlay.classList.add("is-hidden");
busy = false;
}
// ボタンで見どころを巡回ズーム
zoomBtn.addEventListener("click", () => {
spotIndex = (spotIndex + 1) % spots.length;
render(spots[spotIndex]);
});
// Pyodide起動
(async () => {
try {
const mod = await import("https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs");
pyodide = await mod.loadPyodide();
zoomBtn.disabled = false;
await render(spots[0]);
} catch (e) {
msg.textContent = "読込失敗: " + e.message;
}
})();
}
# 外部ライブラリ
https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。