[僕のlonicでスマホアプリ開発]#7 残高グラフ画面

# はじめに
前回の[[僕のlonicでスマホアプリ開発]#6 データインポート/エクスポート画面作成(https://qiita.com/SSKNOK/items/2e71535b154df4670a80)続きになります。
今回作成するスマホアプリは画面が以下の通りになる想定ですが、
- 残高確認画面
- 残高登録画面
- 口座登録画面
- データインポート/エクスポート画面

今回は`残高グラフ画面`を実装します。

「いや、そんな画面無いじゃん」って感じですが、隠し機能って感じですね。
前回開発した残高確認画面にくっついてる「グラフ」ボタンを押下したら遷移して画面を表示するイメージ。
<img width=300 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549748/49cc5049-4102-e7f4-364a-8c3d93464419.png>

# 残高画面開発
## 入力項目

入力項目は特に想定してないですが、パスパラメータで
- 銀行名
- 口座名義人名
- 検索日FROM
- 検索日TO
を受け取ります。

## 詰まったポイント

### パスパラメータ
詰まったポイントではないんですが、他の画面と違って画面遷移時のパラメータがあるのがちょい癖ポイント1つ目です。
何か他に良いパラメータの渡し方はなかったのか。
パラメータを渡すときは`vue-router`の`index.js`にパスパラメータの定義を行います。
```js:src/router/index.js
import { createRouter, createWebHistory } from '@ionic/vue-router';
import TabsPage from '../views/TabsPage.vue'

const routes = [
  {
    path: '/',
    redirect: '/tabs/view'
  },
  {
    path: '/tabs/',
    component: TabsPage,
    children: [
      {
        path: '',
        redirect: '/tabs/view'
      },
      {
        path: 'view',
        component: () => import('@/views/View.vue')
      },
      {
        path: 'balance/:balanceNo',
        component: () => import('@/views/Balance.vue')
      },
      {
        path: 'account',
        component: () => import('@/views/Account.vue')
      },
      {
        path: 'importExport',
        component: () => import('@/views/importExport.vue')
      },
      {
        path: 'graph/:bank/:accountHolder/:fromDate/:toDate', //←ココ
        component: () => import('@/views/Graph.vue')
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

export default router

```

### グラフ描画処理
`createGraph`で描画するデータをコネコネして、最後に`describeGraph`に渡すことでグラフを描画しています。
グラフの描画には`Chart.js`を使っています。
`Chart.js`はとりあえずグラフが描画できればそれでよかったので、あんま深く調べず、[コチラ](https://kouya17.com/posts/21/)をまんまコピって使いました。
```vue:src/views/Graph.vue
<script setup>
/* ----------------------------------
method:グラフ作成。
detail:グラフに必用なデータを取得し、グラフを描画する。
 ---------------------------------- */
const createGraph = function (bank, accountHolder, fromDate, toDate) {
  // 検索条件の組み立て
  // 銀行
  let searchBank = checkEmptyValue(bank)
    ? SEARCH_ALL
    : SEARCH_TEMPLATE.replaceAll("{keyWord}", bank);
  // 口座名義人
  let searchAccountHolder = checkEmptyValue(accountHolder)
    ? SEARCH_ALL
    : SEARCH_TEMPLATE.replaceAll("{keyWord}", accountHolder); // 口座名義人
  // 対象日（FROM）
  let searchFromDate = checkEmptyValue(fromDate) ? MINIMUM_FROM_DATE : fromDate;
  // 対象日（TO）
  let searchToDate = checkEmptyValue(toDate) ? MAXIMUM_TO_DATE : toDate;

  // 口座情報のリストを生成
  let accountList = [];
  storage.forEach((value, key) => {
    let infoJson = JSON.parse(value);
    // データタイプがaccountInfoなら口座情報として読み込む
    if (infoJson.dataType === DATA_TYPE_FOR_ACCOUNT) {
      accountList.push({
        key: key,
        bank: infoJson.bank,
        accountHolder: infoJson.accountHolder,
      });
    }
  });

  // 日付別に残高を合算
  const recordDataMap = new Map();
  storage
    .forEach((value, key) => {
      let infoJson = JSON.parse(value);

      // accountKeyから口座情報を取得して銀行と口座名義人を取得
      let accountInfoBank = "";
      let accountInfoAccountHolder = "";
      accountList.forEach((account) => {
        if (account.key === infoJson.accountKey) {
          accountInfoBank = account.bank;
          accountInfoAccountHolder = account.accountHolder;
          return true;
        }
      });

      // データタイプがbalanceInfoなら残高情報として読み込む
      if (infoJson.dataType === DATA_TYPE_FOR_BALANCE) {
        let balanceData = {
          key: key,
          accountInfoBank: accountInfoBank,
          no: infoJson.no,
          recordDate: infoJson.recordDate,
          balance: infoJson.balance,
          bank: accountInfoBank,
          accountHolder: accountInfoAccountHolder,
        };
        // 表示対象の場合は日付別に合算する。
        if (
          checkBalanceInfoMatchCondition(
            balanceData,
            searchBank,
            searchAccountHolder,
            searchFromDate,
            searchToDate
          )
        ) {
          if (recordDataMap.has(balanceData.recordDate)) {
            let newBalance =
              Number(infoJson.balance) +
              Number(recordDataMap.get(balanceData.recordDate));
            recordDataMap.set(balanceData.recordDate, newBalance);
          } else {
            recordDataMap.set(balanceData.recordDate, infoJson.balance);
          }
        }
      }
    })
    .then(() => {
      let graphData = []; // 描画用のデータ
      recordDataMap.forEach((value, key) => {
        graphData.push({ x: key, y: value });
      });

      //データを時系列順に並べ替え
      graphData.sort(function (first, second) {
        const firstRecordDate = new Date(first.x);
        const secondRecordDate = new Date(second.x);
        if (firstRecordDate < secondRecordDate) {
          return -1;
        } else if (firstRecordDate > secondRecordDate) {
          return 1;
        } else {
          return 0;
        }
      });

      describeGraph(graphData);
    });
};

/* ----------------------------------
method:グラフ描画
detail:受け取った値のリストに対するグラフを描画する。
 ---------------------------------- */
const describeGraph = function (data) {
  // test
  new Chart("chart", {
    type: "line",
    data: {
      datasets: [
        {
          label: "BALANCE GRAPH",
          backgroundColor: "rgb(255, 99, 132)",
          borderColor: "rgb(255, 99, 132)",
          fill: false,
          data: data,
        },
      ],
    },
    options: {
      aspectRatio: 0.6,
      scales: {
        xAxes: [
          {
            type: "time",
            time: {
              unit: "day",
            },
          },
        ],
      },
    },
  });
};
</script>
```

<img width=300 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549748/66e4c86c-a5ee-d2fa-7680-228adc1bf8c9.png>

## ソース全量

<details><summary>Account.vue全量</summary>

```vue:src/views/Graph.vue
<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>GRAPH</ion-title>
      </ion-toolbar>
    </ion-header>
    <ion-content :fullscreen="true">
      <ion-header collapse="condense">
        <ion-toolbar>
          <ion-title size="large">GRAPH</ion-title>
        </ion-toolbar>
      </ion-header>
      <canvas id="chart"></canvas>
    </ion-content>
  </ion-page>
</template>

<script setup>
import {
  IonPage,
  IonHeader,
  IonToolbar,
  IonTitle,
  IonContent,
  IonGrid,
  IonRow,
  IonCol,
  IonIcon,
  IonButton,
  IonItem,
  IonTextarea,
  onIonViewWillEnter,
  alertController,
} from "@ionic/vue";
import { arrowDownOutline, arrowUpOutline } from "ionicons/icons";
import ExploreContainer from "@/components/ExploreContainer.vue";
import { useRoute } from "vue-router";
import { ref, onMounted } from "vue";
import { Storage } from "@ionic/storage";
import { Chart, registerables } from "chart.js";

/* ******** プロパティ ******** */
let storage; // ストレージオブジェクト

const DATA_TYPE_FOR_ACCOUNT = "accountInfo"; // ストレージのデータタイプ（口座情報）
const KEY_PREFIX_FOR_ACCOUNT = "accountInfo_"; // ストレージのキーの接頭辞（口座情報）
const DATA_TYPE_FOR_BALANCE = "balanceInfo"; // ストレージのデータタイプ（残高情報）
const KEY_PREFIX_FOR_BALANCE = "balanceInfo_"; // ストレージのキーの接頭辞（残高情報）
const SEARCH_TEMPLATE = ".*{keyWord}.*";
const SEARCH_ALL = ".*";
const MINIMUM_FROM_DATE = "1900-01-01";
const MAXIMUM_TO_DATE = "9999-12-31";
const TIME_DEFAULT = "T00:00:00";

/* ******** router準備 ******** */
const route = useRoute();

/* ******** ライフサイクルフック ******** */
onIonViewWillEnter(async function () {
  // ストレージオブジェクト生成
  const store = new Storage();
  storage = await store.create();

  const bank = String(route.params["bank"]);
  const accountHolder = String(route.params["accountHolder"]);
  const fromDate = String(route.params["fromDate"]);
  const toDate = String(route.params["toDate"]);

  searchBalance(bank, accountHolder, fromDate, toDate);
});

/* ******** メソッド ******** */
/* ----------------------------------
method:残高情報選択
detail:検索条件にあった残高情報を取得する
 ---------------------------------- */
const searchBalance = function (bank, accountHolder, fromDate, toDate) {
  createGraph(bank, accountHolder, fromDate, toDate);
};

/* ----------------------------------
method:グラフ作成。
detail:グラフに必用なデータを取得し、グラフを描画する。
 ---------------------------------- */
const createGraph = function (bank, accountHolder, fromDate, toDate) {
  // 検索条件の組み立て
  // 銀行
  let searchBank = checkEmptyValue(bank)
    ? SEARCH_ALL
    : SEARCH_TEMPLATE.replaceAll("{keyWord}", bank);
  // 口座名義人
  let searchAccountHolder = checkEmptyValue(accountHolder)
    ? SEARCH_ALL
    : SEARCH_TEMPLATE.replaceAll("{keyWord}", accountHolder); // 口座名義人
  // 対象日（FROM）
  let searchFromDate = checkEmptyValue(fromDate) ? MINIMUM_FROM_DATE : fromDate;
  // 対象日（TO）
  let searchToDate = checkEmptyValue(toDate) ? MAXIMUM_TO_DATE : toDate;

  // 口座情報のリストを生成
  let accountList = [];
  storage.forEach((value, key) => {
    let infoJson = JSON.parse(value);
    // データタイプがaccountInfoなら口座情報として読み込む
    if (infoJson.dataType === DATA_TYPE_FOR_ACCOUNT) {
      accountList.push({
        key: key,
        bank: infoJson.bank,
        accountHolder: infoJson.accountHolder,
      });
    }
  });

  // 日付別に残高を合算
  const recordDataMap = new Map();
  storage
    .forEach((value, key) => {
      let infoJson = JSON.parse(value);

      // accountKeyから口座情報を取得して銀行と口座名義人を取得
      let accountInfoBank = "";
      let accountInfoAccountHolder = "";
      accountList.forEach((account) => {
        if (account.key === infoJson.accountKey) {
          accountInfoBank = account.bank;
          accountInfoAccountHolder = account.accountHolder;
          return true;
        }
      });

      // データタイプがbalanceInfoなら残高情報として読み込む
      if (infoJson.dataType === DATA_TYPE_FOR_BALANCE) {
        let balanceData = {
          key: key,
          accountInfoBank: accountInfoBank,
          no: infoJson.no,
          recordDate: infoJson.recordDate,
          balance: infoJson.balance,
          bank: accountInfoBank,
          accountHolder: accountInfoAccountHolder,
        };
        // 表示対象の場合は日付別に合算する。
        if (
          checkBalanceInfoMatchCondition(
            balanceData,
            searchBank,
            searchAccountHolder,
            searchFromDate,
            searchToDate
          )
        ) {
          if (recordDataMap.has(balanceData.recordDate)) {
            let newBalance =
              Number(infoJson.balance) +
              Number(recordDataMap.get(balanceData.recordDate));
            recordDataMap.set(balanceData.recordDate, newBalance);
          } else {
            recordDataMap.set(balanceData.recordDate, infoJson.balance);
          }
        }
      }
    })
    .then(() => {
      let graphData = []; // 描画用のデータ
      recordDataMap.forEach((value, key) => {
        graphData.push({ x: key, y: value });
      });

      //データを時系列順に並べ替え
      graphData.sort(function (first, second) {
        const firstRecordDate = new Date(first.x);
        const secondRecordDate = new Date(second.x);
        if (firstRecordDate < secondRecordDate) {
          return -1;
        } else if (firstRecordDate > secondRecordDate) {
          return 1;
        } else {
          return 0;
        }
      });

      describeGraph(graphData);
    });
};

/* ----------------------------------
method:残高情報条件チェック
detail:残高情報が検索条件に合致する場合はTrueを返却する
 ---------------------------------- */
const checkBalanceInfoMatchCondition = function (
  balanceData,
  searchBank,
  searchAccountHolder,
  searchFromDate,
  searchToDate
) {
  // 銀行チェック
  const searchBankRegExp = new RegExp(searchBank);
  if (!searchBankRegExp.test(balanceData.bank)) {
    return false;
  }
  // 口座名義人チェック
  const searchAccountHolderRegExp = new RegExp(searchAccountHolder);
  if (!searchAccountHolderRegExp.test(balanceData.accountHolder)) {
    return false;
  }
  // 対象日（FROM）
  if (new Date(balanceData.recordDate) < new Date(searchFromDate)) {
    return false;
  }
  // 対象日（TO）
  if (new Date(balanceData.recordDate) > new Date(searchToDate)) {
    return false;
  }
  return true;
};

/* ----------------------------------
method:空文字・NULL・undefined判定
detail:空文字・NULL・undefinedの場合、trueを返す。
 ---------------------------------- */
const checkEmptyValue = function (targetValue) {
  if (targetValue === null || targetValue === "" || targetValue === undefined ||  targetValue === "undefined") {
    return true;
  }
  return false;
};

/* ----------------------------------
method:グラフ描画
detail:受け取った値のリストに対するグラフを描画する。
 ---------------------------------- */
const describeGraph = function (data) {
  // test
  new Chart("chart", {
    type: "line",
    data: {
      datasets: [
        {
          label: "BALANCE GRAPH",
          backgroundColor: "rgb(255, 99, 132)",
          borderColor: "rgb(255, 99, 132)",
          fill: false,
          data: data,
        },
      ],
    },
    options: {
      aspectRatio: 0.6,
      scales: {
        xAxes: [
          {
            type: "time",
            time: {
              unit: "day",
            },
          },
        ],
      },
    },
  });
};
</script>

<style scoped>
</style>
```
</details>