UNPKG

static-map-generator

Version:

Node.js library for generating static map images using OpenStreetMap tiles with markers, paths, and smart bounds fitting

281 lines 12.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.StaticMap = void 0; const canvas_1 = require("canvas"); const axios_1 = __importDefault(require("axios")); class StaticMap { constructor(options) { this.tileSize = 256; // boundsが指定された場合は自動計算 if (options.bounds) { const viewport = this.calculateOptimalViewport(options.bounds, options.size, options.padding || 20); this.options = { format: 'png', scale: 1, tileServer: 'https://tile.openstreetmap.org', padding: 20, ...options, center: viewport.center, zoom: viewport.zoom }; } else { // 従来通りcenterとzoomを必須とする if (!options.center || options.zoom === undefined) { throw new Error('center and zoom are required when bounds is not specified'); } this.options = { format: 'png', scale: 1, tileServer: 'https://tile.openstreetmap.org', ...options }; } const canvasWidth = this.options.size.width * this.options.scale; const canvasHeight = this.options.size.height * this.options.scale; this.canvas = (0, canvas_1.createCanvas)(canvasWidth, canvasHeight); this.ctx = this.canvas.getContext('2d'); } deg2rad(deg) { return deg * (Math.PI / 180); } rad2deg(rad) { return rad * (180 / Math.PI); } latLngToTile(lat, lng, zoom) { const latRad = this.deg2rad(lat); const n = Math.pow(2, zoom); const x = Math.floor((lng + 180) / 360 * n); const y = Math.floor((1 - Math.asinh(Math.tan(latRad)) / Math.PI) / 2 * n); return { x, y, z: zoom }; } tileToLatLng(x, y, zoom) { const n = Math.pow(2, zoom); const lng = x / n * 360 - 180; const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))); const lat = this.rad2deg(latRad); return { lat, lng }; } async fetchTile(x, y, z) { try { const url = `${this.options.tileServer}/${z}/${x}/${y}.png`; const response = await axios_1.default.get(url, { responseType: 'arraybuffer' }); const img = new canvas_1.Image(); img.src = Buffer.from(response.data); return img; } catch (error) { throw new Error(`Failed to fetch tile: ${error}`); } } latLngToPixel(lat, lng, centerLat, centerLng, zoom) { const scale = Math.pow(2, zoom); const worldWidth = this.tileSize * scale; const worldHeight = this.tileSize * scale; const centerX = (centerLng + 180) * worldWidth / 360; const centerY = worldHeight / 2 - worldWidth * Math.log(Math.tan((90 + centerLat) * Math.PI / 360)) / (2 * Math.PI); const x = (lng + 180) * worldWidth / 360; const y = worldHeight / 2 - worldWidth * Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (2 * Math.PI); return { x: x - centerX + this.options.size.width / 2, y: y - centerY + this.options.size.height / 2 }; } async render() { const { center, zoom, size } = this.options; if (!center || zoom === undefined) { throw new Error('Center and zoom must be defined'); } const centerTile = this.latLngToTile(center.lat, center.lng, zoom); const tilesNeeded = Math.ceil(Math.max(size.width, size.height) / this.tileSize) + 1; const startX = centerTile.x - Math.floor(tilesNeeded / 2); const endX = centerTile.x + Math.ceil(tilesNeeded / 2); const startY = centerTile.y - Math.floor(tilesNeeded / 2); const endY = centerTile.y + Math.ceil(tilesNeeded / 2); this.ctx.fillStyle = '#f0f0f0'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); for (let x = startX; x <= endX; x++) { for (let y = startY; y <= endY; y++) { try { const tile = await this.fetchTile(x, y, zoom); const tileTopLeft = this.tileToLatLng(x, y, zoom); const pixelPos = this.latLngToPixel(tileTopLeft.lat, tileTopLeft.lng, center.lat, center.lng, zoom); this.ctx.drawImage(tile, Math.round(pixelPos.x), Math.round(pixelPos.y), this.tileSize, this.tileSize); } catch (error) { console.warn(`Failed to load tile ${x},${y}:`, error); } } } // パス(コース)を描画 if (this.options.paths) { this.drawPaths(); } // マーカーを描画(パスの上に表示) if (this.options.markers) { this.drawMarkers(); } const format = this.options.format === 'jpg' ? 'image/jpeg' : 'image/png'; return this.canvas.toBuffer(format); } drawMarkers() { if (!this.options.markers || !this.options.center || this.options.zoom === undefined) return; for (const marker of this.options.markers) { const pixelPos = this.latLngToPixel(marker.coordinate.lat, marker.coordinate.lng, this.options.center.lat, this.options.center.lng, this.options.zoom); if (pixelPos.x < 0 || pixelPos.x > this.options.size.width || pixelPos.y < 0 || pixelPos.y > this.options.size.height) { continue; } this.drawMarker(pixelPos.x, pixelPos.y, marker); } } drawMarker(x, y, marker) { const size = marker.size === 'small' ? 8 : marker.size === 'large' ? 16 : 12; const color = marker.color || '#FF0000'; this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(x, y - size / 2, size / 2, 0, 2 * Math.PI); this.ctx.fill(); this.ctx.strokeStyle = '#FFFFFF'; this.ctx.lineWidth = 2; this.ctx.stroke(); if (marker.label) { this.ctx.fillStyle = '#000000'; this.ctx.font = '12px Arial'; this.ctx.textAlign = 'center'; this.ctx.fillText(marker.label, x, y - size - 5); } } drawPaths() { if (!this.options.paths || !this.options.center || this.options.zoom === undefined) return; for (const path of this.options.paths) { this.drawPath(path); } } drawPath(path) { if (path.coordinates.length < 2) return; // 最低2点必要 // パス設定 const color = path.color || '#0066FF'; const width = path.width || 2; const opacity = path.opacity !== undefined ? path.opacity : 1; const lineCap = path.lineCap || 'round'; const lineJoin = path.lineJoin || 'round'; // 透明度を適用した色を設定 this.ctx.globalAlpha = opacity; this.ctx.strokeStyle = color; this.ctx.lineWidth = width; this.ctx.lineCap = lineCap; this.ctx.lineJoin = lineJoin; // 破線パターンがある場合は設定 if (path.dashPattern && path.dashPattern.length > 0) { this.ctx.setLineDash(path.dashPattern); } else { this.ctx.setLineDash([]); // 実線 } // パスの開始 this.ctx.beginPath(); let isFirstPoint = true; for (const coordinate of path.coordinates) { const pixelPos = this.latLngToPixel(coordinate.lat, coordinate.lng, this.options.center.lat, this.options.center.lng, this.options.zoom); // 画面外の点もパスに含める(線が繋がるため) if (isFirstPoint) { this.ctx.moveTo(pixelPos.x, pixelPos.y); isFirstPoint = false; } else { this.ctx.lineTo(pixelPos.x, pixelPos.y); } } // パスを描画 this.ctx.stroke(); // 設定をリセット this.ctx.globalAlpha = 1.0; this.ctx.setLineDash([]); } calculateOptimalViewport(bounds, size, padding) { // 境界の中心点を計算 const centerLat = (bounds.north + bounds.south) / 2; const centerLng = (bounds.east + bounds.west) / 2; const center = { lat: centerLat, lng: centerLng }; // 境界の幅と高さを計算 const latDiff = bounds.north - bounds.south; const lngDiff = bounds.east - bounds.west; // メルカトル投影を考慮した実際の距離を計算 const latRad = this.deg2rad(centerLat); const lngDiffAdjusted = lngDiff * Math.cos(latRad); // 各ズームレベルでテストして最適なものを見つける let optimalZoom = 1; for (let zoom = 1; zoom <= 18; zoom++) { const scale = Math.pow(2, zoom); const worldWidth = this.tileSize * scale; const worldHeight = this.tileSize * scale; // 境界をピクセル座標に変換 const northPixel = this.latLngToPixelAtZoom(bounds.north, centerLng, centerLat, centerLng, zoom); const southPixel = this.latLngToPixelAtZoom(bounds.south, centerLng, centerLat, centerLng, zoom); const westPixel = this.latLngToPixelAtZoom(centerLat, bounds.west, centerLat, centerLng, zoom); const eastPixel = this.latLngToPixelAtZoom(centerLat, bounds.east, centerLat, centerLng, zoom); const requiredWidth = Math.abs(eastPixel.x - westPixel.x) + padding * 2; const requiredHeight = Math.abs(northPixel.y - southPixel.y) + padding * 2; // 境界が画像サイズに収まるかチェック if (requiredWidth <= size.width && requiredHeight <= size.height) { optimalZoom = zoom; } else { break; } } // 最低でもズーム1、最高でも18に制限 optimalZoom = Math.max(1, Math.min(optimalZoom, 18)); return { center, zoom: optimalZoom }; } latLngToPixelAtZoom(lat, lng, centerLat, centerLng, zoom) { const scale = Math.pow(2, zoom); const worldWidth = this.tileSize * scale; const worldHeight = this.tileSize * scale; const centerX = (centerLng + 180) * worldWidth / 360; const centerY = worldHeight / 2 - worldWidth * Math.log(Math.tan((90 + centerLat) * Math.PI / 360)) / (2 * Math.PI); const x = (lng + 180) * worldWidth / 360; const y = worldHeight / 2 - worldWidth * Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (2 * Math.PI); return { x: x - centerX, y: y - centerY }; } // 計算された中心点とズームレベルを取得するためのメソッド getCalculatedCenter() { if (!this.options.center) { throw new Error('Center is not defined'); } return this.options.center; } getCalculatedZoom() { if (this.options.zoom === undefined) { throw new Error('Zoom is not defined'); } return this.options.zoom; } } exports.StaticMap = StaticMap; __exportStar(require("./types"), exports); //# sourceMappingURL=index.js.map