回転寿司ボーダーを実装してみた

めちゃくちゃ久々に実装のブログ書きます!

今はAIがあるので思いついたもの色々できそうだなと思ってまして。今回はAIに作ってもらってます。

ただボーダーといっても回転寿司みたいにレーンが回るのを、今の時代ならCSSだけでできるんじゃないかと思ったわけです。実際はJavaScriptゴリゴリの実装になっちゃったのでまだまだみたいですね。

ということでデモです。

HTMLの中身

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="robots" content="index, follow" />
  <link rel="stylesheet" href="../reset.css">
  <link rel="stylesheet" href="./css/style.css">
</head>

<body>

  <!--
  【使い方】
  1. .kaiten-wrap の中に .kaiten-content を置くだけ
  2. data-lane-color : レーンの色(省略時: #c9a84c)
  3. data-lane-width : レーンの幅px(省略時: 80)
  4. data-speed      : 移動速度px/s(省略時: 80)
  複数設置OK。テキストの増減に高さが自動追従します。
-->

  <h1 class="title">回転寿司ボーダー</h1>
  <div class="kaiten-wrap" data-lane-color="#c9a84c" data-lane-width="80" data-speed="80">
    <canvas></canvas>
    <div class="kaiten-content">
      いらっしゃいませ!<br>
      ここにお好きなテキストを入れてください。<br>
      テキストを増やすとボーダーの高さも変わります。
    </div>
  </div>

  <script src="./js/script.js"></script>
</body>


</html>

特に解説はしません。自分で作ったわけでもないので。キャンバスで描画してもらってるって感じです。ボーダーの色とか太さとかスピードはデータ属性いじればいいはずです。なおJS側にもいじる場所あるので忘れずに。

CSSの中身

@charset "utf-8";

/* ===== 必須スタイル ===== */
.kaiten-wrap {
  position: relative;
  width: 100%;
  box-sizing: border-box;
}

.kaiten-wrap canvas {
  position: absolute;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: 1;
}

.kaiten-content {
  position: relative;
  z-index: 2;
  font-size: 15px;
  line-height: 1.7;
  box-sizing: border-box;
}

/* ===== ページのスタイル(お好みで変更) ===== */
body {
  font-family: sans-serif;
  background: #f5f5f0;
  padding: 2rem;
  margin: 0;
  box-sizing: border-box;
}

.title {
  text-align: center;
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 24px;
  margin-top: 80px;
}

こちらも短いので、解説なしにします。

JSの中身

"use strict";

// =============================================
//  🍣 寿司画像の差し替えはここだけ!
//  src に画像ファイルのパスを指定してください
//  (同じフォルダに置く場合はファイル名だけでOK)
// =============================================
const SUSHI = [
  {
    src: "./images/maguro.png",
  }, // ← 差し替え
  {
    src: "./images/ebi.png",
  }, // ← 差し替え
  {
    src: "./images/kappa.png",
  }, // ← 差し替え
  {
    src: "./images/negitoro.png",
  }, // ← 差し替え
  {
    src: "./images/sa-mon.png",
  }, // ← 差し替え
  {
    src: "./images/tamago.png",
  }, // ← 差し替え
];
// =============================================

// ===================================================
//  回転寿司ボーダー コア
//  .kaiten-wrap を自動検出して初期化します
// ===================================================

function initKaiten(wrap) {
  const canvas = wrap.querySelector("canvas");
  const content = wrap.querySelector(".kaiten-content");
  const ctx = canvas.getContext("2d");

  // オプション(data属性から取得)
  let laneW = parseInt(wrap.dataset.laneWidth || "80");
  let laneColor = wrap.dataset.laneColor || "#c9a84c";
  let speed = parseInt(wrap.dataset.speed || "80");

  let offset = 0;
  let lastTime = null;
  let pathCache = null;

  // 画像プリロード
  const imgObjs = SUSHI.map((s) => {
    const img = new Image();
    img.src = s.src;
    return {
      img,
    };
  });

  // ── パス構築
  function buildPath(W, H, lw) {
    const r = lw * 0.7,
      mx = lw / 2;
    const segs = [];

    function line(x0, y0, x1, y1) {
      const len = Math.hypot(x1 - x0, y1 - y0);
      if (len > 0.5)
        segs.push({
          type: "line",
          x0,
          y0,
          x1,
          y1,
          len,
        });
    }

    function arc(cx, cy, sa, ea) {
      segs.push({
        type: "arc",
        cx,
        cy,
        r,
        startA: sa,
        endA: ea,
        len: Math.abs(ea - sa) * r,
      });
    }
    line(mx + r, mx, W - mx - r, mx);
    arc(W - mx - r, mx + r, -Math.PI / 2, 0);
    line(W - mx, mx + r, W - mx, H - mx - r);
    arc(W - mx - r, H - mx - r, 0, Math.PI / 2);
    line(W - mx - r, H - mx, mx + r, H - mx);
    arc(mx + r, H - mx - r, Math.PI / 2, Math.PI);
    line(mx, H - mx - r, mx, mx + r);
    arc(mx + r, mx + r, Math.PI, Math.PI * 1.5);
    return {
      segs,
      totalLen: segs.reduce((a, s) => a + s.len, 0),
    };
  }

  function posOnPath(d, segs, totalLen) {
    d = ((d % totalLen) + totalLen) % totalLen;
    for (const s of segs) {
      if (d <= s.len) {
        if (s.type === "line") {
          const t = d / s.len;
          return {
            x: s.x0 + (s.x1 - s.x0) * t,
            y: s.y0 + (s.y1 - s.y0) * t,
          };
        }
        const a = s.startA + (d / s.len) * (s.endA - s.startA);
        return {
          x: s.cx + Math.cos(a) * s.r,
          y: s.cy + Math.sin(a) * s.r,
        };
      }
      d -= s.len;
    }
    return {
      x: segs[0].x0,
      y: segs[0].y0,
    };
  }

  // ── レーン描画
  function drawLane(W, H, lw) {
    const r = lw * 0.7,
      mx = lw / 2;

    function tracePath() {
      ctx.beginPath();
      ctx.moveTo(mx + r, mx);
      ctx.lineTo(W - mx - r, mx);
      ctx.arcTo(W - mx, mx, W - mx, mx + r, r);
      ctx.lineTo(W - mx, H - mx - r);
      ctx.arcTo(W - mx, H - mx, W - mx - r, H - mx, r);
      ctx.lineTo(mx + r, H - mx);
      ctx.arcTo(mx, H - mx, mx, H - mx - r, r);
      ctx.lineTo(mx, mx + r);
      ctx.arcTo(mx, mx, mx + r, mx, r);
      ctx.closePath();
    }

    // ベタ塗り
    tracePath();
    ctx.strokeStyle = laneColor;
    ctx.lineWidth = lw;
    ctx.lineJoin = "round";
    ctx.lineCap = "round";
    ctx.stroke();

    // 内側コンテンツエリアを角丸白背景で描く
    const iX = lw,
      iY = lw,
      iW = W - lw * 2,
      iH = H - lw * 2;
    const iR = Math.max(6, r - mx + 2);
    if (iW > 0 && iH > 0) {
      ctx.beginPath();
      ctx.moveTo(iX + iR, iY);
      ctx.lineTo(iX + iW - iR, iY);
      ctx.arcTo(iX + iW, iY, iX + iW, iY + iR, iR);
      ctx.lineTo(iX + iW, iY + iH - iR);
      ctx.arcTo(iX + iW, iY + iH, iX + iW - iR, iY + iH, iR);
      ctx.lineTo(iX + iR, iY + iH);
      ctx.arcTo(iX, iY + iH, iX, iY + iH - iR, iR);
      ctx.lineTo(iX, iY + iR);
      ctx.arcTo(iX, iY, iX + iR, iY, iR);
      ctx.closePath();
      ctx.fillStyle = "#ffffff";
      ctx.fill();
    }
  }

  // ── お皿+画像
  function drawItem(x, y, o, imgSize) {
    const plateR = imgSize * 0.52;
    ctx.save();
    ctx.shadowColor = "rgba(0,0,0,0.25)";
    ctx.shadowBlur = 5;
    ctx.shadowOffsetY = 2;
    ctx.beginPath();
    ctx.arc(x, y, plateR, 0, Math.PI * 2);
    ctx.fillStyle = "#ffffff";
    ctx.fill();
    ctx.restore();
    ctx.beginPath();
    ctx.arc(x, y, plateR, 0, Math.PI * 2);
    ctx.fillStyle = "#ffffff";
    ctx.fill();
    ctx.strokeStyle = "rgba(0,0,0,0.10)";
    ctx.lineWidth = 1.2;
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(x, y, plateR * 0.75, 0, Math.PI * 2);
    ctx.strokeStyle = "rgba(0,0,0,0.05)";
    ctx.lineWidth = 0.8;
    ctx.stroke();
    if (o.img.complete && o.img.naturalWidth > 0) {
      const s = imgSize;
      ctx.drawImage(o.img, x - s / 2, y - s / 2, s, s);
    }
  }

  // ── アニメーション
  function draw(ts) {
    if (!lastTime) lastTime = ts;
    const dt = Math.min(ts - lastTime, 50);
    lastTime = ts;
    offset += (speed * dt) / 1000;

    const W = canvas.width,
      H = canvas.height;
    if (W < 10 || H < 10) {
      requestAnimationFrame(draw);
      return;
    }

    if (
      !pathCache ||
      pathCache.W !== W ||
      pathCache.H !== H ||
      pathCache.lw !== laneW
    ) {
      pathCache = {
        ...buildPath(W, H, laneW),
        W,
        H,
        lw: laneW,
      };
    }
    const { segs, totalLen } = pathCache;

    ctx.clearRect(0, 0, W, H);
    drawLane(W, H, laneW);

    const imgSize = laneW * 0.88;
    const plateR = imgSize * 0.52;
    const n = Math.max(1, Math.floor(totalLen / (plateR * 2 * 1.5)));
    const spacing = totalLen / n;

    for (let i = 0; i < n; i++) {
      const d = (((offset + i * spacing) % totalLen) + totalLen) % totalLen;
      const pos = posOnPath(d, segs, totalLen);
      drawItem(pos.x, pos.y, imgObjs[i % imgObjs.length], imgSize);
    }
    requestAnimationFrame(draw);
  }

  // ── リサイズ
  function resize() {
    content.style.padding = `${laneW + 10}px ${laneW + 14}px`;
    setTimeout(() => {
      canvas.width = wrap.offsetWidth;
      canvas.height = wrap.offsetHeight;
      pathCache = null;
    }, 0);
  }

  new ResizeObserver(resize).observe(wrap);
  resize();
  requestAnimationFrame(draw);
}

// すべての .kaiten-wrap を初期化
document.querySelectorAll(".kaiten-wrap").forEach(initKaiten);

こちらも解説なしで。お皿はキャンバスで描画してるので、お皿の上に載るお寿司の画像を好きなネタに変えてあげればOKです。あとはHTML側で数値をいじった場合は、該当箇所の数値も同様に変更してあげてください。

ってことで以上になります。この発想から回転寿司に限らず、電車を走らせたりとか発想が広がっていくと嬉しいなって思います。ただのボーダーから脱却したい方は参考にしてみてください。