MapLibreにO-Map風のコース線を引くサンプル

スタートの三角形を次のコントロールに向けて回転させたり (三角関数を成人後初めて使った…! ) 、線が三角・丸・二重丸の中に入らないように長さを調節したり、必要な機能を盛り込もうとするとそこそこ面倒くさいんですけど、雨降りの日曜を一日費やしてChatGPTにコード書かせて不具合調査して修正依頼出してを繰り返したら、そこそこいい感じに作ってくれました。

デモ

使い方:
1. Run Penをクリック(タップ)して起動する
2. 右上の線のアイコンをクリックして描画モードに入る
3. 地図の場所を複数クリックして線をひく
4. ダブルクリックまたはエンターキーで入力を終了するとコース線になる
5. 線の真ん中をクリックしてコントロールを増やしたり、コントロールを選択した状態でゴミ箱アイコンで消したり、一度おいたコントロールを動かしたりもできる


サンプルコード

.htmlの拡張子でテキストを保存してブラウザで開けば動きます。
やりたいことに合わせて自由に修正して使ってください。

緯度が高いほどスタートの三角形の回転角度がずれたりとか、コントロール間の距離が近いときの挙動とか、まだ不備はいろいろあります。
プログラミング未経験者が遊びでChatGPTに生成させたコードですが、似たような情報が他に見当たらないのでこれも誰かの役に立つかもしれないと思い、そのまま公開することにします。

<!DOCTYPE html>
<html lang="ja">

<head>
  <!-- ドキュメントメタデータ -->
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CourseSet</title>
  <!-- 外部CSSスタイルシート -->
  <link href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css" rel="stylesheet" />
  <link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.3/mapbox-gl-draw.css" type="text/css">
  <!-- 内部CSSスタイル -->
  <style>
    body {
      margin: 0;
      padding: 0;
    }

    #map {
      position: absolute;
      top: 0;
      bottom: 0;
      width: 100%;
    }
  </style>
</head>

<body>
  <!-- マップコンテナ -->
  <div id="map"></div>

  <!-- 外部JavaScriptライブラリ -->
  <script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
  <script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.3/mapbox-gl-draw.js"></script>
  <script src="https://unpkg.com/@turf/turf"></script>
  <!-- インラインJavaScriptコード -->
  <script>
    window.onload = function() {
      // マップの初期化
      const map = new maplibregl.Map({
        container: 'map',
        style: {
          "version": 8,
          "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
          "sources": {
            "gsi_pale": {
              "type": "raster",
              "tiles": [
                "https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png"
              ],
              "tileSize": 256,
              "attribution": "<a href='https://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
            }
          },
          "layers": [{
            "id": "gsi_pale-layer",
            "type": "raster",
            "source": "gsi_pale",
            "minzoom": 0,
            "maxzoom": 18
          }]
        },
        center: [139.767125, 35.681236],
        zoom: 15
      });

      // イベントハンドラ
      map.on('load', function() {
        // Mapbox Drawを初期化
        const draw = new MapboxDraw({
          displayControlsDefault: false,
          controls: {
            line_string: true,
            trash: true
          }
        });

        // コントロールのクラスをカスタマイズ
        MapboxDraw.constants.classes.CONTROL_BASE = "maplibregl-ctrl";
        MapboxDraw.constants.classes.CONTROL_PREFIX = "maplibregl-ctrl-";
        MapboxDraw.constants.classes.CONTROL_GROUP = "maplibregl-ctrl-group";

        // マップにMapbox Drawコントロールを追加
        map.addControl(draw);

        // イベントリスナーをアタッチ
        map.on('draw.create', handleCreate);
        map.on('draw.update', handleCreate);
        map.on('draw.delete', handleDelete);
        map.on('zoom', updateLineLength);

        // 三角形のシンボルを定義
        const triangleSVG =
          `
          <svg width="100" height="100" viewBox="-20 -20 40 40" xmlns="http://www.w3.org/2000/svg">
            <polygon points="15,0 -7.5,12.99038105676658 -7.5,-12.99038105676658" fill="none" stroke="#a626ff" stroke-width="2" />
          </svg>
        `;

        // マップ上に三角形のシンボルを読み込む
        const triangleImage = new Image();
        triangleImage.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(triangleSVG);
        triangleImage.onload = () => {
          map.addImage('start', triangleImage);
        };

        // グローバル変数
        let currentLineSourceId = null;
        let currentLayerIndex = null;
        let courseCoordinates = null;

        // 描画作成イベントハンドラ
        function handleCreate(e) {
          deleteArea();
          courseCoordinates = e.features[0].geometry.coordinates;

          const distance = pixelsToMapUnits(9, map.getCenter().lat, map.getZoom());
          const segmentCoordinates = shortenLineSegments(courseCoordinates, distance);

          const layerIndex = addLineLayers(segmentCoordinates);
          addPointLayers(courseCoordinates, layerIndex);
          addStartSymbol(courseCoordinates[0], courseCoordinates[1], layerIndex);
          addControlNumbers(courseCoordinates, layerIndex);
        }

        // 描画削除イベントハンドラ
        function handleDelete() {
          deleteArea();
          draw.deleteAll();
        }

        // ピクセルをマップ単位に変換する関数
        function pixelsToMapUnits(pixels, latitude, zoomLevel) {
          const earthCircumference = 40075017;
          const latitudeRadians = latitude * (Math.PI / 180);
          const metersPerPixel = earthCircumference * Math.cos(latitudeRadians) / Math.pow(2, zoomLevel + 8);
          return pixels * metersPerPixel;
        }

        // 線分を短縮する関数
        function shortenLineSegments(courseCoordinates, distance) {
          return courseCoordinates.slice(0, -1).map((_, i) =>
            shortenLine(courseCoordinates[i], courseCoordinates[i + 1], distance)
          );
        }

        // 線を短縮する関数
        function shortenLine(start, end, distance) {
          const line = turf.lineString([start, end]);
          const length = turf.length(line, {
            units: 'meters'
          });

          if (length <= 2 * distance) {
            return [start, end];
          }

          const dx = end[0] - start[0];
          const dy = end[1] - start[1];

          const factor = distance / length;
          const shortenedStart = [start[0] + dx * factor, start[1] + dy * factor];
          const shortenedEnd = [end[0] - dx * factor, end[1] - dy * factor];

          return [shortenedStart, shortenedEnd];
        }

        // マップに線レイヤーを追加する関数
        function addLineLayers(segmentCoordinates) {
          const lineSourceId = 'line-source';
          currentLineSourceId = lineSourceId;

          const features = segmentCoordinates.map(coords => ({
            'type': 'Feature',
            'geometry': {
              'type': 'LineString',
              'coordinates': coords
            }
          }));

          map.addSource(lineSourceId, {
            'type': 'geojson',
            'data': {
              'type': 'FeatureCollection',
              'features': features
            }
          });

          const layerIndex = Date.now();
          currentLayerIndex = layerIndex;

          map.addLayer({
            'id': `line-layer-${layerIndex}`,
            'type': 'line',
            'source': lineSourceId,
            'layout': {
              'line-cap': 'butt',
              'line-join': 'miter'
            },
            'paint': {
              'line-color': '#a626ff',
              'line-width': 3
            }
          });

          return layerIndex;
        }

        // ポイントレイヤーを追加する関数
        function addPointLayers(pointCoordinates, layerIndex) {
          const originalPoints = pointCoordinates.map((coords, index) => ({
            'type': 'Feature',
            'geometry': {
              'type': 'Point',
              'coordinates': coords
            },
            'properties': {
              'index': index
            }
          }));

          const circleSourceId = `circle-source-${layerIndex}`;
          const finishSourceId = `finish-source-${layerIndex}`;

          map.addSource(circleSourceId, {
            'type': 'geojson',
            'data': {
              'type': 'FeatureCollection',
              'features': originalPoints
            }
          });

          map.addLayer({
            'id': `circle-layer-${layerIndex}`,
            'type': 'circle',
            'source': circleSourceId,
            'paint': {
              'circle-radius': 15,
              'circle-stroke-color': '#a626ff',
              'circle-stroke-width': 3,
              'circle-color': 'rgba(0, 0, 0, 0)'
            },
            'filter': ['all', ['!=', ['get', 'index'], 0],
              ['!=', ['get', 'index'], pointCoordinates.length - 1]
            ]
          });

          const finishPoint = pointCoordinates[pointCoordinates.length - 1];

          map.addSource(finishSourceId, {
            'type': 'geojson',
            'data': {
              'type': 'FeatureCollection',
              'features': [{
                'type': 'Feature',
                'geometry': {
                  'type': 'Point',
                  'coordinates': finishPoint


                },
                'properties': {
                  'radius': 18
                }
              }, {
                'type': 'Feature',
                'geometry': {
                  'type': 'Point',
                  'coordinates': finishPoint
                },
                'properties': {
                  'radius': 12
                }
              }]
            }
          });

          map.addLayer({
            'id': `finish-layer-${layerIndex}`,
            'type': 'circle',
            'source': finishSourceId,
            'paint': {
              'circle-radius': ['get', 'radius'],
              'circle-stroke-color': '#a626ff',
              'circle-stroke-width': 3,
              'circle-color': 'rgba(0, 0, 0, 0)'
            }
          });
        }

        // 開始シンボルを追加する関数
        function addStartSymbol(startPoint, nextPoint, layerIndex) {
          const rotation = calculateRotation(startPoint, nextPoint);

          map.addLayer({
            'id': `start-layer-${layerIndex}`,
            'type': 'symbol',
            'source': {
              'type': 'geojson',
              'data': {
                'type': 'FeatureCollection',
                'features': [{
                  'type': 'Feature',
                  'geometry': {
                    'type': 'Point',
                    'coordinates': startPoint
                  },
                  'properties': {
                    'rotation': rotation
                  }
                }]
              }
            },
            'layout': {
              'icon-image': 'start',
              'icon-size': 0.55,
              'icon-rotate': ['get', 'rotation']
            }
          });
        }

        // 回転角を計算する関数
        function calculateRotation(pointA, pointB) {
          const dx = pointB[0] - pointA[0];
          const dy = pointB[1] - pointA[1];
          return -((Math.atan2(dy, dx) * 180) / Math.PI);
        }

        // コントロール番号を追加する関数
        function addControlNumbers(pointCoordinates, layerIndex) {
          const controlPoints = pointCoordinates.slice(1, -1).map((coords, index) => ({
            'type': 'Feature',
            'geometry': {
              'type': 'Point',
              'coordinates': adjustPosition(coords, pointCoordinates)
            },
            'properties': {
              'number': index + 1
            }
          }));
        map.addSource(`number-source-${layerIndex}`, {
            'type': 'geojson',
            'data': {
              'type': 'FeatureCollection',
              'features': controlPoints
            }
          });

          map.addLayer({
            'id': `number-layer-${layerIndex}`,
            'type': 'symbol',
            'source': `number-source-${layerIndex}`,
            'layout': {
              'text-field': ['get', 'number'],
              'text-size': 28,
              'text-offset': [0, 1.5],
            },
            'paint': {
              'text-color': '#a626ff'
            }
          });
        }

        // 位置を調整する関数
        function adjustPosition(coords, allCoords) {
          // オーバーラップを回避するためのダミー実装、実際のロジックが必要です
          // ここでは、制御番号をわずかに右に移動させます
          return [coords[0] + 0.0001, coords[1] + 0.0001];
        }

        // 線の長さを更新する関数
        function updateLineLength() {
          if (!currentLineSourceId || !currentLayerIndex) return;
          const distance = pixelsToMapUnits(9, map.getCenter().lat, map.getZoom());
          const segmentCoordinates = shortenLineSegments(courseCoordinates, distance);

          map.getSource(currentLineSourceId).setData({
            'type': 'FeatureCollection',
            'features': segmentCoordinates.map(coords => ({
              'type': 'Feature',
              'geometry': {
                'type': 'LineString',
                'coordinates': coords
              }
            }))
          });
        }

        // エリアを削除する関数
        function deleteArea() {
          if (currentLineSourceId && currentLayerIndex) {
            const layersToRemove = [
              `line-layer-${currentLayerIndex}`,
              `circle-layer-${currentLayerIndex}`,
              `finish-layer-${currentLayerIndex}`,
              `start-layer-${currentLayerIndex}`,
              `number-layer-${currentLayerIndex}`
            ];

            const sourcesToRemove = [
              currentLineSourceId,
              `circle-source-${currentLayerIndex}`,
              `finish-source-${currentLayerIndex}`,
              `number-source-${currentLayerIndex}`
            ];

            layersToRemove.forEach(layer => {
              map.removeLayer(layer);
            });

            sourcesToRemove.forEach(source => {
              map.removeSource(source);
            });
          }
          currentLineSourceId = null;
          currentLayerIndex = null;
        }
      });
    };
  </script>
</body>

</html>

この記事が気に入ったらサポートをしてみませんか?