ViteとOpenLayersで作る地理院タイル地図表示アプリ
はじめに
ブラウザで地図を表示するだけなら、HTMLへ地図ライブラリを読み込んで数十行のJavaScriptを書く方法でも始められます。ただ、背景地図の切替、位置情報、GeoJSONの重ね合わせ、APIとの接続まで進めると、1ファイルに処理を詰め込んだ構成は早い段階で扱いにくくなります。
今回は、Vite + Vanilla TypeScript + OpenLayersを使い、国土地理院が配信する地理院タイルを表示します。最初の地図表示だけで終わらず、次の機能追加へつなげやすいように、設定、地図生成、画面操作を分けて実装します。
作成するサンプルには、次の機能を入れています。
- 標準地図、淡色地図、写真の切替
- 初期位置へ戻る操作
- ブラウザの位置情報を使った現在地表示
- マウス位置の経度・緯度表示
- ズームレベル、縮尺、出典の表示
- ローカル開発用のVite環境
- 本番配信用のDocker Compose + Nginx構成
この記事で扱うのは背景地図の表示までです。GeoJSON、PMTiles、FastAPIなどは、次の段階で追加できる構成にしてあります。
1. Vite、OpenLayers、地理院タイルの役割
最初に、3つの役割を分けておきます。
| 要素 | 役割 |
|---|---|
| Vite | TypeScriptやCSSを開発・ビルドするための環境 |
| OpenLayers | 地図の描画、座標変換、レイヤー管理、地図操作を担当するライブラリ |
| 地理院タイル | 国土地理院から配信される背景地図データ |
Viteが地図を描くわけではなく、OpenLayersがタイル画像を配信するわけでもありません。Viteで作ったフロントエンドからOpenLayersを呼び出し、OpenLayersが地理院タイルを取得して画面へ並べます。
2. Viteを使う理由
従来から使われてきたWebpackは柔軟で、現在も多くのプロジェクトで利用されています。一方、設定する項目が多くなりやすく、小さな地図アプリを始める段階では準備の負担が大きく感じることがあります。
Viteは開発時にブラウザのES Modulesを活用し、必要なソースを要求に応じて配信します。依存パッケージは事前にまとめ、ソース変更時は関係するモジュールを中心に更新します。そのため、開発サーバーの起動とHMRによる変更反映が速く、TypeScriptの小規模プロジェクトを始めやすいのが利点です。
ここで注意したいのは、「Viteは一切バンドルしない」という意味ではないことです。開発時には依存関係の事前処理を行い、本番用のvite buildでは最適化した静的ファイルを出力します。
| 比較項目 | Webpackを使う一般的な構成 | Vite |
|---|---|---|
| 開発サーバー | 構成に応じてモジュールをまとめてから配信 | ソースをES Modulesとして配信し、依存関係を事前処理 |
| 変更反映 | バンドル構成や規模によって再処理範囲が増える | 変更したモジュールを中心にHMRで更新 |
| 設定 | LoaderやPluginを含め、細かな設定が必要になる場合がある | TypeScriptの基本構成は少ない設定で開始可能 |
| 本番ビルド | Webpackで最適化して出力 | Viteのビルド機能で最適化して出力 |
OpenLayersのようにES Modulesで提供されるライブラリとの相性もよく、必要なモジュールを個別にimportできます。
3. 地理院タイルの基本
3.1 XYZタイル
地理院タイルは、地図を正方形の画像へ分割し、ズームレベルとタイル座標で取得する形式です。URLの{z}、{x}、{y}へ値が入り、OpenLayersが現在の表示範囲に必要な画像だけを取得します。
https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
-
z: ズームレベル -
x: 東西方向のタイル番号 -
y: 南北方向のタイル番号
3.2 今回使用する背景地図
| 表示名 | タイルID | URLの末尾 | 主な用途 |
|---|---|---|---|
| 標準地図 | std |
std/{z}/{x}/{y}.png |
地名、道路、建物、地形を確認 |
| 淡色地図 | pale |
pale/{z}/{x}/{y}.png |
解析結果や主題図の背景 |
| 写真 | seamlessphoto |
seamlessphoto/{z}/{x}/{y}.jpg |
現地状況や土地利用を確認 |
写真タイルは、同じURLでもズームレベルに応じて使われる画像の種類が変わります。また、場所によって追加のクレジットが必要になる場合があります。公開前には、地理院タイル一覧の備考を確認します。
3.3 出典表示
OpenLayersのXYZへattributionsを設定すると、地図右下のAttributionコントロールに出典が表示されます。
new XYZ({
url: 'https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png',
attributions:
'<a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank" rel="noopener noreferrer">地理院タイル</a>',
});
CSSでAttributionを消したり、他のパネルで隠したりしないようにします。
4. 座標系と座標の順序
この部分は、地図表示で間違えやすいところです。
緯度・経度は一般にEPSG:4326で扱います。OpenLayersの標準的な地図表示とXYZタイルは、WebメルカトルのEPSG:3857を使います。そのため、設定ファイルに書いた経度・緯度を、そのまま地図中心へ渡すのではなく、fromLonLat()で変換します。
center: fromLonLat([139.3733, 35.5714])
OpenLayersへ渡す順序は、次のとおりです。
[経度, 緯度]
相模原市付近を例にすると、経度が139.3733、緯度が35.5714です。[緯度, 経度]へ逆にすると、日本から離れた場所が表示されます。
マウス位置を画面へ表示するときは、地図イベントから得たEPSG:3857の座標をtoLonLat()で経度・緯度へ戻します。
5. 動作環境
この記事の実装は、次のバージョンで構成しています。
- Vite 8.2.0
- TypeScript 7.0.2
- OpenLayers 10.10.0
Vite 8系を使うため、Node.jsは次のいずれかを使用します。
- Node.js 20.19以上
- Node.js 22.12以上
バージョン確認は次のコマンドで行います。
node --version
npm --version
6. フォルダー構成
実装を1つのmain.tsへ集めず、責務ごとに分けます。
app/
├─ .env.example
├─ .gitignore
├─ Dockerfile
├─ compose.yaml
├─ index.html
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
├─ nginx/
│ └─ default.conf
├─ public/
│ └─ favicon.svg
└─ src/
├─ main.ts
├─ style.css
├─ vite-env.d.ts
├─ config/
│ ├─ appConfig.ts
│ └─ gsiLayers.ts
├─ map/
│ ├─ createBaseLayers.ts
│ ├─ createLocationLayer.ts
│ └─ createMap.ts
└─ ui/
├─ bindLayerSwitcher.ts
├─ bindMapStatus.ts
├─ bindToolbar.ts
└─ dom.ts
各フォルダーの役割は次のとおりです。
| フォルダー | 内容 |
|---|---|
config |
初期位置、ズーム、地理院タイル定義 |
map |
OpenLayersのMap、タイルレイヤー、現在地レイヤーの作成 |
ui |
セレクトボックス、ボタン、座標表示などDOMとの接続 |
nginx |
本番用静的配信の設定 |
main.tsは、それぞれのモジュールを順番に組み立てる役割だけにします。
7. プロジェクトの準備
空のディレクトリから作る場合は、ViteのVanilla TypeScriptテンプレートを使います。
npm create vite@latest vite-openlayers-gsimap -- --template vanilla-ts
cd vite-openlayers-gsimap
npm install
npm install ol
今回配布するZIPにはファイル一式を収録しているため、展開後はappディレクトリで次を実行すれば起動できます。
npm install
npm run dev
http://localhost:5173/
8. package.json
依存パッケージのバージョンは、サンプルを再現しやすいように固定しています。
{
"name": "vite-openlayers-gsimap",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"ol": "10.10.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.2.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
npm run buildの前にtsc --noEmitを実行し、型エラーがある状態で本番ビルドしないようにしています。
9. 初期表示設定を.envへ分ける
初期位置をソースへ直接書いても動きます。ただし、対象地域を変えるたびにTypeScriptを編集することになります。そこで、アプリ名、初期座標、ズームを.envから読み込みます。
VITE_APP_TITLE=地理院タイルビューア
VITE_INITIAL_LON=139.3733
VITE_INITIAL_LAT=35.5714
VITE_INITIAL_ZOOM=13
VITE_MIN_ZOOM=2
VITE_MAX_ZOOM=18
VITE_DEFAULT_BASE_LAYER=std
Viteでブラウザ側から参照する環境変数には、VITE_の接頭辞が必要です。また、値はすべて文字列として読み込まれるため、appConfig.tsで数値へ変換します。
不正な値が入った場合に地図が起動しなくならないよう、経度は-180~180、緯度は-85~85、ズームは指定範囲へ収めています。
function readNumber(
rawValue: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
if (rawValue === undefined || rawValue.trim() === '') {
return fallback;
}
const parsedValue = Number(rawValue);
if (!Number.isFinite(parsedValue)) {
return fallback;
}
return Math.min(Math.max(parsedValue, minimum), maximum);
}
10. 地理院タイル定義を分離する
背景地図の名称、URL、ズーム範囲、説明、出典をgsiLayers.tsへまとめます。
export type GsiBaseLayerId = 'std' | 'pale' | 'seamlessphoto';
export interface GsiBaseLayerDefinition {
readonly id: GsiBaseLayerId;
readonly title: string;
readonly description: string;
readonly url: string;
readonly minZoom: number;
readonly maxZoom: number;
readonly attributionHtml: string;
}
定義を1か所に集める理由は、画面の選択肢とOpenLayersのレイヤー作成で同じ情報を使うためです。HTMLへ背景地図名を直書きし、TypeScriptへURLを別に書くと、追加や名称変更の際に不整合が起こりやすくなります。
export const GSI_BASE_LAYERS: readonly GsiBaseLayerDefinition[] = [
{
id: 'std',
title: '標準地図',
description: '道路、建物、地名、地形を確認しやすい一般的な背景地図です。',
url: 'https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png',
minZoom: 2,
maxZoom: 18,
attributionHtml: GSI_ATTRIBUTION,
},
// pale、seamlessphotoも同じ形式で定義
];
11. OpenLayersの背景レイヤーを作る
createBaseLayers.tsでは、設定一覧をTileLayerとXYZへ変換します。
const source = new XYZ({
url: definition.url,
minZoom: definition.minZoom,
maxZoom: definition.maxZoom,
attributions: definition.attributionHtml,
crossOrigin: 'anonymous',
transition: 200,
});
const layer = new TileLayer({
source,
visible: definition.id === defaultLayerId,
});
背景地図は、切替のたびに削除・再作成するのではなく、最初に3レイヤーを登録します。選択中のレイヤーだけvisible: trueにし、残りを非表示にします。
また、OpenLayersの汎用プロパティへレイヤーIDと名称を設定してあります。
layer.set('layerId', definition.id);
layer.set('title', definition.title);
layer.set('isBaseLayer', true);
後でレイヤー一覧、凡例、透過率設定を作るときに、このメタデータを利用できます。
12. 地図本体を作る
createMap.tsでは、地図の描画先、レイヤー、初期中心、ズームを受け取ります。
return new Map({
target: options.target,
layers: [...options.baseLayers, ...options.overlayLayers],
controls,
view: new View({
center: fromLonLat([...options.initialCenterLonLat]),
zoom: options.initialZoom,
minZoom: options.minZoom,
maxZoom: options.maxZoom,
}),
});
OpenLayers標準のズーム・Attributionに加え、全画面と縮尺を追加しています。
const controls = defaultControls().extend([
new FullScreen({
tipLabel: '全画面表示を切り替えます',
}),
new ScaleLine({
units: 'metric',
}),
]);
13. 背景地図切替
選択欄のoptionはHTMLへ固定で書かず、GSI_BASE_LAYERSから作ります。
for (const definition of GSI_BASE_LAYERS) {
const option = document.createElement('option');
option.value = definition.id;
option.textContent = definition.title;
selectElement.append(option);
}
選択が変わったら、対象レイヤーだけを表示します。
for (const [id, layer] of layerById.entries()) {
layer.setVisible(id === layerId);
}
淡色地図のように低いズームでタイルが提供されていない場合は、選択時に提供範囲内へズームを調整します。これにより、切り替え直後に画面が空白になるのを避けています。
14. 現在地表示
現在地ボタンでは、ブラウザのGeolocation APIを使用します。
navigator.geolocation.getCurrentPosition(
(position) => {
const { longitude, latitude, accuracy } = position.coords;
const mapCoordinate = fromLonLat([longitude, latitude]);
locationLayer.setPosition(mapCoordinate);
map.getView().animate({
center: mapCoordinate,
zoom: 16,
duration: 500,
});
},
(error) => {
// 権限拒否、取得不能、タイムアウトを分けて表示
},
{
enableHighAccuracy: true,
timeout: 10_000,
maximumAge: 60_000,
},
);
位置情報は、このブラウザ画面内で点と地図中心を更新するためだけに使い、サーバーへ送信しません。
Geolocation APIはセキュアコンテキストで動作します。一般にはhttps://が必要ですが、開発時のhttp://localhostは利用できます。別PCからhttp://192.168.x.x:5173のように開いた場合は、位置情報が無効になることがあります。
15. 座標とズームの表示
OpenLayersのpointermoveイベントからマウス位置を受け取り、toLonLat()で変換します。
map.on('pointermove', (event) => {
if (event.dragging) {
return;
}
const [longitude, latitude] = toLonLat(event.coordinate);
coordinateElement.textContent =
`経度 ${longitude.toFixed(6)} / 緯度 ${latitude.toFixed(6)}`;
});
ズーム値はViewの解像度変更イベントで更新します。
view.on('change:resolution', updateZoom);
地図のドラッグ中は座標表示を更新しないようにし、操作時の不要な描画を抑えています。
16. main.tsは組み立てだけにする
main.tsでは、HTML要素を取得し、レイヤー、地図、UIを順番に接続します。
const baseLayerRegistry = createBaseLayers(APP_CONFIG.defaultBaseLayerId);
const locationLayer = createLocationLayer();
const map = createMap({
target: mapElement,
baseLayers: baseLayerRegistry.layers,
overlayLayers: [locationLayer.layer],
initialCenterLonLat: APP_CONFIG.initialCenterLonLat,
initialZoom: APP_CONFIG.initialZoom,
minZoom: APP_CONFIG.minZoom,
maxZoom: APP_CONFIG.maxZoom,
});
このあと、背景切替、現在地ボタン、座標表示をそれぞれ接続します。
bindLayerSwitcher({ ... });
bindToolbar({ ... });
bindMapStatus({ ... });
機能別に分けておくと、たとえばGeoJSON読込を追加する場合はmap/createGeoJsonLayer.tsとui/bindGeoJsonPanel.tsを追加する形で進められます。
17. CSSで地図の高さを確保する
地図が表示されない原因として多いのが、地図コンテナの高さが0になっているケースです。
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
.app-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
}
.map-shell {
position: relative;
min-height: 0;
}
.map {
width: 100%;
height: 100%;
}
ヘッダーの残り領域を地図へ割り当てるため、Gridレイアウトとminmax(0, 1fr)を使用しています。
独自の操作パネルは地図の上に絶対配置します。OpenLayersの全画面ボタン、縮尺、Attributionと重ならないよう、標準コントロールの位置も調整しています。
18. 開発サーバーの起動
cd app
cp .env.example .env
npm install
npm run dev
Windows PowerShellでは次のようにコピーします。
Copy-Item .env.example .env
ブラウザで次を開きます。
http://localhost:5173/
TypeScriptやCSSを保存すると、HMRにより画面へ反映されます。
19. 型チェックと本番ビルド
npm run typecheck
npm run build
ビルド結果はdist/へ出力されます。
dist/
├─ index.html
├─ favicon.svg
└─ assets/
├─ index-xxxxxxxx.js
└─ index-xxxxxxxx.css
ローカルで本番ビルドを確認する場合は、次を実行します。
npm run preview
http://localhost:4173/
dist/index.htmlをファイルとして直接開くのではなく、vite previewやNginxなどのHTTPサーバーから配信します。
20. Docker Compose + Nginxで配信する
Dockerfileは、Node.jsでViteビルドを行う段階と、Nginxで静的配信する段階を分けています。
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
ARG VITE_APP_TITLE
ARG VITE_INITIAL_LON
ARG VITE_INITIAL_LAT
ARG VITE_INITIAL_ZOOM
ARG VITE_MIN_ZOOM
ARG VITE_MAX_ZOOM
ARG VITE_DEFAULT_BASE_LAYER
ENV VITE_APP_TITLE=${VITE_APP_TITLE} \
VITE_INITIAL_LON=${VITE_INITIAL_LON} \
VITE_INITIAL_LAT=${VITE_INITIAL_LAT} \
VITE_INITIAL_ZOOM=${VITE_INITIAL_ZOOM} \
VITE_MIN_ZOOM=${VITE_MIN_ZOOM} \
VITE_MAX_ZOOM=${VITE_MAX_ZOOM} \
VITE_DEFAULT_BASE_LAYER=${VITE_DEFAULT_BASE_LAYER}
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
Composeから起動します。
docker compose up --build
http://localhost:8080/
停止は次のコマンドです。
docker compose down
.envの値はComposeからDockerのbuild argsへ渡し、Viteのビルド時に取り込まれます。値を変更した場合は、イメージを再ビルドします。
21. よくある問題
地図が白い
次を順番に確認します。
-
#mapと親要素に高さが設定されているか - ブラウザの開発者ツールでJavaScriptエラーが出ていないか
- Networkタブで地理院タイルの画像を取得できているか
- 選択中レイヤーの提供ズーム範囲内か
- 社内ネットワークやプロキシで外部タイル通信が遮断されていないか
npm installでNode.jsのバージョンエラーになる
Node.jsのバージョンを確認します。
node --version
Vite 8系の要件を満たすNode.jsへ更新します。複数案件でNode.jsの世代が異なる場合は、nvm、Volta、asdfなどで切り替える方法もあります。
現在地ボタンが使えない
http://localhost以外のHTTP接続では、ブラウザが位置情報を許可しないことがあります。公開環境はHTTPSにします。
また、ブラウザやOS側で位置情報を拒否している場合は、サイト権限と端末設定を確認します。
出典が見えない
AttributionコントロールをCSSで非表示にしていないか、独自パネルが上に重なっていないか確認します。今回のCSSでは右下に表示されるよう調整しています。
22. 次に追加しやすい機能
この構成を起点に、次の順で機能を増やせます。
- GeoJSONの読込とスタイル設定
- クリックした地物の属性ポップアップ
- GeoPackageやGeoParquetをFastAPI側で変換して配信
- PMTilesの静的配信
- DuckDB Spatialを使ったBBOX検索
- ベクタータイルやMVTの表示
- 計測、作図、編集、ファイル出力
背景地図の定義、地図本体、UIを分けてあるため、main.tsへすべてを書き足す必要はありません。
まとめ
ViteとOpenLayersを組み合わせると、TypeScriptで地図アプリを始めやすく、変更確認も短い手順で行えます。ただし、最初の数十行だけで終わらせず、設定、レイヤー作成、地図作成、UI処理を早めに分けておくことが重要です。
今回の構成では、地理院タイルの表示に加えて、背景切替、現在地、座標、ズーム、Docker配信までを含めました。次の段階でGeoJSONやAPIを追加しても、既存の処理を大きく崩さずに進められます。
参考リンク
- Vite公式ガイド
- Viteのリリース方針
- OpenLayers公式サイト
- OpenLayers Quick Start
- 地理院タイル一覧
- 地理院タイルの仕様
- 国土地理院コンテンツ利用規約
- 出典の記載例

