• #3D
  • #CSS
  • #数学
  • #ビジュアライゼーション
  • #Vue.js
開発mdx-playgroundメモ

(a+b)³ インタラクティブ3Dビジュアライザーの開発記録

2026年2月6日の開発作業のまとめ。(a+b)³ = a³ + 3a²b + 3ab² + b³ という代数の展開公式を、CSS 3D Transformsを使って8つのブロックで視覚的に理解できるインタラクティブページを作成した。

完成ページ: /cube-identity


作業概要

午前中のおよそ2時間半で、以下のコミットを重ねて完成させた。

時刻コミット内容
09:238dcc858初期実装 - 3Dビジュアライザーの骨格を作成
10:03a5a3d40ダイアグラム関連の機能追加
10:372489d50OGP画像生成・SEOメタデータの設定
10:40326308d不要なスタイルルールの削除
10:49f1a4d44SVG凡例ブロックに辺ラベルを追加
11:052a7925f3Dブロックの面に辺の寸法ラベルを表示
11:51d241b13デスクトップ向け2カラムグリッドレイアウト

1. CSS 3D Transformsによる8ブロックキューブの実装

基本方針

(a+b)³ の立方体は、辺の長さが a+b の大きな立方体を8つのブロックに分解したものとして表現できる。

ブロック寸法個数
a x a x a1個黄色 (#fbbf24)
a²ba x a x b3個緑 (#34d399)
ab²a x b x b3個オレンジ (#f97316)
b x b x b1個赤 (#ef4444)

BlockPiece型の設計

各ブロックは組立時の位置と分解時のオフセット位置を持つ。

interface BlockPiece {
  id: string
  label: string
  color: string
  dims: { x: string; y: string; z: string }
  width: number   // x方向
  height: number  // y方向
  depth: number   // z方向
  x: number       // 組立時の位置
  y: number
  z: number
  explodedX: number // 分解時のオフセット
  explodedY: number
  explodedZ: number
}

設計のポイント: dims フィールドで各軸方向が a なのか b なのかを保持している。これは辺ラベルの表示に使う。数値の width/height/depth とは別に文字列として持つことで、表示用と計算用を分離できた。

8ブロックの配置ロジック

8ブロックは (a+b)³ の立方体の各コーナーに対応する。

              +------+------+
             /      /      /|
            / a²b  / ab²  / |
           +------+------+  |
          /      /      /|  +
         / a³   / a²b  / | /|
        +------+------+  |/ |
        |      |      |  +  |
        |  a³  | a²b  | /|  +
        |      |      |/ | /
        +------+------+  |/
        |      |      |  +
        | a²b  | ab²  | /
        |      |      |/
        +------+------+
                        b³ は対角の奥

各ブロックの分解時オフセットは、そのブロックが立方体の「どの象限」にいるかによって決まる。gap = 1.5 として、各軸方向に -gap または +gap をオフセットする。

// a³ は原点側 → 3軸とも -gap
{ explodedX: -gap, explodedY: -gap, explodedZ: -gap }

// b³ は対角側 → 3軸とも +gap
{ explodedX: gap, explodedY: gap, explodedZ: gap }

// a²b-1 (a×a×b, z方向にb) → z軸のみ +gap
{ explodedX: -gap, explodedY: -gap, explodedZ: gap }

面のレンダリング

各ブロックは6面のCSSの div 要素で構成される。面ごとにサイズと transform を計算する。

const getBlockFaces = (block: BlockPiece): FaceDef[] => {
  const s = SCALE.value
  const hw = (block.width * s) / 2   // half width
  const hh = (block.height * s) / 2  // half height
  const hd = (block.depth * s) / 2   // half depth

  return [
    // Front (+z): 幅=x, 高さ=y
    { w: block.width * s, h: block.height * s,
      transform: `translateZ(${hd}px)`, ... },
    // Back (-z)
    { ..., transform: `rotateY(180deg) translateZ(${hd}px)`, ... },
    // Right (+x)
    { w: block.depth * s, h: block.height * s,
      transform: `rotateY(90deg) translateZ(${hw}px)`, ... },
    // ... 残り3面
  ]
}

ここで重要なのが transform-origin の扱い。 各面は marginLeft: -face.w / 2, marginTop: -face.h / 2 で中心をブロックの原点に合わせている。これにより translateZ だけで正しい位置に面が配置される。


2. 分解アニメーション

分解/組立の切り替えは exploded ref を true/false するだけ。

<button @click="exploded = !exploded">
  {{ exploded ? '組み立てる' : '分解する' }}
</button>

アニメーションはCSSの transition で実現している。

.block {
  position: absolute;
  transform-style: preserve-3d;
  transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}

cubic-bezier(0.4, 0, 0.2, 1) は Material Design の standard easing curve で、自然な「減速して止まる」動きになる。ブロックの transform は computed で算出されるため、exploded の値が変わると自動的に再計算され、CSSトランジションがアニメーションを処理する。


3. UIの変遷: スライダーからボタングループへ

初期実装ではスライダーを使用

最初は <input type="range"> でa/bの値を1-5で選択するUIだった。しかし、以下の問題があった。

  • 値が離散的(1, 2, 3, 4, 5)なのにスライダーは連続的なUIで、直感に合わない
  • タッチデバイスで正確な値を選びにくい
  • 現在の選択値が一目でわかりにくい

ボタングループへの変更

5つのボタンを横に並べ、選択中の値をアクティブ表示にする。

<div class="value-buttons">
  <button
    v-for="v in VALUES"
    :key="'a-' + v"
    class="val-btn"
    :class="{ 'val-btn--active': a === v }"
    @click="a = v"
  >{{ v }}</button>
</div>
.val-btn--active {
  background-color: #fbbf24;
  border-color: #fbbf24;
  color: #111827;
}

離散値の選択には、スライダーよりもボタングループのほうがUXが良い。選択状態が一目でわかり、タップ/クリック1回で値を切り替えられる。


4. 動的SCALE計算の実装

問題

a=5, b=5 の場合は合計10で大きな立方体になり、a=1, b=1 の場合は合計2で小さな立方体になる。固定のスケール値では、大きい値のときにはみ出し、小さい値のときは小さすぎるという問題があった。

解決策: フィットサイズからの逆算

const BASE_FIT_SIZE = 220 // シーン内に収まる目標サイズ(px)

const SCALE = computed(() =>
  (BASE_FIT_SIZE / (a.value + b.value)) * zoom.value
)

a + b の値に関わらず、立方体が常に BASE_FIT_SIZE px 程度に収まるようスケールを動的に算出する。さらにホイールズームの倍率を掛け合わせることで、ユーザーが拡大/縮小もできる。


5. a²b の色をグリーンに変更

経緯

初期実装では a²b を青系の色にしていたが、暗い背景に対して視認性が低かった。また、ab² のオレンジと近い色相で区別しにくいという問題があった。

変更後

const COLORS = {
  a3: '#fbbf24',    // 黄色
  a2b: '#34d399',   // 緑(エメラルド)
  ab2: '#f97316',   // オレンジ
  b3: '#ef4444',    // 赤
} as const

黄色 → 緑 → オレンジ → 赤 の4色は、暗背景に対して全てコントラストが高く、互いの区別もつきやすい。数式表示との色の対応も明確になった。


6. SEOメタタグとOGP画像生成

useAsyncDataによるサーバサイドOGP

const { data: ogImageUrl } = await useAsyncData(
  'og-cube-identity',
  () => {
    if (!import.meta.server) return null
    const { generatePageOgImageUrl } = usePageOgSignature()
    return generatePageOgImageUrl({
      type: 'general',
      id: 'cube-identity',
      title: '(a+b)³ インタラクティブ 3D ビジュアライザー'
    })
  },
  { server: true, lazy: false }
)

import.meta.server でサーバサイドでのみOGP画像URLを生成する。usePageOgSignature はプロジェクト共通のComposableで、署名付きURLを生成してOGPクローラーに返す。

useSeoMetaの設定

useSeoMeta({
  title: '(a+b)³ インタラクティブ 3D ビジュアライザー - log.eurekapu.com',
  description: '(a+b)³ = a³ + 3a²b + 3ab² + b³ をCSS 3Dで視覚的に理解するインタラクティブツール。',
  ogUrl: 'https://log.eurekapu.com/cube-identity',
  // Twitter Card, OG Image, etc.
})

Vue/Nuxt のカスタムページ(MDXコンテンツでないページ)でも useSeoMetadefinePageMeta を組み合わせることで、コンテンツページと同等のSEO対応ができる。


7. アイソメトリックSVG凡例の作成

テキスト凡例からの変更

初期は a³: 辺 a の立方体 (1個) のようなテキストリストだったが、各ブロックの形状がわかりにくかった。そこで、アイソメトリック投影のSVGで凡例を描画する方式に変更した。

アイソメトリック投影の実装

2D平面に3Dオブジェクトを描くため、標準的なアイソメトリック変換を使用する。

const COS30 = Math.cos(Math.PI / 6) // ≈ 0.866
const SIN30 = 0.5

const isoProject = (x: number, y: number, z: number): [number, number] => [
  (x - z) * COS30,
  (x + z) * SIN30 - y,
]

y軸が上方向(画面上では減少方向)であることに注意。3D座標 (x, y, z) を2D座標 (screenX, screenY) に変換する。

アイソメトリック投影バグの修正

初期実装では間違ったキューブ面をレンダリングしていた。視点が (+infinity, +infinity, +infinity) 方向にあると仮定すると、見える面は以下の3面になる。

// 視点 (+∞,+∞,+∞) から見える3面
const polygons: IsoPolygon[] = [
  { points: [fbr, bbr, btr, ftr], color: faceColor(color, 0.7) },   // 右面 (x=w)
  { points: [bbl, bbr, btr, btl], color: faceColor(color, 0.9) },   // 左面 (z=d)
  { points: [ftl, ftr, btr, btl], color: faceColor(color, 1.2) },   // 上面 (y=h)
]

バグの原因: 最初は前面・右面・上面をレンダリングしていたが、アイソメトリック投影では前面ではなく左側面(z=d側)が見える。コーナーの命名を整理し、正しい3面を選択することで修正した。

面の明暗による立体感

同じベースカラーの明度を変えることで、面ごとの立体感を出す。

const faceColor = (base: string, shade: number): string => {
  const r = parseInt(base.slice(1, 3), 16)
  const g = parseInt(base.slice(3, 5), 16)
  const b = parseInt(base.slice(5, 7), 16)
  const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
  return `rgb(${clamp(r * shade)}, ${clamp(g * shade)}, ${clamp(b * shade)})`
}
  • 上面: shade = 1.2 (明るい)
  • 右面: shade = 0.7 (暗い)
  • 左面: shade = 0.9 (中間)

8. ブロックへの辺の寸法ラベル追加

3Dブロックの辺ラベル

各面に対してどの辺が a でどの辺が b かを表示する。FaceDefedgeLabels フィールドを追加した。

interface EdgeLabel { text: string; position: 'bottom' | 'left' }
interface FaceDef {
  // ...
  edgeLabels: EdgeLabel[]
}

Front面(+z面)の底辺にx軸方向のラベル、左辺にy軸方向のラベルを表示する。Right面(+x面)の底辺にz軸方向のラベルを表示する。

.edge-label {
  position: absolute;
  font-size: 0.6rem;
  font-weight: 700;
  font-style: italic;
  font-family: 'Georgia', 'Times New Roman', serif;
  color: rgba(255, 255, 255, 0.9);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}

.edge-label--bottom {
  bottom: -1px;
  left: 50%;
  transform: translateX(-50%) translateY(100%);
}

.edge-label--left {
  left: -1px;
  top: 50%;
  transform: translateY(-50%) translateX(-100%);
}

セリフ体のイタリックで数学的な見た目を維持しつつ、テキストシャドウで3D面の色に対する視認性を確保している。

SVG凡例の辺ラベル

renderIsoBlock 関数に dims パラメータを追加し、辺ラベルを計算する。

const renderIsoBlock = (
  w: number, h: number, d: number, color: string,
  dims?: { w: string; h: string; d: string },
): RenderedBlock => {
  // ...
  if (dims) {
    const fontSize = Math.max(9, s * 0.75)
    const offset = fontSize * 1.2

    // 高さラベル: 左辺の中点から左にオフセット
    const hMid = midPoint(btl, bbl)
    dimensionLabels.push({
      text: dims.h, x: hMid[0] - offset, y: hMid[1],
      anchor: 'end', fontSize,
    })

    // 幅ラベル: 底辺左の中点から下にオフセット
    // 奥行きラベル: 底辺右の中点から下にオフセット
  }
  // ...
}

midPoint ユーティリティで辺の中点を計算し、そこからオフセットしてラベルを配置する。ViewBoxはラベル領域を含めて再計算する。


9. デスクトップ向け2カラムレイアウト

レイアウト構成

デスクトップ(1200px以上)では CSS Grid による2カラムレイアウトを採用した。

+------------------+------------------+
|                  |  ビジュアル数式   |
|   3Dシーン       |  (SVGブロック凡例)|
|   (正方形に近い)  +------------------+
|                  | 数式カード | 解説  |
+------------------+------------------+
@media (min-width: 1200px) {
  .main-layout {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
    grid-template-areas:
      "scene visual"
      "scene bottom";
    gap: 1rem;
  }
}

3Dビューアの正方形化

3Dシーンは aspect-ratio: 1 で正方形に近い表示エリアを確保している。

.scene {
  width: 100%;
  aspect-ratio: 1;
  max-height: 600px;
  perspective: 800px;
}

grid-template-rows: 1fr 1fr で左列のシーンカードが2行分のスペースを使い、右列には上下に2つのカードが入る構成になった。

モバイル: 1カラムのスタック

1200px未満では Grid の指定がなく、通常のフローで上から下に積み重なる。カード間の margin-bottom: 1rem で間隔を確保している。


10. トップページへのカードリンク追加

<NuxtLink to="/cube-identity" class="nav-card demo-card">
  <div class="nav-card-icon">🧊</div>
  <div class="nav-card-content">
    <h3>(a+b)³ 3Dビジュアライザー</h3>
  </div>
</NuxtLink>

トップページのナビゲーションカードにリンクを追加し、サイト内からの導線を確保した。


技術的なポイントまとめ

CSS 3D Transforms の注意点

  1. transform-style: preserve-3d を親要素に設定しないと、子要素の3D変換が平面化される
  2. backface-visibility: hidden で面の裏側を非表示にすることで、裏面がちらつくのを防ぐ
  3. perspective は3Dシーン全体のコンテナに設定する。値が小さいほど遠近感が強くなる
  4. 面の配置は「中心から半分の距離だけ translateZ」するのが標準的な手法

Computed + CSS Transitionのパターン

Vue の computed で座標を計算し、CSS の transition でアニメーションさせるパターンは非常に強力。JavaScript でアニメーションフレームを管理する必要がなく、ブラウザのGPUアクセラレーションが自動的に適用される。

[ref: exploded] → [computed: getBlockTransform] → [CSS: transition]

アイソメトリック投影の3面選択

標準的なアイソメトリックビューでは、立方体の6面のうち3面が見える。どの3面が見えるかは視点の方向で決まる。今回は (+x, +y, +z) 方向から見た場合の3面(上面、右面、奥面)を描画した。

動的ViewBox計算

SVG凡例のViewBoxは、ポリゴンの頂点座標とラベルの位置から動的に計算する。ab の値が変わるとブロックのサイズが変わり、ViewBoxも自動的に調整される。

const xs = viewPoints.map(p => p[0])
const ys = viewPoints.map(p => p[1])
const pad = 2
const minX = Math.min(...xs) - pad
const minY = Math.min(...ys) - pad
const maxX = Math.max(...xs) + pad
const maxY = Math.max(...ys) + pad

今日の学び

  • 離散値の入力UIはボタングループがベスト: スライダーは連続値向き。1-5のような離散値はボタンのほうが直感的で操作しやすい
  • CSS 3D Transforms は十分実用的: JavaScript による3Dレンダリングライブラリ(Three.js等)を使わなくても、CSS だけで十分な3D表現ができる。特にアニメーションは transition に任せられるのが大きい
  • アイソメトリック投影の面選択は間違いやすい: 「どの3面が見えるか」は直感に反することがある。コーナーの命名を丁寧に行い、座標系を明確にすることが重要
  • 動的スケーリングは必須: 入力値の範囲が広い場合、固定スケールでは破綻する。フィットサイズからの逆算で常に適切なサイズを維持できる

関連リンク