Angular実践 #5 チャートとデータテーブル

読了 9分

ダッシュボードを作ると言いつつテーブルが 1 つもチャートが 1 つもなければ、少し物足りないですよね。#4 で認証と Product CRUD まで仕上げたので、いよいよこのダッシュボードが実際に「見える」画面になる番です。今回は Angular Material の MatTable で並び替え・ページネーション・検索が効くテーブルを作り、ng2-charts で売上とカテゴリのチャートを描いていきます。最後にダークモードまでトグルで載せて、ダッシュボードの骨格を仕上げます。

このシリーズが初めての方のために短く振り返ると、#1 で Angular Material をセットアップしてサイドバー・ツールバーがあるダッシュボードのシェルを作り、その上にルーティング・フォーム・認証・CRUD を順に重ねてきました。今回はそのシェルの中に入る「コンテンツ」を作る段階だと考えてください。

Angular Material Table — なぜ MatTable なのか? #

Material Table (MatTable) は Angular Material が提供する標準のデータテーブルコンポーネントです。AG Grid や PrimeNG のような強力なグリッドライブラリもありますが、Material Table は次の 3 つの理由で最初の選択肢としておすすめできます。

  • すでに Material を使っているなら、別途依存が増えません。
  • MatPaginatorMatSort との組み合わせがなめらかです。
  • デザインが私たちのアプリの他の Material コンポーネントと自動で噛み合います。

#4 で作った Product 一覧画面を、カードグリッドの代わりにテーブル版に置き換えてみましょう。まず必要な Material モジュールを import して、コンポーネントの骨格を組みます。

src/app/products/product-table.component.ts
@Component({
  selector: 'app-product-table',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatTableModule, MatPaginatorModule, MatSortModule,
    MatFormFieldModule, MatInputModule, MatIconModule,
  ],
  templateUrl: './product-table.component.html',
})
export class ProductTableComponent {
  private productService = inject(ProductService);

  displayedColumns = ['name', 'category', 'price', 'stock', 'actions'];
  dataSource = new MatTableDataSource<Product>([]);

  paginator = viewChild.required(MatPaginator);
  sort = viewChild.required(MatSort);

  constructor() {
    this.productService.list().subscribe((rows) => (this.dataSource.data = rows));
    effect(() => {
      this.dataSource.paginator = this.paginator();
      this.dataSource.sort = this.sort();
    });
  }
}

viewChild.required() (Angular 17.2+) は ViewChild のシグナル版です。effect() の中で paginator と sort が ViewChild としてバインドされる時点で、自動的にデータソースに接続されます。以前の @ViewChild + AfterViewInit の組み合わせよりも、コードがずっと明快になりました。

テンプレート — Column 定義と Sort/Paginator #

src/app/products/product-table.component.html
<table mat-table [dataSource]="dataSource" matSort class="full-width">
  <ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>名前</th>
    <td mat-cell *matCellDef="let row">{{ row.name }}</td>
  </ng-container>

  <ng-container matColumnDef="price">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>価格</th>
    <td mat-cell *matCellDef="let row">{{ row.price | currency:'KRW':'symbol':'1.0-0' }}</td>
  </ng-container>

  <!-- category、stock カラムも同じパターン -->

  <ng-container matColumnDef="actions">
    <th mat-header-cell *matHeaderCellDef></th>
    <td mat-cell *matCellDef="let row">
      <button mat-icon-button [routerLink]="['/products', row.id, 'edit']">
        <mat-icon>edit</mat-icon>
      </button>
    </td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

<mat-paginator [pageSize]="10" [pageSizeOptions]="[5, 10, 25, 50]" />

mat-sort-header が付いたカラムはクリックで並び替えされ、MatPaginator は自動でテーブル下部にページコントロールを描画してくれます。テーブルを 1 つ描いただけで、私たちはすでに並び替えとページネーションをタダで手に入れたわけです。

MatTable のデータソース — MatTableDataSource vs シグナル #

MatTableDataSource はクライアント側の並び替え・フィルタ・ページを一気に処理してくれる便利クラスです。小さなデータ (数百件以下) ならこれで十分ですが、次の 2 つの限界があります。

  • シグナルとの相性が良くありません。dataSource.data = rows のような命令的代入なので、OnPush コンポーネントと自然に噛み合いません。
  • 大きなデータをサーバーから受け取る場合、結局自分でデータフローを扱う必要があります。

modern Angular では、[dataSource] にシグナル・配列をそのまま渡し、並び替え・フィルタを自分たちで computed として組むパターンがだんだん標準化してきています。テーブル自体はデータを受け取って描くだけにして、データの加工はシグナルグラフ上で起こす、という形ですね。このパターンはこの後の検索フィルタで自然に登場します。

Sort / Pagination — クライアント側 vs サーバー側 #

テーブルを作るときに必ず出てくる決定が 1 つあります。並び替えとページネーションをクライアントでやるか、サーバーでやるか?

  • クライアント側: データを一括で受け取ってブラウザで並び替え・ページを処理。コードがシンプルで反応が即時。データが数百件以下のときに適切。
  • サーバー側: ページ・並び替えキーを API パラメータで送り、サーバー側で切って返す。データが数千件以上、あるいは権限ごとに見えるデータが違う場合は必須。

規模を分ける境界は通常「ブラウザに一括で持っていても無理のない量」です。メモリの問題もありますが、並び替え一回で数万件を再比較するとメインスレッドが詰まります。本講座の Product 一覧はシードデータが少ないのでクライアント側で進めますが、実務のアドミンであれば最初からサーバー側を前提に始めるほうが安全です。

検索入力とフィルタ — RxJS debounceTime + toSignal #

テーブルの上に検索入力を付けてみましょう。単に [(ngModel)] でつないで毎キーストロークごとにフィルタを再実行すると、入力が速いときにもたつきを感じます。RxJS の debounceTime で「タイピングが少し止まったときだけ」フィルタを回すのが定石です。そして結果は toSignal() でシグナルの世界に持ち込みます。

src/app/products/product-table.component.ts
export class ProductTableComponent {
  searchCtrl = new FormControl('', { nonNullable: true });

  search = toSignal(
    this.searchCtrl.valueChanges.pipe(startWith(''), debounceTime(200)),
    { initialValue: '' },
  );

  constructor() {
    effect(() => {
      this.dataSource.filter = this.search().trim().toLowerCase();
    });
  }
}
src/app/products/product-table.component.html
<mat-form-field appearance="outline" class="search">
  <mat-label>名前・カテゴリで検索</mat-label>
  <input matInput [formControl]="searchCtrl" />
  <mat-icon matSuffix>search</mat-icon>
</mat-form-field>

MatTableDataSource.filter に文字列を入れると、デフォルトのフィルタがすべてのカラム値を join して検索します。カラムごとに違うフィルタにしたければ dataSource.filterPredicate を関数に差し替えれば OK です。

ヒント
RxJS とシグナルを混ぜるときは「入る入口」と「出る出口」だけ決めておくと混乱しません。入る入口は toSignal() (Observable → Signal)、出る出口は toObservable() (Signal → Observable)。今回の例のようにフォームコントロールは RxJS 側で最後まで処理し、その結果だけシグナルとして受けて effect でテーブルに反映する流れがきれいです。

チャートライブラリの決定 — ng2-charts / ApexCharts / D3 #

チャートライブラリは種類が多すぎて、最初は選ぶこと自体が一仕事です。よく使われる 3 つだけを短く比べてみると:

  • ng2-charts (Chart.js ラッパー): 最もよく使われる無難な選択。Chart.js のパワーをそのまま使いつつ、Angular フレンドリーな API。ライセンス的にも気楽 (MIT)。
  • ApexCharts: より華やかでインタラクティブ。ダッシュボードの「綺麗な絵」へのこだわりがあるときに良いが、バンドルが重め。
  • D3: ライブラリというより可視化の「素材」。自由度は無限大だが、自分で描くものが多くて学習曲線が急。

本講座は ng2-charts で進めます。理由は 2 つ。1 つ目、一般的なダッシュボード (折れ線・棒・ドーナツ・パイ) ではこれでできないことがありません。2 つ目、Chart.js 自体の資料が圧倒的に多いので、詰まったときに解きほぐしやすいです。

ng2-charts のセットアップ #

terminal
npm i ng2-charts chart.js

Standalone 時代の ng2-charts は provideCharts で設定を登録します。

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... 既存の providers
    provideCharts(withDefaultRegisterables()),
  ],
};

withDefaultRegisterables() はよく使われるチャートタイプとコントローラーを一括で登録してくれます。バンドルをさらに減らしたい場合は必要なものだけ選んで登録できますが、通常はデフォルトで十分です。

売上チャート (Line) — 月別売上の可視化 #

ダッシュボードの最初のチャートとして、月別売上の折れ線チャートを描いてみましょう。

src/app/dashboard/sales-chart.component.ts
@Component({
  selector: 'app-sales-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" [options]="options" type="line"></canvas>`,
})
export class SalesChartComponent {
  raw = signal<SalesPoint[]>([
    { month: '1月', amount: 12_400_000 },
    { month: '2月', amount: 15_100_000 },
    { month: '3月', amount: 13_800_000 },
    { month: '4月', amount: 18_900_000 },
    { month: '5月', amount: 21_500_000 },
  ]);

  data = computed<ChartData<'line'>>(() => ({
    labels: this.raw().map((p) => p.month),
    datasets: [{
      label: '月別売上 (円)',
      data: this.raw().map((p) => p.amount),
      tension: 0.3,
      fill: true,
    }],
  }));

  options: ChartConfiguration<'line'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: { legend: { position: 'top' } },
  };
}

肝心なのは 2 つです。data をシグナルベースの computed にした こと、そして maintainAspectRatio: false でコンテナのサイズに合わせて伸びるようにした こと。シグナルで作っておけば、後ほど Date Picker で期間を変えたときに自動で再描画されます。

カテゴリ分布 (Doughnut) — コンポーネント内でデータを加工する #

2 つ目のチャートはカテゴリ別の売上比率を見せるドーナツチャートです。生のトランザクション配列からカテゴリごとの合計を作って、チャートに渡してみましょう。

src/app/dashboard/category-chart.component.ts
@Component({
  selector: 'app-category-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" type="doughnut"></canvas>`,
})
export class CategoryChartComponent {
  sales = input.required<Sale[]>();

  data = computed<ChartData<'doughnut'>>(() => {
    const sums = new Map<string, number>();
    for (const s of this.sales()) {
      sums.set(s.category, (sums.get(s.category) ?? 0) + s.amount);
    }
    const labels = [...sums.keys()];
    return {
      labels,
      datasets: [{ data: labels.map((l) => sums.get(l)!) }],
    };
  });
}

データ加工をコンポーネント内で Map に集めてから、ラベル / データを作っています。親コンポーネントが sales シグナルを更新すれば、ドーナツチャートも自動で再描画されます。

注記
チャートのデータ加工ロジックが大きくなり始めたら、チャートコンポーネントではなく別途「セレクタ (selector) 関数」やサービスの computed signal に切り出すほうがきれいです。コンポーネントがデータを「料理」し始めると、テストが難しくなり、他のウィジェットで同じ加工が必要になったときに重複が生まれます。

Date Picker (Mat) + チャートの連動 #

ダッシュボードに欠かせない「期間選択」を付けてみましょう。Material の Date Range Picker を使えば開始日・終了日を一度で受け取れます。

src/app/dashboard/range-picker.component.ts
@Component({
  selector: 'app-range-picker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [provideNativeDateAdapter()],
  imports: [ReactiveFormsModule, MatFormFieldModule, MatDatepickerModule],
  template: `
    <mat-form-field appearance="outline">
      <mat-label>期間を選択</mat-label>
      <mat-date-range-input [formGroup]="form" [rangePicker]="picker">
        <input matStartDate formControlName="start" placeholder="開始" />
        <input matEndDate formControlName="end" placeholder="終了"
               (dateChange)="changed.emit(form.getRawValue())" />
      </mat-date-range-input>
      <mat-datepicker-toggle matIconSuffix [for]="picker" />
      <mat-date-range-picker #picker />
    </mat-form-field>
  `,
})
export class RangePickerComponent {
  changed = output<{ start: Date | null; end: Date | null }>();
  form = new FormGroup({
    start: new FormControl<Date | null>(null),
    end: new FormControl<Date | null>(null),
  });
}

親のダッシュボードはこのイベントを受けてシグナルを更新し、filteredSales computed がチャートのデータを再計算します。フォーム → シグナル → チャートと流れる単方向データフローが自然に組み上がります。

src/app/dashboard/dashboard.component.ts
export class DashboardComponent {
  range = signal<{ start: Date | null; end: Date | null }>({ start: null, end: null });
  allSales = signal<Sale[]>([/* ... */]);

  filteredSales = computed(() => {
    const { start, end } = this.range();
    if (!start || !end) return this.allSales();
    return this.allSales().filter((s) => s.date >= start && s.date <= end);
  });
}

ダークモード — Material theme + CSS 変数 #

ダッシュボードの最後の仕上げとして、ダークモードのトグルを付けてみましょう。Angular Material 18 からは system-level themingCSS 変数 がより自然に扱えるようになりました。styles.scss でライト / ダークのパレットを両方定義しておき、本文のクラスで切り替える方式が最もシンプルです。

src/styles.scss
@use '@angular/material' as mat;

html {
  @include mat.theme((
    color: (
      primary: mat.$indigo-palette,
      tertiary: mat.$pink-palette,
    ),
    typography: Roboto,
    density: 0,
  ));

  color-scheme: light;
}

html.dark {
  color-scheme: dark;
}

トグルコンポーネントはシグナル 1 つで十分です。

src/app/shared/theme-toggle.component.ts
@Component({
  selector: 'app-theme-toggle',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatIconButton, MatIcon],
  template: `
    <button mat-icon-button (click)="dark.update(v => !v)">
      <mat-icon>{{ dark() ? 'light_mode' : 'dark_mode' }}</mat-icon>
    </button>
  `,
})
export class ThemeToggleComponent {
  dark = signal<boolean>(localStorage.getItem('theme') === 'dark');

  constructor() {
    effect(() => {
      const isDark = this.dark();
      document.documentElement.classList.toggle('dark', isDark);
      localStorage.setItem('theme', isDark ? 'dark' : 'light');
    });
  }
}

color-scheme プロパティは、ブラウザのデフォルトのスクロールバーやフォームの色まで一緒に追従させてくれます。チャートも同じ流れでダークモード用の色を渡せばよく、Chart.js の Chart.defaults.color をシグナルの effect の中で差し替えるパターンが無難です。

まとめ #

今回はダッシュボードの「見える部分」を埋めました。MatTable で並び替え・ページネーション・検索が効くテーブルを、ng2-charts で売上とカテゴリのチャートを、Material Date Range Picker で期間フィルタを、そして Material theme + CSS 変数でダークモードまで — 1 つの画面が実際に「使えるダッシュボード」になりました。

実践トラック最終回となる次回 #6 テストとデプロイ では、本アプリをユニットテスト (Jest/Karma + Testing Library) と e2e テスト (Playwright) でどう守るか、そして静的ホスティング (Firebase/Vercel) と SSR デプロイの選択肢をどう選ぶかを整理します。

X