Leafletで作成された地図ルートのKMLを出力するアプリ

IT・プログラミング

多くのWebサイトで地図表示にLeafletというライブラリが使われています。
ここでは、そのLeafletで表示されたSVGパスのルート情報をKMLに書き出すアプリを作ったので、概要を紹介します。

構成

やり方はいろいろあると思いますが、今回は、以下のような流れで処理を行っています。

  • WordPressのページテンプレートをベースにする
  • 入力されたURLをRenderにホストしたPythonコードのfast APIアプリに渡し、解析を行う
  • 解析結果をもとにWordpress側で描画し、KMLファイル出力機能などをつける

WordPressのページテンプレート

ページテンプレートは以下のようにしました。

<?php
/**
 * Template Name: KML Viewer
 * Description: A custom page template to display map and KML download functionality.
 */

get_header(); ?>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
<style>
  #map {
    height: 500px;
  }
</style>

<div id="content" class="site-content">

    <!-- URL入力フォーム -->
    <form id="search-form">
        <label for="url">URL:</label>
        <input type="text" id="url" name="url" required>
        <button type="submit" class="btn">解析</button>
    </form>

    <!-- Loadingメッセージ -->
    <div id="loadingMessage" style="display:none; position:fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); color: #fff; font-size: 24px; text-align: center; line-height: 100vh;">
        Loading...
    </div>

    <!-- 地図表示 -->
    <div id="map" style="height: 500px;"></div>

    <!-- SVGを表示する場所 -->
    <div id="svg-container"></div>

    <!-- KMLダウンロードボタン -->
    <button id="downloadButton" style="display:none;"  class="btn">KMLファイルをダウンロード</button>

</div><!-- #content -->

<?php get_footer(); ?>

<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script>
jQuery(document).ready(function($) {
    // フォーム送信イベント
    $('#search-form').on('submit', function(e) {
        e.preventDefault(); // フォームのリロードを防ぐ

        const url = $('#url').val(); // 入力されたURLを取得

        // Loading画面を表示
        $('#loadingMessage').show();

        // API呼び出し
        fetch(`https://xxxx.onrender.com/kml-data?url=${encodeURIComponent(url)}`)
            .then(response => response.json())
            .then(data => {
                // Loading画面を非表示
                $('#loadingMessage').hide();

                // 地図の設定
                const { x, y,z, viewX, viewY,transX, transY, svg_element } = data;

                // translate3dの位置調整 (ピクセル)
                const latLng = tileToLatLng(z, x, y);
                const map = L.map('map').setView(latLng, z);

                // 国土地理院の地図タイル
                L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
                    attribution: '&copy; <a href="https://maps.gsi.go.jp/development/ichiran.html" target="_blank">国土地理院</a>',
                    maxZoom: 18,
                    minZoom: 5
                }).addTo(map);

                // SVG要素を地図にオーバーレイ
                $('#svg-container').html(svg_element);
                var svgElement = document.querySelector('#svg');
                // タイルサイズ
                var tileSize = 256;

                // 現在の地図中心の座標(緯度・経度)
                var centerLatLng = map.getCenter();
                var centerPoint = map.latLngToLayerPoint(centerLatLng);

                // SVGのピクセル座標を計算(地図レイヤー座標に対応)
                var svgTopLeftPoint = L.point(centerPoint.x + viewX-transX, centerPoint.y + viewY-transY);
                var svgBottomRightPoint = L.point(svgTopLeftPoint.x + svgElement.clientWidth, svgTopLeftPoint.y + svgElement.clientHeight);

                // SVGの地理的範囲を計算
                var svgTopLeftLatLng = map.layerPointToLatLng(svgTopLeftPoint);
                var svgBottomRightLatLng = map.layerPointToLatLng(svgBottomRightPoint);
                var svgLatLngBounds = L.latLngBounds(svgTopLeftLatLng, svgBottomRightLatLng);
                map.fitBounds(svgLatLngBounds);

                // SVGをオーバーレイとして追加
                var svgOverlay = L.svgOverlay(svgElement, svgLatLngBounds, {
                    opacity: 0.7,
                    interactive: true
                }).addTo(map);

                // KMLダウンロードボタンを表示
                $('#downloadButton').show();
                $('#downloadButton').on('click', function() {
                    downloadKmlFromSvgOverlay(svgOverlay, viewX, viewY);
                });
            })
            .catch(error => {
                $('#loadingMessage').hide();
                alert('データの取得に失敗しました: ' + error.message);
            });
    });

    // タイル座標を緯度経度に変換する関数
    function tileToLatLng(z, x, y) {
        const n = Math.pow(2, z);
        const lon_deg = x / n * 360 - 180;
        const lat_deg = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
        return new L.LatLng(lat_deg, lon_deg);
    }

    function svgOverlayToKml(svgOverlay, viewX, viewY) {
        const bounds = svgOverlay.getBounds();
        const topLeftLatLng = bounds.getNorthWest();
        const bottomRightLatLng = bounds.getSouthEast();
        const offset = 13;
        const svgElement = svgOverlay.getElement();
        const svgWidth = svgElement.viewBox.baseVal.width || svgElement.clientWidth;
        const svgHeight = svgElement.viewBox.baseVal.height || svgElement.clientHeight;
        console.log(svgWidth);
        console.log(svgHeight);
        let kmlContent = `<`;
        kmlContent += `?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2">\n`;
        kmlContent += `<Document>\n`;
        kmlContent += '<name><![CDATA[Route]]></name>\n<Folder>\n'

        const paths = svgElement.querySelectorAll('path');
        paths.forEach((path, index) => {
            const d = path.getAttribute('d');
            if (!d) return;

            let coordinates = '';
            const commands = d.split(/[MLZ]/).filter(Boolean); // パスコマンドを分解
            commands.forEach((command) => {
                const [x, y] = command.trim().split(/\s+/).map(Number);
                if (x != null && y != null) {
                    const lng = topLeftLatLng.lng + (((x-viewX) / svgWidth) * (bottomRightLatLng.lng - topLeftLatLng.lng));
                    const lat = topLeftLatLng.lat - (((y-viewY) / svgHeight) * (topLeftLatLng.lat - bottomRightLatLng.lat));
                    coordinates += `${lng},${lat},0 `;
                }
            });

            if (coordinates.trim()) {

                kmlContent += `<Placemark>\n`;
                kmlContent += `<name>Path ${index + 1}</name>\n`;
                kmlContent += `<LineString>\n`;
                kmlContent += `<coordinates>${coordinates.trim()}</coordinates>\n`;
                kmlContent += `</LineString>\n`;
                kmlContent += `</Placemark>\n`;
            }
        });
        kmlContent += `</Folder>\n`;
        kmlContent += `</Document>\n`;
        kmlContent += `</kml>`;
        return kmlContent;
    }

    // KMLダウンロード処理
    function downloadKmlFromSvgOverlay(svgOverlay, viewX, viewY) {
        const kmlData = svgOverlayToKml(svgOverlay, viewX, viewY);
        const blob = new Blob([kmlData], { type: 'application/vnd.google-earth.kml+xml' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = 'route.kml';
        link.click();
        URL.revokeObjectURL(url);
    }



});
</script>

RenderのFastAPIアプリ

処理に使うAPIはFastAPIで実装し、Render.comの無料ウェブサービスに登録します。

Dockerを使って、環境構築するのでDockerfileを作成します。

# fastapi/Dockerfile
FROM python:3.9

WORKDIR /app


# 必要なツールをインストール
RUN apt-get update && apt-get install -y \
    wget \
    unzip \
    curl \
    gnupg \
    && apt-get clean

# Google Chrome をインストール
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
    && apt-get update && apt-get install -y \
    google-chrome-stable

# ChromeDriver をインストール
RUN CHROME_DRIVER_VERSION=$(curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE) && \
    wget -q https://chromedriver.storage.googleapis.com/${CHROME_DRIVER_VERSION}/chromedriver_linux64.zip && \
    unzip chromedriver_linux64.zip && \
    mv chromedriver /usr/local/bin/ && \
    chmod +x /usr/local/bin/chromedriver


# 必要なPythonパッケージをインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# FastAPIアプリケーションコードをコピー
COPY . .



# FastAPIサーバーを起動
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

FastAPIアプリをapp.pyに作成します。

from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import pandas as pd
import numpy as np
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
import time
from bs4 import BeautifulSoup
import re
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from time import sleep
import os
import shutil
app = FastAPI()

# Allowed origins for CORS
origins = [
    "http://localhost:8060",  # 例: ローカルのWordPress開発環境
    "https://xxxx.com",  # 本番環境のWordPressサイト

]

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# 6. Seleniumでヘッドレスブラウザをセットアップ
def get_rendered_html(url):
    # Chromeオプション設定
    options = webdriver.ChromeOptions()
    options.add_argument('--headless')  # ヘッドレスモード
    options.add_argument('--no-sandbox')  # サンドボックス無効化(Colab用)
    options.add_argument('--disable-dev-shm-usage')  # メモリ使用制限を回避

    # ブラウザを起動
    driver = webdriver.Chrome(service=ChromeService(ChromeDriverManager().install()),  options=options)
    driver.get(url)

    # JavaScriptの実行待機
    time.sleep(5)

    # レンダリング後のHTMLを取得
    html = driver.page_source
    driver.quit()
    return html


@app.get("/kml-data")
def parse_route_data(url: str = Query(..., description="対象のURL")):
    """
    指定されたURLのSVG要素を解析してルート情報を抽出します。
    """
    # 7. URLを指定してHTMLを取得
    html = get_rendered_html(url)
    soup = BeautifulSoup(html, 'html.parser')
    svg_element = soup.find('svg', class_='leaflet-zoom-animated')
    viewbox = svg_element.get('viewbox').split(' ')
    viewX = viewbox[0]
    viewY = viewbox[1]
    svg_element['id'] = 'svg'

    tile_element = soup.find('img', class_='leaflet-tile leaflet-tile-loaded')
    tile_src = tile_element['src']
    style_string = tile_element['style']
    # 正規表現でtranslate3dの値を抽出
    match = re.search(r'transform:\s*translate3d\(([^)]+)\)', style_string)

    # 結果を処理
    if match:
        # '133px, 97px, 0px' の部分を取り出す
        translate_value = match.group(1)

        # 値をコンマで分割して、'px'を取り除き整数に変換
        transX, transY, _ = [int(val.replace('px', '').strip()) for val in translate_value.split(',')]

    z = tile_src.split('/')[-3]
    x = tile_src.split('/')[-2]
    y = tile_src.split('/')[-1].split('.')[0]



    # テンプレートに変数を埋め込むためのデータ
    data = {
        'x': int(x),
        'y': int(y),
        'z': int(z),
        'viewX': float(viewX),
        'viewY': float(viewY),
        'transX': transX,
        'transY': transY,
        'svg_element': str(svg_element),
    }

    return data

あとは、これらをRenderに登録してAPIとして起動させれば準備完了です。

固定ページ

WordPressの固定ページの作成から、新規ページを作成し、テンプレートにKML Viewerを選択してください。

タイトルとURLをコピーしました