Pythonフラクタル生成 (マンデルブロ集合)

Pythonでピクセルごとにマンデルブロ集合を計算し、ImageDataとしてcanvasに着色描画。ボタンで見どころへズーム巡回でき、重い数値計算のブラウザ実行例になる。

外部ライブラリ: https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js

#pyodide#fractal#canvas#math

ライブデモ

使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。