[PR] 本記事にはプロモーションが含まれています。

【開発記】React + TypeScriptで血液検査記録WEBアプリを作る(第6回:グラフ描画と表記揺れの吸収)

React

前回(第5回)は、抽出したデータをテーブルで表示し、画面上で編集・削除できるUIを作成した。 今回はついに本アプリのメイン機能である「時系列グラフの描画」に着手する。

しかし、グラフを描画する前に解決しなければならない厄介な問題がある。
それが「表記揺れ(データの不統一)」だ。 例えば、医療機関や受診した年によって「AST」が「GOT」と表記されていたり、「HbA1c」が「HbA1c(NGSP)」のように全角文字で入力されていたりする。これらをそのままグラフにすると、「別の検査項目」として分断されてしまう。

そこで今回は、グラフ描画ライブラリ Recharts を導入するとともに、データを抽出する段階で日本語特有の表記揺れを完全に吸収する強力な「正規化処理」を実装していく。

1. 表記揺れを吸収する「正規化関数」の作成

全角・半角の混在や、不要なスペースを根絶するために、JavaScriptの標準機能である normalize('NFKC') を活用する。これにより、全角英数字を強制的に半角へ変換し、大文字に統一して空白を取り除くことができる。

ファイルの上部に、以下の normalizeItemName 関数を追加しよう。

TypeScript
// 項目名の表記揺れを吸収し、統一名に変換する関数
  const normalizeItemName = (rawName: string): string => {
    // 1. 全角英数字を半角に統一(NFKC)し、大文字に変換し、スペースもすべて削除する
    const searchName = rawName.normalize('NFKC').toUpperCase().replace(/\s+/g, '');

    // 2. 統一された文字列に対して判定を行う
    if (searchName.includes('AST') || searchName.includes('GOT')) return 'AST';
    if (searchName.includes('ALT') || searchName.includes('GPT')) return 'ALT';
    // γ(ガンマ)は大文字化すると Γ になるため、両方のパターンを網羅
    if (searchName.includes('Γ-GT') || searchName.includes('Y-GT') || rawName.includes('γ-GT') || rawName.includes('γ-GTP')) return 'γ-GTP';
    if (searchName.includes('血糖')) return '血糖値';
    if (searchName.includes('PT 活性')) return 'PT活性';
    if (searchName.includes('HBA1C') || searchName.includes('ヘモグロビンA1C')) return 'HbA1c';

    return rawName.trim();
  };

そして、第4回で作った抽出処理の中でこの関数を呼び出し、綺麗な名前に変換してからStateに保存するように変更する。

TypeScript
// 抽出時に normalizeItemName を通す
        itemName: normalizeItemName(String(row[nameIndex])),

2. グラフ描画用ライブラリ「Recharts」の準備

画面上部にグラフを表示するため、ファイルの一番上で Recharts の部品と、状態管理のための useMemo などをインポートする。

TypeScript
import React, { useState, useMemo } from 'react';
import * as XLSX from 'xlsx';
// ★追加:グラフ描画用のコンポーネントをインポート
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';

3. グラフ表示用のStateとデータ整形ロジックの追加

「今どの検査項目をグラフに表示しているか」を管理するStateと、取り込んだ全データ(uploadedFiles)の中から、その選択された項目の数値だけを時系列順に抽出するロジック(graphData)を作成する。

TypeScript
// ★追加:現在グラフで表示している項目名
  const [selectedItem, setSelectedItem] = useState<string>('AST');

  // ★追加:グラフに渡すためのデータ配列を自動生成する
  const graphData = useMemo(() => {
    // 日付順に並び替えるために配列をソート
    const sortedFiles = [...uploadedFiles].sort((a, b) => a.fileName.localeCompare(b.fileName));
    
    return sortedFiles.map(file => {
      // 選択中の項目(例:'AST')に一致するレコードを探す
      const targetRecord = file.records.find(r => r.itemName === selectedItem);
      return {
        date: file.fileName, // X軸(日付)
        value: targetRecord ? Number(targetRecord.value) : null // Y軸(数値)
      };
    });
  }, [uploadedFiles, selectedItem]);

4. グラフのUI(画面)の実装

画面のUIに、検査項目を切り替えるためのドロップダウンリスト(<select>)と、Recharts を使ったグラフ領域を追加する。ResponsiveContainer を使うことで、画面幅に合わせてグラフが綺麗に伸縮してくれる。

TypeScript
{/* ★追加:グラフ表示エリア */}
      {uploadedFiles.length > 0 && (
        <div style={{ marginBottom: '40px', padding: '20px', backgroundColor: '#fff', borderRadius: '8px', border: '1px solid #ddd' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
            <h2 style={{ margin: 0 }}>推移グラフ</h2>
            <select 
              value={selectedItem} 
              onChange={(e) => setSelectedItem(e.target.value)}
              style={{ padding: '8px', fontSize: '16px', borderRadius: '4px' }}
            >
              <option value="AST">AST (GOT)</option>
              <option value="ALT">ALT (GPT)</option>
              <option value="γ-GTP">γ-GTP</option>
              <option value="HbA1c">HbA1c</option>
              <option value="血糖値">血糖値</option>
            </select>
          </div>
          
          <div style={{ height: '300px', width: '100%' }}>
            <ResponsiveContainer width="100%" height="100%">
              <LineChart data={graphData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
                <CartesianGrid strokeDasharray="3 3" />
                <XAxis dataKey="date" />
                <YAxis />
                <Tooltip />
                <Legend />
                <Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={3} activeDot={{ r: 8 }} name={selectedItem} />
              </LineChart>
            </ResponsiveContainer>
          </div>
        </div>
      )}

5. 完成したコード全体(App.tsx)

ここまでの改修をすべて反映した、第6回時点での App.tsx の全体コードがこちらだ。 複雑なデータ操作とグラフ描画がひとつのファイルに集約されている。

TypeScript
// src/App.tsx
import React, { useState, useMemo } from 'react';
import * as XLSX from 'xlsx';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';

export interface BloodTestRecord {
  itemName: string;
  value: number | string;
  unit: string;
}

export interface UploadedFile {
  fileName: string;
  records: BloodTestRecord[];
}

export default function App() {
  const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
  const [selectedItem, setSelectedItem] = useState<string>('AST');

  const normalizeItemName = (rawName: string): string => {
    const searchName = rawName.normalize('NFKC').toUpperCase().replace(/\s+/g, '');
    if (searchName.includes('AST') || searchName.includes('GOT')) return 'AST';
    if (searchName.includes('ALT') || searchName.includes('GPT')) return 'ALT';
    if (searchName.includes('Γ-GT') || searchName.includes('Y-GT') || rawName.includes('γ-GT') || rawName.includes('γ-GTP')) return 'γ-GTP';
    if (searchName.includes('血糖')) return '血糖値';
    if (searchName.includes('PT 活性')) return 'PT活性';
    if (searchName.includes('HBA1C') || searchName.includes('ヘモグロビンA1C')) return 'HbA1c';
    return rawName.trim();
  };

  const extractBloodTestData = (rawData: any[][]): BloodTestRecord[] => {
    const headerRowIndex = rawData.findIndex(row => row.includes("検査項目") || row.includes("項目名"));
    if (headerRowIndex === -1) return [];

    const headerRow = rawData[headerRowIndex];
    const nameIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && (cell.includes("検査項目") || cell.includes("項目名")));
    const valueIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && cell.includes("結果"));
    const unitIndex = headerRow.findIndex((cell: any) => typeof cell === 'string' && cell.includes("単位"));

    const records: BloodTestRecord[] = [];
    for (let i = headerRowIndex + 1; i < rawData.length; i++) {
      const row = rawData[i];
      if (!row || !row[nameIndex]) continue;

      records.push({
        itemName: normalizeItemName(String(row[nameIndex])),
        value: row[valueIndex] !== undefined ? row[valueIndex] : '',
        unit: row[unitIndex] || '',
      });
    }
    return records;
  };

  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;

    Array.from(files).forEach(file => {
      const reader = new FileReader();
      reader.onload = (event) => {
        const bstr = event.target?.result;
        const workbook = XLSX.read(bstr, { type: 'binary' });
        const worksheet = workbook.Sheets[workbook.SheetNames[0]];
        const rawData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1 });
        
        const formattedRecords = extractBloodTestData(rawData);
        const dateLabel = file.name.replace('.xlsx', '').replace('.xls', '');
        
        setUploadedFiles(prev => [...prev, { fileName: dateLabel, records: formattedRecords }]);
      };
      reader.readAsBinaryString(file);
    });
    
    e.target.value = '';
  };

  const handleEditValue = (fileIndex: number, recordIndex: number, newValue: string) => {
    const newFiles = [...uploadedFiles];
    newFiles[fileIndex].records[recordIndex].value = newValue;
    setUploadedFiles(newFiles);
  };

  const handleDeleteRecord = (fileIndex: number, recordIndex: number) => {
    const newFiles = [...uploadedFiles];
    newFiles[fileIndex].records.splice(recordIndex, 1);
    setUploadedFiles(newFiles);
  };

  // グラフ用のデータ生成
  const graphData = useMemo(() => {
    const sortedFiles = [...uploadedFiles].sort((a, b) => a.fileName.localeCompare(b.fileName));
    return sortedFiles.map(file => {
      const targetRecord = file.records.find(r => r.itemName === selectedItem);
      return {
        date: file.fileName,
        value: targetRecord ? Number(targetRecord.value) : null
      };
    });
  }, [uploadedFiles, selectedItem]);

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '900px', margin: '0 auto', backgroundColor: '#f5f7fa', minHeight: '100vh' }}>
      <h1 style={{ textAlign: 'center', color: '#333' }}>血液検査記録ダッシュボード</h1>
      
      {/* グラフエリア */}
      {uploadedFiles.length > 0 && (
        <div style={{ marginBottom: '40px', padding: '20px', backgroundColor: '#fff', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
            <h2 style={{ margin: 0 }}>推移グラフ</h2>
            <select 
              value={selectedItem} 
              onChange={(e) => setSelectedItem(e.target.value)}
              style={{ padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
            >
              <option value="AST">AST (GOT)</option>
              <option value="ALT">ALT (GPT)</option>
              <option value="γ-GTP">γ-GTP</option>
              <option value="HbA1c">HbA1c</option>
              <option value="血糖値">血糖値</option>
            </select>
          </div>
          <div style={{ height: '300px', width: '100%' }}>
            <ResponsiveContainer width="100%" height="100%">
              <LineChart data={graphData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
                <CartesianGrid strokeDasharray="3 3" vertical={false} />
                <XAxis dataKey="date" />
                <YAxis />
                <Tooltip />
                <Legend />
                <Line type="monotone" dataKey="value" stroke="#4CAF50" strokeWidth={3} activeDot={{ r: 8 }} name={selectedItem} />
              </LineChart>
            </ResponsiveContainer>
          </div>
        </div>
      )}

      {/* ファイルアップロードエリア */}
      <div style={{ marginBottom: '30px', padding: '20px', backgroundColor: '#fff', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
        <p style={{ marginTop: 0, fontWeight: 'bold' }}>Excelファイルを追加読み込み:</p>
        <input type="file" accept=".xlsx, .xls" multiple onChange={handleFileUpload} />
      </div>

      {/* データ管理テーブルエリア */}
      {uploadedFiles.map((fileInfo, fileIndex) => (
        <div key={fileIndex} style={{ marginBottom: '20px', border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden', backgroundColor: '#fff' }}>
          <div style={{ padding: '15px 20px', backgroundColor: '#e3f2fd', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
            <span style={{ fontSize: '18px', fontWeight: 'bold' }}>📅 {fileInfo.fileName}</span>
            <span style={{ color: '#555', fontWeight: 'bold' }}>{fileInfo.records.length} 項目収録</span>
          </div>
          <div style={{ padding: '15px' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
              <thead>
                <tr style={{ borderBottom: '2px solid #ccc' }}>
                  <th style={{ padding: '10px' }}>検査項目</th>
                  <th style={{ padding: '10px' }}>結果 (数値)</th>
                  <th style={{ padding: '10px' }}>単位</th>
                  <th style={{ padding: '10px' }}>操作</th>
                </tr>
              </thead>
              <tbody>
                {fileInfo.records.map((record, recordIndex) => (
                  <tr key={recordIndex} style={{ borderBottom: '1px solid #eee' }}>
                    <td style={{ padding: '10px' }}>{record.itemName}</td>
                    <td style={{ padding: '10px' }}>
                      <input 
                        type="text" 
                        value={record.value} 
                        onChange={(e) => handleEditValue(fileIndex, recordIndex, e.target.value)}
                        style={{ padding: '5px', width: '80px', border: '1px solid #ccc', borderRadius: '4px' }}
                      />
                    </td>
                    <td style={{ padding: '10px' }}>{record.unit}</td>
                    <td style={{ padding: '10px' }}>
                      <button 
                        onClick={() => handleDeleteRecord(fileIndex, recordIndex)}
                        style={{ padding: '5px 10px', backgroundColor: '#ff4d4f', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
                      >
                        削除
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      ))}
    </div>
  );
}

これで、フロントエンド(React)単体で動くプロトタイプとしては完璧な動作をするようになった。複数のExcelを放り込めば、表記揺れを吸収しながら美しいグラフが描画される。

しかし、このままではブラウザをリロードするとせっかく読み込んだデータがすべて消えてしまう。 次回(第7回)からは、ついにバックエンド編へ突入し、データを恒久保存するためのデータベース(SQLite)の構築を進めていく。。

コメント