この記事で学べること#

  • make_subplots()による複雑なレイアウト構築
  • specs配列によるサブプロットタイプの指定
  • rowspanを使った複数行にまたがるプロット
  • 3D + 2Dグラフの統合表示
  • secondary_yによる2軸グラフの作成
  • レイアウト調整(間隔、サイズ、比率)

対象読者#

  • D-3「Plotly.js 3D軌道プロット」を読んだ方
  • D-6「Time Marker実装」を読んだ方
  • 複数のグラフを統合したダッシュボードを作りたい上級者

本記事では、Plotly.jsのmake_subplots()を使って、3D軌道プロットと複数の時系列グラフを統合したダッシュボードを作成する方法を解説します。


サブプロットレイアウトの基礎#

基本的な使い方#

from plotly.subplots import make_subplots
import plotly.graph_objects as go

# 2行2列のサブプロット作成
fig = make_subplots(rows=2, cols=2)

# 各サブプロットにトレースを追加
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[4, 5, 6]), row=1, col=1)
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[7, 8, 9]), row=1, col=2)
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[10, 11, 12]), row=2, col=1)
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[13, 14, 15]), row=2, col=2)

fig.show()

レイアウトの構造#

┌────────┬────────┐
│ (1,1)  │ (1,2)  │  row=1
├────────┼────────┤
│ (2,1)  │ (2,2)  │  row=2
└────────┴────────┘
  col=1    col=2

specs配列: サブプロットタイプの指定#

specsの基本構造#

specs配列は、各サブプロットのタイプ(2D/3D)や結合を指定します。

fig = make_subplots(
    rows=2,
    cols=2,
    specs=[
        [{"type": "xy"}, {"type": "xy"}],     # row 1: 2D × 2
        [{"type": "scene"}, {"type": "xy"}]   # row 2: 3D, 2D
    ]
)

主要なタイプ#

タイプ 説明 用途
"xy" 2Dグラフ(デフォルト) 時系列、散布図、棒グラフ
"scene" 3Dグラフ 3D軌道、3D散布図
"polar" 極座標グラフ レーダーチャート

rowspan/colspan: セルの結合#

rowspanの使い方#

複数行にまたがるサブプロットを作成できます。

fig = make_subplots(
    rows=3,
    cols=2,
    specs=[
        [{"type": "scene", "rowspan": 3}, {"type": "xy"}],  # 左列は3行結合
        [None, {"type": "xy"}],                             # Noneは結合により使用済み
        [None, {"type": "xy"}]                              # Noneは結合により使用済み
    ]
)

レイアウト図:

┌─────────┬─────────┐
│         │ (1,2)   │
│ (1,1)   ├─────────┤
│  3D     │ (2,2)   │
│ (rowspan│         │
│   =3)   ├─────────┤
│         │ (3,2)   │
└─────────┴─────────┘

colspanの使い方#

fig = make_subplots(
    rows=2,
    cols=3,
    specs=[
        [{"type": "xy", "colspan": 2}, None, {"type": "xy"}],  # 左は2列結合
        [{"type": "xy"}, {"type": "xy"}, {"type": "xy"}]
    ]
)

レイアウト図:

┌───────────────┬─────┐
│  (1,1)        │(1,3)│
│  colspan=2    │     │
├─────┬─────┬───┴─────┤
│(2,1)│(2,2)│  (2,3)  │
└─────┴─────┴─────────┘

実用例: 統合ダッシュボード#

レイアウト設計#

目標: 左列に3D軌道、右列に3つの時系列グラフ

┌──────────────┬──────────────┐
│              │ 高度・速度    │
│              │              │
│   3D軌道     ├──────────────┤
│   (rowspan   │ 姿勢角        │
│    =3)       │              │
│              ├──────────────┤
│              │ 制御入力      │
└──────────────┴──────────────┘

実装コード#

from plotly.subplots import make_subplots
import plotly.graph_objects as go
import pandas as pd
import numpy as np

# CSVデータ読み込み
df = pd.read_csv('flight_data.csv')

# サブプロット作成
fig = make_subplots(
    rows=3,
    cols=2,
    specs=[
        [{"type": "scene", "rowspan": 3}, {"type": "xy", "secondary_y": True}],  # 3D + 高度/速度(2軸)
        [None, {"type": "xy"}],                                                   # 姿勢角
        [None, {"type": "xy"}]                                                    # 制御入力
    ],
    column_widths=[0.5, 0.5],       # 左50%, 右50%
    row_heights=[0.33, 0.33, 0.34],  # ほぼ均等
    horizontal_spacing=0.08,         # 列間隔 8%
    vertical_spacing=0.08            # 行間隔 8%
)

# トレース追加については次のセクションで解説

トレースの追加#

3D軌道プロット(左列)#

# 3D軌道データ(ENU座標系: East-North-Up)
x_enu = df['x_enu_m'].values
y_enu = df['y_enu_m'].values
z_enu = df['altitude_m'].values

fig.add_trace(
    go.Scatter3d(
        x=x_enu,
        y=y_enu,
        z=z_enu,
        mode='lines',
        line=dict(color='blue', width=4),
        name='Trajectory',
        showlegend=False
    ),
    row=1, col=1  # 左列(rowspan=3により全体に表示)
)

高度・速度(右列上段、2軸グラフ)#

time = df['time'].values

# 高度(主軸、左Y軸)
fig.add_trace(
    go.Scatter(
        x=time,
        y=df['altitude_m'],
        mode='lines',
        line=dict(color='green', width=3),
        name='Altitude (m)'
    ),
    row=1, col=2,
    secondary_y=False  # 主軸(左Y軸)
)

# 速度(副軸、右Y軸)
fig.add_trace(
    go.Scatter(
        x=time,
        y=df['airspeed_m_s'],
        mode='lines',
        line=dict(color='orange', width=3),
        name='Speed (m/s)'
    ),
    row=1, col=2,
    secondary_y=True  # 副軸(右Y軸)
)

# Y軸ラベル設定
fig.update_yaxes(title_text="Altitude [m]", row=1, col=2, secondary_y=False)
fig.update_yaxes(title_text="Speed [m/s]", row=1, col=2, secondary_y=True)

姿勢角(右列中段)#

# ロール・ピッチ・ヨー角
fig.add_trace(
    go.Scatter(
        x=time,
        y=df['phi_deg'],
        mode='lines',
        line=dict(color='red', width=2),
        name='Roll (φ)'
    ),
    row=2, col=2
)

fig.add_trace(
    go.Scatter(
        x=time,
        y=df['theta_deg'],
        mode='lines',
        line=dict(color='blue', width=2),
        name='Pitch (θ)'
    ),
    row=2, col=2
)

fig.add_trace(
    go.Scatter(
        x=time,
        y=df['psi_deg'],
        mode='lines',
        line=dict(color='darkgreen', width=2),
        name='Yaw (ψ)'
    ),
    row=2, col=2
)

# Y軸ラベル
fig.update_yaxes(title_text="Angle [deg]", row=2, col=2)

制御入力(右列下段)#

# エルロン・エレベータ・ラダー
fig.add_trace(
    go.Scatter(
        x=time,
        y=df['aileron'],
        mode='lines',
        line=dict(color='purple', width=2),
        name='Aileron'
    ),
    row=3, col=2
)

fig.add_trace(
    go.Scatter(
        x=time,
        y=df['elevator'],
        mode='lines',
        line=dict(color='brown', width=2),
        name='Elevator'
    ),
    row=3, col=2
)

fig.add_trace(
    go.Scatter(
        x=time,
        y=df['rudder'],
        mode='lines',
        line=dict(color='cyan', width=2),
        name='Rudder'
    ),
    row=3, col=2
)

# 軸ラベル
fig.update_xaxes(title_text="Time [s]", row=3, col=2)
fig.update_yaxes(title_text="Control [-]", row=3, col=2)

レイアウト調整#

サイズと余白#

fig.update_layout(
    height=1000,  # 全体の高さ(ピクセル)
    width=1400,   # 全体の幅
    margin=dict(l=50, r=50, t=50, b=50),  # 外側余白
    showlegend=True,
    legend=dict(
        x=1.05,  # 凡例位置(右側)
        y=1.0,
        xanchor='left',
        yanchor='top'
    )
)

3Dシーンの設定#

# 3D軌道の軸設定
fig.update_scenes(
    xaxis=dict(title="East [m]", backgroundcolor="rgb(230, 230,230)"),
    yaxis=dict(title="North [m]", backgroundcolor="rgb(230, 230,230)"),
    zaxis=dict(title="Up [m]", backgroundcolor="rgb(230, 230,230)"),
    aspectmode='data'  # データの比率を保持
)

グリッド設定#

# 全ての2Dグラフにグリッドを追加
fig.update_xaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)
fig.update_yaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)

column_widths / row_heights の詳細#

比率による指定#

fig = make_subplots(
    rows=2,
    cols=3,
    column_widths=[0.2, 0.5, 0.3],  # 20%, 50%, 30%
    row_heights=[0.7, 0.3]          # 70%, 30%
)

解釈:

  • 列1: 全幅の20%
  • 列2: 全幅の50%
  • 列3: 全幅の30%
  • 行1: 全高の70%
  • 行2: 全高の30%

不均等レイアウト例#

# 左列を広く、右列を狭く
fig = make_subplots(
    rows=1,
    cols=2,
    column_widths=[0.65, 0.35],  # 65% : 35%
    horizontal_spacing=0.10
)

spacing: 間隔調整#

horizontal_spacing(列間隔)#

fig = make_subplots(
    rows=1,
    cols=3,
    horizontal_spacing=0.05  # 5%の間隔
)

間隔の計算:

  • horizontal_spacing=0.05: 全幅の5%が列間の余白
  • 例: 幅1000pxの場合、50pxの間隔

vertical_spacing(行間隔)#

fig = make_subplots(
    rows=3,
    cols=1,
    vertical_spacing=0.08  # 8%の間隔
)

デフォルト値:

  • horizontal_spacing: 0.2 / cols(列が多いほど狭い)
  • vertical_spacing: 0.3 / rows(行が多いほど狭い)

適切な間隔の目安#

レイアウト horizontal_spacing vertical_spacing
2列以下 0.05 ~ 0.10 0.08 ~ 0.12
3列以上 0.03 ~ 0.08 0.05 ~ 0.10
タイトルなし 0.05 ~ 0.08 0.05 ~ 0.08
タイトルあり 0.08 ~ 0.12 0.10 ~ 0.15

secondary_y: 2軸グラフ#

基本的な使い方#

from plotly.subplots import make_subplots

# secondary_y=Trueを指定
fig = make_subplots(
    rows=1,
    cols=1,
    specs=[[{"secondary_y": True}]]  # 2軸を有効化
)

# 主軸(左Y軸)
fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[10, 20, 30], name="Series 1"),
    secondary_y=False
)

# 副軸(右Y軸)
fig.add_trace(
    go.Scatter(x=[1, 2, 3], y=[100, 200, 300], name="Series 2"),
    secondary_y=True
)

# 軸ラベル
fig.update_yaxes(title_text="Primary Y", secondary_y=False)
fig.update_yaxes(title_text="Secondary Y", secondary_y=True)

2軸が必要な場面#

単位が異なる: 高度[m]と速度[m/s] スケールが大きく異なる: 温度[°C]と気圧[hPa] 異なる物理量: 位置[m]と角度[deg]


完全な実装例#

"""
統合ダッシュボード: 3D軌道 + 時系列グラフ
"""
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import pandas as pd

# データ読み込み
df = pd.read_csv('phugoid_mode.csv')
time = df['time'].values

# サブプロット作成
fig = make_subplots(
    rows=3,
    cols=2,
    specs=[
        [{"type": "scene", "rowspan": 3}, {"type": "xy", "secondary_y": True}],
        [None, {"type": "xy"}],
        [None, {"type": "xy"}]
    ],
    column_widths=[0.5, 0.5],
    row_heights=[0.33, 0.33, 0.34],
    horizontal_spacing=0.08,
    vertical_spacing=0.08
)

# === 3D軌道 (左列) ===
fig.add_trace(
    go.Scatter3d(
        x=df['x_enu_m'],
        y=df['y_enu_m'],
        z=df['altitude_m'],
        mode='lines',
        line=dict(color='blue', width=4),
        name='Trajectory',
        showlegend=False
    ),
    row=1, col=1
)

# === 高度・速度 (右列上段、2軸) ===
fig.add_trace(
    go.Scatter(x=time, y=df['altitude_m'], mode='lines',
               line=dict(color='green', width=3), name='Altitude'),
    row=1, col=2, secondary_y=False
)
fig.add_trace(
    go.Scatter(x=time, y=df['airspeed_m_s'], mode='lines',
               line=dict(color='orange', width=3), name='Speed'),
    row=1, col=2, secondary_y=True
)
fig.update_yaxes(title_text="Altitude [m]", row=1, col=2, secondary_y=False)
fig.update_yaxes(title_text="Speed [m/s]", row=1, col=2, secondary_y=True)

# === 姿勢角 (右列中段) ===
fig.add_trace(go.Scatter(x=time, y=df['phi_deg'], name='Roll',
                         line=dict(color='red', width=2)), row=2, col=2)
fig.add_trace(go.Scatter(x=time, y=df['theta_deg'], name='Pitch',
                         line=dict(color='blue', width=2)), row=2, col=2)
fig.add_trace(go.Scatter(x=time, y=df['psi_deg'], name='Yaw',
                         line=dict(color='darkgreen', width=2)), row=2, col=2)
fig.update_yaxes(title_text="Angle [deg]", row=2, col=2)

# === 制御入力 (右列下段) ===
fig.add_trace(go.Scatter(x=time, y=df['aileron'], name='Aileron',
                         line=dict(color='purple', width=2)), row=3, col=2)
fig.add_trace(go.Scatter(x=time, y=df['elevator'], name='Elevator',
                         line=dict(color='brown', width=2)), row=3, col=2)
fig.add_trace(go.Scatter(x=time, y=df['rudder'], name='Rudder',
                         line=dict(color='cyan', width=2)), row=3, col=2)
fig.update_xaxes(title_text="Time [s]", row=3, col=2)
fig.update_yaxes(title_text="Control [-]", row=3, col=2)

# === レイアウト調整 ===
fig.update_layout(
    height=1000,
    width=1400,
    margin=dict(l=50, r=50, t=50, b=50),
    showlegend=True
)

# 3Dシーン設定
fig.update_scenes(
    xaxis=dict(title="East [m]"),
    yaxis=dict(title="North [m]"),
    zaxis=dict(title="Up [m]"),
    aspectmode='data'
)

# グリッド追加
fig.update_xaxes(showgrid=True, gridcolor='lightgray')
fig.update_yaxes(showgrid=True, gridcolor='lightgray')

# 保存・表示
fig.write_html('dashboard.html')
print("[OK] Dashboard saved: dashboard.html")

よくある問題と対処法#

問題1: トレースが表示されない#

# ❌ 間違い: row, colを指定していない
fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4]))

# ✅ 正しい: row, colを明示的に指定
fig.add_trace(go.Scatter(x=[1, 2], y=[3, 4]), row=1, col=1)

問題2: specsとトレース追加の不一致#

# ❌ 間違い: specs配列でNoneの位置にトレース追加
specs = [
    [{"type": "scene", "rowspan": 2}, {"type": "xy"}],
    [None, {"type": "xy"}]  # (2,1)はNone
]
fig.add_trace(go.Scatter(...), row=2, col=1)  # エラー!

# ✅ 正しい: Noneでない位置に追加
fig.add_trace(go.Scatter(...), row=2, col=2)

問題3: 2軸が機能しない#

# ❌ 間違い: specsでsecondary_yを指定していない
fig = make_subplots(rows=1, cols=1, specs=[[{"type": "xy"}]])
fig.add_trace(go.Scatter(...), secondary_y=True)  # 効果なし

# ✅ 正しい: specsでsecondary_y=Trueを指定
fig = make_subplots(rows=1, cols=1, specs=[[{"secondary_y": True}]])
fig.add_trace(go.Scatter(...), secondary_y=True)  # 動作する

まとめ#

本記事では、Plotly.jsのmake_subplots()を使った複雑なダッシュボードレイアウトの実装方法を解説しました。

重要なポイント:

  • make_subplots(): rows, cols, specsでレイアウト定義
  • specs配列: タイプ(xy/scene)、rowspan/colspan指定
  • add_trace(): row, col指定でトレース追加
  • column_widths, row_heights: 比率指定(合計1.0)
  • horizontal_spacing, vertical_spacing: 間隔調整(全体の比率)
  • secondary_y=True: 2軸グラフの作成
  • 3D + 2Dの統合表示で実用的なダッシュボード実現

次のステップとして、アニメーション機能の統合(D-4, D-5の応用)や、複数CSVファイルの比較表示に挑戦してみましょう。


参照資料#

本記事の執筆にあたり、以下の資料を参照しました [@plotly_python_docs_subplots_2025; @plotly_python_docs_multiple_axes_2025; @plotly_python_docs_3d_scatter_2025; @phase7d_integration_plan_2025]。