Chart.jsで損益分岐点計算アプリを開発してみた

# はじめに
10年以上前にPHPにて作成したミニアプリをJSで作り直しました。

[デモ](http://break-even-point.wealthy-design.com/)

画面：
![FireShot Capture 014 - 損益分岐点計算 - bep.wealthy-design.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/4fdca53f-1b0e-1e84-97ea-c0e8ca177fed.png)


# コード

```html:bep.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>損益分岐点計算</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
    <style>
        /* スタイリングをモダンなデザインに調整 */
        body {
            font-family: 'Helvetica Neue', Arial, 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
            max-width: 1200px;
            margin: 0 auto;
            padding: 2rem;
            background: #f8f9fa;
            color: #333;
        }

        /* 見出しのデザイン */
        .header {
            text-align: center;
            margin-bottom: 3rem;
        }

        h1 {
            color: #2c3e50;
            font-size: 2.5rem;
            font-weight: 700;
            margin-bottom: 1rem;
        }

        /* 計算部分のデザイン */
        .calculator-container {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 2rem;
            background: white;
            padding: 2rem;
            border-radius: 12px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }

        /* 入力セクションのデザイン */
        .input-section {
            padding: 1.5rem;
            background: #f8f9fa;
            border-radius: 8px;
        }

        /* 入力フィールドのデザイン */
        .input-group {
            margin-bottom: 1.5rem;
            display: flex;
            align-items: center;
        }

        .input-group label {
            flex: 1;
            margin-bottom: 0;
            color: #2c3e50;
            font-weight: 600;
        }

        .input-group input {
            width: 50%;
            padding: 0.75rem;
            border: 2px solid #e9ecef;
            border-radius: 6px;
            font-size: 1rem;
            transition: border-color 0.3s ease;
        }

        .input-group input:focus {
            outline: none;
            border-color: #4a90e2;
        }

        /* 結果表示セクションのデザイン */
        .results {
            background: #fff;
            padding: 1.5rem;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
        }

        .result-item {
            display: flex;
            justify-content: space-between;
            margin-bottom: 1rem;
            padding: 0.75rem;
            background: #f8f9fa;
            border-radius: 6px;
        }

        .result-label {
            color: #2c3e50;
            font-weight: 600;
        }

        .result-value {
            color: #4a90e2;
            font-weight: 700;
        }

        /* グラフセクションのデザイン */
        .chart-container {
            margin-top: 2rem;
            padding: 1.5rem;
            background: white;
            border-radius: 12px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            height: 800px;
        }
    </style>
</head>
<body>
    <div class="header">
        <h1>損益分岐点計算</h1>
    </div>

    <div class="calculator-container">
        <div class="input-section">
            <div class="input-group">
                <label for="sales">売上高 (円)</label>
                <input type="text" id="sales" oninput="validateNumber(this)" placeholder="0">
            </div>
            <div class="input-group">
                <label for="fixedCost">固定費 (円)</label>
                <input type="text" id="fixedCost" oninput="validateNumber(this)" placeholder="0">
            </div>
            <div class="input-group">
                <label for="variableCost">変動費 (円)</label>
                <input type="text" id="variableCost" oninput="validateNumber(this)" placeholder="0">
            </div>
        </div>

        <div class="results">
            <!-- 各種結果を表示するエリア -->
            <div class="result-item">
                <span class="result-label">変動費率</span>
                <span class="result-value" id="variableCostRate">-</span>
            </div>
            <div class="result-item">
                <span class="result-label">損益分岐点</span>
                <span class="result-value" id="breakEvenPoint">-</span>
            </div>
            <div class="result-item">
                <span class="result-label">損益分岐点比率</span>
                <span class="result-value" id="breakEvenPointRatio">-</span>
            </div>
            <div class="result-item">
                <span class="result-label">限界利益</span>
                <span class="result-value" id="marginalProfit">-</span>
            </div>
            <div class="result-item">
                <span class="result-label">限界利益率</span>
                <span class="result-value" id="marginalProfitRatio">-</span>
            </div>
        </div>
    </div>

    <div class="chart-container">
        <canvas id="chart"></canvas>
    </div>

    <script>
        let chart = null;

        // 数値をフォーマットする関数
        function formatNumber(num) {
            return new Intl.NumberFormat('ja-JP').format(Math.round(num));
        }

        // パーセント表示用のフォーマット関数
        function formatPercent(num) {
            return new Intl.NumberFormat('ja-JP', {
                style: 'percent',
                minimumFractionDigits: 1,
                maximumFractionDigits: 1
            }).format(num);
        }

        // 入力が数字かどうかをチェックし、数字以外を受け付けない関数
        function validateNumber(input) {
            // 数字のみを受け付ける正規表現
            input.value = input.value.replace(/[^0-9]/g, '');
        }

        // 計算処理と画面更新を行う関数
        function calculateAndUpdate() {
            const sales = Number(document.getElementById('sales').value) || 0;
            const fixedCost = Number(document.getElementById('fixedCost').value) || 0;
            const variableCost = Number(document.getElementById('variableCost').value) || 0;

            // 売上が0の場合は処理を終了
            if (sales === 0) return;

            // 変動費率や損益分岐点の計算
            const variableCostRate = variableCost / sales;
            const marginalProfitRate = 1 - variableCostRate;
            const breakEvenPoint = fixedCost / marginalProfitRate;
            const breakEvenPointRatio = breakEvenPoint / sales;
            const marginalProfit = sales - variableCost;
            const marginalProfitRatio = marginalProfit / sales;

            // 結果の表示を更新
            document.getElementById('variableCostRate').textContent = formatPercent(variableCostRate);
            document.getElementById('breakEvenPoint').textContent = formatNumber(breakEvenPoint) + '円';
            document.getElementById('breakEvenPointRatio').textContent = formatPercent(breakEvenPointRatio);
            document.getElementById('marginalProfit').textContent = formatNumber(marginalProfit) + '円';
            document.getElementById('marginalProfitRatio').textContent = formatPercent(marginalProfitRatio);

            // グラフの更新
            updateChart(sales, fixedCost, variableCostRate);
        }

        // グラフの更新処理
        function updateChart(sales, fixedCost, variableCostRate) {
            const ctx = document.getElementById('chart').getContext('2d');

            const points = 10;  // グラフの分割点数
            const step = sales / points;
            const labels = [];
            const fixedCostData = [];
            const variableCostData = [];
            const totalCostData = [];
            const salesLineData = [];

            for (let i = 0; i <= points; i++) {
                const x = step * i;
                labels.push(formatNumber(x));
                fixedCostData.push(fixedCost);
                variableCostData.push(x * variableCostRate);
                totalCostData.push(fixedCost + (x * variableCostRate));
                salesLineData.push(x);
            }

            // 既存のチャートがある場合は破棄
            if (chart) {
                chart.destroy();
            }

            // 新しいチャートを作成
            chart = new Chart(ctx, {
                type: 'line',
                data: {
                    labels: labels,
                    datasets: [
                        {
                            label: '売上高',
                            data: salesLineData,
                            borderColor: '#4a90e2',
                            backgroundColor: 'rgba(74, 144, 226, 0.1)',
                            borderWidth: 2,
                            fill: false,
                            tension: 0.1
                        },
                        {
                            label: '総費用',
                            data: totalCostData,
                            borderColor: '#e74c3c',
                            backgroundColor: 'rgba(231, 76, 60, 0.1)',
                            borderWidth: 2,
                            fill: false,
                            tension: 0.1
                        },
                        {
                            label: '固定費',
                            data: fixedCostData,
                            borderColor: '#2ecc71',
                            backgroundColor: 'rgba(46, 204, 113, 0.1)',
                            borderWidth: 2,
                            fill: false,
                            tension: 0.1
                        }
                    ]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    interaction: {
                        intersect: false,
                        mode: 'index'
                    },
                    plugins: {
                        legend: {
                            position: 'top',
                            labels: {
                                usePointStyle: true,
                                padding: 20
                            }
                        },
                        title: {
                            display: true,
                            text: '損益分岐点グラフ',
                            padding: {
                                top: 10,
                                bottom: 30
                            },
                            font: {
                                size: 16
                            }
                        },
                        tooltip: {
                            position: 'nearest'
                        }
                    },
                    scales: {
                        x: {
                            title: {
                                display: true,
                                text: '売上高 (円)',
                                padding: {
                                    top: 10
                                }
                            },
                            grid: {
                                display: true,
                                drawBorder: true,
                                drawOnChartArea: true
                            }
                        },
                        y: {
                            title: {
                                display: true,
                                text: '金額 (円)',
                                padding: {
                                    bottom: 10
                                }
                            },
                            grid: {
                                display: true,
                                drawBorder: true,
                                drawOnChartArea: true
                            }
                        }
                    }
                }
            });
        }

        // イベントリスナーの設定
        document.getElementById('sales').addEventListener('input', calculateAndUpdate);
        document.getElementById('fixedCost').addEventListener('input', calculateAndUpdate);
        document.getElementById('variableCost').addEventListener('input', calculateAndUpdate);
    </script>
</body>
</html>

```