• #SVG
  • #ダイアグラム
  • #プレゼンテーション
  • #UI/UX
  • #Vue.js
開発mdx-playgroundメモ

25種類のSVG図解カタログをVue.jsで構築した

図解の種類をカタログ形式で閲覧できるページを作った。6カテゴリ・25種類のダイアグラムを、それぞれSVGで描き起こして、スライドスタイルのプレゼンテーション表示で見せる構成にした。朝からひたすらSVGを書いて、UIの調整を繰り返した1日。


完成したもの

/diagram-types/a/1 のようなURLで、カテゴリとダイアグラム番号を指定してアクセスできるページ。デスクトップではMiller Columns(3ペイン)レイアウト、モバイルではカードベースの詳細表示になる。

6カテゴリ・25種類の構成:

カテゴリID図解数含まれる図解
階層・構造A5ツリー図、ピラミッド図、階層・レイヤー、トライアングル、フレームワーク図
プロセス・フローB6フロー図(横型/縦型)、サイクル図、階段・ステップ図、ガントチャート、2軸タイムライン
関係性C4放射図、相関図、ベン図、マトリクス
比較D5ビフォーアフター、項目比較図、規模比較図、TAM-SAM-SOM、導入効果図
グルーピングE5ハニカム構造、グループ図(少/多)、数式、地図・マップ
リスト・箇条書きF4箇条書き(縦/横/羅列)、チャート+ポイント

技術的なポイント

Miller Columnsレイアウト

Finder風の3カラムナビゲーション。左からカテゴリ一覧、図解一覧、詳細ビューの順に配置する。

.miller-columns {
  display: grid;
  grid-template-columns: 200px 240px 1fr;
  flex: 1;
  overflow: hidden;
}

カテゴリを選択すると中央カラムの図解一覧が切り替わり、図解を選ぶと右側の詳細ビューが更新される。この「左で選んで右に詳細が出る」というパターンは、macOSのFinderやメールクライアントでおなじみのUIなので、説明不要で操作できる。

URLは /diagram-types/{category}/{no} の形式でルーティングしており、watch でリアクティブに router.replace する。ブラウザの戻る/進むボタンにも対応している。

スライドスタイルのプレゼンテーション表示

各ダイアグラムを4:3比率(640x480)のSVG viewBoxに収める形式にした。最初はカード型で作っていたが、プレゼン資料のスライドのように見せたほうが、図解の使い方をイメージしやすいと考えてリファクタリングした。

SVGの内部構造は3つのゾーンに分けている:

Zone 1 (タイトル):    y = 0..45   → 図解の名前(小さめの灰色テキスト)
Zone 2 (メッセージ):  y = 45..72  → 図解の説明文(foreignObjectで改行対応)
Zone 3 (コンテンツ):  y = 75..465 → SVGイラスト本体

Zone 3のコンテンツ配置は図解ごとに高さが異なるので、contentOffset という computed プロパティで個別にy方向のオフセットを調整している。

const contentOffset = computed(() => {
  const offsets: Record<string, number> = {
    'tree': 82,
    'pyramid': 35,
    'layer': 65,
    // ...各図解ごとに個別調整
  }
  return offsets[props.id] ?? 0
})

最初は全ての図解を均一にレイアウトしようとしたが、ツリー図は上に余白が必要で、マトリクスは上下中央に置くのが自然で、と図解ごとに最適な位置が全然違った。結局、個別にオフセット値を設定するのが一番素直な解決策だった。

タイトルとメッセージのフォント設計

最初のバージョンではタイトルを大きく、メッセージを小さく表示していた。途中でスワップして、タイトルは控えめな灰色テキスト(11px)、メッセージは本文として読めるサイズにした。

<!-- タイトル: 控えめに -->
<text x="28" y="34" fill="#6b7280" font-size="11">{{ name }}</text>

<!-- メッセージ: 説明文として -->
<foreignObject x="28" y="40" width="584" height="50">
  <p class="slide-description">{{ description }}</p>
</foreignObject>

プレゼンスライドでは「タイトルが一番目立つ」のが普通だが、このカタログではユーザーが見たいのは図解そのものであり、タイトルは「今どの図解を見ているか」のラベルに過ぎない。だからタイトルを脇役にして、メッセージを読みやすくした。

スライド番号の扱い

初期バージョンでは各スライドのタイトルに A-1, B-3 のような番号を含めていた。しかし、これはカテゴリ内のローカルな番号であり、全体での位置関係がわからない。

最終的にグローバルインデックス(1/25, 2/25...)をページ番号として使う方針に変更した。

const globalIndex = computed(() => {
  if (!selectedDiagram.value) return 0
  return allDiagrams.findIndex(d => d.id === selectedDiagram.value!.id) + 1
})

allDiagrams は全カテゴリのダイアグラムをフラットに並べた配列なので、findIndex で現在の図解の位置を取得する。


SVGイラストの描画

25種類のSVGをすべて手書き(座標直打ち)で作成した。Figmaなどのツールは使わず、Vue SFCのテンプレート内に直接SVGを書いている。

背景矩形のstroke削除

最初は各図形要素に stroke を付けていたが、背景の矩形(スライドフレーム)にstrokeが付いているとスライド全体が「枠線で囲まれた感」が出てしまい、プレゼンスライドらしさが損なわれる。stroke を削除して fill のみにしたところ、見た目がすっきりした。

<!-- Before: 枠線あり -->
<rect x="0" y="0" width="640" height="480" fill="#fff" stroke="#e5e7eb" rx="2" />

<!-- After: 枠線なし -->
<rect x="0" y="0" width="640" height="480" fill="#fff" rx="2" />

Before/Afterダイアグラムの矢印変更

ビフォーアフター図の中央に「Before → After」を示す矢印を配置した。最初は単純なテキスト矢印()を使っていたが、SVGのpath要素で三角形の矢印を描くほうが見栄えがよかった。矢印のサイズや色をSVGの他の要素と統一できるのもメリット。

フレームワーク図(ピラミッド x マトリクス)

A-5のフレームワーク図は、左側にピラミッド型の価値軸、右側にカテゴリ別の施策マトリクスを配置する複合構造。コンサル資料でよく見る構成で、情報量が多い。

SVGで実装する際のポイントは、ピラミッドの三角形とマトリクスのグリッドを同じ高さに揃えること。ピラミッドは polygon 要素で描き、マトリクスは rect + text の繰り返しで構成した。

2軸タイムライン

B-6の2軸タイムラインは、上下2つのテーマ(例: 技術と市場)の変化を同時に見せる図解。中央に時間軸の横線を引き、上下にイベントを配置する。

実装上の工夫は、上のイベントと下のイベントが重ならないように横位置をずらすこと。イベント数が多いとスペースが足りなくなるので、SVGのviewBoxサイズに収まる範囲で4〜5イベント程度に絞った。

導入効果図(ROI)

D-5の導入効果図は、SaaS・DX案件の事例紹介スライドで定番の構成。導入前後の数値比較と定性効果、リソース情報を1枚にまとめる。

数値の変化を棒グラフで表現し、隣にKey Metricsをリスト表示。定量と定性を1枚に収めるレイアウトは情報の詰め込みすぎになりやすいので、余白の確保に気を使った。

チャート+箇条書き(Chart + Bullets)

F-4のチャート+ポイントは、コンサル資料で最もよく使われるレイアウト。左にチャート(棒グラフや円グラフ)、右にKey Insightsを箇条書きで並べる。

「データを見せる」と「メッセージを伝える」を1枚で両立させる構成。SVGでは左半分にダミーの棒グラフを描き、右半分にテキストを配置した。


Merit/Demerit表示から「特徴」テキストへの変更

初期設計では各ダイアグラムに「メリット」と「デメリット」をラベル付きで表示していた。しかし実際に使ってみると、メリット/デメリットという二項対立のラベルが重苦しく感じた。

最終的に、左サイドに「特徴」というヘッディングで2つのテキストを並べる形にした。

<div class="detail-traits">
  <h3 class="traits-heading">特徴</h3>
  <p class="traits-text">{{ selectedDiagram.merit }}</p>
  <p class="traits-text">{{ selectedDiagram.demerit }}</p>
</div>

データ構造上は merit / demerit のフィールド名が残っているが、表示上はフラットなテキストとして扱う。ユーザーが「これはメリットで、これはデメリット」と意識しなくても、読めば自然にわかる内容なので、ラベルは不要と判断した。


キーボードナビゲーション

矢印キーで前後のダイアグラムに移動できるようにした。左右キーだけでなく上下キーも対応しているのは、Miller Columnsの操作感に合わせたため。

function handleKeydown(e: KeyboardEvent) {
  const el = document.activeElement
  if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) return
  if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
    e.preventDefault()
    goPrev()
  } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
    e.preventDefault()
    goNext()
  }
}

カテゴリをまたぐナビゲーションも対応している。カテゴリAの最後のダイアグラムで「次へ」を押すと、カテゴリBの最初のダイアグラムに遷移する。

function goNext() {
  const catIdx = currentCategoryIndex.value
  const dIdx = currentDiagramIndex.value
  const cat = selectedCategory.value
  if (dIdx < cat.diagrams.length - 1) {
    // 同じカテゴリ内の次へ
    selectedDiagramNo.value = cat.diagrams[dIdx + 1].no
  } else if (catIdx < categories.length - 1) {
    // 次のカテゴリの先頭へ
    const nextCat = categories[catIdx + 1]
    selectedCategoryId.value = nextCat.id
    selectedDiagramNo.value = nextCat.diagrams[0].no
  }
}

データ設計

ダイアグラムのデータは TypeScript の型定義とデータファイルに分離している。

// types.ts
export type DiagramCategoryId = 'a' | 'b' | 'c' | 'd' | 'e' | 'f'

export interface DiagramType {
  category: DiagramCategoryId
  no: number
  id: string
  name: string
  description: string
  merit: string
  demerit: string
  source?: string
}

カテゴリごとにデータ配列を分けて定義し、allCategories でまとめている。この構造にしたのは、カテゴリ単位での操作(カテゴリ選択時のフィルタリングなど)が多いため。フラットな配列1つにまとめるよりも、カテゴリごとに分けたほうがコードの意図が読みやすい。

export const allCategories: DiagramCategory[] = [
  { id: 'a', name: '階層・構造', diagrams: diagramsCategoryA },
  { id: 'b', name: 'プロセス・フロー', diagrams: diagramsCategoryB },
  // ...
]

export const allDiagrams: DiagramType[] = allCategories.flatMap(c => c.diagrams)

allDiagrams はグローバルインデックスの計算やフラットな検索で使う。


レイアウト修正の試行錯誤

今日一番時間をかけたのは、各種レイアウトの微調整だった。

問題1: スライドの縦位置がバラバラ

図解ごとにSVGの高さが違うので、Zone 3(コンテンツゾーン)内での縦位置がバラバラになった。ツリー図は上に伸びるし、マトリクスは正方形だし、ガントチャートは横に広い。

解決策: contentOffset で個別調整。地道だが確実。

問題2: foreignObjectの改行

SVGの <text> 要素は自動改行しない。説明文が長い場合に折り返すため、<foreignObject> でHTMLの <p> タグを埋め込んだ。ただし foreignObject はSVGとHTMLの境界なので、CSSの適用範囲に注意が必要。

問題3: モバイルとデスクトップの切り替え

Miller Columnsはモバイルでは使いにくい。768px以下ではMiller Columnsを非表示にして、カード型の詳細表示に切り替える。レスポンシブ対応は display: none / display: grid の切り替えで実装。


今日の学び

  • SVGの座標直打ちは意外と速い: Figmaで描いてexportするより、直接座標を書いたほうが修正が早い。特に「5px右にずらしたい」みたいな微調整は、コード上で数値を変えるだけで済む
  • viewBoxの4:3比率は使いやすい: 640x480のviewBoxはプレゼンスライドの標準比率。コンテンツを配置する際の感覚がつかみやすく、スクリーンに表示したときも自然に見える
  • ラベルは少ないほうがいい: Merit/Demeritのラベルを外して「特徴」にまとめただけで、情報量は変わらないのに読みやすくなった。ラベルが多いと視線が分散する
  • カテゴリまたぎのナビゲーション: 「最後の要素で次へ押したら次のカテゴリへ」は当たり前の動作だが、実装を忘れがち。ユーザーは矢印キーで全件を順番に見たいと思っている

ファイル構成

apps/web/app/
  components/diagram-types/
    data/
      types.ts          # TypeScript型定義
      diagrams.ts       # 25種類のダイアグラムデータ
    DiagramIllustration.vue  # SVGイラスト描画コンポーネント(全25種類)
  pages/diagram-types/
    [category]/
      [no].vue          # Miller Columnsページ

関連リンク