Skip to content
地図を扱うためのライブラリであるLeafletをVue, Vuetify環境で使ってみる記事のサムネイル

地図を扱うためのライブラリであるLeafletをVue, Vuetify環境で使ってみる

Leafletは、地図を扱うためのJavaScriptライブラリです。 今回はそのLeafletを使って簡単な地図アプリケーションを作成してみます。 業務ではVueを使用することが多いため、Vue環境でLeafletを導入してみます。 また、UIフレームワークとしてVuetifyを使用し、環境構築から実践していきます。 また当記事でLeafletやVuetifyの全ての機能は網羅していないことをご承知おきください。

環境構築

まずはVueの環境を構築し、そこにLeafletとVuetifyをインストールしていきます。 今回使用するNode.jsとNpmのバージョンは以下です。

  • Node.js 22.11.0
  • Npm 10.9.0

Vueのプロジェクト作成

以下のコマンドを流しVueのプロジェクトを作成します。今回私はsample-vue-leafletというプロジェクト名で作成しますが適宜変更してください。

bash
npm create vue@latest

いくつかの質問がありますが私は下記のようにしました。

√ Project name: ... sample-vue-leaflet
√ Add TypeScript? ... Yes
√ Add JSX Support? ... No
√ Add Vue Router for Single Page Application development? ... Yes
√ Add Pinia for state management? ... Yes
√ Add Vitest for Unit Testing? ... No
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? » Yes
√ Add Prettier for code formatting? ... Yes

同じようにすると当記事と同じ環境が作成されるかと思いますが好みに合わせてください。 また使用しないファイルがsrc配下にいくつかあります。記事中に削除の記載はしないので必要のないファイルは適宜削除してください。

Vuetifyのインストールとセットアップ

次はUIフレームワークのVuetifyをインストールします。アイコンも一緒にインストールしてしまいます。 以下コマンドを流してください。

bash
npm i vuetify @mdi/font

ツリーシェイキングや、VuetifyのSass変数上書きするために以下もインストールします。

bash
npm i -D vite-plugin-vuetify sass-loader sass

インストールできたらまずはvite.config.tsファイルでvite-plugin-vuetifyを読み込みます。

ts
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import vuetify from 'vite-plugin-vuetify'; 

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vuetify(), 
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    },
  },
})

次にVuetifyを使用できるように読み込んであげます。pluginsディレクトリを作成し、そこにvuetify.tsファイルを作成し以下のようにします。 私はダークモードが好みなので、ダークモードの設定とボタンをデフォルトでoutlinedにしておきます。 また作成したファイルをmain.tsで読み込みます。

ts
import '@mdi/font/css/materialdesignicons.css';
import { createVuetify } from 'vuetify';
import 'vuetify/styles';

const vuetify = createVuetify({
  theme: {
    defaultTheme: 'dark',
  },
  defaults: {
    VBtn: {
      variant: 'outlined',
    },
  },
});

export default vuetify;
ts
import { createPinia } from 'pinia';
import { createApp } from 'vue';

import App from './App.vue';
import vuetify from './plugins/vuetify'; 
import router from './router';

const app = createApp(App);

app.use(createPinia());
app.use(router);
app.use(vuetify); 

app.mount('#app');

スタイル調整のためにstylesディレクトリを作成しsettings.scssを作成します。 v-footerコンポーネントが、メインコンンテンツがないときに画面を埋めてしまうので、以下を事前に設定しておきます。

scss
@use 'vuetify/settings' with (
  $footer-flex: 1 1 1
);

用意したscssファイルを読み込ませるためにvite.config.tsファイルに記載します。

ts
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vueDevTools from 'vite-plugin-vue-devtools';
import vuetify from 'vite-plugin-vuetify';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vuetify({ 
      styles: { 
        configFile: 'src/styles/settings.scss', 
      }, 
    }), 
    vueDevTools(),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
});

ここまでできたら実際にVuetifyのコンポーネントを使用してみましょう。 Vuetifyはv-appコンポーネントの中に置く必要があります。 App.vue内にv-appコンポーネントを配置してみます。

vue
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>

<template>
  <v-app>
    <v-main>
      <router-view />
    </v-main>
  </v-app>
</template>

ヘッダーにあたるv-app-barも配置します。 こちらはコンポーネント化して読み込みます。 フッターは今回は配置するものが少ないのでそのままApp.vueに配置します。 またv-app-barに配置したv-btnは基本的にoutlinedにならないようです。 v-app-bar内のv-btnをoutlinedにするには、variantで直接指定が必要そうです。 今回はそのままにしておきます。 v-iconに使用しているアイコンはこちらから確認することができます。

vue
<script setup lang="ts">
import { useTheme } from 'vuetify';

const theme = useTheme();

function toggleTheme() {
  theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark';
}
</script>

<template>
  <v-app-bar>
    <v-app-bar-title>Vue + Leaflet + Vuetify</v-app-bar-title>
    <v-btn @click="toggleTheme">
      <v-icon>mdi-theme-light-dark</v-icon>
    </v-btn>
  </v-app-bar>
</template>
vue
<script setup lang="ts">
import { RouterView } from 'vue-router';

import AppHeader from '@/components/AppHeader.vue'; 
</script>

<template>
  <v-app>
    <AppHeader /> 
    <v-main>
      <router-view />
    </v-main>
    <v-footer class="justify-center"> 
      <p><small>&copy; 2025 〇×△□</small></p> 
    </v-footer> 
  </v-app>
</template>
vue
<template>
  <p>Hello World</p>
</template>

ここまででVuetifyのセットアップは終わりになります。 他のVuetifyのコンポーネントやユーティリティクラスなどは、ドキュメントを参照してください。

Leafletのインストール

LeafletとVue3用のLeafletコンポーネントをインストールします。 下記コマンドを流します。

bash
npm i @vue-leaflet/vue-leaflet leaflet

TypeScriptを使用しているのでtypesファイルもインストールします。

bash
npm i -D @types/leaflet

地図を表示させるためのコンポーネントファイルを作成し、そこにVue用のLeafletコンポーネント読み込んであげます。 基本的にはl-mapコンポーネント内に配置します。地図は国土地理院のものを今回使用させていただきます。 l-tile-layerコンポーネントをl-map内に配置しurlを渡してあげると表示させることができます。 l-control-scaleはスケールバーになります。メートルで表示させるよう指定します。 そして私は佐賀出身なので起動時の中心座標に佐賀駅を指定します。 作ったコンポーネントをHomeView.vueで読み込んで画面に表示させます。

vue
<script setup lang="ts">
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { ref } from 'vue';

// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};
</script>

<template>
  <l-map :zoom="zoom" :center="center" :use-global-leaflet="false" :options="mapOptions">
    <l-tile-layer :url="mapUrl"></l-tile-layer>
    <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
  </l-map>
</template>
vue
<script setup lang="ts"> 
import MapMain from '@/components/MapMain.vue'; 
</script> 

<template>
  <MapMain /> 
</template>

INFO

今回はVue3用のLeafletコンポーネントを使用していますが、Vue2のものと大きく変わりはないようなので、ドキュメントはVue2用のものを参照しています。

ここまででVue, Leaflet, Vuetifyの環境を作ることができました。 ここからはLeafletの機能をいくつか試したり、Vuetifyのコンポーネントを使用したりしてみます。

座標とズームレベルを保持する

現在の状態だと、画面を移動したり、ズームレベルを変えたりしても画面をリフレッシュすると全て元の状態に戻ってしまいます。 最初の起動以降は中心座標とズームレベルを保持するように変更してみます。 まずはズームレベルから行ないます。 プロジェクト作成時にVue RouterをインストールしているのでVue Routerでパラメータにズームレベルを保持し、マウント時にパラメータがあればzoomの値を書き換えます。 書き換えるだけだと上手くいかなかったので、@readyでsetViewをしています。

vue
<script setup lang="ts">
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { onMounted, ref, watch } from 'vue'; 
import { useRoute, useRouter } from 'vue-router'; 

const route = useRoute(); 
const router = useRouter(); 

const mapInstance = ref<L.Map | null>(null); 
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom); 

const onMapReady = (map: L.Map) => { 
  mapInstance.value = map; 
  mapInstance.value.setView(center.value, zoom.value); 
}; 

onMounted(() => { 
  if (route.query.zoom) updateZoom(Number(route.query.zoom)); 
}); 

// ズームレベルが変更されたらパラメータ付与
watch(zoom, () => { 
  router.replace({ 
    query: { 
      ...route.query, 
      zoom: zoom.value, 
    }, 
  }); 
}); 
</script>

<template>
  <l-map
    :zoom="zoom"
    :center="center"
    :use-global-leaflet="false"
    :options="mapOptions"
    @ready="onMapReady"
    @update:zoom="updateZoom"
  >
    <l-tile-layer :url="mapUrl"></l-tile-layer>
    <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
  </l-map>
</template>

これでズームレベルの保持ができるようになりました。 続いて中心座標も同じように保持できるように変更を加えます。 ズームレベルを変更すると中心座標も変わるので、今回はひとつのwatchでやってしまおうと思います。

vue
<script setup lang="ts">
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]); 

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) { 
    center.value = [Number(route.query.lat), Number(route.query.lng)]; 
  } 
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => { 
  router.replace({ 
    query: { 
      ...route.query, 
      zoom: zoom.value, 
      lat: center.value[0], 
      lng: center.value[1], 
    }, 
  }); 
}); 
</script>

<template>
  <l-map
    :zoom="zoom"
    :center="center"
    :use-global-leaflet="false"
    :options="mapOptions"
    @ready="onMapReady"
    @update:zoom="updateZoom"
    @update:center="updateCenter"
  >
    <l-tile-layer :url="mapUrl"></l-tile-layer>
    <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
  </l-map>
</template>

これでズームレベルと中心座標をパラメータで保持することができました。 実際にズームレベルを初期値の12から変更したり、中心座標を移動させたりして画面をリロードすると、リロード前のズームレベルと中心座標になっているかと思います。

マーカー、アイコン、ポップアップを地図上に表示させる

ここではLeafletのマーカー、アイコン、ポップアップのコンポーネントを使用してみます。

マーカー

マーカーは地図上にピン立てすることができます。 例として佐賀空港にピン立てしてみようと思います。 mapコンポーネントが大きくなってきたのでマーカーは別のコンポーネントでやってみます。 componentsフォルダにMapMarker.vueを作成してそこに記述し、MapMain.vueで読み込みます。

vue
<script setup lang="ts">
import { LMarker } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';

// 佐賀空港にピン立て
const markerLatLng: PointTuple = [33.1504684, 130.3031056];
</script>

<template>
  <l-marker :lat-lng="markerLatLng"></l-marker>
</template>
vue
<script setup lang="ts">
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import MapMarker from '@/components/MapMarker.vue'; 

const route = useRoute();
const router = useRouter();

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]);

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) {
    center.value = [Number(route.query.lat), Number(route.query.lng)];
  }
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => {
  router.replace({
    query: {
      ...route.query,
      zoom: zoom.value,
      lat: center.value[0],
      lng: center.value[1],
    },
  });
});
</script>

<template>
  <l-map
    :zoom="zoom"
    :center="center"
    :use-global-leaflet="false"
    :options="mapOptions"
    @ready="onMapReady"
    @update:zoom="updateZoom"
    @update:center="updateCenter"
  >
    <l-tile-layer :url="mapUrl"></l-tile-layer>
    <MapMarker /> 
    <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
  </l-map>
</template>

これで佐賀空港にピン立てできたかと思います。

アイコン

アイコンはマーカーのアイコンを変更することができます。 試しに先ほどピン立てしたマーカーのアイコンを変更してみようと思います。 当記事と同じ方法でプロジェクトを作成していた場合、assetsフォルダの中にロゴがあるのでそちらを使用してみます。 アイコンをインポートしてicon-urlへ渡し、サイズは数値のタプルで渡します。

vue
<script setup lang="ts">
import { LIcon, LMarker } from '@vue-leaflet/vue-leaflet'; 
import type { PointTuple } from 'leaflet';

import Logo from '@/assets/logo.svg'; 

const markerLatLng: PointTuple = [33.1504684, 130.3031056];
const iconSize: PointTuple = [35, 30]; 
</script>

<template>
  <l-marker :lat-lng="markerLatLng">
    <l-icon :icon-url="Logo" :icon-size="iconSize"></l-icon> 
  </l-marker>
</template>

これで先ほどのマーカーのアイコンがVueのロゴに変わったかと思います。

ポップアップ

マーカーの子要素として配置すると、マーカーを押下した際にポップアップを出すことができます。 今回はマーカーの名前と緯度・経度を表示させたいと思います。 都合上、全ての値はコンポーネント内にベタ書きします。

vue
<script setup lang="ts">
import { LIcon, LMarker, LPopup } from '@vue-leaflet/vue-leaflet'; 
import type { PointTuple } from 'leaflet';

import Logo from '@/assets/logo.svg';

const markerLatLng: PointTuple = [33.1504684, 130.3031056];
const iconSize: PointTuple = [35, 30];
const popupAnchor: PointTuple = [0, -12]; 
</script>

<template>
  <l-marker :lat-lng="markerLatLng">
    <l-icon :icon-url="Logo" :icon-size="iconSize" :popup-anchor="popupAnchor"></l-icon> 
    <l-popup>佐賀空港<br />緯度:{{ markerLatLng[0] }}<br />経度:{{ markerLatLng[1] }}</l-popup> 
  </l-marker>
</template>

これでマーカーを押下するとポップアップが表示されるようになったかと思います。 アイコンのサイズ的にか、少しポップアップの位置がズレてたのでpopup-anchorで調整しています。

KMLのアップロード機能

では最後にKMLファイルのアップロード機能を実装してみようと思います。 地図上にKMLファイルをドラッグアンドドロップするとそのKMLが地図上に表示されるような実装にしたいと思います。 ただしKMLをそのまま表示させるのではなく、一度GeoJSONに変換し、そのGeoJSONを表示させます。

KMLの表示

まずはKMLをGeoJSONに変換するためのライブラリをインストールします。

bash
npm i @tmcw/togeojson

ライブラリをインストールしたらMapMain.vueにドラッグアンドドロップの処理を記述します。 GeoJSONの型が合っているか少々不安ですが、これで進めてみます。 地図上にドラッグアンドドロップでイベントを発火させたいので、l-mapの親要素としてdivをひとつ追加しイベントを付与しています。 divタグに振っているクラスはVuetifyのユーティリティクラスになります。

vue
<script setup lang="ts">
import { kml } from '@tmcw/togeojson'; 
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; 
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import MapMarker from '@/components/MapMarker.vue';

const route = useRoute();
const router = useRouter();

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};
const geoJsonData = ref<FeatureCollection<Geometry | null, GeoJsonProperties> | null>(null); 

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]);

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

const handleFileDrop = (e: DragEvent) => { 
  const files = e.dataTransfer?.files; 
  for (const file of files ?? []) { 
    const reader = new FileReader(); 
    const parser = new DOMParser(); 

    reader.onload = (e: ProgressEvent<FileReader>) => { 
      const kmlContent = e.target?.result; 
      if (typeof kmlContent === 'string') { 
        const kmlDocument = parser.parseFromString(kmlContent, 'text/xml'); 
        const geoJson = kml(kmlDocument); 
        const newGeoJsonData: FeatureCollection<Geometry | null, GeoJsonProperties> = { 
          type: 'FeatureCollection', 
          features: [...(geoJsonData.value?.features || []), ...geoJson.features], 
        }; 

        geoJsonData.value = newGeoJsonData; 
      } 
    }; 

    reader.readAsText(file); 
  }
}; 

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) {
    center.value = [Number(route.query.lat), Number(route.query.lng)];
  }
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => {
  router.replace({
    query: {
      ...route.query,
      zoom: zoom.value,
      lat: center.value[0],
      lng: center.value[1],
    },
  });
});
</script>

<template>
  <div class="w-100 h-100" draggable="true" @dragover.prevent @drop.stop.prevent="handleFileDrop"> 
    <l-map
      :zoom="zoom"
      :center="center"
      :use-global-leaflet="false"
      :options="mapOptions"
      @ready="onMapReady"
      @update:zoom="updateZoom"
      @update:center="updateCenter"
    >
      <l-tile-layer :url="mapUrl"></l-tile-layer>
      <MapMarker />
      <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
    </l-map>
  </div> 
</template>

続いてGeoJSONを表示させるためにl-geo-jsonコンポーネントを使用しますが、こちらもファイルを分けたいと思います。 MapAreaLayer.vueファイルを作成し、MapMain.vueで読み込みます。

vue
<script setup lang="ts">
import { LGeoJson } from '@vue-leaflet/vue-leaflet';
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import type { ImageOverlay, PathOptions } from 'leaflet';

defineProps<{
  geoJsonData: FeatureCollection<Geometry | null, GeoJsonProperties> | null;
}>();

// GeoJSONから値を取得
const setFeatureStyle = (feature: Feature<Geometry, GeoJsonProperties> | null): PathOptions => ({
  fillColor: feature?.properties?.fill ?? '#3388ff',
  fillOpacity: feature?.properties?.['fill-opacity'] ?? 0.2,
  weight: feature?.properties?.['stroke-width'] ?? 3,
  color: feature?.properties?.stroke ?? '#3388ff',
});

// 取得した値を適用
const geoJsonOptions = {
  onEachFeature: (feature: Feature<Geometry, GeoJsonProperties> | null, layer: ImageOverlay) => {
    // マーカー以外のものにスタイルを当てる
    if (feature?.geometry?.type !== 'Point') layer.setStyle(setFeatureStyle(feature));

    // 名前をツールチップとして表示させる
    if (feature?.properties?.name) {
      layer.bindTooltip(feature.properties.name, {
        permanent: true,
        direction: 'center',
        className: 'tooltip',
      });
    }
  },
};
</script>

<template>
  <l-geo-json v-if="geoJsonData" :geojson="geoJsonData" :options="geoJsonOptions"></l-geo-json>
</template>
vue
<script setup lang="ts">
import { kml } from '@tmcw/togeojson';
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import MapAreaLayer from '@/components/MapAreaLayer.vue'; 
import MapMarker from '@/components/MapMarker.vue';

const route = useRoute();
const router = useRouter();

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};
const geoJsonData = ref<FeatureCollection<Geometry | null, GeoJsonProperties> | null>(null);

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]);

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

const handleFileDrop = (e: DragEvent) => {
  const files = e.dataTransfer?.files;
  for (const file of files ?? []) {
    const reader = new FileReader();
    const parser = new DOMParser();

    reader.onload = (e: ProgressEvent<FileReader>) => {
      const kmlContent = e.target?.result;
      if (typeof kmlContent === 'string') {
        const kmlDocument = parser.parseFromString(kmlContent, 'text/xml');
        const geoJson = kml(kmlDocument);
        const newGeoJsonData: FeatureCollection<Geometry | null, GeoJsonProperties> = {
          type: 'FeatureCollection',
          features: [...(geoJsonData.value?.features || []), ...geoJson.features],
        };

        geoJsonData.value = newGeoJsonData;
      }
    };

    reader.readAsText(file);
  }
};

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) {
    center.value = [Number(route.query.lat), Number(route.query.lng)];
  }
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => {
  router.replace({
    query: {
      ...route.query,
      zoom: zoom.value,
      lat: center.value[0],
      lng: center.value[1],
    },
  });
});
</script>

<template>
  <div class="w-100 h-100" draggable="true" @dragover.prevent @drop.stop.prevent="handleFileDrop">
    <l-map
      :zoom="zoom"
      :center="center"
      :use-global-leaflet="false"
      :options="mapOptions"
      @ready="onMapReady"
      @update:zoom="updateZoom"
      @update:center="updateCenter"
    >
      <l-tile-layer :url="mapUrl"></l-tile-layer>
      <MapMarker />
      <MapAreaLayer :geoJsonData="geoJsonData" /> 
      <l-control-scale position="bottomright" :imperial="false" :metric="true"></l-control-scale>
    </l-map>
  </div>
</template>

続いて適当なKMLデータを用意します。 今回はGoogle Earthを使用して作成しました。 Google Earthで作成が面倒な場合はサンプルを用意してるので使用してください。 これを地図上にドラッグアンドドロップをすると地図上に地理データが表示されるかと思います。 2つ同時にドラッグアンドドロップしても、2つとも表示されるかと思います。

ダイアログでメッセージを表示

ここまででKMLを地図上に表示できましたが、アップロードが成功しているか確認できないため、メッセージを表示させてみます。 今回は、Vuetifyのダイアログコンポーネントを使用して表示してみます。 ダイアログはコンポーネント化し、別ファイルとして管理します。 このような汎用的なコンポーネントは他のページでも使用する可能性があるため、必要な値はストアで管理することにします。 まずストアの作成から始めましょう。

ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useDialogStore = defineStore('dialog', () => {
  const isDialogOpen = ref(false);
  const message = ref('');

  return { isDialogOpen, message };
});

ストアを作成したので次はダイアログのコンポーネントを作成します。 MessageDialog.vueを作成し、先ほど作ったストアも使用します。

vue
<script setup lang="ts">
import { storeToRefs } from 'pinia';

import { useDialogStore } from '@/stores/dialog';

const dialogStore = useDialogStore();
const { isDialogOpen, message } = storeToRefs(dialogStore);
</script>

<template>
  <v-dialog v-model="isDialogOpen" width="auto">
    <v-card max-width="400" prepend-icon="mdi-update" :text="message">
      <template v-slot:actions>
        <v-btn class="ms-auto" text="Ok" @click="isDialogOpen = false"></v-btn>
      </template>
    </v-card>
  </v-dialog>
</template>

ダイアログは色々な場所で使うことが想定されるので今回はApp.vueで読み込みます。

vue
<script setup lang="ts">
import { RouterView } from 'vue-router';

import AppHeader from '@/components/AppHeader.vue';
import MessageDialog from '@/components/MessageDialog.vue'; 
</script>

<template>
  <v-app>
    <AppHeader />
    <v-main>
      <router-view />
      <MessageDialog /> 
    </v-main>
    <v-footer class="justify-center" height="60">
      <p><small>&copy; 2025 〇×△□</small></p>
    </v-footer>
  </v-app>
</template>

KMLファイルのドラッグアンドドロップの処理のところでダイアログを表示するよう処理を加えます。

vue
<script setup lang="ts">
import { kml } from '@tmcw/togeojson';
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { storeToRefs } from 'pinia'; 
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import MapAreaLayer from '@/components/MapAreaLayer.vue';
import MapMarker from '@/components/MapMarker.vue';
import { useDialogStore } from '@/stores/dialog'; 

const route = useRoute();
const router = useRouter();
const dialogStore = useDialogStore(); 
const { isDialogOpen, message } = storeToRefs(dialogStore); 

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};
const geoJsonData = ref<FeatureCollection<Geometry | null, GeoJsonProperties> | null>(null);

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]);

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

const handleFileDrop = (e: DragEvent) => {
  const files = e.dataTransfer?.files;
  for (const file of files ?? []) {
    const reader = new FileReader();
    const parser = new DOMParser();

    reader.onload = (e: ProgressEvent<FileReader>) => {
      const kmlContent = e.target?.result;
      if (typeof kmlContent === 'string') {
        const kmlDocument = parser.parseFromString(kmlContent, 'text/xml');
        const geoJson = kml(kmlDocument);
        const newGeoJsonData: FeatureCollection<Geometry | null, GeoJsonProperties> = {
          type: 'FeatureCollection',
          features: [...(geoJsonData.value?.features || []), ...geoJson.features],
        };

        geoJsonData.value = newGeoJsonData;
      }
    };

    reader.readAsText(file);
    message.value = 'KMLファイルのアップロードに成功しました。'; 
    isDialogOpen.value = true; 
  }
};

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) {
    center.value = [Number(route.query.lat), Number(route.query.lng)];
  }
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => {
  router.replace({
    query: {
      ...route.query,
      zoom: zoom.value,
      lat: center.value[0],
      lng: center.value[1],
    },
  });
});
</script>

...

これでアップロード時にメッセージが表示されるようになったかと思います。

サイズ制限、簡易的なバリデーション実装

アップロードするKMLのサイズが大きすぎると負荷がかかるため、サイズの制限や簡易的なバリデーションをつけて終わりにしたいと思います。

vue
<script setup lang="ts">
import { kml } from '@tmcw/togeojson';
import { LControlScale, LMap, LTileLayer } from '@vue-leaflet/vue-leaflet';
import type { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import type { PointTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import MapAreaLayer from '@/components/MapAreaLayer.vue';
import MapMarker from '@/components/MapMarker.vue';
import { useDialogStore } from '@/stores/dialog';

const route = useRoute();
const router = useRouter();
const dialogStore = useDialogStore();
const { isDialogOpen, message } = storeToRefs(dialogStore);

const mapInstance = ref<L.Map | null>(null);
// 佐賀駅を初期中心座標
const center = ref<PointTuple>([33.2641808, 130.2948219]);
const zoom = ref(12);
const mapUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
const mapOptions = {
  attributionControl: false,
};
const geoJsonData = ref<FeatureCollection<Geometry | null, GeoJsonProperties> | null>(null);

const updateZoom = (currentZoom: number) => (zoom.value = currentZoom);
const updateCenter = (currentCenter: { lat: number; lng: number }) =>
  (center.value = [currentCenter.lat, currentCenter.lng]);

const onMapReady = (map: L.Map) => {
  mapInstance.value = map;
  mapInstance.value.setView(center.value, zoom.value);
};

const handleFileDrop = (e: DragEvent) => {
  const files = e.dataTransfer?.files;
  for (const file of files ?? []) {
    const reader = new FileReader();
    const parser = new DOMParser();
    const fileName = file.name; 
    const fileSize = file.size; 
    // 今回は10MBで設定
    const MAX_KML_SIZE_MB = 10; 
    // 0.1MBで設定したときに小数点を省くため
    const MAX_KML_SIZE_BYTES = Math.trunc(MAX_KML_SIZE_MB * 1024 ** 2); 

    // アップロードしたKMLファイルが設定された容量を超えていた場合
    if (fileSize > MAX_KML_SIZE_BYTES) { 
      message.value = `ファイルサイズが${MAX_KML_SIZE_MB}MBを超えているためアップロードできません。`; 
      isDialogOpen.value = true; 
      return; 
    } 

    reader.onload = (e: ProgressEvent<FileReader>) => {
      const kmlContent = e.target?.result;
      if (typeof kmlContent === 'string') {
        const kmlDocument = parser.parseFromString(kmlContent, 'text/xml');
        const perseError = kmlDocument.querySelector('parsererror'); 
        // パースできないファイル形式または拡張子が.kmlではなかったら
        if (perseError || !fileName.endsWith('.kml')) { 
          message.value = `KMLファイルの解析に失敗しました。`; 
          isDialogOpen.value = true; 
          return; 
        } 
        const geoJson = kml(kmlDocument);
        const newGeoJsonData: FeatureCollection<Geometry | null, GeoJsonProperties> = {
          type: 'FeatureCollection',
          features: [...(geoJsonData.value?.features || []), ...geoJson.features],
        };

        geoJsonData.value = newGeoJsonData;
      }
    };

    reader.readAsText(file);
    message.value = 'KMLファイルのアップロードに成功しました。';
    isDialogOpen.value = true;
  }
};

onMounted(() => {
  if (route.query.zoom) updateZoom(Number(route.query.zoom));
  if (route.query.lat && route.query.lng) {
    center.value = [Number(route.query.lat), Number(route.query.lng)];
  }
});

// ズームレベル、中心座標が変更されたらパラメータ付与
watch([zoom, center], () => {
  router.replace({
    query: {
      ...route.query,
      zoom: zoom.value,
      lat: center.value[0],
      lng: center.value[1],
    },
  });
});
</script>

...

これで設定した容量より大きいサイズだったり、KMLファイルではないものをドラッグアンドドロップしたときに、エラーメッセージを出せるようになりました。


いかがでしたでしょうか。LeafletもVuetifyも機能が多く一部しか紹介しきれませんでした。 興味がありましたらぜひ実際に環境構築をしてみて、ドキュメントに目を通しながら色々な機能を実装してみてはいかがでしょうか。

この記事の完成コードはこちら