🙆‍♀️

GeoJSONのbboxの座標変換

3 min read

GeoJSON(EPSG:4326)のbboxを別の座標系に計算し直したいとき、単純に左下と右上の座標をそれぞれ投影してしまいがちですが、これでは正しい座標にならないときがあります。

{
  type: "Polygon",
  coordinates: [
    [
      [-180, 80],
      [180, 80],
      [180, 90],
      [-180, 90],
      [-180, 80]
    ]
  ]
}

例えば極を囲むようなポリゴン[1]のbboxをEPSG:3995に変換したいの場合、4326では[-180, 80, 180, 90]ですが、これを単純に変換してしまうと四角が潰れてしまいます(実際にやってみよう)。

また線分が長いと変換後の線分が曲がり(大圏)、元の四隅からはみ出る部分が発生するかもしれない[2]

じゃあどうすればいいのかというと、愚直に線分間に点をたくさん挿入して、それらを投影変換後、最大最小を求めてやるくらいしか方法がない。[3]

以下のサンプルコードではGeoJSONの座標は必ずEPSG:4326の緯度経度座標系であると仮定しています。 [4]

function toBbox(
  polygon,
  code
) {
  const xs = [];
  const ys = [];
  if (code && code !== "EPSG:4326") {
    for (let i = 0; i < polygon.coordinates[0].length - 1; i++) {
      const interpolated = linearInterpolationPoints(
        polygon.coordinates[0][i].slice(0, 2),
        polygon.coordinates[0][i + 1].slice(0, 2),
        {
          partition: 10, // エイヤの数字
        }
      );

      for (let j = 0; j < interpolated.length - 1; j++) {
        const transformed = proj4("EPSG:4326", code, [
          interpolated[j][0],
          interpolated[j][1],
        ]) as [number, number];
        xs.push(transformed[0]);
        ys.push(transformed[1]);
      }
    }
  } else {
    polygon.coordinates[0].forEach((lonlats) => {
      xs.push(lonlats[0]);
      ys.push(lonlats[1]);
    });
  }

  const minLon = Math.min(...xs);
  const minLat = Math.min(...ys);
  const maxLon = Math.max(...xs);
  const maxLat = Math.max(...ys);
  return [minLon, minLat, maxLon, maxLat];
}

function calcLinearInterpolationPoint (
  i,
  p1,
  p2,
  partition
) {
  const div = Math.floor(partition);
  if (_eq(p1[0], p2[0])) {
    return [p1[0], p1[1] + (i * (p2[1] - p1[1])) / (div + 1)];
  } else if (_eq(p1[1], p2[1])) {
    return [p1[0] + (i * (p2[0] - p1[0])) / (div + 1), p1[1]];
  }
  const x = p1[0] + (i * (p2[0] - p1[0])) / (div + 1);
  return [x, linearInterpolationY(p1, p2, x)];
};

function linearInterpolationPoints(
  p1,
  p2,
  userOptions={}
): Points {
  const options = Object.assign(
    {
      partition: 0,
    },
    userOptions
  );
  if (options.partition <= 0) return [p1, p2];
  const ret: Points = [];
  for (let i = 0; i < options.partition + 1; i++) {
    ret.push(calcLinearInterpolationPoint(i, p1, p2, options.partition));
  }
  ret.push(p2);

  return ret;
}

function linearInterpolationY(
  p1,
  p2,
  x
) {
  if (_eq(p1[0], p2[0])) return p1[1];
  return p1[1] + ((x - p1[0]) * (p2[1] - p1[1])) / (p2[0] - p1[0]);
}

function _eq(a, b) {
  return Math.abs(a - b) < 1e-8;
}

やってることは基本的にはこの記事でやってることの逆の操作。

toBbox(
  {
    type: "Polygon",
      coordinates: [
      [
        [-180, 80],
        [180, 80],
        [180, 90],
        [-180, 90],
        [-180, 80],
      ],
    ],
  },
  "EPSG:3995"
)
> [-1078093.1792349985, -1045060.0346785864, 1078093.1792349985, 1089179.4556261837]
脚注
  1. 厳密には囲んでおらず接しているがEPSG:4326ではこれが限界。 ↩︎

  2. ここまで派手に歪むことはないだろうが、考慮はしておく. ↩︎

  3. 分割数をエイヤで決めるしかないので、もっと厳密な方法があれば知りたい。 ↩︎

  4. 試したい投影法がproj4にデフォルトで登録されてない場合は、事前にdefsで登録する必要がある。epsg.ioで調べるか、epsg-indexを使う。 ↩︎