【後編2】React+Leaflet+Twitter「ナラティブ」アプリ

# 【後編2】React+Leaflet連携 & Twitter連携で「地図×SNS×ナラティブ」アプリを完成させよう

お待たせしました！前回の「[後編1](#)」では、**Express+PostgreSQL**でモックアップAPIを作り、データベースに投稿を保存する一連の流れを確認しました。今回は**後編2**として、実際に**React**（フロントエンド）と**Leaflet**（地図表示）を組み合わせ、さらに**Twitter API**を活用してSNS上のデータを取得し、地図に可視化するイメージを実装していきます。  

---

## 全体構成のおさらい

```
my-project/
 ┣ frontend/    ← React + Leaflet
 ┣ server/      ← Node.js (Express) + PostgreSQL接続
 ┗ database     ← (PostgreSQL/PostGIS)
```

- **フロントエンド**: React + Leaflet.js  
- **バックエンド**: Express（Node.js）でAPI化  
- **DB**: PostgreSQLで投稿データやTwitterデータを管理  
- **Twitter API**: Bearer Tokenなどを使い位置情報付きツイートを取得

前編1では **`/api/posts`** による投稿の読み書きを実装しました。  
今回のステップは以下のとおりです。

1. **Leafletを使った地図表示**＆ユーザーが地図クリック→投稿できるフォーム  
2. **PostGIS**を使った位置情報カラムの追加と保存  
3. **Twitter API**を呼び出して位置情報付きツイートを取得し、DBに取り込む（簡易版）  
4. 画面に投稿 & ツイートを両方マッピングして可視化

---

# 1. フロントエンド：React + Leafletで地図表示

## 1.1 フロントエンド初期設定

既に`frontend`フォルダを作成している前提です。無い場合は以下のように:

```bash
cd my-project
npx create-react-app frontend
cd frontend
npm install leaflet react-leaflet axios
```

- `leaflet`, `react-leaflet` : 地図表示ライブラリ
- `axios` : HTTPクライアント

### package.json 一例

```jsonc
{
  "name": "frontend",
  "dependencies": {
    "react": "^18.x",
    "react-dom": "^18.x",
    "react-leaflet": "^4.x",
    "leaflet": "^1.x",
    "axios": "^0.27.x"
  },
  "scripts": {
    "start": "react-scripts start",
    // ... 
  }
}
```

## 1.2 地図コンポーネント作成

`src/components/MapView.js` のように分けると管理しやすいです。

```jsx
// src/components/MapView.js
import React, { useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';

function MapView({ markers }) {
  const [clickPosition, setClickPosition] = useState(null);

  // 地図クリックイベントを捕捉
  useMapEvents({
    click(e) {
      setClickPosition(e.latlng);
    }
  });

  return (
    <MapContainer center={[35.0, 135.0]} zoom={5} style={{ height: '80vh' }}>
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution="&copy; OpenStreetMap contributors"
      />

      {markers.map((m) => (
        <Marker key={m.id} position={[m.lat, m.lng]}>
          <Popup>
            <h3>{m.title}</h3>
            <p>{m.content}</p>
          </Popup>
        </Marker>
      ))}

      {clickPosition && (
        <Marker position={[clickPosition.lat, clickPosition.lng]}>
          <Popup>ここに投稿しますか？</Popup>
        </Marker>
      )}
    </MapContainer>
  );
}

export default MapView;
```

- `markers` はサーバーから取得した投稿データを配列として受け取り、Markerとして表示する想定。  
- `clickPosition` にはユーザーがクリックした座標が入り、そこからフォーム送信などの操作を行う（後述）。

## 1.3 ルートコンポーネントに組み込む

`src/App.js` など:

```jsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import MapView from './components/MapView';

function App() {
  const [markers, setMarkers] = useState([]);

  // 起動時にAPIから既存投稿を取得
  useEffect(() => {
    axios.get('http://localhost:4000/api/posts')
      .then(res => setMarkers(res.data))
      .catch(err => console.error(err));
  }, []);

  return (
    <div>
      <h1>地図×ナラティブ × SNS</h1>
      <MapView markers={markers} />
    </div>
  );
}

export default App;
```

### 動作確認

```bash
cd frontend
npm start
```

ブラウザが `http://localhost:3000/` で起動し、地図が表示されるはずです。  
まだ投稿を地図に反映する機能を追加していないので、`markers` が空ならMarkerも無しとなります。

---

# 2. PostGISで位置情報を保存

## 2.1 DBテーブルの拡張

「後編1」では`posts`テーブルに `title, content, created_at` しかありませんでした。ここに「**geom**」列を追加して地理情報を扱えるようにします。

```sql
ALTER TABLE posts
  ADD COLUMN geom geometry(Point, 4326);

-- Default値は無し。必要なら:
-- UPDATE posts SET geom = ST_SetSRID(ST_Point(lon, lat), 4326) ...
```

> これにより、`geom`に世界測地系（SRID=4326）のポイント情報を保存できます。

## 2.2 Express側：INSERT時にgeomをセット

`server/index.js` のPOST処理を以下のように改修:

```js
app.post('/api/posts', async (req, res) => {
  try {
    const { title, content, lat, lng } = req.body;
    
    const queryText = `
      INSERT INTO posts (title, content, geom)
      VALUES ($1, $2, ST_SetSRID(ST_Point($3, $4), 4326))
      RETURNING *
    `;
    const values = [title, content, lng, lat];
    
    const result = await pool.query(queryText, values);
    res.json(result.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'DB insert error' });
  }
});
```

> ここでは `(lng, lat)` の順序で `ST_Point` を作成している点に注意。実際には「経度が先、緯度が後」が標準です。

## 2.3 GETのレスポンスに lat/lng を含める

同じくGET `/api/posts`時にDBの`geom`を緯度経度に変換して返却:

```js
app.get('/api/posts', async (req, res) => {
  try {
    const sql = `
      SELECT id, title, content, 
             ST_X(geom) as lng, 
             ST_Y(geom) as lat,
             created_at
      FROM posts
      ORDER BY id DESC
    `;
    const result = await pool.query(sql);
    res.json(result.rows);  // each row now has lat/lng
  } catch (err) {
    res.status(500).json({ error: 'DB query error' });
  }
});
```

こうすればフロントエンドで `{ id, title, content, lat, lng }` をMarkerの座標に使えます。

---

# 3. 地図上で投稿フォームを実装

## 3.1 フォームの追加

先ほどの `MapView.js` 内 `clickPosition` を利用し、ポップアップや別コンポーネントで入力を行う仕組みにします。以下は簡易例:

```jsx
// MapView.js
function MapView({ markers }) {
  const [clickPosition, setClickPosition] = useState(null);
  const [formVisible, setFormVisible] = useState(false);

  useMapEvents({
    click(e) {
      setClickPosition(e.latlng);
      setFormVisible(true);
    }
  });

  return (
    <div style={{ position: 'relative' }}>
      <MapContainer ...>
        <TileLayer ... />
        {markers.map(...)}
        {clickPosition && (
          <Marker position={[clickPosition.lat, clickPosition.lng]}>
            <Popup>投稿フォームを表示しますか？</Popup>
          </Marker>
        )}
      </MapContainer>

      {formVisible && (
        <PostForm
          latlng={clickPosition}
          onClose={() => setFormVisible(false)}
        />
      )}
    </div>
  );
}
```

## 3.2 PostForm コンポーネント

```jsx
// PostForm.js (簡易例)
import axios from 'axios';
import React, { useState } from 'react';

function PostForm({ latlng, onClose }) {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const handleSubmit = async () => {
    try {
      const payload = {
        title,
        content,
        lat: latlng.lat,
        lng: latlng.lng
      };
      await axios.post('http://localhost:4000/api/posts', payload);
      alert('投稿しました');
      onClose();
    } catch (err) {
      console.error(err);
      alert('エラーが発生しました');
    }
  };

  return (
    <div style={{
      position: 'absolute', top: 50, right: 50, backgroundColor: '#fff', padding: '1rem'
    }}>
      <h3>新規投稿</h3>
      <p>緯度: {latlng.lat}, 経度: {latlng.lng}</p>
      <div>
        <label>タイトル：</label>
        <input value={title} onChange={(e) => setTitle(e.target.value)} />
      </div>
      <div>
        <label>本文：</label>
        <textarea value={content} onChange={(e) => setContent(e.target.value)} />
      </div>
      <button onClick={handleSubmit}>送信</button>
      <button onClick={onClose}>キャンセル</button>
    </div>
  );
}

export default PostForm;
```

フロントエンド上で「マップクリック → PostForm表示 → 送信」でDBに保存 → 位置情報付き投稿が完成です。  
送信後、Markerリストを再取得するように工夫するとリアルタイムにマーカーが追加されます。

---

# 4. Twitter API連携（位置情報付きツイート取得）

## 4.1 Expressルート: `/api/twitter` などを用意

`server/index.js` (例):

```js
const { TwitterApi } = require('twitter-api-v2');
const TWITTER_BEARER = process.env.TWITTER_BEARER_TOKEN || '...';

app.get('/api/twitter/search', async (req, res) => {
  try {
    const client = new TwitterApi(TWITTER_BEARER);
    const keyword = req.query.q || '公園';

    // API v2 search
    const results = await client.v2.search(keyword, {
      expansions: ['geo.place_id'],
      'tweet.fields': ['created_at', 'geo', 'text']
      // place_id情報など地理データを使うには特別な承認が必要なケースも
    });

    res.json(results);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Twitter API error' });
  }
});
```

> **注意**: Twitterの位置情報付きツイートはAPIポリシーが変わりやすく、最新の規約をチェックしてください。

## 4.2 取得したツイートをDBに格納

やり方は「`POST /api/twitter/save`」などを作り、取得したツイートをPostGISへ挿入するフローを実装します。  
いったん簡易化のため省略例:

```js
app.post('/api/twitter/save', async (req, res) => {
  // req.body.tweets に { text, lat, lng, created_at } といった形式で渡す
  // DBにINSERT (INSERT INTO posts(title, content, geom) ... )
});
```

## 4.3 フロントエンドで呼び出し & 地図に表示

```jsx
// 例: Twitter検索ボタンを設置
function TwitterSearch() {
  const [keyword, setKeyword] = useState('');

  const handleSearch = async () => {
    const res = await axios.get(`http://localhost:4000/api/twitter/search?q=${keyword}`);
    console.log(res.data); // ここでTwitterデータを確認
    // 必要に応じてDBに保存 or 画面にマップする
  };

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={handleSearch}>Twitter検索</button>
    </div>
  );
}
```

フロントUIは用途に合わせて設計します。位置情報のあるツイート（`geo`フィールドや`place`情報）を抽出できた場合、`lat, lng`をMarker化すればOK。

---

# 5. デプロイと運用のヒント

1. **フロント＆バックの同時起動**: 
   - `npm run start` (frontend) と `npm run start` (server) を並行して行う。  
   - CORS設定を正しくする (`app.use(cors())`)。
2. **Docker Compose**で一括起動: 
   - `docker-compose.yml`を用意し、`Node.js/Express` + `PostgreSQL/PostGIS` + `React` などをまとめて管理する。
3. **Twitterの有料プラン注意**: 
   - 位置情報検索や大規模データ取得には制限や有料枠があるかもしれない。常に最新情報を確認。
4. **地図UI**: 
   - Leaflet以外にもMapbox GLやDeck.glを組み合わせる選択肢あり。  
   - 大量のマーカーを描画する際はパフォーマンスに注意。

---

# 6. まとめ

- **Leaflet + PostGIS**を組み合わせることで、「位置情報付きのナラティブ投稿」を直感的に扱えるようになりました。  
- **Twitter API**を絡めれば外部から取得したデータも地図に統合可能です。  
- 今回は最小限の例ですが、さらに**写真アップロード**や**検索フィルタ**などを追加すると実用度が高まります。

**これで「後編2」完了です！**  
ぜひ、このサンプルをベースにしたアプリを拡張し、**「地図上で多様なナラティブを収集＆可視化できる仕組み」**を作り上げてみてください。お疲れさまでした。

---

## 参考リンク

- **Leaflet**: [https://leafletjs.com/](https://leafletjs.com/)  
- **React Leaflet**: [https://react-leaflet.js.org/](https://react-leaflet.js.org/)  
- **PostGIS**: [https://postgis.net/](https://postgis.net/)  
- **Twitter API**: [https://developer.twitter.com/](https://developer.twitter.com/)  

以上で**後編2**の解説でした。投稿フォームやTwitter連携など、ぜひ実際に動かしてみてさらなる機能追加に挑戦してみてください！