Chapter 41無料公開

表によるデータの可視化 (DataTables): v-datatable

超Lチカ団編集局
超Lチカ団編集局
2024.01.14に更新

表によるデータの可視化 (DataTables): v-datatable

v-table よりリッチな、表形式でデータの可視化ができるコンポーネントです。

基本

データをテーブル形式で表示します。表示する行の数を指定すると、データ数が指定した数より多い時は、自動的にページ送り機能がつきます。

image.png

<template>
  <v-container>
    <p class="text-h3">DataTable</p>
    <v-data-table
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    ></v-data-table>
  </v-container>
</template>

<script>
export default {
  data () {
    return {

      itemsPerPage: 5,
      pages: [
        {value: 5, title: '5'},
        {value: 10, title: '10'},
        {value: 20, title: '20'},
        {value: -1, title: '$vuetify.dataFooter.itemsPerPageAll'}
      ],
      headers: [
        {
          title: '番号',
          align: 'end',
          sortable: false,
          key: 'no',
        },
        { title: '都道府県名', align: 'start', key: 'pref_jp' },
        { title: '英語名', align: 'start', key: 'pref_en' },
        { title: '面積(km2)', align: 'end', key: 'area' },
        { title: '人口(千人)', align: 'end', key: 'population' },
      ],
      pref: [
        {no: 1,pref_jp: "北海道",pref_en: "Hokkaido",area: 83457,population: 5400},
        {no: 2,pref_jp: "青森",pref_en: "Aomori",area: 9645,population: 1321},
        {no: 3,pref_jp: "岩手",pref_en: "Iwate",area: 15279,population: 1284},
        {no: 4,pref_jp: "宮城",pref_en: "Miyagi",area: 6862,population: 2328},
        {no: 5,pref_jp: "秋田",pref_en: "Akita",area: 11636,population: 1037},
        {no: 6,pref_jp: "山形",pref_en: "Yamagata",area: 6652,population: 1131},
        {no: 7,pref_jp: "福島",pref_en: "Fukushima",area: 13783,population: 1935},
        {no: 8,pref_jp: "茨城",pref_en: "Ibaraki",area: 6096,population: 2919},
        {no: 9,pref_jp: "栃木",pref_en: "Tochigi",area: 6408,population: 1980},
        {no: 10,pref_jp: "群馬",pref_en: "Gumma",area: 6362,population: 1976},
        {no: 11,pref_jp: "埼玉",pref_en: "Saitama",area: 3768,population: 7239},
        {no: 12,pref_jp: "千葉",pref_en: "Chiba",area: 5082,population: 6197},
        {no: 13,pref_jp: "東京",pref_en: "Tokyo",area: 2104,population: 13390},
        {no: 14,pref_jp: "神奈川",pref_en: "Kanagawa",area: 2416,population: 9096},
        {no: 15,pref_jp: "新潟",pref_en: "Niigata",area: 10364,population: 2313},
        {no: 16,pref_jp: "富山",pref_en: "Toyama",area: 2046,population: 1070},
        {no: 17,pref_jp: "石川",pref_en: "Ishikawa",area: 4186,population: 1156},
        {no: 18,pref_jp: "福井",pref_en: "Fukui",area: 4190,population: 790},
        {no: 19,pref_jp: "山梨",pref_en: "Yamanashi",area: 4201,population: 841},
        {no: 20,pref_jp: "長野",pref_en: "Nagano",area: 13105,population: 2109},
        {no: 21,pref_jp: "岐阜",pref_en: "Gifu",area: 9768,population: 2041},
        {no: 22,pref_jp: "静岡",pref_en: "Shizuoka",area: 7255,population: 3705},
        {no: 23,pref_jp: "愛知",pref_en: "Aichi",area: 5116,population: 7455},
        {no: 24,pref_jp: "三重",pref_en: "Mie",area: 5762,population: 1825},
        {no: 25,pref_jp: "滋賀",pref_en: "Shiga",area: 3767,population: 1416},
        {no: 26,pref_jp: "京都",pref_en: "Kyoto",area: 4613,population: 2610},
        {no: 27,pref_jp: "大阪",pref_en: "Osaka",area: 1901,population: 8836},
        {no: 28,pref_jp: "兵庫",pref_en: "Hyogo",area: 8396,population: 5541},
        {no: 29,pref_jp: "奈良",pref_en: "Nara",area: 3691,population: 1376},
        {no: 30,pref_jp: "和歌山",pref_en: "Wakayama",area: 4726,population: 971},
        {no: 31,pref_jp: "鳥取",pref_en: "Tottori",area: 3507,population: 574},
        {no: 32,pref_jp: "島根",pref_en: "Shimane",area: 6708,population: 697},
        {no: 33,pref_jp: "岡山",pref_en: "Okayama",area: 7010,population: 1924},
        {no: 34,pref_jp: "広島",pref_en: "Hiroshima",area: 8480,population: 2833},
        {no: 35,pref_jp: "山口",pref_en: "Yamaguchi",area: 6114,population: 1408},
        {no: 36,pref_jp: "徳島",pref_en: "Tokushima",area: 4147,population: 764},
        {no: 37,pref_jp: "香川",pref_en: "Kagawa",area: 1862,population: 981},
        {no: 38,pref_jp: "愛媛",pref_en: "Ehime",area: 5679,population: 1395},
        {no: 39,pref_jp: "高知",pref_en: "Kochi",area: 7105,population: 738},
        {no: 40,pref_jp: "福岡",pref_en: "Fukuoka",area: 4847,population: 5091},
        {no: 41,pref_jp: "佐賀",pref_en: "Saga",area: 2440,population: 835},
        {no: 42,pref_jp: "長崎",pref_en: "Nagasaki",area: 4106,population: 1386},
        {no: 43,pref_jp: "熊本",pref_en: "Kumamoto",area: 7268,population: 1794},
        {no: 44,pref_jp: "大分",pref_en: "Oita",area: 5100,population: 1171},
        {no: 45,pref_jp: "宮崎",pref_en: "Miyazaki",area: 6795,population: 1114},
        {no: 46,pref_jp: "鹿児島",pref_en: "Kagoshima",area: 9045,population: 1668},
        {no: 47,pref_jp: "沖縄",pref_en: "Okinawa",area: 2277,population: 1421}
      ],
    }
  }
}
</script>
  • v-model:items-per-page: 1ページ内に表示するデータの行数を指定します。
  • headers: テーブルのヘッダ(項目名など)のデータを指定します。
  • item: データ本体を指定します。headers と項目数が一致しないと、表示が崩れます。
  • items-per-page-text: 表示する行数を指定するメニューの左側に表示される文字列です。デフォルトでは item per pages になります。
  • items-per-page-options: 表示する行数を選択するメニューの選択肢のリストを指定します。デフォルトでは 10, 20, 50, 100, ALL という選択肢のメニューになります。

class="elevation-1"を指定しないと、テーブルと背景の境(枠)がなくなります。class="border" でも枠はつきます。

項目ソート

DataTable はデフォルトで項目ごとのソート機能がついています。テーブルの最上段の項目名を押すことでソートが実行されます。ソートは、項目名を押すたびに、昇順、降順、先頭の項目の番号順、という順序でソート順序が入れ替わります。

面積で降順ソートすると下図のようになります。

image.png

<template>
  <v-container>
    <v-data-table
      density="compact"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    ></v-data-table>
  </v-container>
</template>

multi-sort prop を使うと、複数項目によるソートが可能になります。ソートを禁止したい場合は、禁止したい列の headersortable: false を追加します。上の例では key: no の列に対するソートを禁止しています。

項目の上下の空白を指定: density

density prop を入れると各行の上下のスペースの空き具合を指定できます。density="compact" とすると、かなり詰まった状態になります。comfortable だとやや詰まりになります。

image.png

<template>
  <v-container>
    <v-data-table
      density="compact"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    ></v-data-table>
  </v-container>
</template>

選択: show-select

show-select prop を使うと、行を選択するチェックボックスがつきます。デフォルトでは複数選択が許可されています。select-strategy="single" とすると複数選択を禁止できます。

image.png

<template>
  <v-container>
    <v-data-table
      density="compact"
      show-select
      v-model="selected"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      item-value="no"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    ></v-data-table>
    <div>
      {{ selected }}
    </div>
  </v-container>
</template>

show-select を指定するときは、同時に item-value でデータを区別するためにつかう key を指定します。これを指定しないと、どのチェックボックスを選択しても全てのデータが選択されます。

選択したデータは v-model で指定した変数に配列として入ります。配列に入るのは、選択した行の item-value で指定した列の値です。

行にボタンを追加する: v-slot:item

v-slot:item を使うことで、各行の中にボタンやアイコンなどを表示する列を追加できます。

image.png

<template>
  <v-container>
    <v-data-table
      density="compact"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      item-value="no"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"

    >
      <template v-slot:item.actions="{ item }">
        <v-icon
          size="small"
          @click="deleteItem(item.raw)"
        >
          mdi-delete
        </v-icon>
      </template>

    </v-data-table>
  </v-container>
</template>

<script>
export default {
  methods: {
    deleteItem(item){
      alert( item.no + "番のデータを削除します");
    }
  },
  data () {
    return {

      itemsPerPage: 5,
      selected: [],
      pages: [
        {value: 5, title: '5'},
        {value: 10, title: '10'},
        {value: 20, title: '20'},
        {value: -1, title: '$vuetify.dataFooter.itemsPerPageAll'}
      ],
      headers: [
        { title: '番号', align: 'end', sortable: false, key: 'no' },
        { title: '都道府県名', align: 'start', key: 'pref_jp' },
        { title: '英語名', align: 'start', key: 'pref_en' },
        { title: '面積(km2)', align: 'end', key: 'area' },
        { title: '人口(千人)', align: 'end', key: 'population' },
        { title: '操作', align: 'end', key: 'actions' },
      ],
      pref: [ // 省略
      ],
    }
  }
}
</script>

上の例では v-slot:item.actions としていますが、この場合は headers に key: 'actions' という項目を持つデータ(項目)を追加しておく必要があります。headers の中に  v-slot:item.xxxxxxxx と一致する key をもつ項目がない場合は、v-slot で指定したボタンなどは表示されません。

行に他の項目から計算した項目を追加: v-slot:item

ボタンを追加したときと同じ方法で、行に表示する項目を追加できます。下の図では人口密度の項目を都度計算して表示させています。

image.png

<template>
  <v-container>
    <v-data-table
      density="compact"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      item-value="no"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    >
      <template v-slot:item.density="{ item }">
          {{ (item.population*1000 / item.area).toFixed(2) }}
      </template>
    </v-data-table>

  </v-container>
</template>

<script>
export default {
  methods: {
    deleteItem(item){
      alert( item.no + "番のデータを削除します");
    }
  },
  data () {
    return {
      search: ``,
      itemsPerPage: 5,
      selected: [],
      pages: [
        {value: 5, title: '5'},
        {value: 10, title: '10'},
        {value: 20, title: '20'},
        {value: -1, title: '$vuetify.dataFooter.itemsPerPageAll'}
      ],
      headers: [
        { title: '番号', align: 'end', sortable: false, key: 'no' },
        { title: '都道府県名', align: 'start', key: 'pref_jp' },
        { title: '英語名', align: 'start', key: 'pref_en' },
        { title: '面積(km2)', align: 'end', key: 'area' },
        { title: '人口(千人)', align: 'end', key: 'population' },
        { title: '人口密度', align: 'end', key: 'density', sortable: false },
      ],
pref: [ // 省略
      ],
    }
  }
}
</script>

この方法で追加した項目は、ソート機能が使えません(自力でソート機能を実装しない限り)。上の例では sortable: false を指定して、人口密度でのソートを禁止しています。

検索

search prop で文字列を指定すると、その文字列を含むデータだけを表示します。

image.png

<template>
  <v-container>
    <v-text-field
        v-model="search"
        append-icon="mdi-magnify"
        label="検索"
        single-line
        hide-details
      ></v-text-field>

    <v-data-table
      density="compact"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items="pref"
      item-value="no"
      :search="search"
      :items-per-page-options="pages"
      items-per-page-text="表示行数"
      class="elevation-1"
    >
    </v-data-table>
  </v-container>
</template>

デフォルトでは、すべての列のデータを対象にフィルタ(検索)します。特定の列のデータを使ってフィルタしたいときは、フィルタする関数を自力で書く必要があります。詳しくは下記を参照してください。

サーバのデータを順次読み込む(ページ単位)

サーバにあるデータの一部だけ表示しておいて、ページをめくっていったりスクロールさせていくと、残りが読み込まれて表示される、みたいなこともできます。

次のページに進んだり、ページに表示する項目数を変更したときにサーバにデータを読みに行くデータテーブルを作成するためには v-data-table-server を使用します。

image

表示するページを変更する操作をすると、該当するデータをサーバから読み出します。読み出し中は loadingtrue にすることで、テーブル内の文字が薄い表示になり、プログレスバーのアニメーションが表示されます。

データの読み込みは @update:options で指定した関数で読み込みます。関数の引数は page(新たに表示するページ番号), itemsPerPage(1ページ当たりの表示件数), sortBy(ソートのキーに使用する項目名) の三つです。page は先頭ページの番号が 1 なので、サーバにデータを取得しにいくときはその点を考慮する必要があります。

上記以外の部分は、通常の v-datatable と同じです。

<template>
  <v-container>
    <v-data-table-server class="w-75"
      v-model:items-per-page="itemsPerPage"
      :headers="headers"
      :items-length="totalItems"
      :items="serverItems"
      :loading="loading"
      item-value="id"
      @update:options="loadItems"
    ></v-data-table-server>
  </v-container>
</template>
<script>
const fetch = async ( postData ) => {
  return new Promise((resolve, reject) => {
    const url = "http://localhost:8001";
    //console.log(url);
    //console.log(postData);
    const xhr = new XMLHttpRequest();
    xhr.open("POST", url);
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.onload = () => {
      console.log(xhr.status);
      const data = JSON.parse(xhr.responseText);
      console.log( data );
      if ( data != null ){
        console.log("success!");
        resolve(data);
      }
      console.log("error!");
      reject(new Error("no data"));
    };
    xhr.onerror = () => {
      console.log(xhr.status);
      console.log("error!");
      reject(new Error(xhr.statusText));
    };
    xhr.send(JSON.stringify(postData));
  });
};

export default {
  data: () => ({
    itemsPerPage: 5,
    headers: [
      {
        title: 'ID',
        align: 'center',
        sortable: false,
        key: 'id',
      },
      { title: 'Contents1', key: 'data', align: 'center' },
      { title: 'Contents2', key: 'data', align: 'center' },
      { title: 'Contents3', key: 'data', align: 'center' },
      { title: 'Contents4', key: 'data', align: 'center' },
    ],
    search: '',
    serverItems: [],
    loading: true,
    totalItems: 100,
  }),
  methods: {
    loadItems ({ page, itemsPerPage, sortBy }) {
      this.loading = true;
      const start = (page-1) * itemsPerPage;
      const end = start + itemsPerPage;
      const obj = {
        start: start,
        end: end
      }
      fetch(obj).then((data) => {
        this.serverItems = [];
        for ( let i = 0; i < data.length; i++ ){
          this.serverItems.push( {
            id: i,
            data: data[i]
          });
        }
        this.loading = false;
      });
    },
  },
}
</script>

サーバのサンプル

上記のコードからデータを読みだせる node (v16以上) + express のコードを参考までに下記に示します。下記のサーバは、データの先頭の番号 (start) と末尾の番号 (end) を指定すると、"Data n" (start =< n < end) という文字列を指定された数値の範囲で作成し、リストとしてクライアントへ返します。

このサーバでは、レスポンスするときにわざと3秒待つことで、ロードに時間がかかるときの挙動がわかるようにしています。

実行には express, body-parser, cors モジュールの追加が必要です。

$ yarn add express body-parser cors

起動は node server.js です。

server.js
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const port = 8001;
const cors = require('cors');
const { setTimeout } = require('timers/promises');

app.use(bodyParser.urlencoded({
    extended: true
}));

app.use(cors({
  origin: 'http://localhost:3000', 
  credentials: true, 
}));
app.use(bodyParser.json());

app.post('/', async (req, res) => {
  console.log(req.body);
  arr = [];
  for ( let i = parseInt(req.body.start); i < parseInt(req.body.end); i++ ){
    arr.push( "Data " + i );
  }
  await setTimeout( 3000 );
  res.json(arr);
});

app.listen(port, () => {
  console.log(`port: ${port}!`);
});

おまけで、python3 + FastAPI ベースのプログラムも示しておきます。pip install uvicorn fastapi としておく必要があります。サーバは下記のように、8001 番ポートを指定して起動する必要があります。

$ uvicorn server:app --port 8001
server.py
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse

from pydantic import BaseModel

import time

class Range(BaseModel):
  start: int
  end: int

def stringify(data):
  return JSONResponse(content=jsonable_encoder(data))

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"], 
    allow_headers=["*"]
)

@app.post("/")
def getData(r: Range):
  arr = []
  for i in range(r.start, r.end):
    arr.append( "Data " + str(i))
  time.sleep(3)  # わざと3秒待つ
  return stringify(arr)

詳しくは下記を参照してください。