SvelteKit・Svelte MapLibre GL・microCMS・Amplify Gen 2で旅行地図日記アプリを作る！

弊社では[ciscorn](https://github.com/ciscorn)さんの先導で[Svelte-maplibre-gl](https://github.com/MIERUNE/svelte-maplibre-gl)という**Svelte 5で利用可能なMapLibre GL JSのラッパーライブラリを開発**しています！

詳しい説明は他記事に譲るとして、今回はそれを使って**簡単な地図アプリを作る**！ということをやってみようと思いますー！

地図を出すだけならほんっっっっとうに簡単なので、今回はAmplifyでデプロイするということもやってみましょう！

また、アプリケーションっぽさを出すために外部のAPIを利用しようと思います。
今回はmicroCMSを利用して、地図で見れる旅行日記的なアプリケーションを目指してみました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/4fb25df7-74d4-0cca-5804-40faabbc78db.png)

コードはこちらに上がっています！
（あまりに安直な名称）

https://github.com/nokonoko1203/svelte-amplify-maplibre-microcms-sample

## プロジェクトの準備

早速プロジェクトを作成していきましょう！

SvelteKitでプロジェクト作成します。今回はTypeScriptを使っていきます！

```sh
❯ npx sv create sample
┌  Welcome to the Svelte CLI! (v0.6.6)
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with Typescript?
│  Yes, using Typescript syntax
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  prettier, eslint, vitest, tailwindcss
│
◇  tailwindcss: Which plugins would you like to add?
│  none
│
◇  Which package manager do you want to install dependencies with?
│  npm
│
◆  Successfully setup add-ons
│
◆  Successfully installed dependencies
│
◇  Successfully formatted modified files
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: cd sample                                                            │
│  2: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  3: npm run dev -- --open                                                │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!
```

必要なパッケージをインストールしていきます。

```sh
npm i -D amplify-adapter microcms-js-sdk svelte-maplibre-gl
```

`npm run dev`でサーバーが立ち上がればOKです！

## データの用意

[geojson.io](https://geojson.io/#map=5.11/38.73/137.06)などで適当なGISデータを作成します。
今回は適当に札幌・東京・名古屋あたりにピンを指してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/f86742ce-a977-a744-9d1a-37c4d68b5e9b.png)

`properties.contentId`が`xxxxxxxxxxxx`になっていますが、後からmicroCMSの記事のIDを差し込みます。

こんな感じのデータです。

`src/lib/assets/data/sample.json`

```json
{
	"type": "FeatureCollection",
	"features": [
		{
			"type": "Feature",
			"properties": {
				"contentId": "xxxxxxxxxxxx"
			},
			"geometry": {
				"coordinates": [136.90538087518667, 35.1815214332498],
				"type": "Point"
			}
		},
		{
			"type": "Feature",
			"properties": {
				"contentId": "xxxxxxxxxxxx"
			},
			"geometry": {
				"coordinates": [139.76626680582535, 35.681460699146825],
				"type": "Point"
			}
		},
		{
			"type": "Feature",
			"properties": {
				"contentId": "xxxxxxxxxxxx"
			},
			"geometry": {
				"coordinates": [141.35069555833195, 43.06870048875908],
				"type": "Point"
			}
		}
	]
}
```

次に、ピンとして利用するためのアイコンを用意します。
今回は[ICOOON MONO](https://icooon-mono.com/)の鉛筆アイコンを利用します。

`src/lib/assets/data/icon.png`

![icon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/2af0165f-e910-2ad1-7bf6-6ba5250459cd.png)

これら2つのデータは`src/lib/assets/data`に格納しておいてください。
（`icon.png`と`sample.json`という名称で格納しました。）

## ルーターの作成

SvelteKitはファイルベースでルーティングしてくれます。
なので、`/map`というURLを利用したい場合は`src/routes`に`map`というフォルダを作ります。

ひとまず`/map/+page.svelte`というファイルを作成しましょう。
中身はこれだけ。

`/map/+page.svelte`

```svelte
/map
```

http://localhost:5173/map に接続すると/mapが表示されたかと思います。

## 地図を出す

やってもやらなくても良いですがroutesディレクトリのルートにある`src/routes/+page.ts`にこんな記述しておくと、http://localhost:5173 にリクエストした時にも強制的に`http://localhost:5173/map`にリダイレクトしてくれます。

`src/routes/+page.ts`

```ts
import { redirect } from "@sveltejs/kit";

export function load() {
	redirect(303, "/map");
}
```

次に、`/map/+page.svelte`を修正して地図を出します。

`/map/+page.svelte`

```svelte
<script lang="ts">
	import maplibregl from 'maplibre-gl';
	import { MapLibre } from 'svelte-maplibre-gl';

	let map: maplibregl.Map | undefined = $state.raw();
</script>

<div class="h-full w-screen">
	<MapLibre
		bind:map
		class="h-[100vh] min-h-[300px]"
		style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
		zoom={5}
		center={{ lng: 137.5, lat: 38.5 }}
	></MapLibre>
</div>
```

**たったこれだけで、地図が出せます**！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/a3b3b541-27a2-5999-d801-38d8535f2fa8.png)

## データの追加、アイコンの表示

以下のように修正してみましょう！

`/map/+page.svelte`

```ts
<script lang="ts">
	import icon from "$lib/assets/data/icon.png";
	import geoJson from "$lib/assets/data/sample.json";

	import maplibregl from "maplibre-gl";
	import {
		GeoJSONSource,
		ImageLoader,
		MapLibre,
		SymbolLayer,
	} from "svelte-maplibre-gl";

	let map: maplibregl.Map | undefined = $state.raw();
	let geojson = geoJson as GeoJSON.FeatureCollection<GeoJSON.Geometry>;

	const fetchPostData = async (id: string) => {
		try {
			const res = await fetch(`/api/posts/${id}`);
			if (!res.ok) throw new Error("Failed to fetch post data");
			return await res.json();
		} catch (error) {
			console.error(error);
			return null;
		}
	};

	const handlePopup = async (e: maplibregl.MapLayerMouseEvent) => {
		const id = e.features?.[0]?.properties.contentId;
		if (!id) return;

		const post = await fetchPostData(id);

		if (post) {
			new maplibregl.Popup()
				.setLngLat(e.lngLat)
				.setHTML(
					`
					<div>
						<h2 class="text-xl">${post.title}</h2>
						<img src="${post.eyecatch.url}" alt="${post.title}" class="w-full h-auto" />
					</div>
				`,
				)
				.addTo(map!);
		} else {
			new maplibregl.Popup()
				.setLngLat(e.lngLat)
				.setHTML("<p>データの取得に失敗しました。</p>")
				.addTo(map!);
		}
	};
</script>

<div class="h-full w-screen">
	<MapLibre
		bind:map
		class="h-[100vh] min-h-[300px]"
		style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
		zoom={5}
		center={{ lng: 137.5, lat: 38.5 }}
	>
		<GeoJSONSource id="sample-source" data={geojson}>
			<ImageLoader
				images={{
					icon: icon,
				}}
			>
				<SymbolLayer
					onclick={(e) => {
						handlePopup(e);
					}}
					id="sample-layer"
					layout={{
						"icon-image": "icon",
						"icon-size": 0.75,
					}}
				></SymbolLayer>
			</ImageLoader>
		</GeoJSONSource>
	</MapLibre>
</div>
```

以下のような構成になっています。

- `GeoJSONSource`: **作成したjsonを読み取る**
- `ImageLoader`: **画像を読み取る**
- `SymbolLayer`: **読み込まれたjsonに書かれている位置情報に、シンボル（今回なら読み込んだ画像）を表示する**

**Sourceでデータを読み込み、Layerでデータを表示する流れ**です。

ブラウザでアクセスすると、地図にアイコンが出てきました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/4df990bd-146b-e55a-2b77-6ef277138477.png)

ただ、今はmicroCMSのデータを受け取れないため、アイコンをクリックしてもデータが取得できないかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/49ef6650-e7cd-e483-d96b-0b36306d17be.png)

## microCMSを触ってみる

とても簡単にサインアップできるので、会員登録などの話は端折ります！

ダッシュボードにアクセスするとこんな感じで記事一覧が出てきます。
（会員登録したばっかりだと、サンプル記事があると思います。）
記事を書いていない人はまず適当に書いてみましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/e765eb9b-ec3f-b3e5-8112-a0ab243b0811.png)

画面右上の歯車アイコンから配信されているAPIの設定を変更できます。
今回はエンドポイントの記事配信のパスを`/api/v1/posts`に変更したのと…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/c74a2e42-d743-41f1-9563-1557fa91593b.png)

APIから配信される情報を変更してみました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/a877b259-3975-2bfc-6702-8b6210002092.png)

サムネイル画像はこんな感じで別途アップロードしています。

![image copy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/94ee7b42-e3a7-149f-e58f-013398ecfe33.png)

APIキーはここからコピーして、プロジェクトのルートに`.env`ファイルを配置し、`MICROCMS_API_KEY`として設定しておきましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/e0414f7e-c82c-c765-f225-22e3a7b525b1.png)

`.env`

```tex
MICROCMS_API_KEY=
SERVICE_DOMAIN=
```

`SERVICE_DOMAIN`は画面左上のSampleってとこに書いてる黒塗りの部分に記載の情報を格納してください。

![image copy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/3d709097-86a1-140d-aa74-74c591b16dd5.png)

## microCMSのAPIから配信されるデータを受け取る

データを受け取って、**地図上に「タイトル」と「サムネイル画像」を表示**していきます！
SvelteKitではフロントだけでなくサーバー側の処理をしたり、WebAPIを作成することもできるので、microCMSのデータを取得するAPIを作成してみましょう！

と、その前にまずはAPIとしてちゃんと動作するか確認しておきましょう。
こんな感じでapiディレクトリ以下に`+server.ts`を配置するだけです。

`src/routes/api/+server.ts`
```ts
import { json } from "@sveltejs/kit";

export const GET = async () => {
	return json({ message: "Hello from the server!" });
};
```

http://localhost:5173/api に接続するとちゃんとレスポンスが返ってきていますね！

```json
{
    message: "Hello from the server!"
}
```

ではAPIを作成していきましょう！

`src/routes/api/posts/[id]/+server.ts`

```ts
import { getPostDetail } from "$lib/cms/cms-client.server";
import { error, json } from "@sveltejs/kit";

export const GET = async ({ params }) => {
	const { id } = params;

	if (!id) {
		throw error(400, "ID is required");
	}

	const post = await getPostDetail(id);

	if (!post) {
		throw error(404, "Post not found");
	}

	return json(post);
};
```

`getPostDetail`関数は別のユーティリティを作成し、そこから読み取っています。
それらの処理を作っていきましょう！存在していないフォルダは適宜作成していってください！

`src/lib/cms/index.ts`

```ts
export { getPostDetail } from "./cms-client.server";
export type { List, Post, PostCategory } from "./types";
```

型情報は、先ほど修正したAPIのスキーマに合わせて修正してあげます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/55927873-ca9d-c393-47c0-e4d03dd14654.png)

`src/lib/cms/types.ts`

```ts
type MicrocmsImage = {
	url: string;
	height: number;
	width: number;
};

export type PostCategory = {
	id: string;
	name: string;
	description: string;
	createdAt: string;
	updatedAt: string;
	publishedAt: string;
	revisedAt: string;
};

export type Post = {
	id: string;
	createdAt: string;
	updatedAt: string;
	publishedAt: string;
	revisedAt: string;
	title: string;
	content: string;
	eyecatch: MicrocmsImage;
	category: PostCategory;
};

export type List<T> = {
	contents: T[];
	totalCount: number;
	offset: number;
	limit: number;
};
```

APIのレスポンスはダッシュボードから手軽に確認することができるため、調整が楽でした！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/de387726-1a2c-ccaf-858e-720ac27ed397.png)

`src/lib/cms/cms-client.server.ts`

```ts
import { createClient, type MicroCMSQueries } from "microcms-js-sdk";

import { MICROCMS_API_KEY, SERVICE_DOMAIN } from "$env/static/private";
import type { Post } from "./types";

let client: ReturnType<typeof createClient> | undefined = undefined;
const getClient = () => {
	if (client) return client;
	client = createClient({
		serviceDomain: SERVICE_DOMAIN ?? "",
		apiKey: MICROCMS_API_KEY ?? "",
	});
	return client;
};

const getPostDetail = async (
	id: string,
	queries: MicroCMSQueries = {},
): Promise<Post | null> => {
	const client = getClient();
	try {
		return (await client.get({
			endpoint: "posts",
			contentId: id,
			queries,
		})) as Post;
	} catch {
		return null;
	}
};

export { getPostDetail };

```

最後に、`src/lib/assets/data/sample.json`の`contentId`を書いたブログのIDに変更しましょう！

するとこんな感じでタイトルと画像を取得できるはずです！！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/a3657fac-ce2e-8143-cab9-965151d1c43d.png)

## Amplifyにデプロイしてみる

Amplify（というかAWS）への登録なども割愛します！　

すでにアップロードしているものが見えちゃってますが、左上の「**新しいアプリを作成**」からデプロイのための設定を行うことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/9f2e0ea6-e1ca-6024-6ee3-1cdd4d470cca.png)

**GitHubなどで書いたソースコード一式をパブリックリポジトリとして登録しておく**と、デプロイがめっちゃ簡単です！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/8530ad87-df59-5e58-25a0-daeab754861d.png)

「次へ」を入力するとGitHubの連携する画面が出てくるので適宜許可しましょう。
するとリポジトリが選択できるようになると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/e1621c1a-43ae-72c7-6233-5e3326bf1bcb.png)

出てこなければ「GitHubのアクセス許可をアップデート」しましょう。
リポジトリとブランチを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/2cb13da7-f4aa-7399-a749-87195c89c916.png)

適宜修正し…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/00d7506f-a840-33e5-ee05-226939da5bd8.png)

環境変数なども入れておき…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/40477f8b-5c0b-3d0e-2726-a336abdf5249.png)

設定を確認してデプロイするだけ！！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/922aad33-4696-53b0-8a88-ce90b23b9661.png)

GOGO！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/a51315da-9dfd-40c4-e6b8-1b7f8e1ab701.png)

で、**ビルドに失敗したりするわけっすよね…**
悲しい…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/3e4df9b7-fa58-9d9a-5a51-01a5c9a8f7cc.png)

今回は、ビルドされたファイル群がないぞーと怒られています。

ローカルマシンで`npm run build`と入力すると多分`build`というフォルダができると思います。

でもビルドの設定を確認すると`baseDirectory: dist`の記載がありますね。
これを`baseDirectory: build`に変更してあげます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/170ff833-290d-fbee-4d1f-f431af4ac05f.png)

また、公式ドキュメントに示されている通り、**サーバー側で環境変数を利用するためにはビルド時に`.env`を出力してあげる**必要があるようでした。

https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/ssr-environment-variables.html

結果的にデプロイ用の設定はこんな感じになります。

```yml
version: 1
frontend:
    phases:
        preBuild:
            commands:
                - npm ci --cache .npm --prefer-offline
        build:
            commands:
                - echo "MICROCMS_API_KEY=$MICROCMS_API_KEY" >> .env
                - echo "SERVICE_DOMAIN=$SERVICE_DOMAIN" >> .env
                - npm run build
    artifacts:
        baseDirectory: build
        files:
            - '**/*'
    cache:
        paths:
            - '.npm/**/*'
```

設定を変更したので再度デプロイしてみましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/58e09fa2-855a-9363-9b0f-8bea1a013221.png)

成功しましたー！！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/4fc8634e-95f9-1b8d-c012-f5abedd7d8d2.png)

ということで書かれたURLに接続してみましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/3bf495a3-59c9-33bf-b8a0-d9b89a2e76b1.png)

良さそうですね！

こんな感じで設定しておくと、mainブランチにマージされると自動的に再デプロイされます！
最高！

## おわりに

ということで、**SvelteKit**・**Svelte MapLibre GL**・**microCMS**・**Amplify Gen 2**を利用して地図上にブログ記事を載せる旅行地図日記的なアプリを作ってみました！

僕はサーバー寄りのエンジニアなんですが、こんだけ便利なツール群が揃っているとフロントエンドもたのしいなーと思いました！

構築からデプロイまでめちゃくちゃスムーズに行けるようになっているので、皆さんも地図アプリ作ってみてくださーい！