turf.jsを用いたメルカトル図法地図上の線の大圏航路補正・逆子午線分割
技術スタック JavascriptGIS

turf.jsを用いたメルカトル図法地図上の線の大圏航路補正・逆子午線分割

2024.09.22

私がメルカトル図法を使用した地図上で、mapboxに2点以上の線を引く処理を実装していたとき、線が直線的に描画されるという問題が発生しました。さらに、経度180度の逆子午線を跨ぐ場合、mapboxでは大回りの線が描かれてしまい、その修正も必要でした。例えば、東京→ロサンゼルスの2点を結ぶと、東経180度を越えるのでなく、西回りでぐるっと線が引かれてしまいました。

本記事では、JavaScriptライブラリであるturf.jsを用いて、補正と分割を行った表示用のパスを算出手法について紹介します。

大圏航路とは?なぜ補正が必要?

大圏航路(Great Circle Route)は、地球上の2点を最短距離で結ぶ経路です。なんとなく2点を結んだ直線が最短経路と思ってしましますが、実際はそうではありません。よく見る平面の地図はメルカトル図法という方法で表現されており、引いた線が実際の地球上の経路、距離、面積と一致しません。これは地球が球面であり、球面上の線を平面上の線の描画と一致しないからです。例えば、東京→デリーの最短ルートは実際以下の通りで、直線的に結んだものではありません。(左が補正なしの直線)

メルカトル図法は、緯度が高くなるほど距離の比率が歪むため、大圏航路を描くと地図上で曲線として表示されるのが特徴です。しかし、通常のGeoJSONなどを使用して単純に線を引くと、2点間を直線で結んでしまい、大圏航路としての正確さが失れます。

これは2点の距離が離れるほど、平面上に引いた直線と実際の線と乖離します。短い距離、少なくとも日本列島ぐらいであれば問題ありませんが、大陸レベルだったり海路・空路を表現するときはその乖離が顕著になります。

海路・空路を記載するような地図はこの補正を考慮しないといけません。わかりやすくすると以下の通りです。

逆子午線を越えるときには分割が必要

逆子午線とは、経度180度の線を指します。mapboxで経度180度を跨ぐ線を描こうとすると、通常の経路補正では大回りで描かれてしまい、直感的に不自然になります。

そのため、逆子午線を越える線を正しく描画するには、線を分割して複数のLineStringやMultiLineStringに分ける処理を行いました。図としてこのような感じです。

基本的にturf.jsを使えば解決できる

JavaScriptのturf.jsライブラリを用いることで、これらの問題を解決できます。turf.jsはGeoJSONデータの操作に強力な機能を提供しており、大圏航路の計算を行うとき、180度を越えるとき自動的に分割したパスを渡してくれます。

2点間に補正点を算出

まず、2点間の線を大圏航路で補正するために、turf.jsのturf.greatCircle関数を使用しました。この関数を使うことで2点間を大圏航路で補正し、曲線的な追加のパスを生成できます。 180度を超えない2点の場合、補正した緯度経度の配列が戻ります。180度を越える場合、180度で分割した2つのlineの緯度経度配列が戻ります。

import * as turf from '@turf/turf';
import { Feature } from "geojson";

const path = [lat:number,lng:number][]
const displayPath: [number, number][][] = [];

// 各ラインのパスごとに大円航路を計算したパスを追加
for (let i = 0; i < path.length - 1; i++) {
    const startPoint = path[i];
    const endPoint = path[i + 1];

    const currentPoint = turf.point([startPoint[1], startPoint[0]]); // turf.js は経度・緯度と入力したり戻ってくるので注意。
    const nextPoint = turf.point([endPoint[1], endPoint[0]]);

    const greatCircle = turf.greatCircle(currentPoint, nextPoint, { npoints: 30 }); // npoints が追加する補正点。多いほど滑らか

    const coordinates = greatCircle.geometry.coordinates;

    // LineString の場合
    if (Array.isArray(coordinates[0]) && !Array.isArray(coordinates[0][0])) {
        displayPath.push(coordinates as [number, number][]);
    } 
    // MultiLineString の場合
    else if (Array.isArray(coordinates[0][0])) {
        coordinates.forEach((segment) => {
            displayPath.push(segment as [number, number][]);
        });
    }
}

const geojson = {
    type: "Feature",
    properties: {},
    geometry: {
        type: "MultiLineString",
        coordinates: displayPath,
    },
} as Feature;

return geojson;

わかりやすいように、180度を越える・超えないパターンでの大圏航路の補正です。

import * as turf from '@turf/turf';

const nonOver = turf.greatCircle(
    [139.6503, 35.6762],  // 東京の座標
    [77.2090, 28.6139], // デリーの座標
    { npoints: 30 }
);

console.log(nonOver)
/**
[
  [ 139.6503, 35.67620000000001 ],
  [ 137.45530635075792, 36.00345099838847 ],
  [ 135.24315241142824, 36.29039534292487 ],
  [ 133.01584313143206, 36.53630839532294 ],
  [ 130.77553308674652, 36.74055732953035 ],
  [ 128.52450866279412, 36.90260805727012 ],
  [ 126.26516729530344, 37.022031252284606 ],
  [ 123.99999414238111, 37.09850731283235 ],
  [ 121.73153667001746, 37.131830125727525 ],
  [ 119.46237772511748, 37.121909525547366 ],
  [ 117.19510773852923, 37.0687723782786 ],
  [ 114.93229674053903, 36.972562257930214 ],
  [ 112.67646687998554, 36.83353772552399 ],
  [ 110.43006611487587, 36.65206926027127 ],
  [ 108.19544368887564, 36.42863493057574 ],
  [ 105.97482792817907, 36.163814925900674 ],
  [ 103.7703067926894, 35.858285097980165 ],
  [ 101.58381150098748, 35.51280968026889 ],
  [ 99.41710342759748, 35.12823336735064 ],
  [ 97.27176435078754, 34.705472941209955 ],
  [ 95.14919001606306, 34.245508629231686 ],
  [ 93.05058687993017, 33.749375370334334 ],
  [ 90.97697181428099, 33.21815415185492 ],
  [ 88.92917448617246, 32.6529635619425 ],
  [ 86.90784208162192, 32.05495168160128 ],
  [ 84.91344601479491, 31.42528841842546 ],
  [ 82.94629025402638, 30.7651583616406 ],
  [ 81.00652090116452, 30.075754216293156 ],
  [ 79.09413667798896, 29.358270854084918 ],
  [ 77.209, 28.613900000000005 ]
]
*/

const over = turf.greatCircle(
    [139.6503, 35.6762],  // 東京の座標
    [-147.7164, 64.8378], // アラスカの座標
    { npoints: 30 }
);

console.log(over)
/*
  [
    [ 139.6503, 35.67620000000001 ],
    [ 140.80178756980726, 37.16607636013253 ],
    [ 141.99941083018345, 38.64436492965305 ],
    [ 143.24727313996488, 40.10993078543407 ],
    [ 144.54983478842868, 41.56151748104379 ],
    [ 145.91194328157903, 42.9977313513525 ],
    [ 147.33886324769875, 44.41702385324905 ],
    [ 148.83630454782175, 45.81767177569259 ],
    [ 150.41044655311683, 47.19775518537249 ],
    [ 152.0679557258351, 48.55513303641521 ],
    [ 153.8159925691613, 49.88741647693477 ],
    [ 155.66220265313686, 51.19194004906208 ],
    [ 157.6146847535422, 52.465731224424154 ],
    [ 159.68192717003024, 53.705479070518045 ],
    [ 161.87270110244128, 54.90750333482882 ],
    [ 164.19589776721827, 56.06772589186521 ],
    [ 166.66029413034695, 57.18164734362052 ],
    [ 169.27423139783608, 58.24433259326387 ],
    [ 172.0451917704491, 59.25041037671838 ],
    [ 174.9792638386216, 60.19409291290832 ],
    [ 178.08049701800238, 61.069222785803966 ],
    [ 180, 61.53895140096846 ]
  ],
  [
    [ -180, 61.53895140096846 ],
    [ -178.64983787529644, 61.86935452669037 ],
    [ -175.2140408098754, 62.587877615485425 ],
    [ -171.618756489847, 63.21818519285117 ],
    [ -167.87562800253605, 63.753888204781546 ],
    [ -164.0017045661598, 64.18906791167207 ],
    [ -160.0194735821535, 64.51855132598804 ],
    [ -155.9564136218279, 64.73818576863974 ],
    [ -151.84402691822368, 64.84508272020929 ],
    [ -147.7164, 64.8378 ]
  ]
]
*/
Copyright © 2021 jun. All rights reserved.