かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

OpenStreetMap + React Leaflet で地図を表示したメモ

最近は地図関係のアプリを開発してて、ライブラリも多く実装したものを定期的にメモに残しておこうと思う

Google Map の API が高額でいろいろなレイヤーを重ね合わせるのが少し面倒そうだったので、OpenStreetMap を使う方法を探索した

OpenSteetMap を使えるライブラリはかなりたくさんあり色々試したが、本記事では React Leaflet でシンプルな地図を表示させたメモです

環境
  • React 18.2.0
  • leaflet 1.9.4
  • react-leaflet: 4.2.1

React Leaflet を使う準備

$ npm i react-leaflet leaflet
$ npm i -D @types/leaflet

React Leafret で OpenStreetMap を表示する

// Map.tsx
import { latLng } from "leaflet";
import { MapContainer, TileLayer } from "react-leaflet";
// leaflet のスタイルがないと地図が崩れる
import "leaflet/dist/leaflet.css";
// 地図を表示させるコンテナの height が必要
import "./map.css";

const position = latLng([35.3607411, 138.727262]);
const zoom = 5;

export const Map = (): JSX.Element => {
  return (
    <MapContainer center={position} zoom={zoom}>
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
        {/* OpenStreetMap のスタイルを選択して指定 */}
        url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
        maxZoom={13}
        minZoom={2}
      />
    </MapContainer>
  );
};
/* map.css */
.leaflet-container {
  width: 100%;
  height: 100vh;
}

React Leaflet + OpenStreetMap

これでシンプルな地図が表示できた。
ハマりどころは Leaflet の CSS (leaflet/dist/leaflet.css) が必要なことと、Leaflet が出力するコンテナの高さがないと地図が表示されないこと。TypeScript だと position を latLng 関数を挟まないと Type Error になることくらい

クリックした箇所に marker を表示させる

useMapEvent hook で地図上のイベントを取得できるので、クリックされた lat, long を取得して Marker として表示させる

// LocationMarkers.tsx
import { useState } from "react";
import { LatLng, latLng, icon } from "leaflet";
import { Marker, Popup, useMapEvent } from "react-leaflet";

const ICON = icon({
  iconUrl: "/images/leaflet/marker-icon.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [0, -30],
});

export const LocationMarkers = (): JSX.Element => {
const [markers, setMarkers] = useState<LatLng[]>([]);
  // useMapEvent は `<MapContainer>` の内側からしか呼び出せない
  const map = useMapEvent("click", (e) => {
    const { lat, lng } = e.latlng;
    setMarkers((markers) => [...markers, latLng(lat, lng)]);
  });

  return (
    <>
      {markers.map((marker) => (
        <Marker key={marker.toString()} position={marker} icon={ICON}>
          <Popup>{marker.toString()}</Popup>
        </Marker>
      ))}
    </>
  );
};

<MapContainer> 内に作成した <LocationMarkers /> コンポーネントを追加すれば OK

// Map.tsx
export const Map = (): JSX.Element => {
  return (
    <MapContainer center={position} zoom={zoom}>
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
        url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
        maxZoom={13}
        minZoom={2}
      />
      <LocationMarkers />
    </MapContainer>
  );
};

markers

地図のクリックした箇所にマーカーが表示されマーカーをクリックするとポップアップが表示されるようになった!

🐞 デフォルトのマーカー画像が表示されない問題

Leaflet.Marker.prototype.options.iconLeaflet.icon を渡す方法・Leaflet.Icon.Default.mergeOptions にアイコン画像を設定する方法を issue でみつけたが leaflet v1.9.4, react-leaflet v4.2.1 の環境では画像が壊れたままになってしまったので、カスタムアイコンを作って表示させた

h3-js で取得したエリアを React Leaflet で表示させる

h3geo.org h3-js は Uber の開発した世界を六角形のグリッドで表すJavaScript のライブラリ。
h3-js で取得した六角形のエリアを lat, long の矩形 (polygon) として地図上に表示させてみます

$ npm i h3-js

h3 では世界をどのサイズの六角形のグリッド化しているのかというサイズを resolution という数字で表しています。
今回はマーカーと同じようにクリックした位置が含まれる resolution を Polygon 化して地図上にプロットします。

// HexLayer.tsx
import { FC, useState } from "react";
import { LatLng } from "leaflet";
import { Polygon, useMapEvent } from "react-leaflet";
import { cellToBoundary, CoordPair, latLngToCell } from "h3-js";

const resolution = 3;

export const HexLayer: FC = () => {
  const [hexagons, setHexagons] = useState<{index: string, polygon: CoordPair[]}[]>([]);
  const map = useMapEvent("click", (e) => {
    const { lat, lng } = e.latlng;
    const h3Index = latLngToCell(lat, lng, resolution);
    const boundary = cellToBoundary(h3Index);

    setHexagons((hexagons) => [...hexagons, {
      index: h3Index,
      polygon: boundary,
    }]);
  });

  return (
    <>
      {hexagons.map(({ index, polygon }) => (
        <Polygon key={index} positions={polygon} />
      ))}
    </>
  );
}

h3 hex

h3-js で取得したエリアを Polygon として表示させることが出来ました!
※ Leaflet は左右に地図を移動させると経度が -600 や 1000 のように -180 - 180 の範囲を超えてしまうようです。h3-js の cellToBoundary で変換した座標は -180 〜 180 の間に収まる数値に変換されるため、地図を移動させまくると Ploygon が表示されないようにみえてしまいます

Sample

所管

React-Leaflet の example には実例が色々とコードと共に載っているので、扱いやすいライブラリだなと感じました。
一方で Leaflet に依存しているので、ちょっとした問題があると Leaflet 側まで見に行かなければならない事もあるのは注意が必要そうです (経度が延々大きくも小さくもなる問題とか)

また React-Leaflet は地図上に表示させているマーカーや矩形を 画像や SVG といった HTML 要素で表現しているので表示量が多くなると Canvas ベースのものより処理が重くなるのではないかな〜という気がしています。 単体でできることが豊富なのでサクッと地図を使ったモックなどを作るのには向いてそうですね!

おわり


[参考]

13歳からの地政学 サクッと読めて面白かった!

Next.js SSG したサイトを別ドメインから参照させててハマった

あるプロジェクトで Page routing の Next.js を SSG で作成したサイトを Vercel でホスティングしたものを CloudFront と Lambda@Edge を使って別ドメインの URL からサイトを表示させるようにしていた。
図で書くとこんな感じ サービス構成

CloudFront を使った別ドメイン (b.com/foo) からサイトを見たときだけページ内リンク (next/link) で遷移時にアプリがクラッシュしてしまう問題が発生した。

Next.js error: a client-side exception has occurred

すっごくレアケースだと思うけど記録としてメモしておく

環境

  • Next 13.5.4
  • React 18.2.0
  • TypeScript 5.2.2

結論 /_next/data へのアクセスに問題があった

Next.js の SSG (Static Site Generation) は Static Exports ではない

今回の問題は Next.js の SSG は常に静的な HTML を配信するだけものだと勘違いしていた点にあった。

dev took の Network タブで観察してみた結果、Vercel でホスティングしている Next.js の SSG されたアプリは初回アクセスは生成された HTML が帰るがクライアントサイドで react 化され、アプリ内リンクなどは staticProps となる JSON を preftech で取得し、その JSON を元にページの再レンダリングを行っていてた。 SSG したのだから HTML だけで完結しているのでは?と思いドキュメントを読んで勘違いしていたことに気がついた

Statically generates both HTML and JSON
When a page with getStaticProps is pre-rendered at build time, in addition to the page HTML file, Next.js generates a JSON file holding the result of running getStaticProps.
This JSON file will be used in client-side routing through next/link or next/router. When you navigate to a page that’s pre-rendered using getStaticProps, Next.js fetches this JSON file (pre-computed at build time) and uses it as the props for the page component. This means that client-side page transitions will not call getStaticProps as only the exported JSON is used.
cf. Data Fetching: getStaticProps | Next.js

Static Exports
Next.js enables starting as a static site or Single-Page Application (SPA), then later optionally upgrading to use features that require a server.
Since Next.js supports this static export, it can be deployed and hosted on any web server that can serve HTML/CSS/JS static assets.
cf. Deploying: Static Exports | Next.js

next.config.jsoutput: "export" オプションを指定していると build 時に out ディレクトリに HTML が出力されるが、これは Static Exports であり SSG ではなく、Static Export は完全に静的な HTML を生成するものではなく使っている機能によっては SPA として動作するのだと解った。

今回のサイトでは getStaticPaths を使ったダイナミックルーティングを行い、ページで表示する内容を getStaticProps で事前に取得するようにしていたので、各ページの HTML と共に各ページの static props になる JSON が出力されており、サイトは初回アクセス以降はクライアントサイドで SPA となりページ内リンクでは JSON を取得してページを再レンダリングしているという事だった。
(static props となる JSON が取得できないとか、next/link を使わず a タグでリンクした場合は通常のページ遷移になるのだと思うが検証していない)

static props の JSON/_next/data 内に格納されている

SSG で build されたサイトは static props となるデータが /_next/data/{BUILD_ID} 内に ページ名.json の形で出力されていた。
SSG されたサイトが SPA のように振る舞うとき、その json を取得してそれを元に画面を再レンダリングしており、CloudFront 側のドメイン (b.com) からアクセスした際に /_next/data で ferch が走っていたために、コードの存在しないドメイン宛の b.com/_next/data にリクエストが飛んでいた。
本来存在しないリクエストなのでエラーになるはずだが、今回のケースでは status code 200 が返っていたために SPA としてページを更新しようとして不正なレスポンスを static props として使ったためにページがクラッシュしてしまっていたというオチ

解決方法

  1. next/link の箇所が SPA として動作するので、next/link を辞めて a タグを使う
  2. /_next/data のパスに実際のコードのあるドメイン (origin) を追加する

next/link を使わない方法はシンプルにタグを置き換えればよいだけなので、今回は /_next/dataドメインを追加する方法を試してみた

ビルドされた /_next/data のパスにドメイン (origin) を追加する

assetPrefix というオプションがあるが、これは static ファイル (.next/static or /out/_next/static) に対してのみ有効で /_next/data へのアクセスにドメインを追加することはできない

While assetPrefix covers requests to _next/static, it does not influence the following paths:

  • Files in the public folder; if you want to serve those assets over a CDN, you'll have to introduce the prefix yourself
  • /_next/data/ requests for getServerSideProps pages. These requests will always be made against the main domain since they're not static.
  • /_next/data/ requests for getStaticProps pages. These requests will always be made against the main domain to support Incremental Static Generation, even if you're not using it (for consistency).

cf. next.config.js Options: assetPrefix | Next.js

/_next/dataドメインを設定できる dataPathPrefix というアイディアも提案されていたようだが App Router になれば解決するとして採用はされていないようだった

assetPrefix に似たオプションで basePath というものがあるが、これはあるドメインの別階層に Next のアプリをデプロイした場合にパスを設定できるオプションで / 始まりしか許容されておらずドメインを追加することは出来なかった

string-replace-loader を使ってビルド時にパスを置換する

Next.js の提供しているオプションでは /_next/dataドメインを追加することは不可能そうだった。 Next.js のビルドには webpack が使われているので、ビルド時に文字列を置換する string-replace-loader ローダーを使って /_next/dataドメイン付きに強制的に置換してしまう方法は可能だった。

// next.config.js
const isProd = process.env.VERCEL_ENV === 'production';

const nextConfig = {
  reactStrictMode: true,
  // souce map の出力
  productionBrowserSourceMaps: isProd ? false : true,
  // webpack の設定を追加
  webpack: (config) => {
    config.module.rules.push(
      ...[
        isProd
          ? {
              test: /.*.js$/,
              loader: 'string-replace-loader',
              options: {
                search: '/_next/data',
                replace: `${ORIGIN.replace(/\/$/, '')}/_next/data`,
              },
            }
          : undefined
      ].filter(Boolean)
    );
  },
}

これで /_next/data 内にある json を取得するドメインを変更することが出来た (hack的なので漏れや他のバグを産み出すある可能性はあるが)

CORS 問題

ユーザーがアクセスするドメインb.com で実際のコードが置かれているのが a.com。Next のアプリが裏側で fetch しようとした場合 b.com から a.com/_next/data/{BUILD_ID}/**/*.json にアクセスしようとするので、CORS 問題が発生する

検索していると CORS を許可する方法として下記の4パターンがあった

  1. Serverless Function でレスポンスに header を追加する
  2. middleware (/src/middleware.js) でレスポンスに header を追加する
  3. next.config.js に headers オプションを追加する
  4. vercel.jsonheaders オプションを追加する

cf.

今回はシンプルそうな 4 の vercel.json に headers を追加することで CORS が発生しなくなったのでそれ以外の調査はしていない

vercel.json に headers オプションを追加して CORS を許可する

verce.json

{
  "headers": [
    {
      "source": "/_next/data/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Credentials", "value": "true" },
        {
          "key": "Access-Control-Allow-Origin",
          "value": "*"
        },
        { "key": "Access-Control-Allow-Methods", "value": "GET" },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-Nextjs-Data"
        }
      ]
    }
  ]
}

verce.jsonAccess-Control-Allow-Origin は whitelist のように複数のオリジンを配列で渡すことが出来なかった (build error になる) ので * として、メソッドを GET に制限することにした

これで CloudFront 経由の b.com からアクセスしても next/link が正常に動作して SPA のように json でページが更新されるようになった!!!

preview モードで prefetch だけが CORS になる問題

ここまでで 9割問題ないのだが、Vercel の preview モードでホスティングしているサイト (ステージング環境) も CloudFront 経由でアクセスできるようになっており、preview モードのサイトを CloudFront 経由の別ドメインでアクセスした時だけ /next/link の preftech が CORS のエラーになっていた。 (画面更新のための JSON の GET は正常に行えている)

謎…

dev tool でエラーの箇所のコードを見ていたら preview mode の場合は cookie が必要だと書かれたコメントを発見した

function fetchRetry(
  url: string,
  attempts: number,
  options: Pick<RequestInit, 'method' | 'headers'>
): Promise<Response> {
  return fetch(url, {
    // Cookies are required to be present for Next.js' SSG "Preview Mode".
    // Cookies may also be required for `getServerSideProps`.
    //
    // > `fetch` won’t send cookies, unless you set the credentials init
    // > option.
    // https://developer.mozilla.org/docs/Web/API/Fetch_API/Using_Fetch
    //
    // > For maximum browser compatibility when it comes to sending &
    // > receiving cookies, always supply the `credentials: 'same-origin'`
    // > option instead of relying on the default.
    // https://github.com/github/fetch#caveats
    credentials: 'same-origin',
    method: options.method || 'GET',
    headers: Object.assign({}, options.headers, {
      'x-nextjs-data': '1',
    }),
  }).then((response) => {

https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/router.ts#L425-L447

恐らく Vercel の Preview モードは本来コメントを残したりする機能が付いているので vercel にログインしている / アクセス可能であるという cookie が必要になっているのではないかと思った。
preftech がエラーになってもステージング環境だけだし、ページの遷移も問題なく行えているのでこの件に関しては対応しないものとした。

所管

SSG と static exports を混同していたことや、SSG すれば HTML しか使ってないのような勘違いもあったが、本来エラーになってほしいリクエストが 200 番で返されていたために予期せぬクラッシュが発生してしまっていた。
Next.js をビルドした状態でかつ本番環境でしか発生していない問題だったので、フレームワークをいい感じにしてくれるブラックボックスとして扱っていたために調査が難しく時間がかかる結果となった。 また Next.js はいろいろなオプションが用意されているが /_next/dataドメインを変更する方法が無いなど、別のドメインから参照させるような使い方はあまり想定してないようにも思った。

教訓
フレームワークをドキュメントにないような使い方をする時はフレームワークの挙動についてよく理解しておかないとバグを踏む!

ただのバグ報告が大作になってしまった…
おわり


[参照]

どういう意味でで SSG なんや?このマンガ!?

Google Spreadsheet ◯年の月末日のリストを自動で作りたい

忘れてたのでメモ

Spreadsheet では 2024年1月 のような表記でも日付フォーマットの場合日にちを持っている。なので、2024-01-01 の 2024年1月 と 2024-01-31 の 2024年1月 は別物として扱われる。
例えば年始から各月末の変化をグラフにする際に年始は年始の 2024-01-01 と 各月末 2024-01-31, 2024-02-29, 2024-03-31 ... 2024-12-31 の項目を作る必要があり都度月末を入力するのは面倒なので、年だけ入れれば各月の月末の日を自動で生成させたい。
退屈なことは関数にやらせよう。

ゴール

A B
1 2024

この B1 の年を使って自動的に年始 1/1 と 1月〜12月の月末日を出力させる

結論 ARRAYFORMULA と SEQUENCE と EOMONTH を利用する

年始の日付は単純に DATE を使えば良い

=DATE(B1, 1, 1)

各月の末日をリストで出力

=ARRAYFORMULA( EOMONTH( DATE(B1, SEQUENCE(12), 1), 0) )

月末日をリスト表示

解説

  1. SEQUENCE(12) で月になる 1 〜 12 の連番を取得
  2. ARRAYFORMULA を使って各月の日付をリスト出力
  3. EOMONTH(日付, 0) で当月末日に変換

1. SEQUENCE(12) で月になる 1 〜 12 の連番を取得する

参考: SEQUENCE 関数 - Google ドキュメント エディタ ヘルプ

=SEQUENCE(12)

1〜12 が出力される SEQUENCE(12)

2. ARRAYFORMULA を使って各月の日付をリスト出力する

SEQUENCE を使って出力した数字を月として使えば良い

=DATE(B1, SEQUENCE(12),1)
# => 2024-01-01

これだと 1/1 の日付だけになってしまう。
SEQUENCE(12) が出力する 1〜12 全てに DATE を適応するには配列処理ができる ARRAYFORMULA の中で使用する必要がある

=ARRAYFORMULA(DATE(B1, SEQUENCE(12), 1))

ARRAYFORMULA(DATE(B1, SEQUENCE(12), 1))

3. EOMONTH(日付, 0) で当月末日に変換する

後は EOMONTH 関数で DATE 関数ラップして当月末に変換すれば良い
参考: EOMONTH - Google Docs Editors Help

=ARRAYFORMULA(EOMONTH(DATE(B1, SEQUENCE(12), 1), 0))

ARRAYFORMULA(EOMONTH(DATE(B1, SEQUENCE(12), 1), 0))

まとめ

連番を作るのに ROW を使う方法は関数を入力するセルに合わせて変更しなければならずイケてないな〜と感じてたので、SEQUENCE を使う方法を知れて良かったです!

おわり ₍ᐢ. ̫.ᐢ₎


[参考]