PageSourceSearch

https://simplerouteprinter.pages.dev/src/map/export-map.js

js simplerouteprinter.pages.dev collected 2026-10-03 05:56:17 UTC 11,800 bytes, 368 lines download raw bytes

1// 画像出力専用の不可視地図を管理するクラス
2
3import { waitTilesLoaded } from './capture.js';
4import { drawCanvasMarker, MARKER_CONFIG } from './marker-utils.js';
5
6// ポリラインのスタイル設定
7const POLYLINE_CONFIG = {
8  color: '#0066CC',
9  width: 4
10};
11
12// パディング設定(マーカー+コメント吹き出しが見切れないように)
13const PADDING_PIXELS = 80;
14
15export class ExportMapManager {
16  constructor() {
17    this.exportMap = null;
18    this.container = null;
19  }
20
21  /**
22   * 出力用の不可視地図を作成
23   * @param {maplibregl.Map} displayMap - 表示用地図(設定をコピーする元)
24   */
25  async createExportMap(displayMap) {
26    console.log('Creating export map...');
27    
28    // 1. 隠しコンテナを作成
29    this.container = document.createElement('div');
30    this.container.id = 'export-map-container';
31
32    // 画像エクスポート用のオフスクリーンコンテナ。
33    // ユーザーに表示されない背景マップを描画し、マーカー付き地図画像を静かに生成するために使用。
34    const EXPORT_CONTAINER_WIDTH = 800;
35    const EXPORT_CONTAINER_HEIGHT = 600;
36    const EXPORT_CONTAINER_HIDDEN_POSITION = -9999;
37    this.container.style.cssText = `
38      position: absolute;
39      left: ${EXPORT_CONTAINER_HIDDEN_POSITION}px;
40      width: ${EXPORT_CONTAINER_WIDTH}px;
41      height: ${EXPORT_CONTAINER_HEIGHT}px;
42      visibility: hidden;
43    `;
44    document.body.appendChild(this.container);
45
46    // 2. 現在の表示地図の設定を取得
47    const center = displayMap.getCenter();
48    const zoom = displayMap.getZoom();
49    const style = displayMap.getStyle();
50    
51    // 現在表示中のレイヤーを特定
52    let visibleLayer = 'pale-layer';
53    const layers = ['pale-layer', 'photo-layer', 'std-layer', 'osm-layer', 'blank-layer', 'esry-layer'];
54    for (const layer of layers) {
55      if (displayMap.getLayoutProperty(layer, 'visibility') === 'visible') {
56        visibleLayer = layer;
57        break;
58      }
59    }
60
61    // 3. 出力用地図を作成
62    this.exportMap = new maplibregl.Map({
63      container: this.container,
64      style: style,
65      center: center,
66      zoom: zoom,
67      interactive: false,  // インタラクション無効
68      trackResize: false,  // リサイズ追跡無効
69      preserveDrawingBuffer: true,  // Canvas出力用
70      attributionControl: false
71    });
72
73    // 4. スタイル読み込み完了を待機
74    await new Promise(resolve => {
75      this.exportMap.once('load', () => {
76        console.log('Export map loaded');
77        
78        // 表示レイヤーの設定を同期
79        layers.forEach(layer => {
80          const visibility = layer === visibleLayer ? 'visible' : 'none';
81          if (this.exportMap.getLayer(layer)) {
82            this.exportMap.setLayoutProperty(layer, 'visibility', visibility);
83          }
84        });
85        
86        resolve();
87      });
88    });
89  }
90
91  /**
92   * バウンディングボックスを計算
93   * @param {Array} coordinates - 座標の配列
94   * @returns {Object} bounds { minLng, maxLng, minLat, maxLat }
95   */
96  calculateBounds(coordinates) {
97    if (coordinates.length === 0) {
98      return null;
99    }
100
101    let minLng = Infinity;
102    let maxLng = -Infinity;
103    let minLat = Infinity;
104    let maxLat = -Infinity;
105
106    for (const coord of coordinates) {
107      if (coord.lng < minLng) minLng = coord.lng;
108      if (coord.lng > maxLng) maxLng = coord.lng;
109      if (coord.lat < minLat) minLat = coord.lat;
110      if (coord.lat > maxLat) maxLat = coord.lat;
111    }
112
113    return { minLng, maxLng, minLat, maxLat };
114  }
115
116  /**
117   * ポリライン全体が収まる単一画像をキャプチャ
118   * @param {number} width - 画像幅
119   * @param {number} height - 画像高さ
120   * @param {Array} allCoordinates - すべてのルート座標
121   * @returns {Promise<string>} Base64エンコードされた画像データ
122   */
123  async captureFullRouteImage(width, height, allCoordinates) {
124    console.log('Capturing full route image');
125
126    // サイズ設定
127    this.container.style.width = `${width}px`;
128    this.container.style.height = `${height}px`;
129    this.exportMap.resize();
130
131    // バウンディングボックスを計算
132    const bounds = this.calculateBounds(allCoordinates);
133    if (!bounds) {
134      throw new Error('No coordinates to capture');
135    }
136
137    // ポリライン全体が収まるようにビューを設定
138    this.exportMap.fitBounds(
139      [[bounds.minLng, bounds.minLat], [bounds.maxLng, bounds.maxLat]],
140      {
141        padding: PADDING_PIXELS,
142        duration: 0
143      }
144    );
145
146    // タイル読み込み待機
147    await new Promise(r => this.exportMap.once('idle', r));
148
149    // ポリラインを追加
150    this.addPolyline(allCoordinates);
151    await new Promise(r => this.exportMap.once('idle', r));
152
153    // キャプチャ
154    await waitTilesLoaded(this.exportMap);
155
156    // Canvas を取得してマーカーを直接描画
157    const mapCanvas = this.exportMap.getCanvas();
158    const outputCanvas = document.createElement('canvas');
159    outputCanvas.width = mapCanvas.width;
160    outputCanvas.height = mapCanvas.height;
161    const ctx = outputCanvas.getContext('2d');
162
163    // 地図を描画
164    ctx.drawImage(mapCanvas, 0, 0);
165
166    // マーカーを Canvas に直接描画
167    this.drawMarkersOnCanvas(ctx, allCoordinates);
168
169    const dataURL = outputCanvas.toDataURL('image/png');
170
171    // ポリラインを削除
172    this.removePolyline();
173
174    return dataURL;
175  }
176
177  /**
178   * ポリラインレイヤーを追加
179   * @param {Array} coordinates - ルート座標の配列
180   */
181  addPolyline(coordinates) {
182    // 座標が2つ未満の場合はポリラインを描画しない
183    if (coordinates.length < 2) {
184      return;
185    }
186
187    // GeoJSON LineString データを作成
188    const geojsonData = {
189      type: 'Feature',
190      geometry: {
191        type: 'LineString',
192        coordinates: coordinates.map(coord => [coord.lng, coord.lat])
193      }
194    };
195
196    // ソースを追加
197    if (!this.exportMap.getSource('export-polyline')) {
198      this.exportMap.addSource('export-polyline', {
199        type: 'geojson',
200        data: geojsonData
201      });
202    } else {
203      this.exportMap.getSource('export-polyline').setData(geojsonData);
204    }
205
206    // ラインレイヤーを追加
207    if (!this.exportMap.getLayer('export-polyline-layer')) {
208      this.exportMap.addLayer({
209        id: 'export-polyline-layer',
210        type: 'line',
211        source: 'export-polyline',
212        layout: {
213          'line-join': 'round',
214          'line-cap': 'round'
215        },
216        paint: {
217          'line-color': POLYLINE_CONFIG.color,
218          'line-width': POLYLINE_CONFIG.width
219        }
220      });
221    }
222  }
223
224  /**
225   * ポリラインレイヤーを削除
226   */
227  removePolyline() {
228    if (this.exportMap.getLayer('export-polyline-layer')) {
229      this.exportMap.removeLayer('export-polyline-layer');
230    }
231    if (this.exportMap.getSource('export-polyline')) {
232      this.exportMap.removeSource('export-polyline');
233    }
234  }
235
236  /**
237   * Canvas に直接マーカーを描画
238   * @param {CanvasRenderingContext2D} ctx - Canvas 2D コンテキスト
239   * @param {Array} coordinates - ルート座標の配列
240   */
241  drawMarkersOnCanvas(ctx, coordinates) {
242    if (coordinates.length === 0) {
243      return;
244    }
245
246    const { width, height } = MARKER_CONFIG;
247    // MapLibre GL が使用している pixelRatio を取得
248    // map.getPixelRatio() で MapLibre 内部の値を取得(window.devicePixelRatio と異なる場合がある)
249    const dpr = this.exportMap.getPixelRatio();
250    const scaledWidth = width * dpr;
251    const scaledHeight = height * dpr;
252
253    coordinates.forEach((coord, index) => {
254      // 座標をピクセル位置に変換
255      // project() は CSS ピクセル座標を返すため、物理ピクセルに変換が必要
256      const point = this.exportMap.project([coord.lng, coord.lat]);
257      const physicalX = point.x * dpr;
258      const physicalY = point.y * dpr;
259
260      // マーカーの描画位置(ピンの先端が座標位置を指す)
261      const x = physicalX - scaledWidth / 2;
262      const y = physicalY - scaledHeight;
263
264      // 一時的な Canvas を作成してマーカーを描画(dpr でスケーリング)
265      const markerCanvas = document.createElement('canvas');
266      markerCanvas.width = scaledWidth;
267      markerCanvas.height = scaledHeight;
268      const markerCtx = markerCanvas.getContext('2d');
269      markerCtx.scale(dpr, dpr);
270
271      drawCanvasMarker(markerCtx, index + 1);
272
273      // メインの Canvas にマーカーを描画
274      ctx.drawImage(markerCanvas, x, y);
275
276      // コメントがある場合はラベルを描画
277      if (coord.comment && coord.comment.trim()) {
278        this.drawCommentLabel(ctx, physicalX, physicalY - scaledHeight, coord.comment, dpr);
279      }
280    });
281  }
282
283  /**
284   * コメントラベルを Canvas に描画(矢印付き吹き出し)
285   * @param {CanvasRenderingContext2D} ctx - Canvas 2D コンテキスト
286   * @param {number} x - ラベルの中心X座標(物理ピクセル)
287   * @param {number} y - ラベルの下端Y座標(矢印の先端、物理ピクセル)
288   * @param {string} text - ラベルテキスト
289   * @param {number} dpr - デバイスピクセル比
290   */
291  drawCommentLabel(ctx, x, y, text, dpr = 1) {
292    // CSS ピクセル値を dpr でスケーリング
293    const padding = 6 * dpr;
294    const fontSize = 12 * dpr;
295    const borderRadius = 4 * dpr;
296    const arrowWidth = 8 * dpr;
297    const arrowHeight = 8 * dpr;
298
299    ctx.font = `${fontSize}px Arial, sans-serif`;
300    const metrics = ctx.measureText(text);
301    const textWidth = metrics.width;
302    const textHeight = fontSize;
303
304    const boxWidth = textWidth + padding * 2;
305    const boxHeight = textHeight + padding * 2;
306    const boxX = x - boxWidth / 2;
307    const boxY = y - arrowHeight - boxHeight;
308
309    // 吹き出し全体のパス(角丸ボックス + 矢印)
310    ctx.beginPath();
311    // 左上から開始(角丸)
312    ctx.moveTo(boxX + borderRadius, boxY);
313    // 上辺
314    ctx.lineTo(boxX + boxWidth - borderRadius, boxY);
315    // 右上角丸
316    ctx.arcTo(boxX + boxWidth, boxY, boxX + boxWidth, boxY + borderRadius, borderRadius);
317    // 右辺
318    ctx.lineTo(boxX + boxWidth, boxY + boxHeight - borderRadius);
319    // 右下角丸
320    ctx.arcTo(boxX + boxWidth, boxY + boxHeight, boxX + boxWidth - borderRadius, boxY + boxHeight, borderRadius);
321    // 下辺(矢印まで)
322    ctx.lineTo(x + arrowWidth / 2, boxY + boxHeight);
323    // 矢印
324    ctx.lineTo(x, boxY + boxHeight + arrowHeight);
325    ctx.lineTo(x - arrowWidth / 2, boxY + boxHeight);
326    // 下辺(矢印から左下角丸まで)
327    ctx.lineTo(boxX + borderRadius, boxY + boxHeight);
328    // 左下角丸
329    ctx.arcTo(boxX, boxY + boxHeight, boxX, boxY + boxHeight - borderRadius, borderRadius);
330    // 左辺
331    ctx.lineTo(boxX, boxY + borderRadius);
332    // 左上角丸
333    ctx.arcTo(boxX, boxY, boxX + borderRadius, boxY, borderRadius);
334    ctx.closePath();
335
336    // 背景(白)
337    ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
338    ctx.fill();
339
340    // 枠線(青)
341    ctx.strokeStyle = '#1976D2';
342    ctx.lineWidth = 1 * dpr;
343    ctx.stroke();
344
345    // テキスト
346    ctx.fillStyle = '#333';
347    ctx.textAlign = 'center';
348    ctx.textBaseline = 'middle';
349    ctx.fillText(text, x, boxY + boxHeight / 2);
350  }
351
352  /**
353   * 地図とコンテナを破棄
354   */
355  destroy() {
356    console.log('Destroying export map...');
357    
358    if (this.exportMap) {
359      this.exportMap.remove();
360      this.exportMap = null;
361    }
362    
363    if (this.container) {
364      this.container.remove();
365      this.container = null;
366    }
367  }
368}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.