🌊

react-native-mapをオフラインで使う

2024/03/24に公開

ExpoでGoogle mapを使う

react-native-mapを使うことで簡単にgoogle mapをアプリケーションに組み込むことができる

ただし、GoogleのMap SDK for Android / iOSを使用する

詳細な設定は各所にあるので、ここでは割愛する。

react-native-mapをオフラインで使えるか?

使える。

しかし、事前にタイルデータを端末にダウンロードしておく必要がある。

タイルデータのソースとして今回はGoogle Tile APIを使うこととする。

https://developers.google.com/maps/documentation/tile?hl=ja

無料枠はあるが、Map SDKとともに課金されるので注意

Google Maps Tiles APIを有効にする

GCPの設定を先に済ませておく

https://developers.google.com/maps/documentation/tile/cloud-setup?hl=ja

Tiles APIをテストする

https://developers.google.com/maps/documentation/tile/roadmap?hl=ja

Googleの公式ドキュメントに従って、

  1. セッショントークンを取得
  2. 任意の座標(x,y,z)のタイルを取得する

をテストしてみる

ts-nodeで実行するなら以下の通り

const API_KEY = 'your api key'

async function createSession() {
  const url = `https://tile.googleapis.com/v1/createSession?key=${API_KEY}`;
  const requestBody = {
    mapType: "roadmap",
    language: "en-US",
    region: "JP"
  };

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(requestBody),
    });

    if (!response.ok) {
      throw new Error('Request failed: ' + response.status);
    }

    const data = await response.json();
    console.log('Session created:', data);
  } catch (error) {
    console.error('Error creating session:', error);
  }
}

createSession()

タイルをExpo上でダウンロードする

タイルを取得する時には2つの条件を設定する

  1. 取得したいRegion
  2. ズームレベル

例として、Regionとズームレベルを固定して取得してみる

  • Region = 大阪府大阪市
    • 緯度経度のデルタは取って来たい範囲を示す
    • 1度で110kmぐらい
  • ズームレベル = 12

全体のコード

https://medium.com/@mellet/adding-offline-capabilities-for-mapview-in-expo-dd9c1b1ab732

FetchTiles()を適当なボタンにでも仕込んで呼び出せば、端末のローカルストレージにタイルがダウンロードされる。

import * as FileSystem from 'expo-file-system';
import { Region } from 'react-native-maps';

const AppConstants = {
  TILE_FOLDER: `${FileSystem.documentDirectory}tiles`,
  MAP_URL: 'https://tile.googleapis.com/v1/2dtiles',
  key: '<YOUR TILES API KEY>',
};

const mapRegion = {
  latitude: 34.6937,
  longitude: 135.5021,
  latitudeDelta: 0.05,
  longitudeDelta: 0.05,
};

async function createSession(key: string) {
  const url = `https://tile.googleapis.com/v1/createSession?key=${key}`;
  const requestBody = {
    mapType: 'roadmap',
    language: 'en-US',
    region: 'JP',
  };

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(requestBody),
    });

    if (!response.ok) {
      throw new Error('Request failed: ' + response.status);
    }

    const data = await response.json();
    return data.session;
  } catch (error) {
    console.error('Error creating session:', error);
  }
}

export const FetchTiles = async () => {
  const session = await createSession(AppConstants.key);
  const inputZoomLevel = 0;
  const currentZoom = 15;
  const minZoom = currentZoom;
  const maxZoom = currentZoom + inputZoomLevel;

  const tiles = tileGridForRegion(mapRegion, minZoom, maxZoom);
  // Create directory for tiles
  // TODO: Batch to speed up
  for (const tile of tiles) {
    const folder = `${AppConstants.TILE_FOLDER}/${tile.z}/${tile.x}`;
    await FileSystem.makeDirectoryAsync(folder, { intermediates: true });
    //   console.log('DocumentDir is ', FileSystem.documentDirectory);
    console.log('Created directory', folder);
  }

  // Download tiles in batches to avoid excessive promises in flight
  const BATCH_SIZE = 100;

  let batch = [];

  for (const tile of tiles) {
    const fetchUrl = `${AppConstants.MAP_URL}/${tile.z}/${tile.x}/${tile.y}?session=${session}&key=${AppConstants.key}`;
    console.log('fetchUrl is ', fetchUrl);
    const localLocation = `${AppConstants.TILE_FOLDER}/${tile.z}/${tile.x}/${tile.y}.png`;

    const tilePromise = FileSystem.downloadAsync(fetchUrl, localLocation);
    batch.push(tilePromise);

    if (batch.length >= BATCH_SIZE) {
      await Promise.all(batch);
      batch = [];
    }
  }

  await Promise.all(batch);
};

export type Tile = {
  x: number;
  y: number;
  z: number;
};

export function tileGridForRegion(region: Region, minZoom: number, maxZoom: number): Tile[] {
  let tiles: Tile[] = [];

  for (let zoom = minZoom; zoom <= maxZoom; zoom++) {
    const subTiles = tilesForZoom(region, zoom);
    tiles = [...tiles, ...subTiles];
  }

  return tiles;
}

function degToRad(deg: number): number {
  return (deg * Math.PI) / 180;
}
function lonToTileX(lon: number, zoom: number): number {
  return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
}

function latToTileY(lat: number, zoom: number): number {
  return Math.floor(
    ((1 - Math.log(Math.tan(degToRad(lat)) + 1 / Math.cos(degToRad(lat))) / Math.PI) / 2) * Math.pow(2, zoom),
  );
}

function tilesForZoom(region: Region, zoom: number): Tile[] {
  const minLon = region.longitude - region.longitudeDelta;
  const minLat = region.latitude - region.latitudeDelta;
  const maxLon = region.longitude + region.longitudeDelta;
  const maxLat = region.latitude + region.latitudeDelta;

  const minTileX = lonToTileX(minLon, zoom);
  const maxTileX = lonToTileX(maxLon, zoom);
  const minTileY = latToTileY(maxLat, zoom);
  const maxTileY = latToTileY(minLat, zoom);

  const tiles: Tile[] = [];

  for (let x = minTileX; x <= maxTileX; x++) {
    for (let y = minTileY; y <= maxTileY; y++) {
      tiles.push({ x, y, z: zoom });
    }
  }

  return tiles;
}

コンポーネント上での使用例

import React, { FC, useState } from 'react';
import { StyleSheet, Text, ActivityIndicator } from 'react-native';
import { Button } from 'src/components/ui/Button';
import { Card } from 'src/components/ui/Card';
import { TitleText } from 'src/components/ui/Text';
import { FetchTiles } from './fetch-tile';

type Props = {
  //   mapRegion?: Region;
  onFinish: () => void;
};

export const DownloadTilesMenu: FC<Props> = ({ onFinish }) => {
  const [isLoading, setIsLoading] = useState(false);

  const handleFetchTiles = async () => {
    setIsLoading(true);
    await FetchTiles();

    alert('Finished downloading tiles, you are now viewing the offline map.');

    setIsLoading(false);
    onFinish();
  };

  return (
    <Card>
      <TitleText>Push Buttons to download tiles</TitleText>
      <Text style={styles.warningMessage}>
        Warning! High Zoom level requests so many tiles that costs high when you use Tiles API.
      </Text>
      {isLoading && <ActivityIndicator />}
      {!isLoading && <Button title="Dowload tiles" onPress={handleFetchTiles} />}
    </Card>
  );
};

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    bottom: 15,
    left: 0,
    right: 0,
    zIndex: 999,
  },
  warningMessage: {
    marginVertical: 10,
    color: '#bbb',
    fontStyle: 'italic',
    fontSize: 10,
    textAlign: 'center',
  },
  estimate: {
    marginVertical: 15,
    textAlign: 'center',
  },
});

MapViewで表示する初期位置はFetchしたタイルとは別の地域を指定すると良い。

例として東京を指定しておく

const URL_TEMPLATE = `${FileSystem.documentDirectory}tiles/{z}/{x}/{y}.png`;

export function Map() {
	return (
    <>
      <MapView
        initialRegion={{
          latitude: 35.6894,
          longitude: 139.6917,
          latitudeDelta: 0.05,
          longitudeDelta: 0.05,
        }}
        style={styles.container}
      >
        <UrlTile
          urlTemplate={URL_TEMPLATE}
          zIndex={1}
        />
      </MapView>
      <DownloadTilesMenu onFinish={() => {}} />
    </>
    )
}
    
    
const createStyles = (theme: Theme) => {
	return StyleSheet.create({
    container: {
      flex: 1,
      alignItems: 'center',
      justifyContent: 'center',
},

Discussion