• #design
  • #jleague
  • #financial-quiz
未分類

P/L Waterfallチャート構造変更案

P/L WaterfallチャートのJリーグ財務データページにおける構造を変更する。

背景・問題点

特別損益が漏れている

特別利益・特別損失がチャートに含まれていないのが現在のP/L Waterfallチャートの問題である。

例: V・ファーレン長崎の場合

  • 営業損失: ▲1,225百万円
  • 営業外: 33百万円(ネット値で表示)
  • 税引前利益: 8百万円
  • 法人税等: 6百万円
  • 純利益: 2百万円

営業損失▲1,225 + 営業外33 = ▲1,192 なのに税引前利益が8となっている

→ 特別利益が約1,200百万円あるはずだが、チャートに表示されていない

その他の問題

  • 税引前利益を表示しているが、要素が多くなりすぎている
  • バーの数が7本と多く、横幅を取る
  • 営業外はネット値で表示されており、収益・費用の内訳が見えない

Before(現在の構造)

現在のP/L Waterfall

現在のバー構成: 7本

#バー内容
1売上高積み上げ棒(スポンサー/入場料/配分金/その他)
2営業費用積み上げ棒(人件費/試合関連/運営費/その他/販管費)
3営業利益/損失結果バー
4営業外ネット値(営業外収益 - 営業外費用)
5税引前利益/損失結果バー
6法人税等単一バー
7純利益/損失結果バー

問題点まとめ

  1. 特別利益・特別損失が完全に欠落 → 数値の整合性が取れない
  2. 営業外がネット値 → 内訳が見えない
  3. バーが7本 → 横幅を取りすぎ
  4. 税引前利益 → 中間値で冗長

After(変更後の構造)

alt text

┌─────────┐   ┌─────────┐
│ 売上高  │   │営業費用 │
│(積み上げ)│   │(積み上げ)│
│         │   │         │
│スポンサー│   │ 人件費  │
│ 入場料  │   │試合関連 │
│ 配分金  │   │ 運営費  │
│ その他  │   │ その他  │
│         │   │ 販管費  │
└────┬────┘   └────┬────┘
     │              │
     └──────┬───────┘
            ▼
     ┌─────────┐
     │営業利益 │
     │/営業損失│
     └────┬────┘
          │
          ▼
┌─────────┐       ┌─────────┐
│営業外等 │       │営業外等 │
│ 収益   │       │ 費用   │
│(積み上げ)│       │(積み上げ)│
│         │       │         │
│営業外収益│       │営業外費用│
│特別利益 │       │特別損失 │
│法人税還付│       │法人税等 │
└────┬────┘       └────┬────┘
     │                   │
     └─────────┬─────────┘
               ▼
        ┌─────────┐
        │ 純利益  │
        │/純損失  │
        └─────────┘

変更後のバー数: 6本

  1. 売上高(積み上げ棒)
  2. 営業費用(積み上げ棒)
  3. 営業利益/損失
  4. 営業外等収益(積み上げ棒)← NEW
    • 営業外収益
    • 特別利益
    • 法人税還付(マイナスの場合)
  5. 営業外等費用(積み上げ棒)← NEW
    • 営業外費用
    • 特別損失
    • 法人税等(プラスの場合)
  6. 純利益/純損失

削除される要素

  • 税引前利益/損失(不要)

変更のポイント

  1. 特別損益の追加: 特別利益・特別損失を明示的に表示
  2. 積み上げ棒の活用: 営業外と特別と法人税を積み上げて表示
  3. 法人税の分岐処理:
    • 法人税等がプラス → 費用側に積み上げ
    • 法人税等がマイナス(還付)→ 収益側に積み上げ
  4. 要素の削減: 税引前利益を削除し、シンプルに

データ構造の確認

更新後のPLDetailedData型(2025-12-26実装完了)

interface PLDetailedData {
  revenue: number               // 売上高
  costOfRevenue: number         // 売上原価(チーム人件費等)
  grossProfit: number           // 売上総利益
  sgaExpenses: number           // 販管費
  operatingIncome: number       // 営業利益
  nonOperatingIncome: number    // 営業外収益
  nonOperatingExpenses: number  // 営業外費用
  ordinaryIncome: number        // 経常利益 ← NEW
  extraordinaryIncome: number   // 特別利益 ← NEW
  extraordinaryLoss: number     // 特別損失 ← NEW
  preTaxIncome: number          // 税引前利益
  taxes: number                 // 法人税等
  netIncome: number             // 純利益
}

追加されたフィールド

  • ordinaryIncome: number // 経常利益
  • extraordinaryIncome: number // 特別利益
  • extraordinaryLoss: number // 特別損失

関連テストコード

既存テスト

ファイル内容
jleague-balance.test.tsBSの貸借一致テスト(資産 = 負債 + 純資産)
jleague-variance.test.ts営業利益増減ウォーターフォールの計算テスト
waterfall-integrity.test.ts一般企業用PLの整合性テスト(Jリーグ用ではない)

jleague-variance.test.ts の検証内容

営業利益増減チャートの計算ロジックを検証:

  • 前年営業利益 + 収益増減 - 費用増減 = 当期営業利益
  • 費用増加 → マイナス影響(赤バー)
  • 費用減少 → プラス影響(緑バー)

注意: このテストは「営業利益増減」のみを検証しており、P/L Waterfall全体の特別損益は検証していない。


実装タスク

  1. データソース確認: Jリーグ経営情報開示に特別損益が含まれている(j-league-data.jsonに既存)
  2. データ型更新: PLDetailedData に特別損益フィールドを追加 → types/financial.ts
  3. データ変換関数更新: convertToPLDetailed() で特別損益を含めるよう修正 → composables/jleague/useJLeagueData.ts
  4. チャートコンポーネント更新: JLeaguePLWaterfallSection.vue の描画ロジック変更
  5. テスト追加: tests/pl-detailed-data.test.ts でPLDetailedDataの整合性テストを実装

実装済み変更(2025-12-26)

types/financial.ts:

  • ordinaryIncome (経常利益) 追加
  • extraordinaryIncome (特別利益) 追加
  • extraordinaryLoss (特別損失) 追加

composables/jleague/useJLeagueData.ts:

  • convertToPLDetailed() 関数で上記フィールドを生データから変換

tests/pl-detailed-data.test.ts:

  • PLDetailedDataの整合性テスト
  • 特別損益の反映: 267/267 (100.0%) 成功
  • V・ファーレン長崎など特別利益が大きいケースでの検証

components/jleague/JLeaguePLWaterfallSection.vue:

  • バー構成を7本から6本に変更
  • 営業外等収益・営業外等費用の積み上げ棒を追加
  • 引き出し線による小セグメントのラベル表示を実装(詳細は下記)

積み上げ棒グラフの小セグメント対応(引き出し線)

各セグメントの高さが小さい場合に積み上げ棒グラフでラベルがセグメント内に収まらない問題がある。この問題を「引き出し線(Leader Line)」で解決した。

問題

┌─────────┐
│ 人件費  │ ← 高さ十分:ラベルが入る
│  1,382  │
├─────────┤
│試合関連 │ ← 高さ不足:ラベルが潰れる
├─────────┤
│ 運営費  │ ← 高さ不足
├─────────┤
│ その他  │ ← 高さ不足
├─────────┤
│ 販管費  │
│   817   │
└─────────┘

28px未満のセグメントの高さでは、ラベル(項目名+金額)が物理的に入らない。

解決策: 引き出し線

          ┌─────────┐
          │ 人件費  │
          │  1,382  │
          ├────●────┤
試合関連 137 ──┤         │
  運営費 365 ──●         │
  その他 207 ──┤         │
          ├────●────┤
          │ 販管費  │
          │   817   │
          └─────────┘

引き出し線で小さいセグメントはバーの外にラベルを表示する。

実装詳細

1. セグメントインターフェース

interface Segment {
  label: string
  value: number
  color: string
  y: number       // セグメントのY座標
  height: number  // セグメントの高さ
  labelY?: number // 引き出し線ラベルの調整済みY座標
}

2. 引き出し線の表示条件

// 高さ28px以上: セグメント内にラベル表示
// 高さ28px未満: 引き出し線でバー外にラベル表示
const needsLeaderLine = (seg: Segment) => seg.height < 28

3. 引き出し線の配置

        バー
    ┌─────────┐
    │         │
    │    ●────┼── ラベル
    │   3/4   │
    └─────────┘
         ↑
    黒丸の位置
  • 黒丸の位置: バー幅の3/4地点(中央より右寄り)
  • 線の終点: バー幅の1/2地点から少し左(bar.x + bar.width / 2 - 3
  • ラベル位置: 線の終点からさらに左(bar.x + bar.width / 2 - 6
// 引き出し線の描画
<line
  :x1="bar.x + bar.width * 3/4"  // 黒丸(右寄り)
  :y1="seg.y + seg.height / 2"
  :x2="bar.x + bar.width / 2 - 3" // 線の終点
  :y2="seg.labelY"
/>
<circle
  :cx="bar.x + bar.width * 3/4"
  :cy="seg.y + seg.height / 2"
  r="2.5"
  fill="#333"
/>
<text
  :x="bar.x + bar.width / 2 - 6"  // ラベル位置
  :y="seg.labelY"
  text-anchor="end"
>
  {{ seg.label }} {{ seg.value }}
</text>

4. ラベル重なり防止アルゴリズム

小セグメントが複数ある場合、ラベルが重なる問題を解決する。

方針: 下から上に向かって調整(上方向にスペースがあるため)

function adjustSegmentLabelPositions(segments: Segment[], minGap: number = 14): void {
  // 初期位置: 各セグメントの中央
  for (const seg of segments) {
    seg.labelY = seg.y + seg.height / 2
  }

  // 引き出し線が必要なセグメントのみ対象
  const leaderSegments = segments.filter(seg => seg.height < 28)
  if (leaderSegments.length <= 1) return

  // 下から上に向かって調整
  // 一番下のラベルは元の位置を維持、上のラベルを上方向にずらす
  for (let i = leaderSegments.length - 2; i >= 0; i--) {
    const curr = leaderSegments[i]
    const below = leaderSegments[i + 1]

    const gap = below.labelY - curr.labelY
    if (gap < minGap) {
      // 重なりを解消:現在のラベルを上にずらす
      curr.labelY = below.labelY - minGap
    }
  }
}

なぜ「下から上」なのか?

売上高バー(大きい)
┌─────────┐
│         │  ← この空間にラベルを展開できる
│         │
├─────────┤
│ seg1    │ ← 上に押し上げられる
├─────────┤
│ seg2    │ ← 上に押し上げられる
├─────────┤
│ seg3    │ ← 元の位置を維持(アンカー)
└─────────┘
    ↓
  ゼロライン ← これより下に出ると見切れる
  • 売上高の方が大きいため、上方向にスペースがある
  • 下方向に押し出すとチャート領域外に出てしまう
  • 一番下のセグメントを基準(アンカー)にして上に展開

5. バー種類別のラベル表示設定

バーラベル表示引き出し線
売上高表示左側
営業費用表示左側
営業利益/損失表示(バー上)なし
営業外等収益非表示左側
営業外等費用非表示左側
純利益/損失表示(バー上)なし

合計値を表示する意味がないため営業外等収益・営業外等費用はバー上のラベルを非表示とし、内訳のみ引き出し線で表示。

interface BarData {
  // ...
  hideLabel?: boolean  // trueの場合、バー上のラベル非表示
}

// 営業外等収益・費用では hideLabel: true を設定

設計上の考慮点

  1. 一貫性: 全ての引き出し線を左側から出す(統一感)
  2. 可読性: 黒丸を右寄りに配置し、線とラベルが売上高バーと被らないようにする
  3. スペース活用: 上方向にラベルを展開し、チャート領域内に収める
  4. 1項目の場合: セグメントが1つだけの場合は真横に表示(自然な配置)