地図画像
PDFの地図画像をGoogle MapからPMTilesに変更する
Reactでreact-pdf/rendererを使って、入力した情報をPDF出力し、埋め込む地図をGoogle Map APIから取得し貼り付けていました。しかし、無料枠の上限を越えるとお金がかかる可能性があるので、別の方法も模索していました。ある時、Open Street Mapを使って貼り付ける方法を発見。それがこちら。PMTilesとMapLibreを使うやり方です。参考にしたのが下記サイト。
バージョンはこんな感じ。
"@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と変更しました。
地図を取得するコードに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の方が見慣れている上に、情報が多かったのはよかったなとは思うものの、お金の心配をしなくていいのはありがたいと思います。