地図画像

PDFの地図画像をGoogle MapからPMTilesに変更する

Reactでreact-pdf/rendererを使って、入力した情報をPDF出力し、埋め込む地図をGoogle Map APIから取得し貼り付けていました。しかし、無料枠の上限を越えるとお金がかかる可能性があるので、別の方法も模索していました。ある時、Open Street Mapを使って貼り付ける方法を発見。それがこちら。PMTilesとMapLibreを使うやり方です。参考にしたのが下記サイト。

Google Maps APIの課金が気になるので、日本全国の地図をPMTiles+MapLibreで自前配信できるようにした - Qiita
Webサイトに地図を載せる場合、Google Maps Platformは有力な選択肢です。 一方で、Google Ma…
MapLibre v6.0.0がリリースされたので対応してみた

バージョンはこんな感じ。

"@react-pdf/renderer": "^4.3.1",
"maplibre-gl": "^6.2.0",
"pmtiles": "^4.4.1",

まず、サーバーに下記サイトのデータを配置しました。フォルダ名をmapとして、そこにアクセスするようにします。その際、japan-20260101.jsonにあるurlをpmtiles:///map/japan-20260101.pmtilesと変更しました。

自分のサーバーで日本地図を表示する|Japan PMTiles
APIキーや回数課金なしで日本地図を表示できる、開発者向けPMTilesデータとMapLibreのサンプルコードです。

地図を取得するコードにmaplibre-glとpmtilesを使います。installする感じです。Geminiやcopilotに助けてもらいながら、コードを試しました。ただ、地図画像は取得出来ているのに、表示されません。原因を探っていくとsetWorkerUrl(workerUrl);の記述がされていませんでした。AIがここに気づかず、人間が見つけねばならなかったので、まだまだ人間の洞察力も捨てたものじゃないなと思います。

import { setWorkerUrl, addProtocol, Map } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import { PMTiles, Protocol } from "pmtiles";
import "maplibre-gl/dist/maplibre-gl.css";

setWorkerUrl(workerUrl);

let isPmtilesProtocolRegistered = false;
const globalProtocol = new Protocol();

const initPmtilesProtocol = () => {
  if (!isPmtilesProtocolRegistered) {
    addProtocol("pmtiles", globalProtocol.tile);

    const pmtilesPath = "./map/japan-20260101.pmtiles";
    const pmtilesInstance = new PMTiles(pmtilesPath);
    globalProtocol.add(pmtilesInstance);

    isPmtilesProtocolRegistered = true;
  }
};

export const GenerateMapImage = async (
  lat: number,
  lng: number,
  zoom: number,
  width: number,
  height: number,
  container: HTMLDivElement,
): Promise<string> => {
  container.style.width = `${width}px`;
  container.style.height = `${height}px`;

  initPmtilesProtocol();

  await new Promise((resolve) =>
    requestAnimationFrame(() => setTimeout(resolve, 50)),
  );

  return new Promise((resolve, reject) => {
    try {
      const map = new Map({
        container,
        style: "./map/japan-20260101.json",
        center: [lng, lat],
        zoom,
        canvasContextAttributes: {
          preserveDrawingBuffer: true,
        },
        interactive: false,
        fadeDuration: 0,
        localIdeographFontFamily: "sans-serif",
      });

      map.on("load", () => {
        map.addSource("marker-source", {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                geometry: {
                  type: "Point",
                  coordinates: [lng, lat],
                },
                properties: {},
              },
            ],
          },
        });

        const bounds = map.getBounds();
        const east = bounds.getEast();
        const south = bounds.getSouth();

        const paddingLng = (east - lng) * 0.12; // 右端から内側へ
        const paddingLat = (lat - south) * 0.15; // 下端から内側へ
        const attributionLng = east - paddingLng;
        const attributionLat = south + paddingLat;

        map.addSource("attribution-source", {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                geometry: {
                  type: "Point",
                  coordinates: [attributionLng, attributionLat], // 右下の座標
                },
                properties: {
                  text: "© OpenStreetMap contributors",
                },
              },
            ],
          },
        });

        map.addLayer({
          id: "marker-circle",
          type: "circle",
          source: "marker-source",
          paint: {
            "circle-radius": 10,
            "circle-color": "#FF3B30",
            "circle-stroke-width": 3,
            "circle-stroke-color": "#FFFFFF",
          },
        });

        map.addLayer({
          id: "marker-center-dot",
          type: "circle",
          source: "marker-source",
          paint: {
            "circle-radius": 3,
            "circle-color": "#FFFFFF",
          },
        });

        map.addLayer({
          id: "marker-attribution-background",
          type: "symbol",
          source: "attribution-source",
          layout: {
            "text-field": ["get", "text"],
            "text-font": ["sans-serif"],
            "text-size": 8,
            "text-anchor": "bottom-right", // 右下に固定
          },
          paint: {
            "text-color": "#ffffff", // 背景と同色にして一旦隠す
            "text-halo-color": "#ffffff", // 🚨 矩形の背景色(白)
            "text-halo-width": 6, // 🚨 矩形サイズ(文字の周りに敷くパディング量)
            "text-halo-blur": 0, // ぼかさずにクッキリ四角くする
          },
        });

        map.addLayer({
          id: "marker-attribution-text",
          type: "symbol",
          source: "attribution-source",
          layout: {
            "text-field": ["get", "text"],
            "text-font": ["sans-serif"],
            "text-size": 8,
            "text-anchor": "bottom-right",
          },
          paint: {
            "text-color": "#555555",
          },
        });
      });

      const fallbackTimer = setTimeout(() => {
        try {
          const img = map.getCanvas().toDataURL("image/png");

          map.remove();
          container.remove();
          resolve(img);
        } catch (e) {
          reject(e);
        }
      }, 4000);

      map.on("idle", () => {
        clearTimeout(fallbackTimer);
        try {
          const img = map.getCanvas().toDataURL("image/png");
          map.remove();
          resolve(img);
        } catch (e) {
          reject(e);
        }
      });

      map.on("error", (e) => {
        console.error("MapLibre内部エラー:", e);
      });
    } catch (error) {
      reject(error);
    }
  });
};

あとはPDFのプレビューで、PDFの雛形と先ほどの画像作成を読み込むだけ。

import React, { useState, useEffect, useRef } from "react";
import {
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalBody,
  ModalCloseButton,
  Button,
  HStack,
} from "@chakra-ui/react";
import { PDFViewer, PDFDownloadLink } from "@react-pdf/renderer";
import { ShootPdf } from "./ShootPdf";
import { GenerateMapImage } from "./GenerateMapImage";
import type { ShootSheetData } from "../../types";

interface PdfPreviewModalProps {
  isOpen: boolean;
  onClose: () => void;
  data: ShootSheetData;
}

const generateFileName = (implementationDate?: string): string => {
  if (implementationDate) {
    const match = implementationDate.match(/(\d{4})年(\d{2})月(\d{2})日/);
    if (match) {
      const [, year, month, day] = match;
      return `shootsheet_${year}-${month}-${day}.pdf`;
    }
  }

  const today = new Date();
  const year = today.getFullYear();
  const month = String(today.getMonth() + 1).padStart(2, "0");
  const day = String(today.getDate()).padStart(2, "0");
  return `shootsheet_${year}-${month}-${day}.pdf`;
};

export const PdfPreviewModal: React.FC<PdfPreviewModalProps> = ({
  isOpen,
  onClose,
  data,
}) => {
  const fileName = generateFileName(data.date);
  const [mapImages, setMapImages] = useState<Record<number, string>>({});
  const [isMapLoaded, setIsMapLoaded] = useState(false);
  const [pdfUnsupported, setPdfUnsupported] = useState(false);

  const isMountedRef = useRef(false);

  useEffect(() => {
    if (!isOpen) {
      return;
    }

    isMountedRef.current = true;

    (async () => {
      const results: Record<number, string> = {};

      if (!data.locationDetails || data.locationDetails.length === 0) {
        if (isMountedRef.current) setIsMapLoaded(true);
        return;
      }

      for (let i = 0; i < data.locationDetails.length; i++) {
        if (!isMountedRef.current) break;

        const loc = data.locationDetails[i];

        if (loc.latitude && loc.longitude && loc.zoom) {
          try {
            const tempContainer = document.createElement("div");
            tempContainer.style.width = "300px";
            tempContainer.style.height = "200px";
            tempContainer.style.position = "absolute";
            tempContainer.style.left = "-9999px"; // 画面外に見えないように配置
            document.body.appendChild(tempContainer);

            const img = await GenerateMapImage(
              Number(loc.latitude),
              Number(loc.longitude),
              Number(loc.zoom),
              300,
              200,
              tempContainer,
            );
            results[i] = img;
            // console.log("loc check:", loc.latitude, loc.longitude, loc.zoom);
          } catch (e) {
            setPdfUnsupported(true);
            console.error(
              `❌ [PdfPreviewModal] ${i}件目の地図生成でエラーが発生しました:`,
              e,
            );
            break;
          }
        } else {
          console.warn(
            `⚠️ [PdfPreviewModal] ${i}件目のデータに必要な値(lat, lng, zoom)が足りません。`,
            loc,
          );
        }
      }

      if (isMountedRef.current) {
        setMapImages(results);
        setIsMapLoaded(true);
      }
    })();

    return () => {
      isMountedRef.current = false;
      setMapImages({});
      setIsMapLoaded(false);
    };
  }, [isOpen, data]);

  const requiredImageCount = data.locationDetails.filter(
    (loc) => loc.latitude && loc.longitude && loc.zoom,
  ).length;
  const loadedImagesCount = Object.keys(mapImages).length;
  const isReadyToRenderPDF =
    isMapLoaded && loadedImagesCount === requiredImageCount;

  return (
    <Modal isOpen={isOpen} onClose={onClose} size="6xl">
      <ModalOverlay />
      <ModalContent maxW="90vw" h="90vh">
        <ModalHeader>
          <HStack justify="space-between" align="center" pr={10}>
            <span>PDF プレビュー</span>
            {pdfUnsupported ? (
              <Button colorScheme="red" size="sm" isDisabled>
                PDF生成不可
              </Button>
            ) : !isReadyToRenderPDF ? (
              <Button
                colorScheme="blue"
                size="sm"
                isLoading
                loadingText="地図生成中..."
              >
                PDFダウンロード
              </Button>
            ) : (
              <PDFDownloadLink
                document={<ShootPdf data={data} mapImages={mapImages} />}
                fileName={fileName}
              >
                {({ loading }) => (
                  <Button
                    colorScheme="blue"
                    size="sm"
                    isLoading={loading}
                    loadingText="準備中..."
                  >
                    PDFダウンロード
                  </Button>
                )}
              </PDFDownloadLink>
            )}
          </HStack>
        </ModalHeader>
        <ModalCloseButton />
        <ModalBody p={0}>
          {pdfUnsupported ? (
            <div
              style={{
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
                height: "100%",
                fontSize: "14px",
                color: "#666",
                textAlign: "center",
              }}
            >
              このブラウザでは PDF を生成できません。 <br />
              WebGL2 が無効化されているか、地図画像の生成に失敗しました。
            </div>
          ) : isReadyToRenderPDF ? (
            <PDFViewer width="100%" height="100%" style={{ border: "none" }}>
              <ShootPdf
                key={JSON.stringify(mapImages)}
                data={data}
                mapImages={mapImages}
              />
            </PDFViewer>
          ) : (
            <div
              style={{
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
                height: "100%",
                fontSize: "14px",
                color: "#666",
                textAlign: "center",
              }}
            >
              地図データを読み込み中...しばらくお待ちください。
            </div>
          )}
        </ModalBody>
      </ModalContent>
    </Modal>
  );
};

ブラウザがBraveだとセキュリティがきつくて、画像生成できないのにはビックリ。WebGL2エラーがでます。ずっと、Braveで確認していたので、 原因を探るのに苦労しました。chrome、Edge、Firefox、Safariだと下記エラーはでなかったです。

GPUInitializationError: WebGL2 is required to display this map.
We are sorry, but it seems that your browser does not support WebGL2,
a technology for rendering 3D graphics on the web.
Read more on https://wiki.openstreetmap.org/wiki/This_map_requires_WebGL

変更してみて、GoogleMapの方が見慣れている上に、情報が多かったのはよかったなとは思うものの、お金の心配をしなくていいのはありがたいと思います。

コメント