このページの中身

流れる雲

背景エフェクト / 動く素材トップ

等高線でできた雲がゆっくり流れ、触れた跡がしばらく残る。空・地域・案内。明るい地に静かな動きを置きたいとき。

試して調整する#

見た目を調整する
元コードの調整項目をすべて出す既定値は元の実装のまま16 項目

元の実装の既定値で動いています

判断#

向く場面: 空・地域・案内。明るい地に静かな動きを置きたいとき。

避ける場面: 文字が主役の面に、そのまま重ねること。

つまみは元の実装が持っている項目そのままです。実際に文字を重ねた状態で速さと密度を詰めます。

組み込み条件#

依存: 素のCanvas 2D。外部ライブラリは要らない。

負荷: 1画面に1つ。画面外・非表示タブ・動きを減らす設定で止まる。

再利用: コピーした単一HTMLがそのまま動く。

単一HTMLをコピーする#

下のコードだけで実物サンプルと同じ画面が動きます。調整欄を動かすと、値を反映したコードへ更新されます。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,follow">
<title>流れる雲 実物サンプル</title>
<style>
:root {
  color-scheme: light dark;
  --ink-light:#1a2421; --mut-light:#5a6a72; --signal-light:#2a7ab8; --bg-light:#f4f8fb;
  --ink-dark:#f4f8fb; --mut-dark:#a8bac7; --signal-dark:#48b8ff; --bg-dark:#07111f;
}
* { box-sizing:border-box; }
html,body { margin:0; min-height:100%; }
body { font-family:"Hiragino Sans","Yu Gothic UI",system-ui,sans-serif; transition:background .3s,color .3s; }
html[data-demo-theme="light"], html[data-demo-theme="light"] body {
  background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); }
html[data-demo-theme="dark"], html[data-demo-theme="dark"] body {
  background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); }
@media (prefers-color-scheme:light) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-light); color:var(--ink-light); --ink:var(--ink-light); --mut:var(--mut-light); --signal:var(--signal-light); } }
@media (prefers-color-scheme:dark) {
  html:not([data-demo-theme]), html:not([data-demo-theme]) body {
    background:var(--bg-dark); color:var(--ink-dark); --ink:var(--ink-dark); --mut:var(--mut-dark); --signal:var(--signal-dark); } }
.sample { position:relative; min-height:100dvh; overflow:hidden; isolation:isolate; display:grid; align-items:end; }
.effect { position:absolute; inset:0; z-index:0; }
.effect canvas { display:block; width:100%; height:100%; touch-action:manipulation; }
.copy { position:relative; z-index:1; width:min(42rem,100%); padding:clamp(2rem,7vw,6rem); }
.label { margin:0 0 1rem; color:var(--signal); font:600 .72rem/1.4 ui-monospace,Consolas,monospace; letter-spacing:.16em; }
h1 { margin:0; max-width:14ch; font-size:clamp(2rem,5.5vw,4.5rem); line-height:1.05; letter-spacing:-.04em; }
.lead { margin:1.5rem 0 0; max-width:36rem; color:var(--mut); font-size:clamp(.95rem,1.4vw,1.1rem); line-height:1.85; }
.readout { position:absolute; inset:1.5rem 1.5rem auto auto; margin:0; color:var(--mut); font:500 .68rem/1.6 ui-monospace,Consolas,monospace; letter-spacing:.1em; writing-mode:vertical-rl; z-index:2; }
.theme-toggle { position:absolute; inset:1.5rem auto auto 1.5rem; z-index:2; display:flex; gap:.25rem; }
.theme-toggle button {
  min-block-size:36px; padding-inline:.75rem; font:600 .72rem/1 ui-monospace,Consolas,monospace;
  letter-spacing:.08em; border:1px solid var(--mut); background:transparent; color:var(--ink); cursor:pointer; border-radius:4px; }
.theme-toggle button[aria-pressed="true"] { background:var(--signal); color:var(--bg-dark); border-color:var(--signal); }
@media (max-width:640px) { .copy{padding:2rem 1.25rem 3rem}.readout{display:none} }
.bare .copy, .bare .readout, .bare .theme-toggle { display:none; }
</style>

</head>
<body>
<script id="effect-config" type="application/json">{"DRIFT_SPEED":0.3,"IMPRINT_DECAY":0.997,"IMPRINT_GAIN":0.06,"POINTER_RADIUS":400,"CELL":20,"FIELD_MIN":-2,"FIELD_MAX":3.6,"FILL_ALPHA_MIN":0.005,"FILL_ALPHA_MAX":0.84,"FILL_STEP":2,"TIME_SCALE":0.4,"POINTER_EASE":0.1,"AMP_EASE":0.06,"IMPRINT_MAX":2.8,"RIDGE":0.05,"OCTAVES":3.3,"LACUNARITY":2.0,"GAIN":0.4,"WARP":4.0,"FIELD_AMPLITUDE":3.0,"GRADIENT_AMP":-6.8}</script>
<script>if (/[?&]bare=1/.test(location.search)) document.documentElement.classList.add('bare');
if (/[?&]tap=1/.test(location.search)) { addEventListener('load', function () {
  setTimeout(function () { [[0.5,0.5],[0.3,0.6],[0.7,0.4]].forEach(function (p, i) {
    setTimeout(function () { document.dispatchEvent(new MouseEvent('click',
      {clientX: innerWidth * p[0], clientY: innerHeight * p[1], bubbles: true})); }, i * 220);
  }); }, 300); }); }</script>
<script>(function(){var m=location.hash.match(/[#&]cfg=([^&]*)/);if(!m)return;
try{JSON.parse(decodeURIComponent(m[1]));document.getElementById('effect-config').textContent=decodeURIComponent(m[1]);}catch(e){}})();</script>
<script>(function(){
  var root=document.documentElement, cfgNode=document.getElementById('effect-config');
  var themeKeys=[];
  function syncTheme(t){
    root.setAttribute('data-demo-theme',t);
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.setAttribute('aria-pressed',b.dataset.theme===t?'true':'false');});
    if(!themeKeys.length)return;
    try{var cfg=JSON.parse(cfgNode.textContent||'{}');
      themeKeys.forEach(function(k){cfg[k]=t==='dark'?0:1;});
      cfgNode.textContent=JSON.stringify(cfg);}catch(e){}
  }
  var fixed=null;
  if(fixed){syncTheme(fixed);}else{var q=location.search.match(/[?&]theme=(light|dark)/); if(q) syncTheme(q[1]);}
  addEventListener('DOMContentLoaded',function(){
    document.querySelectorAll('.theme-toggle button').forEach(function(b){
      b.addEventListener('click',function(){syncTheme(b.dataset.theme);});});});})();</script>
<main class="sample">
  <div class="theme-toggle" aria-label="配色切替">
    <button type="button" data-theme="light" aria-pressed="false">LIGHT</button>
    <button type="button" data-theme="dark" aria-pressed="false">DARK</button>
  </div>
  <div class="effect" aria-hidden="true"><div class="site-home-hero"><canvas class="site-home-field-canvas" id="effect-canvas"></canvas></div></div>
  <div class="copy">
    <p class="label">背景エフェクト</p>
    <h1>流れる雲</h1>
    <p class="lead">等高線でできた雲がゆっくり流れ、触れた跡がしばらく残る。空・地域・案内。明るい地に静かな動きを置きたいとき。空・地域・案内。明るい地に静かな動きを置きたいとき。</p>
  </div>
  <p class="readout">単一HTML・動きを減らす設定に対応</p>
</main>
<script>
/* ---- ここから下は元の実装をそのまま持ってきたもの --------------------- */
var __CFG__ = JSON.parse(document.getElementById('effect-config').textContent || '{}');
(() => {
  function hexToRgb(hex) {
    const value = hex.replace("#", "");
    return [
      parseInt(value.slice(0, 2), 16),
      parseInt(value.slice(2, 4), 16),
      parseInt(value.slice(4, 6), 16),
    ];
  }

  const SHOW_CONTOURS = false;      // 等高線を表示する/しない
  const FIELD_HEX = "#0288d1";     // 塗りの色。空・海・プールのようなクリアな青
  const CONTOUR_HEX = FIELD_HEX;   // 等高線の色(塗りと同色・透明度で溶かす)
  const FIELD_RGB = hexToRgb(FIELD_HEX);
  const CONTOUR_RGB = hexToRgb(CONTOUR_HEX);
  const STROKE_RGB = CONTOUR_RGB.join(", "); // strokeStyle 用の「R, G, B」文字列
  const [FILL_R, FILL_G, FILL_B] = FIELD_RGB; // ImageData 用に R/G/B を展開
  const STROKE_ALPHA = SHOW_CONTOURS ? 0.2 : 0; // 大きくすると:等高線が 濃く 見える
  const FIELD_MIN = (__CFG__["FIELD_MIN"] !== undefined ? __CFG__["FIELD_MIN"] : (-2));         // 大きくすると:低い値側のコントラストが変わる(やや複雑) 雲が多くなる、雑になる -1
  const FIELD_MAX = (__CFG__["FIELD_MAX"] !== undefined ? __CFG__["FIELD_MAX"] : (3.6));        // 大きくすると:高い値側のコントラストが変わる(やや複雑) 雲と空の区別がなくなる  2.6~3.6
  const FILL_ALPHA_MIN = (__CFG__["FILL_ALPHA_MIN"] !== undefined ? __CFG__["FILL_ALPHA_MIN"] : (0.005)); // 大きくすると:薄い部分が 濃く なる(濃淡がなくなる)
  const FILL_ALPHA_MAX = (__CFG__["FILL_ALPHA_MAX"] !== undefined ? __CFG__["FILL_ALPHA_MAX"] : (0.84));  // 大きくすると:濃い部分が より濃く(全体が目立つ)なる(絵画っぽくなる)↔減らすと曇る
  const FILL_STEP = (__CFG__["FILL_STEP"] !== undefined ? __CFG__["FILL_STEP"] : (2));          // 大きくすると:描画が粗くなり 軽く なる(画質はやや落ちる)
  const CELL = (__CFG__["CELL"] !== undefined ? __CFG__["CELL"] : (20));              // 大きくすると:うねりの 塊が大きく なる(粗い) 20:20~30
  const TIME_SCALE = (__CFG__["TIME_SCALE"] !== undefined ? __CFG__["TIME_SCALE"] : (0.4));       // 大きくすると:速く動く(時間が速く経過する) 0.4~0.8
  const POINTER_EASE = (__CFG__["POINTER_EASE"] !== undefined ? __CFG__["POINTER_EASE"] : (0.1));     // 大きくすると:マウス位置に 素早く ついてくる(キビキビ)
  const AMP_EASE = (__CFG__["AMP_EASE"] !== undefined ? __CFG__["AMP_EASE"] : (0.06));        // 大きくすると:カーソルが入ったとき 描き込みが 早く 立ち上がる
  const POINTER_RADIUS = (__CFG__["POINTER_RADIUS"] !== undefined ? __CFG__["POINTER_RADIUS"] : (400));   // 大きくすると:マウスの影響が広くなる(周辺まで動く)
  // ─── マウスの痕跡:離れても残す蓄積バッファ(足し算ではなく描き込んで記憶する) ───
  const IMPRINT_GAIN = (__CFG__["IMPRINT_GAIN"] !== undefined ? __CFG__["IMPRINT_GAIN"] : (0.06));    // 1フレームの描き込み量。大きいほど 跡が早く濃く 付く
  const IMPRINT_DECAY = (__CFG__["IMPRINT_DECAY"] !== undefined ? __CFG__["IMPRINT_DECAY"] : (0.997));  // 跡の自己回復。1=完全に残す(永続)/ 小さいほど 早く癒える
  const IMPRINT_MAX = (__CFG__["IMPRINT_MAX"] !== undefined ? __CFG__["IMPRINT_MAX"] : (2.8));      // 跡の濃さの上限。飽和・白飛びを防ぐクランプ(旧 POINTER_AMP 相当の強さ)
  const LEVELS = [-2.4, -1.8, -1.2, -0.6, 0, 0.6, 1.2, 1.8, 2.4]; // 等高線の本数・間隔
//  const LEVELS = [-2.2, -1.8, -1.44, -1.12, -0.84, -0.6, -0.4, -0.24, -0.12, -0.04, 0, 0.04, 0.12, 0.24, 0.4, 0.6, 0.84, 1.12, 1.44, 1.8, 2.2]; // 等高線の本数・間隔
//  const LEVELS = [-2.2, -1.44, -0.84, -0.4, -0.12, 0, 0.12, 0.4, 0.84, 1.44, 2.2]; // 等高線の本数・間隔
  const FIELD_RANGE = FIELD_MAX - FIELD_MIN;

  // ─── 地形シェイプ:円形のうねり ⇄ 山脈の尾根と谷 ───
  const RIDGE = (__CFG__["RIDGE"] !== undefined ? __CFG__["RIDGE"] : (0.05));          // 0: なめらかな円形のうねり / 1: 山脈の尾根と谷。間で度合いを調整 見た目が複雑化  0.05~0.25
  const OCTAVES = (__CFG__["OCTAVES"] !== undefined ? __CFG__["OCTAVES"] : (3.3));           // フラクタルの重ね合わせ数。多いほど尾根の細部が増える(やや重く なる) 多様化・細かくなる↔安っぽく見える 3~4
  const LACUNARITY = (__CFG__["LACUNARITY"] !== undefined ? __CFG__["LACUNARITY"] : (2.0));      // オクターブごとの周波数倍率。大きいほど細かい起伏が乗る 2.0ほぼ固定
  const GAIN = (__CFG__["GAIN"] !== undefined ? __CFG__["GAIN"] : (0.4));            // オクターブごとの振幅減衰。大きいほど細部が強く 残る 0.4ほぼ固定
  const WARP = (__CFG__["WARP"] !== undefined ? __CFG__["WARP"] : (4.0));            // ドメインワープの最大強さ。大きいほど尾根が 曲がりくねる(RIDGE に比例して効く) 0.7~4.7
  const FIELD_AMPLITUDE = (__CFG__["FIELD_AMPLITUDE"] !== undefined ? __CFG__["FIELD_AMPLITUDE"] : (3.0)); // 大きくすると濃淡がはっきり、絵画っぽくなる。フィールド値の振れ幅。等高線(LEVELS)と塗りの濃淡量に対応  2~4
  
  const GRADIENT_AMP = (__CFG__["GRADIENT_AMP"] !== undefined ? __CFG__["GRADIENT_AMP"] : (-6.8));  // 左右の晴れ勾配。負の値 = 左ほど雲が多く右ほど晴れ。0 で勾配なし -6.8:-6.6~-4.8
  const DRIFT_SPEED = (__CFG__["DRIFT_SPEED"] !== undefined ? __CFG__["DRIFT_SPEED"] : (0.3));  // 大きくすると雲が右方向への速く移動する。0 で流れなし(うねりのみ) 0.2:0.01

  let cleanup = null;

  function smoothStep(value) {
    return value * value * (3 - 2 * value);
  }

  function boot() {
    cleanup?.();
    cleanup = null;

    const hero = document.querySelector(".site-home-hero");
    if (!(hero instanceof HTMLElement)) {
      return;
    }

    const canvas = hero.querySelector(".site-home-field-canvas");
    if (!(canvas instanceof HTMLCanvasElement)) {
      return;
    }

    const context = canvas.getContext("2d");
    if (!context) {
      return;
    }

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const radiusSquared = POINTER_RADIUS * POINTER_RADIUS;
    const pointer = { x: -9999, y: -9999, tx: -9999, ty: -9999, amp: 0, tamp: 0 };
    const fieldSeed = {
      phaseX: Math.random() * Math.PI * 2,
      phaseY: Math.random() * Math.PI * 2,
      scaleX: 0.0100 + Math.random() * 0.003,
      // 縦の周波数。scaleX より大きいほど雲が縦に圧縮(横長)に見える。ここでは約1.5倍に設定
      scaleY: 0.0100 + Math.random() * 0.003,
    };

    let devicePixelRatio = 1;
    let width = 0;
    let height = 0;
    let cols = 0;
    let rows = 0;
    let field = null;
    let imprint = null; // マウスの痕跡(フレームをまたいで保持する蓄積レイヤ)
    let fillImage = null;

    const layout = () => {
      devicePixelRatio = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
      const bounds = canvas.getBoundingClientRect();
      width = Math.max(1, Math.round(bounds.width));
      height = Math.max(1, Math.round(bounds.height));
      canvas.width = Math.round(width * devicePixelRatio);
      canvas.height = Math.round(height * devicePixelRatio);
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      cols = Math.ceil(width / CELL) + 1;
      rows = Math.ceil(height / CELL) + 1;
      field = new Float32Array(cols * rows);
      imprint = new Float32Array(cols * rows); // リサイズ時は痕跡をリセット(グリッド寸法が変わるため)
      fillImage = null;
    };

    // nested-sine を 120°ずつ3方向に配置 → 3回転対称で斜め偏りなし。
    // 旧: 2方向だと等値線が45°方向に揃って斜め伸びが出ていた。
    const baseNoise = (x, y, t) => {
      const p = x;
      const q = -0.5 * x + 0.866 * y;
      const r = -0.5 * x - 0.866 * y;
      return (
        Math.sin(p + Math.sin(q * 0.8 + t * 0.50)) +
        Math.sin(q + Math.sin(r * 0.8 + t * 0.47)) +
        Math.sin(r + Math.sin(p * 0.8 + t * 0.53))
      ) / 2;
    };

    // フラクタル(fBm)。オクターブを重ね、各層で円形(なめらか)と尾根(折り返し)を RIDGE で補間。
    const fractal = (x, y, t) => {
      let amplitude = 1;
      let frequency = 1;
      let sum = 0;
      let norm = 0;
      for (let octave = 0; octave < OCTAVES; octave += 1) {
        const n = baseNoise(x * frequency, y * frequency, t);
        const ridged = 1 - 2 * Math.abs(n); // ゼロ交差に沿って鋭い尾根(折り目)ができる
        const shaped = n + (ridged - n) * RIDGE; // RIDGE=0→円形 / RIDGE=1→尾根
        sum += shaped * amplitude;
        norm += amplitude;
        amplitude *= GAIN;
        frequency *= LACUNARITY;
      }
      return sum / norm;
    };

    const compute = (time) => {
      const stamping = pointer.tamp > 0; // 描き込みは滞在中のみ。離れたら即停止し、あとは DECAY に任せる
      const warpStrength = WARP * RIDGE; // 円形側ではワープを弱め、山脈側で曲げを強める
      for (let gridY = 0; gridY < rows; gridY += 1) {
        for (let gridX = 0; gridX < cols; gridX += 1) {
          const x = gridX * CELL;
          const y = gridY * CELL;
          const nx = x * fieldSeed.scaleX + fieldSeed.phaseX - time * DRIFT_SPEED;
          const ny = y * fieldSeed.scaleY + fieldSeed.phaseY;
          // 低周波のドメインワープで大きな円形の輪郭を崩し、尾根を曲げる
          const warpX = warpStrength * baseNoise(nx * 0.4 + 11.5, ny * 0.4, time * 0.2);
          const warpY = warpStrength * baseNoise(nx * 0.4, ny * 0.4 + 7.3, time * 0.2);
          // 左右の晴れ勾配:左(x=0)で+GRADIENT_AMP/2、右(x=width)で-GRADIENT_AMP/2
          const gradient = GRADIENT_AMP * (0.5 - x / width);
          const base = FIELD_AMPLITUDE * fractal(nx + warpX, ny + warpY, time) + gradient;

          // マウスの痕跡:その場で足すのではなく蓄積バッファへ描き込む。
          // 毎フレーム少しだけ自己回復(DECAY)させ、カーソルが居る間は描き込む(GAIN)。
          // これで離れても痕跡が残り、放置すればゆっくり癒える。MAX で飽和を防ぐ。
          const cell = gridY * cols + gridX;
          let mark = imprint[cell] * IMPRINT_DECAY;
          if (stamping) {
            const dx = x - pointer.x;
            const dy = y - pointer.y;
            mark +=
              pointer.amp * IMPRINT_GAIN * Math.exp(-(dx * dx + dy * dy) / radiusSquared);
            if (mark > IMPRINT_MAX) {
              mark = IMPRINT_MAX;
            }
          }
          imprint[cell] = mark;

          field[cell] = base + mark;
        }
      }
    };

    const sampleField = (x, y) => {
      const gridX = x / CELL;
      const gridY = y / CELL;
      const x0 = Math.max(0, Math.min(cols - 2, Math.floor(gridX)));
      const y0 = Math.max(0, Math.min(rows - 2, Math.floor(gridY)));
      const fx = Math.max(0, Math.min(1, gridX - x0));
      const fy = Math.max(0, Math.min(1, gridY - y0));
      const corner = y0 * cols + x0;
      const topLeft = field[corner];
      const topRight = field[corner + 1];
      const bottomLeft = field[corner + cols];
      const bottomRight = field[corner + cols + 1];
      const top = topLeft + (topRight - topLeft) * fx;
      const bottom = bottomLeft + (bottomRight - bottomLeft) * fx;
      return top + (bottom - top) * fy;
    };

    const valueToAlpha = (value) => {
      const normalized = Math.max(0, Math.min(1, (value - FIELD_MIN) / FIELD_RANGE));
      const eased = smoothStep(normalized);
      return Math.round((FILL_ALPHA_MIN + eased * (FILL_ALPHA_MAX - FILL_ALPHA_MIN)) * 255);
    };

    const ensureFillImage = () => {
      const pixelWidth = canvas.width;
      const pixelHeight = canvas.height;
      if (
        fillImage &&
        fillImage.width === pixelWidth &&
        fillImage.height === pixelHeight
      ) {
        return fillImage;
      }
      fillImage = context.createImageData(pixelWidth, pixelHeight);
      return fillImage;
    };

    const drawFill = () => {
      const image = ensureFillImage();
      const buffer = image.data;
      const pixelWidth = image.width;
      const pixelHeight = image.height;
      const invDevicePixelRatio = 1 / devicePixelRatio;
      const step = FILL_STEP;

      for (let pixelY = 0; pixelY < pixelHeight; pixelY += step) {
        const y = pixelY * invDevicePixelRatio;
        for (let pixelX = 0; pixelX < pixelWidth; pixelX += step) {
          const x = pixelX * invDevicePixelRatio;
          const alpha = valueToAlpha(sampleField(x, y));
          for (let blockY = 0; blockY < step && pixelY + blockY < pixelHeight; blockY += 1) {
            const rowStart = ((pixelY + blockY) * pixelWidth + pixelX) * 4;
            for (let blockX = 0; blockX < step && pixelX + blockX < pixelWidth; blockX += 1) {
              const index = rowStart + blockX * 4;
              buffer[index] = FILL_R;
              buffer[index + 1] = FILL_G;
              buffer[index + 2] = FILL_B;
              buffer[index + 3] = alpha;
            }
          }
        }
      }

      context.putImageData(image, 0, 0);
    };

    const drawContours = () => {
      context.lineWidth = 1.1;
      context.lineJoin = "round";
      context.lineCap = "round";
      context.strokeStyle = `rgba(${STROKE_RGB}, ${STROKE_ALPHA})`;
      context.beginPath();

      for (let levelIndex = 0; levelIndex < LEVELS.length; levelIndex += 1) {
        const threshold = LEVELS[levelIndex];
        for (let gridY = 0; gridY < rows - 1; gridY += 1) {
          for (let gridX = 0; gridX < cols - 1; gridX += 1) {
            const corner = gridY * cols + gridX;
            const topLeft = field[corner];
            const topRight = field[corner + 1];
            const bottomRight = field[corner + cols + 1];
            const bottomLeft = field[corner + cols];
            const insideTopLeft = topLeft > threshold;
            const insideTopRight = topRight > threshold;
            const insideBottomRight = bottomRight > threshold;
            const insideBottomLeft = bottomLeft > threshold;
            const code =
              (insideTopLeft ? 8 : 0) |
              (insideTopRight ? 4 : 0) |
              (insideBottomRight ? 2 : 0) |
              (insideBottomLeft ? 1 : 0);
            if (code === 0 || code === 15) {
              continue;
            }

            const x0 = gridX * CELL;
            const y0 = gridY * CELL;
            const x1 = x0 + CELL;
            const y1 = y0 + CELL;
            const points = [];
            if (insideTopLeft !== insideTopRight) {
              points.push(x0 + ((threshold - topLeft) / (topRight - topLeft)) * CELL, y0);
            }
            if (insideTopRight !== insideBottomRight) {
              points.push(x1, y0 + ((threshold - topRight) / (bottomRight - topRight)) * CELL);
            }
            if (insideBottomLeft !== insideBottomRight) {
              points.push(
                x0 + ((threshold - bottomLeft) / (bottomRight - bottomLeft)) * CELL,
                y1,
              );
            }
            if (insideTopLeft !== insideBottomLeft) {
              points.push(x0, y0 + ((threshold - topLeft) / (bottomLeft - topLeft)) * CELL);
            }

            if (points.length === 4) {
              context.moveTo(points[0], points[1]);
              context.lineTo(points[2], points[3]);
            } else if (points.length === 8) {
              if (code === 5) {
                context.moveTo(points[0], points[1]);
                context.lineTo(points[2], points[3]);
                context.moveTo(points[4], points[5]);
                context.lineTo(points[6], points[7]);
              } else {
                context.moveTo(points[0], points[1]);
                context.lineTo(points[6], points[7]);
                context.moveTo(points[2], points[3]);
                context.lineTo(points[4], points[5]);
              }
            }
          }
        }
      }

      context.stroke();
    };

    const draw = () => {
      context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
      context.clearRect(0, 0, width, height);
      drawFill();
      if (SHOW_CONTOURS) {
        context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
        drawContours();
      }
    };

    let animationFrame = 0;
    let startedAt = null;
    let frozenAt = null;
    let isVisible = true;
    let isDocumentVisible = !document.hidden;
    let resizeTimer;

    const shouldRun = () => isVisible && isDocumentVisible;

    const frame = (timestamp) => {
      animationFrame = 0;
      if (!shouldRun()) {
        return;
      }
      if (startedAt === null) {
        startedAt = timestamp;
      }
      pointer.x += (pointer.tx - pointer.x) * POINTER_EASE;
      pointer.y += (pointer.ty - pointer.y) * POINTER_EASE;
      pointer.amp += (pointer.tamp - pointer.amp) * AMP_EASE;
      compute(((timestamp - startedAt) / 1000) * TIME_SCALE);
      draw();
      animationFrame = requestAnimationFrame(frame);
    };

    const start = () => {
      if (animationFrame !== 0 || !shouldRun()) {
        return;
      }
      if (frozenAt !== null && startedAt !== null) {
        startedAt += performance.now() - frozenAt;
        frozenAt = null;
      }
      animationFrame = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (animationFrame !== 0) {
        cancelAnimationFrame(animationFrame);
        animationFrame = 0;
      }
      if (frozenAt === null) {
        frozenAt = performance.now();
      }
    };

    const drawStatic = () => {
      layout();
      pointer.amp = 0;
      compute(0);
      draw();
    };

    const handleResize = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        if (reduceMotion) {
          drawStatic();
          return;
        }
        layout();
        if (!shouldRun()) {
          draw();
        }
      }, 160);
    };

    const handleVisibilityChange = () => {
      isDocumentVisible = !document.hidden;
      if (isDocumentVisible && isVisible) {
        start();
      } else {
        stop();
      }
    };

    const handlePointerMove = (event) => {
      const heroBounds = hero.getBoundingClientRect();
      if (
        event.clientX < heroBounds.left ||
        event.clientX > heroBounds.right ||
        event.clientY < heroBounds.top ||
        event.clientY > heroBounds.bottom
      ) {
        pointer.tamp = 0;
        return;
      }

      const bounds = canvas.getBoundingClientRect();
      pointer.tx = event.clientX - bounds.left;
      pointer.ty = event.clientY - bounds.top;
      pointer.tamp = 1;
    };

    const handlePointerLeave = () => {
      pointer.tamp = 0;
    };

    const handleDocumentPointerMove = (event) => {
      handlePointerMove(event);
    };

    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(hero);

    const intersectionObserver = new IntersectionObserver(
      (entries) => {
        isVisible = entries[0]?.isIntersecting ?? true;
        if (isVisible) {
          start();
        } else {
          stop();
        }
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    document.addEventListener("visibilitychange", handleVisibilityChange);
    document.addEventListener("pointermove", handleDocumentPointerMove);
    hero.addEventListener("pointerleave", handlePointerLeave);
    window.addEventListener("resize", handleResize);

    cleanup = () => {
      stop();
      clearTimeout(resizeTimer);
      document.removeEventListener("visibilitychange", handleVisibilityChange);
      document.removeEventListener("pointermove", handleDocumentPointerMove);
      hero.removeEventListener("pointerleave", handlePointerLeave);
      window.removeEventListener("resize", handleResize);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };

    if (reduceMotion) {
      drawStatic();
    } else {
      layout();
      compute(0);
      draw();
      start();
    }
  }

  window.__siteHomeFieldBoot = boot;
  window.__siteHomeFieldShutdown = () => {
    cleanup?.();
    cleanup = null;
  };

  // 静的ページでは defer 読み込み後にそのまま起動する。
  boot();
})();


/* ---- ここまで --------------------------------------------------------- */
(function () {
  var reduced = matchMedia('(prefers-reduced-motion: reduce)');
  function report() {
    document.documentElement.dataset.materialActive = 'true';
    document.documentElement.dataset.materialRunning = String(!reduced.matches && !document.hidden);
  }
  addEventListener('message', function (e) {
    var d = e.data || {};
    /* 値は起動時に1度だけ読む実装なので、ここでは受け取らない。
       親は同じコードで iframe を組み直す(読み込み直しの点滅を起こさない) */
    if (d.type === 'material-active') {
      /* 元コードは自前の IntersectionObserver で止まる。
         画面から外せば、その仕組みがそのまま働く */
      var host = document.querySelector('#effect-canvas, #hero-wave');
      if (host) host.style.display = d.active === true ? '' : 'none';
      document.documentElement.dataset.materialActive = String(d.active === true);
      document.documentElement.dataset.materialRunning = String(
        d.active === true && !reduced.matches && !document.hidden);
    }
  });
  document.addEventListener('visibilitychange', report);
  reduced.addEventListener('change', report);
  report();
})();
</script>
</body>
</html>

AIエージェントへ渡す場合は、「このHTMLを対象ページのヒーロー背景へ組み込み、文字と主要ボタンを前面に残してください」と添えます。

組み込み時の確認#

関連#

この資料は、Webサイトを作るときに引くためのものです。 何のために作ったか。作っているのは AK²Lab。