人力オートクルーズ

:::note
**安全第一！**  
このアプリは運転中に視覚的フィードバックを提供することで、視線を動かさずに使えるよう設計されています。ただし、使用時には必ず停車して設定を行い、安全運転を最優先にしてください。
:::

## 1. はじめに

自動車の運転中、一定の速度を維持することは燃費向上や安全運転において重要です。特に高速道路では、アクセルペダルを微調整しながら速度をキープする必要があります。しかし、オートクルーズ機能は高度な技術であり、車両価格に影響します。

そこで私は、「**人力オートクルーズ**」という革新的（！）な発想を思いつきました。最新のWeb技術であるNuxt 3とGeolocation APIを駆使し、誰でも手軽に使えるWebアプリとして実装しました。このアプリは、GPSから取得した現在の速度と目標速度を比較し、画面の色変化で速度調整をサポートします。つまり、**ハードウェアを使わずに、人間の力で速度を一定に保つ**という挑戦です。

:::note
**基本設計**
`GPSから速度を算出` ⇒ `目標速度と比較して画面の色を変化`
※ 視界の端で色が認識できるので、画面を注視しなくてもクルーズしたい速度より遅いのか速いのかが認識できる
:::

![2024-12-04_23h16_23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67192/bf5a6752-e809-70ab-5470-e781c84d1aa0.png)
↑目標速度(60km/h)より遅い場合

![2024-12-04_23h16_27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67192/bc4c8ff8-e132-79d0-be05-8cc9b9646718.png)
↑目標速度(60km/h)と走行速度が一致している場合

![2024-12-04_23h16_30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67192/b082436c-09b8-9801-33cb-dc5e702abc5b.png)
↑目標速度(60km/h)より速い場合

この記事は「Qiita Advent Calendar 2024」のクソアプリアドベントカレンダーの一環として執筆しています。


## 2. アイデアの発端

私は基本的に**自動化**が大好きなプログラマーです。CI/CDパイプラインの最適化から、自宅のスマートホーム化まで、あらゆるものを自動化してきました。ただ、**車**に関しては話が別です。

車の**中途半端な自動化機能**には「違う…そうじゃない…」と感じることが多く、むしろ無効化して**手動制御**に戻したくなる派です。例えば、**AT（オートマ）車**よりも**MT（マニュアル）車**が好きですし、暗くなると自動でライトが点灯する**オートライト機能**も使いません。**レインセンサー**による**自動ワイパー**も、感覚が合わないのでオフにしています。

結局、**人間の感覚と機械のセンサーの解像度の差**が「違う、そうじゃない」を生むのだと思います。人間が感じ取れる微細な変化を、機械のセンサーが完全にキャッチするのは難しい。車の運転が好きなエンジニア仲間には共感してもらえる話ですが、車に興味ない方や車が必要ない民からすると何のことやらでしょうね～。

しかし、そんな私でも**オートクルーズ（クルーズコントロール）機能**に関しては**肯定派**で、**高速道路**では常用していました。ところが、最近**車を買い替え**たところ、この機能が搭載されておらず、正直「あわわ…」となりました。でも、そこでふと思ったのです。

**『人力でオートクルーズすればいいのでは…！？』**

技術オタクの血が騒ぎました。これは最新のWeb技術で実現できるんじゃないか、と。クソアプリとしてのネタ性も高い。こうして、「人力オートクルーズ」プロジェクトが始まりました。


## 3. 技術スタックの選定

### 要件

- **Webベースでの実装**：インストール不要で誰でも使える。
- **デバイスのセンサーやGPSへのアクセス**：速度情報を取得するため。
- **最新のWeb技術を活用**：技術オタクとして新しいものを試したい。

### 選定した技術

- **Nuxt 3**：Vue 3ベースの最新フレームワーク。
  - Composition APIなど新機能を試せる。
  - 開発効率とパフォーマンスが高い。
- **Geolocation API**：デバイスのGPSから位置情報を取得。
  - 加速度センサーよりも精度が高い。
  - 主要なブラウザでサポート。
- **TypeScript**：コードの可読性と保守性向上。
- **Tailwind CSS**：効率的なスタイリング。
- **PWA対応**：ホーム画面に追加してネイティブアプリのように使える。

### 加速度センサーを断念した理由

当初は加速度センサーで速度を算出しようとしましたが、

- **ノイズとドリフトの問題**：正確な速度が得られない。
- **ブラウザの制限**：セキュリティ上アクセスが制限されている。
- **ユーザー体験の低下**：許可ダイアログが煩雑。

これらの理由から断念し、GPSを採用しました。

### 技術スタック選定のまとめ

技術オタクとして、最新のWeb技術を（無駄に）ふんだんに盛り込むことで、開発プロセス自体を楽しむことができました。特にNuxt 3とGeolocation APIの組み合わせは、Webアプリケーションの可能性を広げるものであり、今回のクソアプリ開発には最適でした。

結果として、ユーザーがインストール不要でアクセスでき、デバイスのハードウェア機能を活用したアプリを実現できました。これらの技術選定により、「人力オートクルーズ」というニッチで無駄な機能を、技術的に実装することができたのです。


## 4. 実装の詳細

ここでは、「人力オートクルーズ」アプリの具体的な実装について詳しく解説します。主に、速度取得のためのセンサー処理と、そのデータをどのように画面の色変化に反映させているかに焦点を当てます。

#### 4.1 速度取得の実装

速度を取得するために、Vue.jsのComposition APIを利用して`useGpsSpeedSensor`というComposableを作成しました。このComposableは、GPSから現在の速度を取得し、リアクティブな`speed`値を提供します。

以下が実装した`useGpsSpeedSensor`のコードです。

```typescript
// composables/useGpsSpeedSensor.ts

import { ref, onUnmounted, watch } from 'vue';

interface GpsSpeedSensorOptions {
  watchOptions?: PositionOptions;
  isDemoMode?: Ref<boolean>;
}

export function useGpsSpeedSensor(options: GpsSpeedSensorOptions = {}) {
  const isDemoMode = options.isDemoMode ?? ref(false);
  const watchOptions = options.watchOptions ?? {
    enableHighAccuracy: true,
    maximumAge: 0,
    timeout: 5000,
  };

  // リアクティブなデータの定義
  const speed = ref(0); // 現在の速度（km/h 単位）
  const status = ref('Waiting for the sensor...');
  const error = ref<string | null>(null);
  const isRunning = ref(false);

  let watchId: number | null = null;
  let demoIntervalId: number | null = null;

  // センサーの監視を開始します。
  const start = () => {
    // 既に実行中であれば何もしない
    if (isRunning.value) return;
    isRunning.value = true;

    if (isDemoMode.value) {
      // デモモードの処理
      status.value = 'Started in DEMO mode.';
      let demoSpeed = 0;
      let increment = 0.5; // 速度の増減幅

      demoIntervalId = window.setInterval(() => {
        demoSpeed += increment;
        if (demoSpeed >= 100 || demoSpeed <= 0) increment *= -1; // 0～100 km/h の範囲で速度を変化
        speed.value = parseFloat(demoSpeed.toFixed(2));
      }, 100);
    } else {
      // 実際のGPSセンサーを使用
      if ('geolocation' in navigator) {
        status.value = 'Activating GPS sensor...';
        watchId = navigator.geolocation.watchPosition(
          (position) => {
            if (position.coords.speed !== null) {
              // GPSから取得した速度を使用（m/s から km/h に変換）
              speed.value = parseFloat((position.coords.speed * 3.6).toFixed(2));
              status.value = 'Speed acquired.';
            } else {
              // 速度が取得できない場合は位置の変化から速度を計算
              calculateSpeedFromPosition(position);
            }
          },
          (err) => {
            error.value = err.message;
            status.value = 'Failed to acquire GPS.';
            isRunning.value = false;
          },
          watchOptions
        );
      } else {
        error.value = 'Your device does not support GPS.';
        status.value = 'GPS is not available.';
        isRunning.value = false;
      }
    }
  };

  // 位置情報の変化から速度を計算します。
  let lastPosition: GeolocationPosition | null = null;

  const calculateSpeedFromPosition = (position: GeolocationPosition) => {
    if (lastPosition) {
      const deltaTime = (position.timestamp - lastPosition.timestamp) / 1000; // ミリ秒から秒に変換
      if (deltaTime > 0) {
        const distance = getDistanceFromLatLonInMeters(
          lastPosition.coords.latitude,
          lastPosition.coords.longitude,
          position.coords.latitude,
          position.coords.longitude
        );
        const calculatedSpeed = (distance / deltaTime) * 3.6; // km/h に変換
        speed.value = parseFloat(calculatedSpeed.toFixed(2));
        status.value = 'Speed calculated.';
      }
    }
    lastPosition = position;
  };

  // 2点間の緯度・経度から距離を計算します。
  const getDistanceFromLatLonInMeters = (
    lat1: number,
    lon1: number,
    lat2: number,
    lon2: number
  ) => {
    const R = 6371000; // 地球の半径（メートル）
    const dLat = deg2rad(lat2 - lat1);
    const dLon = deg2rad(lon2 - lon1);
    const a =
      Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(deg2rad(lat1)) *
        Math.cos(deg2rad(lat2)) *
        Math.sin(dLon / 2) *
        Math.sin(dLon / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    const distance = R * c;
    return distance;
  };

  // 度数をラジアンに変換します。
  const deg2rad = (deg: number) => {
    return deg * (Math.PI / 180);
  };

  // センサーの監視を停止します。
  const stop = () => {
    if (!isRunning.value) return;
    isRunning.value = false;

    if (isDemoMode.value) {
      if (demoIntervalId !== null) {
        clearInterval(demoIntervalId);
        demoIntervalId = null;
      }
    } else {
      if (watchId !== null) {
        navigator.geolocation.clearWatch(watchId);
        watchId = null;
      }
    }
    // 状態のリセット
    speed.value = 0;
    status.value = isDemoMode.value ? 'DEMO mode.' : 'Waiting for the sensor...';
    error.value = null;
    lastPosition = null;
  };

  // isDemoMode の変更を監視し、状態を更新します
  watch(isDemoMode, (newVal, oldVal) => {
    if (isRunning.value) {
      stop();
      start();
    } else {
      status.value = newVal ? 'DEMO mode.' : 'Waiting for the sensor...';
    }
  });

  // コンポーネントがアンマウントされたときに監視を停止します
  onUnmounted(() => {
    stop();
  });

  return {
    speed,
    status,
    error,
    isRunning,
    start,
    stop,
  };
}
```

##### 4.1.1 リアクティブデータの定義

`ref`を使って、リアクティブなデータを定義しています。

- `speed`: 現在の速度（km/h）
- `status`: センサーの状態メッセージ
- `error`: エラーメッセージ
- `isRunning`: センサーが動作中かどうかのフラグ

```typescript
const speed = ref(0);
const status = ref('Waiting for the sensor...');
const error = ref<string | null>(null);
const isRunning = ref(false);
```

##### 4.1.2 センサーの開始と停止

`start`関数でセンサーの監視を開始し、`stop`関数で監視を停止します。`isDemoMode`の値に応じて、デモモードか実際のGPSモードかを切り替えています。

```typescript
const start = () => {
  // 実行中かどうかをチェック
  if (isRunning.value) return;
  isRunning.value = true;

  if (isDemoMode.value) {
    // デモモードの処理
  } else {
    // GPSセンサーの処理
  }
};

const stop = () => {
  // 実行中でなければ何もしない
  if (!isRunning.value) return;
  isRunning.value = false;

  // デモモードとGPSモードで停止処理を分岐
};
```

##### 4.1.3 GPSデータの取得

`navigator.geolocation.watchPosition`を使用して、位置情報をリアルタイムに取得します。取得した位置情報から速度を計算します。

```typescript
if ('geolocation' in navigator) {
  status.value = 'Activating GPS sensor...';
  watchId = navigator.geolocation.watchPosition(
    (position) => {
      if (position.coords.speed !== null) {
        // 直接速度が取得できる場合
        speed.value = parseFloat((position.coords.speed * 3.6).toFixed(2));
        status.value = 'Speed acquired.';
      } else {
        // 位置の変化から速度を計算
        calculateSpeedFromPosition(position);
      }
    },
    (err) => {
      error.value = err.message;
      status.value = 'Failed to acquire GPS.';
      isRunning.value = false;
    },
    watchOptions
  );
} else {
  error.value = 'Your device does not support GPS.';
  status.value = 'GPS is not available.';
  isRunning.value = false;
}
```

##### 4.1.4 速度の計算ロジック

GPSから直接速度が取得できない場合、前回の位置情報との比較から速度を計算します。ハーバサインの公式を用いて2点間の距離を算出し、時間差で割ることで速度を求めます。

```typescript
const calculateSpeedFromPosition = (position: GeolocationPosition) => {
  if (lastPosition) {
    const deltaTime = (position.timestamp - lastPosition.timestamp) / 1000; // 秒に変換
    if (deltaTime > 0) {
      const distance = getDistanceFromLatLonInMeters(
        lastPosition.coords.latitude,
        lastPosition.coords.longitude,
        position.coords.latitude,
        position.coords.longitude
      );
      const calculatedSpeed = (distance / deltaTime) * 3.6; // km/h に変換
      speed.value = parseFloat(calculatedSpeed.toFixed(2));
      status.value = 'Speed calculated.';
    }
  }
  lastPosition = position;
};
```

##### 4.1.5 デモモードの実装

デモモードでは、一定の速度で値が増減するように`setInterval`を使用して速度をシミュレートします。

```typescript
if (isDemoMode.value) {
  status.value = 'Started in DEMO mode.';
  let demoSpeed = 0;
  let increment = 0.5; // 速度の増減幅

  demoIntervalId = window.setInterval(() => {
    demoSpeed += increment;
    if (demoSpeed >= 100 || demoSpeed <= 0) increment *= -1; // 0～100 km/h の範囲で速度を変化
    speed.value = parseFloat(demoSpeed.toFixed(2));
  }, 100);
}
```

#### 4.2 画面の色変化ロジック

取得した速度を元に、画面の背景色を変化させます。目標速度との差異を計算し、その差異に応じて色相（Hue）や明度（Lightness）を調整します。

```typescript
const targetSpeed = ref(60); // ユーザーが設定したい目標速度

const backgroundColor = computed(() => {
  const speedDifference = speed.value - targetSpeed.value;
  // 差異に基づいて色相を計算（例：遅いと青、速いと赤、中間が緑）
  const hue = calculateHueFromSpeedDifference(speedDifference);
  return `hsl(${hue}, 100%, 50%)`;
});
```

`backgroundColor`をコンポーネントのスタイルにバインドし、リアクティブに背景色が変化するようにします。

#### 4.3 UIコンポーネントとの連携

`useGpsSpeedSensor`で取得したデータを、Vueコンポーネント内で使用します。

```vue
<template>
  <div :style="{ backgroundColor }" class="app-container">
    <div class="speed-display">
      <p>Current Speed: {{ speed }} km/h</p>
      <p>Status: {{ status }}</p>
      <p v-if="error">Error: {{ error }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useGpsSpeedSensor } from '@/composables/useGpsSpeedSensor';

const targetSpeed = ref(60);
const { speed, status, error, start, stop } = useGpsSpeedSensor();

const backgroundColor = computed(() => {
  // 背景色の計算ロジック
});
</script>
```

#### 4.4 安全性への配慮

- **操作不要の設計**: アプリ起動後は、ユーザーが操作する必要がないように設計しています。
- **視覚的なフィードバック**: 画面の色変化のみで速度の過不足を知らせるため、運転中の注意散漫を最小限に抑えます。
- **デモモードの提供**: 運転中でなくともアプリを試せるよう、デモモードを実装しました。


## 5. まとめ

「人力オートクルーズ」という無謀なアイデアを、最新のWeb技術で実現しました。GPSを用いて現在の速度を取得し、画面の色変化で速度調整をサポートすることで、ハードウェアなしでオートクルーズ的な体験を提供します。

技術的にはNuxt 3やGeolocation APIを活用し、Webアプリでもデバイスのハードウェア機能を活用できることを示しました。クソアプリではありますが、技術オタクとして楽しく開発できました。

最後までお読みいただき、ありがとうございました。ぜひ皆さんも試してみてください！（※安全運転で！）


## 実際のアプリ

https://crume.me/

:::note
🚨 **重要なお知らせ：これは絶対守るべし！** 🚨  
運転中に画面を凝視したり操作するのは、完全にアウトです。具体的には、これは**道路交通法第71条**で明確に規定されています。要するに、「運転中にそんなことするなよ！」ということです。
:::

このアプリを使うときは、**必ず停車してから操作や確認を行うようにしましょう**。安全第一でお願いします！