(a+b)³ インタラクティブ3Dビジュアライザーの開発記録
2026年2月6日の開発作業のまとめ。(a+b)³ = a³ + 3a²b + 3ab² + b³ という代数の展開公式を、CSS 3D Transformsを使って8つのブロックで視覚的に理解できるインタラクティブページを作成した。
完成ページ: /cube-identity
作業概要
午前中のおよそ2時間半で、以下のコミットを重ねて完成させた。
| 時刻 | コミット | 内容 |
|---|---|---|
| 09:23 | 8dcc858 | 初期実装 - 3Dビジュアライザーの骨格を作成 |
| 10:03 | a5a3d40 | ダイアグラム関連の機能追加 |
| 10:37 | 2489d50 | OGP画像生成・SEOメタデータの設定 |
| 10:40 | 326308d | 不要なスタイルルールの削除 |
| 10:49 | f1a4d44 | SVG凡例ブロックに辺ラベルを追加 |
| 11:05 | 2a7925f | 3Dブロックの面に辺の寸法ラベルを表示 |
| 11:51 | d241b13 | デスクトップ向け2カラムグリッドレイアウト |
1. CSS 3D Transformsによる8ブロックキューブの実装
基本方針
(a+b)³ の立方体は、辺の長さが a+b の大きな立方体を8つのブロックに分解したものとして表現できる。
| ブロック | 寸法 | 個数 | 色 |
|---|---|---|---|
| a³ | a x a x a | 1個 | 黄色 (#fbbf24) |
| a²b | a x a x b | 3個 | 緑 (#34d399) |
| ab² | a x b x b | 3個 | オレンジ (#f97316) |
| b³ | b x b x b | 1個 | 赤 (#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コンテンツでないページ)でも useSeoMeta と definePageMeta を組み合わせることで、コンテンツページと同等の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 かを表示する。FaceDef に edgeLabels フィールドを追加した。
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 の注意点
transform-style: preserve-3dを親要素に設定しないと、子要素の3D変換が平面化されるbackface-visibility: hiddenで面の裏側を非表示にすることで、裏面がちらつくのを防ぐperspectiveは3Dシーン全体のコンテナに設定する。値が小さいほど遠近感が強くなる- 面の配置は「中心から半分の距離だけ 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は、ポリゴンの頂点座標とラベルの位置から動的に計算する。a や b の値が変わるとブロックのサイズが変わり、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面が見えるか」は直感に反することがある。コーナーの命名を丁寧に行い、座標系を明確にすることが重要
- 動的スケーリングは必須: 入力値の範囲が広い場合、固定スケールでは破綻する。フィットサイズからの逆算で常に適切なサイズを維持できる