1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React+Typescript】周辺のお店リストを取得するため、OverpassAPIを使ってみた

Last updated at Posted at 2024-10-27

はじめに

家計簿アプリを個人で開発しているのですが、近くのお店リストがあればinstagramのようにタグ付けしながら簡単に家計簿を入力できるんじゃないかと思いました。
有名どころだとGoogle map?お金がかかってしまいそう?など色々考えて、最終的にはOverpass APIを利用することにしました。

色々調査

地図検索系APIをいくつか調査してみました。
まず、思いついたのはGoogle map API、以下のように情報の正確性・開発する上でドキュメントが豊富などかなりアプリに使いやすいのかなといった印象でした。
ただ、お金がかかってしまう可能性があるのが個人的には非常にネックでした。自分で作ったアプリをApp storeに出してみたいのですが、今回は完全無料で開発する(app storeに出すためのお金を除く)ことがポリシーにあったため使いませんでした。しかし、次回使ってみたいなと思うような機能豊富APIです。

Google map API

メリット

  • データの正確性、更新頻度が高い
  • 豊富なドキュメント
  • マップ表示やストリートビュー表示、標高、タイムゾーン取得など様々な機能提供

デメリット

  • 無料利用枠200ドル分だが、超えると従量課金

私のアプリは大雑把な情報でもよく、多少情報量や情報の正確さが劣っていても無料で使えるAPIを求めていました。色々探してみるとOverpass APIにたどり着きました。
Overpass APIは、OpenStreetMapという誰でも自由に地図を使えるように、みんなでオープンデータの地図情報を作るプロジェクトだそうです。参考の記事には「地図のWikipedia」と表現しているものもあり、確かに、と思ったりしました。
SQLのような感じで、Overpass QLという形式のクエリを投げてレスポンスを受けるAPIです。

Overpass API

メリット

  • 無料で利用できる
  • OpenStreetMapのプロジェクトが個人的に面白い

デメリット

  • Google mapのメリットには全体的に勝てなそう
  • すべてのデータ形式が統一されておらず、少し情報を取得しにくい

上記のようにGoogle mapさすが!といった感じですが、今回は無料であるOver pass APIを採用することにしました

Overpass QL試してみる

まず、Overpass QLを利用して近くのお店リストを取得するクエリを試してみます。
overpass turboというサイトがあり、こちらで試すことができます。
以下は試しに東京駅付近のお店や施設を取得できるクエリです。
下記は東京駅(緯度35.681236, 経度139.767125)を中心に半径500m以内の"shop", "amenity"でタグ付けされている施設を取得する例です。

[out:json];
(
  node["shop"] (around:500, 35.681236, 139.767125);
  node["amenity"] (around:500, 35.681236, 139.767125);
);
out body;
>;

image.png

image.png

上記のように該当箇所がポイントアウトされて、それぞれの場所のデータがjson形式で抽出できます。
いくつかデータを抽出してみると以下のようにデータごとに形式がバラバラで扱いにくかったです。とはいえ、これだけのデータがあれば使えそうではあります。

{
  "type": "node",
  "id": 261023513,
  "lat": 35.6804624,
  "lon": 139.7692620,
  "tags": {
    "amenity": "bank",
    "brand": "みずほ銀行",
    "brand:en": "Mizuho Bank",
    "brand:ja": "みずほ銀行",
    "brand:wikidata": "Q2882956",
    "brand:wikipedia": "en:Mizuho Bank",
    "name": "みずほ銀行",
    "name:en": "Mizuho Bank",
    "name:ja": "みずほ銀行",
    "wheelchair": "limited"
  }
},
{
  "type": "node",
  "id": 261023533,
  "lat": 35.6804609,
  "lon": 139.7695624,
  "tags": {
    "amenity": "car_rental",
    "wheelchair": "yes"
  }
},
{
  "type": "node",
  "id": 1420766273,
  "lat": 35.6798771,
  "lon": 139.7706973,
  "tags": {
    "addr:block_number": "4",
    "addr:city": "中央区",
    "addr:housenumber": "15",
    "addr:neighbourhood": "3",
    "addr:postcode": "103-0027",
    "addr:province": "東京都",
    "addr:quarter": "日本橋",
    "amenity": "police",
    "name": "中央警察署八重洲通交番",
    "name:en": "Koban",
    "note": "National-Land Numerical Information (Public Facility) 2006, MLIT Japan",
    "note:ja": "国土数値情報(公共施設データ)平成19年 国土交通省",
    "source": "KSJ2",
    "source_ref": "http://nlftp.mlit.go.jp/ksj/jpgis/datalist/KsjTmplt-P02-v2_0.html",
    "wheelchair": "limited"
  }
}

ここであるカテゴリの施設を調べたいとしましょう。近くの「銀行」を取得したい場合以下のようなクエリを投げればよいです。

[out:json];
(
  node["amenity"] ["name"~"銀行"](around:500, 35.681236, 139.767125);
);
out body;
>;

image.png

image.png

{
  "type": "node",
  "id": 261023513,
  "lat": 35.6804624,
  "lon": 139.7692620,
  "tags": {
    "amenity": "bank",
    "brand": "みずほ銀行",
    "brand:en": "Mizuho Bank",
    "brand:ja": "みずほ銀行",
    "brand:wikidata": "Q2882956",
    "brand:wikipedia": "en:Mizuho Bank",
    "name": "みずほ銀行",
    "name:en": "Mizuho Bank",
    "name:ja": "みずほ銀行",
    "wheelchair": "limited"
  }
},
{
  "type": "node",
  "id": 2011628569,
  "lat": 35.6851519,
  "lon": 139.7652752,
  "tags": {
    "addr:full": "東京都中央区八重洲1-2-16",
    "addr:postcode": "103-0028",
    "amenity": "bank",
    "atm": "yes",
    "branch": "東京中央支店",
    "brand": "みずほ銀行",
    "brand:en": "Mizuho Bank",
    "brand:ja": "みずほ銀行",
    "brand:wikidata": "Q2882956",
    "brand:wikipedia": "en:Mizuho Bank",
    "name": "みずほ銀行",
    "name:en": "Mizuho Bank",
    "name:ja": "みずほ銀行",
    "name:ja_rm": "Mizuho Ginkou",
    "opening_hours": "Mo-Fr 09:00-15:00",
    "operator": "みずほ銀行",
    "phone": "+81-3-32015111",
    "ref": "110",
    "source": "Bing 2010;(URL)",
    "website": "https://www.mizuhobank.co.jp/",
    "wheelchair": "yes"
  }
},
{
  "type": "node",
  "id": 2023811559,
  "lat": 35.6843810,
  "lon": 139.7659558,
  "tags": {
    "addr:full": "東京都千代田区丸の内1-6-1",
    "addr:postcode": "100-0005",
    "amenity": "bank",
    "atm": "yes",
    "branch": "丸之内支店",
    "brand": "みずほ銀行",
    "brand:en": "Mizuho Bank",
    "brand:ja": "みずほ銀行",
    "brand:wikidata": "Q2882956",
    "brand:wikipedia": "en:Mizuho Bank",
    "name": "みずほ銀行",
    "name:en": "Mizuho Bank",
    "name:ja": "みずほ銀行",
    "name:ja_rm": "Mizuho Ginkou",
    "opening_hours": "Mo-Fr 09:00-15:00",
    "operator": "みずほ銀行",
    "phone": "+81-3-32161111",
    "ref": "005",
    "source": "(URL)",
    "website": "https://www.mizuhobank.co.jp/",
    "wheelchair": "yes"
  }
},

こんな感じで、条件を絞って取得するということもできました。

実際の利用法(React+Typescript)

以下のようにTypescriptにOverpass QLを埋め込み、axiosで投げる。返ってきた結果をTypescriptの形に成形する。以下は実装です。


import axios from "axios"; // axios:HTTPメソッドのためのライブラリ

// 緯度経度
type LocationType = {
  latitude: number | null,
  longitude: number | null;
};

// Overpass APIの結果の型
type MapList = {
  shopName: string, // ショップ名
  shopLocationName: string, // ショップの場所(緯度経度)
  distance: number; // 距離
};

//haversine fomula(半正矢関数 ※単位はmとする
const haversineDistance = (
  location1: LocationType,
  location2: LocationType,
): number => {
  const R = 6371; // 地球の半径(キロメートル)

  if (
    location1.latitude == null ||
    location1.longitude == null ||
    location2.latitude == null ||
    location2.longitude == null
  ) {
    return 0;
  }

  const toRadians = (degree: number) => (degree * Math.PI) / 180;

  const dLat = toRadians(location2.latitude - location1.latitude);
  const dLon = toRadians(location2.longitude - location1.longitude);

  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(toRadians(location1.latitude)) *
      Math.cos(toRadians(location2.latitude)) *
      Math.sin(dLon / 2) ** 2;

  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

  return R * c * 1000;
};

// 近くのお店・施設のリストを返す関数
const getShopList = async (
  searchString: string, // 検索文字列(今回は検索したいものに含まれる文字列)
  currentLocation: LocationType, // 現在地の緯度経度
): Promise<MapList[]> => {
  const searchRadius: number = 500;

  // Overpass QLをここに埋め込む
  const query = `
  [out:json];
  (
    node["shop"]["name"~"${searchString}"](around:${searchRadius.toString()}, ${
      currentLocation.latitude
    }, ${currentLocation.longitude});
    node["amenity"]["name"~"${searchString}"](around:${searchRadius.toString()}, ${
      currentLocation.latitude
    }, ${currentLocation.longitude});
  );
  out body;
  `;

  try {
    // axiosによりOverpass QLをPOSTで投げる
    const response = await axios.post(
      "https://overpass-api.de/api/interpreter",
      `data=${encodeURIComponent(query)}`,
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      },
    );

    // MapList型の配列として、Overpass QLの結果を成形
    const shopList: MapList[] = response.data.elements.map((element: any) => {
      const shopName = element.tags?.name || "Unnamed Shop";
      const shopLocationName =
        element.tags?.branch || element.tags?.["addr:full"];
      const shopLocation: LocationType = {
        latitude: element.lat,
        longitude: element.lon,
      };
      const distance = parseFloat(
        haversineDistance(currentLocation, shopLocation).toFixed(0),
      );

      return {
        shopName,
        shopLocationName,
        distance, // 数値型で距離を保存
      };
    });

    // 数値型の距離でソート
    shopList.sort((a, b) => a.distance - b.distance);

    return shopList;
  } catch (error) {
    console.error("Error fetching shops:", error);
    return [];
  }
};

export default { getShopList };

上記のメソッドを画面に出力するために、Reactでコンポーネントに結果を埋め込むということをします。
以下は色々省略していますが、実装です。

type ListItemProps = {
  shopName: string,
  shopLocationName: string,
  distance: number;
};

const ListItem: React.FC<ListItemProps> = ({ ...ListItemProps }) => (
  <View>
    <Text>{ListItemProps.shopName}</Text>
    <Text>
      {ListItemProps.shopLocationName}
    </Text>
    <Text>{ListItemProps.distance}m</Text>
  </View>
);

const HomeMain: React.FC = () => {
  const [searchText, setSearchText] = useState<string>("");
  const [shopList, setShopList] = useState<ListItemProps[]>([]);
  
  // 現在地の緯度経度を出力するメソッド(今回は省略)
  const { currentLocation } = useCurrentLocation(refreshing); 

  useEffect(() => {
    const fetchShops = async () => {
      if (currentLocation.latitude && currentLocation.longitude) {
        const list = await ShopListApi.getShopList(searchText, currentLocation);
        setShopList(list);
      }
    };

    fetchShops();
  }, [currentLocation, searchText]);
  
  return (
      <View>
        <TextInput
          placeholder="Search"
          value={searchText}
          onChangeText={setSearchText}
        />
      </View>
      <FlatList
        data={shopList}
        renderItem={({ item }) => (
            <ListItem
              shopName={item.shopName}
              shopLocationName={item.shopLocationName}
              distance={item.distance}
            />
        )}
        keyExtractor={(item, index) => index.toString()}
      />
  );
};

スタイルとか色々省いていますが、結果以下のようになります。
検索文字列を入力すると非同期でOverpass QLによりAPIが叩かれ結果がリストとして出力されます。(ローディング機能とか、スタイルとか、なんか色々省いてます。)

mojikyo45_640-2.gif mojikyo45_640-2.gif

上記で現在地情報(currentLocation)を利用していますが、こちらの実装は以下の記事で紹介しています。
良ければのぞいてみてください。

私の学習したことが誰かの役に立てば満足です。
上記のアプリは完成したらお披露目したいと思います。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?