Observable Framework のデータローダーを Rust で書く方法

## はじめに

[Observable Framework](https://observablehq.com/framework/)は、高性能なデータ可視化アプリケーションを構築するためのオープンソースの静的サイトジェネレーターです。2024年にメジャーアップデートされ、以下のような特徴を持っています：

- **クライアントサイドの高速な処理**: サーバー駆動型のプラットフォームと異なり、すべての処理をクライアントサイドで行うことで、よりスムーズでレスポンシブな体験を提供します。([Appsilonのブログ記事より](https://www.appsilon.com/post/observable-framework-data-science-dashboards))
- **柔軟なデータローダー**: あらゆるプログラミング言語でデータローダーを作成できます。ビルド時にデータの静的スナップショットを事前計算することで、高速なパフォーマンスを実現します。([Observable公式ブログより](https://observablehq.com/blog/data-loaders-for-the-win))
- **最新の可視化機能**: Mosaic vgplotのサポートにより、数百万のデータポイントに対する調整されたビューとクロスフィルタリング機能を提供します。([Observable リリースノートより](https://observablehq.com/release-notes/2024-03-13-framework-1-3-update))

このフレームワークをRustと組み合わせることで、以下のような利点が得られます：

1. Rustの高速な処理能力を活かしたデータの前処理
2. 型安全性による堅牢なデータ処理
3. メモリ効率の良い大規模データの取り扱い

本記事では、Rustでデータローダーを実装し、Observable Frameworkでそのデータを可視化する方法を解説します。

## 環境設定

環境設定の詳細は以下の公式ドキュメントを参考にしています：
- [Observable Framework セットアップガイド](https://observablehq.com/framework/getting-started)
- [Rust 公式インストールガイド](https://www.rust-lang.org/tools/install)
- [rust-script 公式サイト](https://rust-script.org/)

### システム要件
- **Node.js**: 20.6以降 ([Observable Framework 公式ドキュメントより](https://observablehq.com/framework/getting-started))
- **Rust**: 1.74以降 ([rust-script 公式サイトより](https://rust-script.org/))
  - Windowsの場合、Rust用のMSVCビルドツールが必要 ([Rust 公式インストールガイドより](https://www.rust-lang.org/tools/install))

### Observable Framework のセットアップ

```bash
# バージョン確認
node --version  # 20.6.0以降であることを確認

# プロジェクトの作成
npx @observablehq/framework@latest create

(質問に答えるとプロジェクトが作成されます)

cd rust-sample

# 開発サーバーの起動
npm run dev

# ビルドとデプロイ（必要な場合）
npm run build
npm run deploy  # Observable Cloudへのデプロイ
```

デプロイについては、[Observable Framework デプロイガイド](https://observablehq.com/framework/deploying)を参照してください。

下記は例として添付されているダッシュボードです。ロケット発射数がまとめられています。Falcon9の貢献の大きさがわかりますね。

![Screenshot 2024-12-03 at 0.39.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79562/3a3fc6f7-11b3-8fa6-19d0-64f36f5f52a6.png)

### rust-script のインストール
Rustのスクリプト実行環境として、rust-scriptを使用します。rust-scriptは以下のような利点を提供します：

- **シンプルな実行環境**: 単一ファイルでRustのスクリプトを実行できます。通常のRustプロジェクトのような `Cargo.toml` の設定は不要です。
- **高速な開発サイクル**: コンパイルとビルドのプロセスが自動化され、開発者は実装に集中できます。
- **メモリ安全性の維持**: Rustの特徴である所有権システムと借用チェッカーによる安全性を維持しながら、スクリプト言語のような手軽さを実現しています。
- **標準ライブラリとクレートの完全サポート**: Rustの豊富なエコシステムを最大限に活用できます。

インストール手順は以下の通りです：

```bash
# Rustのインストール（まだの場合）
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# rust-scriptのインストール
cargo install rust-script

# バージョン確認
rust-script --version
```

### 今回使うクレート
主に以下のクレートを使用します（2024年12月2日現在の最新安定版）：
- **serde** (1.0.193): 構造化されたデータのシリアライズ/デシリアライズを行います
- **serde_json** (1.0.108): JSONデータの処理を担当します
- **calamine** (0.24.0): エクセルファイルの効率的な読み込みを提供します

rust-scriptの特徴的な機能として、ファイルの先頭にマジックコメントを使用して依存関係を直接記述できます。これにより、`Cargo.toml`を作成することなく必要なクレートを指定できます：

```rust
//! ```cargo
//! [dependencies]
//! serde = { version = "1.0.193", features = ["derive"] }
//! serde_json = "1.0.108"
//! calamine = "0.24.0"
//! ```
```

追加情報や詳しい実装例については、[Observable Framework 公式ドキュメント](https://observablehq.com/framework/)や[Crafty CTOのブログ記事](https://craftycto.com/blog/observable-framework-blazing-fast/)も参考になります。

## 1. シンプルな例：JSONデータの生成

ここでは、基本的な例として、JSONデータを生成してObservable Frameworkで表示する実装を見ていきます。

### プロジェクトの構造
このサンプルでは、以下のようなディレクトリ構造を使用します：

```
src/
├── rust-sample_01.md          # ページファイル
└── data/
    └── rust-sample_01.json.rs # Rustデータローダー
```

### データローダーの実装
ここでは[アニメイトタイムズ | 2024秋アニメまとめ一覧｜10月放送開始 新作アニメ・再放送アニメ情報](https://www.animatetimes.com/tag/details.php?id=5947) を参考にさせてもらい、今年の秋アニメをいくつか出力するコードを書いてみました。 

```rust
//! ```cargo
//! [dependencies]
//! serde = { version = "1.0.215", features = ["derive"] }
//! serde_json = "1.0.133"
//! ```

use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Serialize, Deserialize, Debug)]
struct Anime {
    title: String,
    description: String,
    release_date: String,
    episodes: u32,
    studio: String,
}

fn main() -> std::io::Result<()> {
    let animes = vec![
        Anime {
            title: String::from("アオのハコ"),
            description: String::from("バスケットボール部の男子高校生とバドミントン部の女子高校生の青春ラブストーリー。"),
            release_date: String::from("2024-10-04"),
            episodes: 12,
            studio: String::from("テレコム・アニメーションフィルム"),
        },
        Anime {
            title: String::from("ダンダダン"),
            description: String::from("超常現象をテーマにしたSFアクション。"),
            release_date: String::from("2024-10-03"),
            episodes: 12,
            studio: String::from("サイエンスSARU"),
        },
        Anime {
            title: String::from("チ。―地球の運動について―"),
            description: String::from("地動説をテーマにした歴史ドラマ。"),
            release_date: String::from("2024-10-05"),
            episodes: 25,
            studio: String::from("マッドハウス"),
        },
        Anime {
            title: String::from("ドラゴンボールDAIMA"),
            description: String::from("「ドラゴンボール」シリーズの新作。"),
            release_date: String::from("2024-10-11"),
            episodes: 13,
            studio: String::from("東映アニメーション"),
        },
        Anime {
            title: String::from("ブルーロック VS. U-20 JAPAN"),
            description: String::from("「ブルーロック」シリーズの続編。"),
            release_date: String::from("2024-10-05"),
            episodes: 14,
            studio: String::from("エイトビット"),
        },
        Anime {
            title: String::from("夏目友人帳 漆"),
            description: String::from("「夏目友人帳」シリーズの第7期。"),
            release_date: String::from("2024-10-10"),
            episodes: 12,
            studio: String::from("朱夏"),
        },
        Anime {
            title: String::from("ねこに転生したおじさん"),
            description: String::from("普通のサラリーマンだったおじさんが、ある日突然ねこに転生し、会社の社長に拾われることで始まる愉快な日常を描く。"),
            release_date: String::from("2024-10-07"),
            episodes: 12,
            studio: String::from("スタジオエイトカラーズ"),
        },
    ];

    // JSONオブジェクトの作成
    let json_data = json!({
        "animes": animes,
    });

    // 標準出力に書き込み
    let stdout = std::io::stdout();
    let handle = stdout.lock();

    serde_json::to_writer_pretty(handle, &json_data)?;

    Ok(())
}
```

### Observable Frameworkでの表示
生成したJSONデータは、Observable Frameworkで以下のようにすればロードできます。`src/rust-sample_01.md` に以下の内容を記述します：

```md
# Rust - Observable Sample 01

\```js
const data = FileAttachment("./data/rust-sample_01.json").json();
\```

\```js
display(data.users);
\```
```

`display()` コマンドを使うことで簡単にデータの内容が確認できます

![Screenshot 2024-12-03 at 0.35.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79562/a492e9be-3dfd-5688-cd1d-eebab82b2ed2.png)

## 2. 実践的な例：エクセルデータの可視化

次に、より実践的な例として[農林水産省 | 令和 4 年 農業産出額及び生産農業所得（都道府県別）](https://www.maff.go.jp/j/tokei/kekka_gaiyou/seisan_shotoku/r4_betsu/) から取得したエクセルファイル(e015-04-009.xlsx)を Rust で読み込んでみます。

このエクセルは下記のような内容になっています。

![Screenshot 2024-12-02 at 0.34.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79562/ae735beb-7cbc-f349-82d5-03aa5e3cda30.png)

### データローダーの実装
以下は、[calamine](https://docs.rs/calamine/latest/calamine/) を使ってエクセルファイルを読み込み、JSON形式で出力する例です：

まず、`rust-sample_02.md` と `rust-sample_02.json.rs`、ダウンロードしたエクセルファイルを追加します

```
src/
├── rust-sample_01.md 
├── rust-sample_02.md          # 追加
└── data/
    ├── rust-sample_01.json.rs
    ├── rust-sample_02.json.rs # 追加
    └── e015-04-009.xlsx       # 追加
    
```

```rust
//! ```cargo
//! [dependencies]
//! serde = { version = "1.0.215", features = ["derive"] }
//! serde_json = "1.0.133"
//! calamine = "0.24.0"
//! ```

use calamine::{open_workbook, Data, Reader, Xlsx};
use serde::Serialize;
use serde_json::json;
use std::error::Error;

#[derive(Serialize)]
struct PrefectureData {
    id: usize,
    prefecture: String,
    total_agricultural_output: Value,
    crop_subtotal: Value,
    rice: Value,
    livestock_subtotal: Value,
    agricultural_income: Value,
}

#[derive(Serialize)]
struct Value {
    value: f64,
    unit: String,
    name_ja: String,
}

#[derive(Serialize)]
struct AgriculturalData {
    prefectures: Vec<PrefectureData>,
    notes: Vec<String>,
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut workbook: Xlsx<_> = open_workbook("./src/data/e015-04-009.xlsx")?;
    let mut prefecture_data = Vec::new();
    let mut notes = Vec::new();
    let mut id_counter = 1;

    if let Ok(range) = workbook.worksheet_range("都道府県ア（実額）") {
        let rows: Vec<_> = range.rows().collect();

        for row in rows.iter().skip(9) {
            if let Some(Data::String(name)) = row.get(0) {
                let name = name.trim();
                if !name.is_empty() {
                    if name.starts_with("注：") {
                        notes.push(name.to_string());
                        continue;
                    }
                    if name == "合計" {
                        continue;
                    }
                    
                    let data = PrefectureData {
                        id: id_counter,
                        prefecture: name.to_string(),
                        total_agricultural_output: Value {
                            value: match row.get(2) {
                                Some(Data::Float(val)) => *val,
                                _ => 0.0,
                            },
                            unit: "億円".to_string(),
                            name_ja: "農業産出額計".to_string(),
                        },
                        crop_subtotal: Value {
                            value: match row.get(4) {
                                Some(Data::Float(val)) => *val,
                                _ => 0.0,
                            },
                            unit: "億円".to_string(),
                            name_ja: "耕種小計".to_string(),
                        },
                        rice: Value {
                            value: match row.get(6) {
                                Some(Data::Float(val)) => *val,
                                _ => 0.0,
                            },
                            unit: "億円".to_string(),
                            name_ja: "米".to_string(),
                        },
                        livestock_subtotal: Value {
                            value: match row.get(28) {
                                Some(Data::Float(val)) => *val,
                                _ => 0.0,
                            },
                            unit: "億円".to_string(),
                            name_ja: "畜産小計".to_string(),
                        },
                        agricultural_income: Value {
                            value: match row.get(47) {
                                Some(Data::Float(val)) => *val,
                                _ => 0.0,
                            },
                            unit: "億円".to_string(),
                            name_ja: "生産農業所得".to_string(),
                        },
                    };
                    prefecture_data.push(data);
                    id_counter += 1;
                }
            }
        }
    }

    let agricultural_data = AgriculturalData {
        prefectures: prefecture_data,
        notes,
    };

    // JSONデータの作成と出力
    let stdout = std::io::stdout();
    let handle = stdout.lock();
    serde_json::to_writer_pretty(handle, &agricultural_data)?;

    Ok(())
}
```

上記を実行すると下記のようなjsonが出力されます

```json
{
  "prefectures": [
    {
      "id": 1,
      "prefecture": "北海道",
      "total_agricultural_output": {
        "value": 12919.0,
        "unit": "億円",
        "name_ja": "農業産出額計"
      },
      "crop_subtotal": {
        "value": 5384.0,
        "unit": "億円",
        "name_ja": "耕種小計"
      },
      "rice": {
        "value": 1067.0,
        "unit": "億円",
        "name_ja": "米"
      },
      "livestock_subtotal": {
        "value": 7535.0,
        "unit": "億円",
        "name_ja": "畜産小計"
      },
      "agricultural_income": {
        "value": 0.0,
        "unit": "億円",
        "name_ja": "生産農業所得"
      }
    },
    ...
  ],
  "notes": [
    "注：順位付けは、秘密保護上統計数値を公表していない都道府県を除いたものであり、原数値（100万円）により判定した。"
  ]
}
```

### Observable Frameworkでのグラフ表示

ここでは米の生産額をグラフにしてみましょう：

```md
# Rust - Observable Sample 02

\```js
const data = FileAttachment("./data/rust-sample_02.json").json();
\```

\```js
const riceData = data.prefectures.map((d) => ({
  id: d.id,
  prefecture: d.prefecture,
  rice: d.rice.value,
}));
\```

\```js
const plot = Plot.plot({
  title: "都道府県別の米の生産額",
  x: {
    label: "都道府県",
    tickRotate: 90,
    tickFormat: (d) => riceData.find((item) => item.id === d).prefecture,
  },
  y: {
    label: "米の生産額(億円)",
  },
  marginBottom: 70,
  marks: [
    Plot.barY(riceData, {
      x: "id",
      y: "rice",
    }),
  ],
});
\```

\```js
display(plot);
\```
```

![Screenshot 2024-12-03 at 1.09.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/79562/f06ab709-5731-3990-d026-a60934c137e7.png)

## あとがき

Observable Framework のデータローダーを Rust で実装した例はあまり見かけませんので、この記事を参考にしていただき、そしてデータ分析・データ可視化の分野でもRustが活躍するようになると幸いです。