アー今日はここにも行かなくちゃ、あそこにも行かなくては、忙しい、忙しい、うーん間に合うかな?とりあえず急いで行ってみるか!などと思っていても、結局間に合わずお詫びの電話を掛ける羽目に。そんな経験は無いだろうか?目的地に到達するには、Google Mapsを使えは最短距離、ルートなどを現時点での交通状態などを考慮して検索してくれる。
しかし、回らないといけない場所が10箇所あったらどうするだろう?それが一回所に集中していればいいが、複数箇所に散らばっていたら、どのような順番で回ればよいか考えるのは難しい。後になって、こういう順序で回ればよかったなあ。などと思っても後の祭りである。Google MapsのPC版は目的地を入力して、順番を入れ替えることはできても、ルートの最適化はできない。つまり、リストの順番を手作業で並べ替えて計算して、どういう順番で回るのが効率的かを考えなくてはならない。これだけでも大変な時間がかかる。
もくじ
Google Cloud Platformを使ってGoogle Mapsをカスタマイズする
Google Cloud Platformは聞きなれない人が多いかもしれないが、Google社のエンジニアチームが結集して作ったプログラムで、サーバークラスターで動作する。それを利用するためにはGoogle Cloud Platformに登録して、API KEYを取得することがひつようとなるだが、その方法については後日説明するとして、いったいどんなことができるのかサンプルプログラムを公開することにしよう。
注意点としては、最後から4行目のYOUR_API_KEYと言う箇所を自分で取得したAPI KEYに置き換えること、そして各種の設定をGoogle Cloud Consoleで行う必要があるということだ。この件は別の記事でアプリの使い方も含めて説明する予定である。
少し長くなるのだが、完全なコードを以下に提示するので使って欲しい。ワンクリックでコピーできるので、メモ帳に貼り付けてhtmlという拡張子を付けて保存すればよい。APIの設定によってはローカルでも使える。
一応このサイトでも下記のようにサンプルファイルをそのまま動かせるようにはしてあるが、ある程度機能制限があるので注意してほしい。例えば経由地が5か所しか選べないとか、標高データが表示されない、検索するルートの距離に制限がかかっているので長いルートが検索できないなどである。しかし、実際の操作感覚はフルバージョンと同じである。
キャリブレーションポイント
Selected Algorithm: Google
距離一覧表
ルート最適化後に経由地の詳細が表示されます。
ただし、下記に掲載しているコードの1223行目のYOUR_API_KEYを自分で取得し、置き換えないと動かないというか、まずGoogle Mapsが表示されないので注意されたい。
上記のプログラムは複数地点を経由する最短ルートを検索するためにつくられたものだ。最短ルートはスタート地点やゴール地点によっても変わってくるが、それも指定することが可能である、際のルートはスクリーンショットを見せるが、このように表示される。
実際にコードをダウンロードして使ってみよう
下記のコードは上記のリンク先と全く同じコードだが、API KEYが入っていないという違いがある。これは自分で取得してノートーパッドにでも貼り付けて編集してほしい。右上のCOPYボタンで一発でコピーできる。
<!DOCTYPE html>
<html lang="ja">
<head>
<title>Map Calibration and Route Calculation with TSP Algorithms</title>
<meta charset="UTF-8">
<style>
body {
font-size: 1.5em; /* 全体のフォントサイズを1.5倍に */
}
#map {
height: 50vh;
width: 100%;
}
#uploadedImageContainer {
height: 50vh;
width: 100%;
display: none;
overflow: auto;
text-align: center;
position: relative;
}
#uploadedImage {
max-width: 100%;
height: auto;
transform-origin: top left;
}
#distance-table {
margin-top: 10px;
font-size: 1em;
background-color: #f9f9f9;
padding: 10px;
border: 1px solid #ddd;
}
#distance-table table {
width: 100%;
border-collapse: collapse;
}
#distance-table th, #distance-table td {
padding: 5px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.custom-cursor-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
cursor: default;
}
#controls {
background: white;
padding: 10px;
z-index: 5;
}
#calibrationInfo {
margin-top: 10px;
}
#calibration-zoom-controls {
position: absolute;
top: 200px; /* ここを変更して透明度ボタンと重ならないように調整 */
right: 10px;
z-index: 10;
background: white;
padding: 10px;
border: 1px solid #ccc;
}
.default-cursor {
cursor: default !important;
}
.waypoint {
cursor: pointer;
padding: 5px;
border-bottom: 1px solid #ddd;
font-size: 14px;
}
.highlight {
background-color: yellow;
}
.goal-highlight {
background-color: lightblue;
}
#total-distance {
margin-top: 10px;
font-size: 1em;
}
#error-message {
color: red;
font-weight: bold;
margin-top: 10px;
font-size: 1em;
}
.map-marker-label {
font-size: 100px; /* フォントサイズを40pxに設定 */
color: black; /* テキストの色を黒に設定 */
background-color: white; /* 背景色を白に設定 */
border-radius: 50%; /* テキストの背景を丸くする */
padding: 5px; /* テキストの周りに余白を追加 */
}
/* ボタンや入力フィールドのサイズを調整 */
.custom-file-upload {
display: inline-block;
padding: 10px 20px;
cursor: pointer;
background-color: #4CAF50;
color: white;
font-size: 1.5em;
border: none;
border-radius: 5px;
}
#fileInput {
display: none; /* ファイル選択ボタンを非表示にする */
}
#fileInput,
#fileInput + label,
#increaseOpacity,
#decreaseOpacity,
#reapplyOverlay,
#applyOverlay,
button {
font-size: 1.5em; /* ボタンとテキストのフォントサイズを増やします */
padding: 10px 20px; /* ボタンのパディングを調整して大きく見せます */
margin: 5px; /* ボタン間の余白を調整します */
}
</style>
</head>
<body>
<!-- Custom Confirm Dialog -->
<div id="customDialog" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%, -50%); background:white; padding:20px; border:2px solid #ccc; z-index:1000;">
<div id="dialogMessage"></div>
<button id="dialogOkBtn">OK</button>
<button id="dialogCancelBtn">キャンセル</button>
</div>
<div id="controls">
<label for="fileInput" class="custom-file-upload">ファイルを選択</label>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif" />
<!-- ボタンの並びを調整 -->
<button id="startCalibration">キャリブレーション開始</button>
<button onclick="loadData()">データを復元</button>
<button id="increaseOpacity">透明度アップ</button>
<button id="decreaseOpacity">透明度ダウン</button>
<button id="resetOverlay">オーバーレイリセット</button>
<button id="reapplyOverlay" style="display: none;">オーバーレイ再表示</button>
<button id="applyOverlay" style="display: none;">オーバーレイ適用</button>
<button onclick="saveData()">データを保存</button>
<!-- 拡大縮小ボタンを一番右端に配置 -->
<div id="calibration-zoom-controls" style="display: inline-block; vertical-align: middle; margin-left: 10px;">
<button id="zoomInButton" onclick="zoomInCalibrationImage()">拡大</button>
<button id="zoomOutButton" onclick="zoomOutCalibrationImage()">縮小</button>
</div>
<div id="imageSizeInfo"></div>
<div id="calibrationInfo">
<h3>キャリブレーションポイント</h3>
<ul id="calibrationPoints"></ul>
</div>
</div>
<div>
<label for="latitude">Latitude:</label>
<input type="text" id="latitude" readonly>
<label for="longitude">Longitude:</label>
<input type="text" id="longitude" readonly>
<button onclick="calculateRoute()">Calculate Route</button>
<button onclick="clearWaypoints()">Clear All</button>
<button onclick="showCurrentLocation()">Show Current Location</button>
<button onclick="setAlgorithm('Google')">Use Google Algorithm</button>
<button onclick="setAlgorithm('greedy')">Use Greedy Algorithm</button>
<button onclick="setAlgorithm('branchAndBound')">Use Branch and Bound Algorithm</button>
<button id="waypointModeButton">WAYPOINTモード開始</button> <!-- WAYPOINTモード開始ボタンを追加 -->
<button id="exitWaypointModeButton" style="display:none;">WAYPOINTモード終了</button>
<div id="total-distance"></div>
<div id="error-message"></div>
<p id="algorithm">Selected Algorithm: Google</p>
</div>
<div id="uploadedImageContainer">
<img id="uploadedImage" />
</div>
<div id="map" class="default-cursor"></div>
<div id="waypoints"></div>
<div id="distance-table">
<h3>距離一覧表</h3>
<div id="distance-list"></div>
</div>
<div id="directions-panel">
<p>ルート最適化後に経由地の詳細が表示されます。</p>
</div>
<script>
function showCustomConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('customDialog');
const dialogMessage = document.getElementById('dialogMessage');
const okBtn = document.getElementById('dialogOkBtn');
const cancelBtn = document.getElementById('dialogCancelBtn');
dialogMessage.innerText = message;
dialog.style.display = 'block';
okBtn.onclick = () => {
dialog.style.display = 'none';
resolve(true);
};
cancelBtn.onclick = () => {
dialog.style.display = 'none';
resolve(false);
};
});
}
let map;
let customOverlay;
let calibrationStep = 0;
let pixelCoords = [];
let latLngCoords = [];
let imgNaturalWidth, imgNaturalHeight;
let overlayOpacity = 0.5;
let calibrationImageScale = 1;
let directionsService;
let directionsRenderer;
let waypts = [];
let markers = [];
let startPointIndex = null;
let endPointIndex = null;
let currentLocationMarker;
let overlayImageUrl;
let imageMarkers = [];
let calculatedDistances = [];
let selectedAlgorithm = 'Google';
let waypointMode = false; // WAYPOINTモードフラグ
function applyCalibration() {
if (pixelCoords.length === 4 && latLngCoords.length === 4) {
const nwLatLng = latLngCoords[0];
const seLatLng = latLngCoords[2];
const bounds = new google.maps.LatLngBounds(
new google.maps.LatLng(seLatLng.lat, nwLatLng.lng),
new google.maps.LatLng(nwLatLng.lat, seLatLng.lng)
);
if (customOverlay) {
customOverlay.setMap(null);
}
customOverlay = new google.maps.GroundOverlay(
overlayImageUrl,
bounds
);
customOverlay.setMap(map);
} else {
showCustomConfirm('キャリブレーションポイントが不足しています。').then(() => {});
}
}
function updateMapMarkers() {
markers.forEach(marker => marker.setMap(null));
waypts.forEach((waypt, index) => {
const marker = new google.maps.Marker({
position: waypt.location,
map: map,
label: {
text: `${index + 1}`,
className: 'map-marker-label',
color: "black"
},
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: 'black',
fillOpacity: 1,
strokeWeight: 2,
strokeColor: 'white'
},
zIndex: google.maps.Marker.MAX_ZINDEX + 1
});
markers.push(marker);
});
}
function initMap() {
map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 35.6895, lng: 139.6917 },
zoom: 13,
clickableIcons: false, // ランドマークのクリック無効化
gestureHandling: 'greedy' // ジェスチャーの処理
});
directionsService = new google.maps.DirectionsService();
directionsRenderer = new google.maps.DirectionsRenderer();
directionsRenderer.setMap(map);
const input = document.createElement('input');
input.type = 'text';
input.id = 'pac-input';
input.className = 'controls';
input.placeholder = 'Search Box';
map.controls[google.maps.ControlPosition.TOP_LEFT].push(input);
const searchBox = new google.maps.places.SearchBox(input);
map.addListener('bounds_changed', function() {
searchBox.setBounds(map.getBounds());
});
searchBox.addListener('places_changed', function() {
const places = searchBox.getPlaces();
if (places.length === 0) {
return;
}
markers.forEach(marker => marker.setMap(null));
markers = [];
const bounds = new google.maps.LatLngBounds();
places.forEach(place => {
if (!place.geometry) {
console.log("Returned place contains no geometry");
return;
}
// プレイスを手動でWAYPOINTとして追加する
const lat = place.geometry.location.lat();
const lng = place.geometry.location.lng();
addWaypoint(lat, lng); // ここでWAYPOINTを追加
markers.push(new google.maps.Marker({
map: map,
title: place.name,
position: place.geometry.location
}));
if (place.geometry.viewport) {
bounds.union(place.geometry.viewport);
} else {
bounds.extend(place.geometry.location);
}
});
map.fitBounds(bounds);
});
map.addListener('idle', setCustomCursor);
map.addListener('mousemove', setCustomCursor);
map.addListener('drag', setCustomCursor);
map.addListener('zoom_changed', setCustomCursor);
map.addListener('click', async function(event) {
if (waypointMode) { // WAYPOINTモードが有効な場合のみWAYPOINTを追加
if (waypts.length >= 21) {
await showCustomConfirm('経由地の最大数は21です。');
return;
}
const lat = event.latLng.lat();
const lng = event.latLng.lng();
document.getElementById('latitude').value = lat;
document.getElementById('longitude').value = lng;
addWaypoint(lat, lng);
} else if (calibrationStep > 0 && calibrationStep <= 8) {
const latLng = event.latLng;
try {
const result = await showCustomConfirm(`Googleマップ上の緯度経度: ${latLng.lat()}, ${latLng.lng()}を使用しますか?`);
if (result) {
const marker = new google.maps.Marker({
position: latLng,
map: map,
title: `Calibration Point ${calibrationStep}`
});
markers.push(marker);
latLngCoords.push({ lat: latLng.lat(), lng: latLng.lng() });
updateCalibrationInfo();
calibrationStep++;
if (calibrationStep > 4) {
document.getElementById('applyOverlay').style.display = 'block';
}
}
} catch (error) {
console.error("Error during map click handling:", error);
}
}
});
}
document.getElementById('waypointModeButton').addEventListener('click', function() {
waypointMode = true;
document.getElementById('waypointModeButton').style.display = 'none';
document.getElementById('exitWaypointModeButton').style.display = 'inline';
// SEARCH BOXを無効化
document.getElementById('pac-input').disabled = true;
});
document.getElementById('exitWaypointModeButton').addEventListener('click', function() {
waypointMode = false;
document.getElementById('waypointModeButton').style.display = 'inline';
document.getElementById('exitWaypointModeButton').style.display = 'none';
// SEARCH BOXを再度有効化
document.getElementById('pac-input').disabled = false;
});
function setCustomCursor() {
const mapDiv = map.getDiv();
mapDiv.style.cursor = 'default';
const mapElements = mapDiv.querySelectorAll('*');
mapElements.forEach(element => {
element.style.cursor = 'default';
});
}
function zoomInCalibrationImage() {
calibrationImageScale *= 1.2;
updateCalibrationImage();
}
function zoomOutCalibrationImage() {
calibrationImageScale /= 1.2;
updateCalibrationImage();
}
function updateCalibrationImage() {
const img = document.getElementById('uploadedImage');
img.style.transform = `scale(${calibrationImageScale})`;
document.querySelectorAll('.marker').forEach((marker, index) => {
const rect = img.getBoundingClientRect();
const scaleX = rect.width / imgNaturalWidth;
const scaleY = rect.height / imgNaturalHeight;
const pixelCoord = pixelCoords[index];
const x = pixelCoord.x * scaleX;
const y = pixelCoord.y * scaleY;
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
});
}
document.getElementById('fileInput').addEventListener('change', function (event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
overlayImageUrl = e.target.result;
const img = new Image();
img.src = overlayImageUrl;
img.onload = function () {
imgNaturalWidth = img.naturalWidth;
imgNaturalHeight = img.naturalHeight;
document.getElementById('uploadedImage').src = overlayImageUrl;
document.getElementById('uploadedImageContainer').style.display = 'block';
document.getElementById('imageSizeInfo').textContent = `画像のサイズ: 幅=${imgNaturalWidth}px, 高さ=${imgNaturalHeight}px`;
};
};
reader.readAsDataURL(file);
}
});
function addMarkerToImage(x, y) {
const marker = document.createElement('div');
marker.classList.add('marker');
marker.style.left = `${x}px`;
marker.style.top = `${y}px`;
marker.style.position = 'absolute';
marker.style.width = '10px';
marker.style.height = '10px';
marker.style.backgroundColor = 'red';
marker.style.borderRadius = '50%';
document.getElementById('uploadedImageContainer').appendChild(marker);
imageMarkers.push(marker); // 画像マーカーを配列に保存
}
document.getElementById('uploadedImage').addEventListener('click', function (e) {
if (calibrationStep > 0 && calibrationStep <= 8) {
const img = document.getElementById('uploadedImage');
const rect = img.getBoundingClientRect();
const scaleX = imgNaturalWidth / rect.width;
const scaleY = imgNaturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
pixelCoords.push({ x: x, y: y });
showCustomConfirm(`画像上のピクセル位置: (${x}, ${y})を使用しますか?`).then((result) => {
if (result) {
updateCalibrationInfo();
addMarkerToImage(e.clientX - rect.left, e.clientY - rect.top);
calibrationStep++;
}
});
}
});
document.getElementById('startCalibration').addEventListener('click', function () {
calibrationStep = 1;
pixelCoords = [];
latLngCoords = [];
updateCalibrationInfo();
showCustomConfirm('キャリブレーションを開始します。画像上の既知のポイントをクリックし、その後Googleマップ上で対応するポイントをクリックしてください。これを4回繰り返します。').then(() => {});
});
document.getElementById('increaseOpacity').addEventListener('click', function () {
if (customOverlay) {
overlayOpacity = Math.min(overlayOpacity + 0.1, 1.0);
customOverlay.setOpacity(overlayOpacity);
}
});
document.getElementById('decreaseOpacity').addEventListener('click', function () {
if (customOverlay) {
overlayOpacity = Math.max(overlayOpacity - 0.1, 0.0);
customOverlay.setOpacity(overlayOpacity);
}
});
document.getElementById('resetOverlay').addEventListener('click', function () {
if (customOverlay) {
customOverlay.setMap(null);
customOverlay = null;
document.getElementById('reapplyOverlay').style.display = 'block';
}
});
document.getElementById('reapplyOverlay').addEventListener('click', function () {
if (overlayImageUrl) {
calculateBoundsAndOverlay();
}
});
function determinePosition(x, y, width, height) {
if (x <= width / 2 && y <= height / 2) {
return '左上 (Top-Left)';
} else if (x > width / 2 && y <= height / 2) {
return '右上 (Top-Right)';
} else if (x > width / 2 && y > height / 2) {
return '右下 (Bottom-Right)';
} else {
return '左下 (Bottom-Left)';
}
}
function updateCalibrationInfo() {
const calibrationPointsList = document.getElementById('calibrationPoints');
calibrationPointsList.innerHTML = '';
for (let i = 0; i < pixelCoords.length; i++) {
const pixelCoord = pixelCoords[i];
const latLngCoord = latLngCoords[i] || {};
const positionLabel = determinePosition(pixelCoord.x, pixelCoord.y, imgNaturalWidth, imgNaturalHeight);
const listItem = document.createElement('li');
listItem.innerHTML = `${positionLabel}: ピクセル位置: (${pixelCoord.x.toFixed(2)}, ${pixelCoord.y.toFixed(2)}), 緯度経度: (${latLngCoord.lat || ''}, ${latLngCoord.lng || ''}) <button onclick="removeCalibrationPoint(${i})">Remove</button>`;
calibrationPointsList.appendChild(listItem);
}
}
function removeCalibrationPoint(index) {
if (index < pixelCoords.length) {
pixelCoords.splice(index, 1);
}
if (index < latLngCoords.length) {
latLngCoords.splice(index, 1);
}
if (index < markers.length && markers[index]) {
markers[index].setMap(null); // マーカーを地図から削除
markers.splice(index, 1); // マーカーを配列から削除
}
const imageMarkers = document.querySelectorAll('.marker');
if (index < imageMarkers.length && imageMarkers[index]) {
imageMarkers[index].remove(); // DOMから要素を削除
}
markers.forEach((marker, i) => {
marker.setTitle(`Calibration Point ${i + 1}`);
});
calibrationStep = Math.max(pixelCoords.length, latLngCoords.length);
updateCalibrationInfo();
if (pixelCoords.length < 4 || latLngCoords.length < 4) {
document.getElementById('applyOverlay').style.display = 'none';
}
}
document.getElementById('applyOverlay').addEventListener('click', calculateBoundsAndOverlay);
function calculateBoundsAndOverlay() {
if (pixelCoords.length !== 4 || latLngCoords.length !== 4) {
showCustomConfirm('キャリブレーションが完了していません。').then(() => {});
return;
}
const nwPixel = pixelCoords[0];
const nePixel = pixelCoords[1];
const sePixel = pixelCoords[2];
const swPixel = pixelCoords[3];
const nwLatLng = latLngCoords[0];
const neLatLng = latLngCoords[1];
const seLatLng = latLngCoords[2];
const swLatLng = latLngCoords[3];
const latPerPixelX = (neLatLng.lat - nwLatLng.lat) / (nePixel.x - nwPixel.x);
const lngPerPixelX = (neLatLng.lng - nwLatLng.lng) / (nePixel.x - nwPixel.x);
const latPerPixelY = (swLatLng.lat - nwLatLng.lat) / (swPixel.y - nwPixel.y);
const lngPerPixelY = (swLatLng.lng - nwLatLng.lng) / (swPixel.y - nwPixel.y);
const nwLat = nwLatLng.lat - (nwPixel.y * latPerPixelY);
const nwLng = nwLatLng.lng - (nwPixel.x * lngPerPixelX);
const seLat = seLatLng.lat + ((imgNaturalHeight - sePixel.y) * latPerPixelY);
const seLng = seLatLng.lng + ((imgNaturalWidth - sePixel.x) * lngPerPixelX);
const imageBounds = {
north: nwLat,
south: seLat,
east: seLng,
west: nwLng
};
if (customOverlay) {
customOverlay.setMap(null);
}
const sw = new google.maps.LatLng(imageBounds.south, imageBounds.west);
const ne = new google.maps.LatLng(imageBounds.north, imageBounds.east);
const overlayBounds = { southWest: sw, northEast: ne };
class CustomOverlay extends google.maps.OverlayView {
constructor(image, bounds) {
super();
this.image_ = image;
this.bounds_ = bounds;
this.div_ = null;
}
onAdd() {
const div = document.createElement('div');
div.style.border = 'none';
div.style.borderWidth = '0px';
div.style.position = 'absolute';
const img = document.createElement('img');
img.src = this.image_;
img.style.width = '100%';
img.style.height = '100%';
img.style.position = 'absolute';
img.style.opacity = overlayOpacity;
div.appendChild(img);
this.div_ = div;
const panes = this.getPanes();
panes.overlayLayer.appendChild(div);
}
draw() {
const overlayProjection = this.getProjection();
const sw = overlayProjection.fromLatLngToDivPixel(this.bounds_.southWest);
const ne = overlayProjection.fromLatLngToDivPixel(this.bounds_.northEast);
const div = this.div_;
div.style.left = sw.x + 'px';
div.style.top = ne.y + 'px';
div.style.width = (ne.x - sw.x) + 'px';
div.style.height = (sw.y - ne.y) + 'px';
}
onRemove() {
this.div_.parentNode.removeChild(this.div_);
this.div_ = null;
}
setOpacity(opacity) {
this.div_.firstChild.style.opacity = opacity;
}
setBounds(bounds) {
this.bounds_ = bounds;
this.draw();
}
}
customOverlay = new CustomOverlay(document.getElementById('uploadedImage').src, overlayBounds);
customOverlay.setMap(map);
document.getElementById('applyOverlay').style.display = 'none';
document.getElementById('reapplyOverlay').style.display = 'none';
calibrationStep = 0;
}
function addWaypoint(lat, lng) {
const index = waypts.length;
waypts.push({ location: { lat: lat, lng: lng } });
const marker = new google.maps.Marker({
position: { lat: lat, lng: lng },
map: map,
label: {
text: `${index + 1}`,
className: 'map-marker-label'
},
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: 'black',
fillOpacity: 1,
strokeWeight: 2,
strokeColor: 'white'
},
zIndex: google.maps.Marker.MAX_ZINDEX + 1
});
markers.push(marker);
updateWaypointsList();
updateMapMarkers();
}
map.addListener('zoom_changed', function() {
const zoomLevel = map.getZoom();
const fontSize = zoomLevel * 2; // ズームレベルに応じてフォントサイズを設定
document.querySelectorAll('.map-marker-label').forEach(label => {
label.style.fontSize = fontSize + 'px';
});
});
function updateWaypointsList() {
const waypointsDiv = document.getElementById('waypoints');
waypointsDiv.innerHTML = '';
waypts.forEach((waypt, index) => {
const lat = waypt.location.lat;
const lng = waypt.location.lng;
const waypointDiv = document.createElement('div');
waypointDiv.className = 'waypoint';
waypointDiv.innerHTML = `Waypoint ${index + 1}: (${lat.toFixed(6)}, ${lng.toFixed(6)}) <button onclick="removeWaypoint(${index})">Remove</button>`;
waypointDiv.id = `waypoint-${index}`;
waypointDiv.onclick = () => setPoint(index);
waypointsDiv.appendChild(waypointDiv);
});
highlightPoints();
}
function removeWaypoint(index) {
waypts.splice(index, 1);
markers[index].setMap(null);
markers.splice(index, 1);
updateWaypointsList();
updateMapMarkers();
}
function setPoint(index) {
if (startPointIndex === index) {
startPointIndex = null;
document.getElementById('error-message').innerText = 'スタート地点が解除されました。再度クリックして新しいスタート地点を設定してください。';
} else if (endPointIndex === index) {
endPointIndex = null;
document.getElementById('error-message').innerText = 'ゴール地点が解除されました。再度クリックして新しいゴール地点を設定してください。';
} else if (startPointIndex === null) {
startPointIndex = index;
document.getElementById('error-message').innerText = 'スタート地点が設定されました。次の地点をクリックしてゴール地点を設定してください。';
} else if (endPointIndex === null) {
endPointIndex = index;
document.getElementById('error-message').innerText = 'ゴール地点が設定されました。Calculate Routeボタンを押してルートを計算してください。';
}
highlightPoints();
}
function highlightPoints() {
const waypointDivs = document.querySelectorAll('.waypoint');
waypointDivs.forEach((div, idx) => {
div.classList.remove('highlight', 'goal-highlight');
if (idx === startPointIndex) {
div.classList.add('highlight');
} else if (idx === endPointIndex) {
div.classList.add('goal-highlight');
}
});
}
function calculateGoogleRoute() {
const orderedWaypoints = waypts.map((waypoint) => waypoint.location);
if (startPointIndex !== null && endPointIndex !== null) {
const start = orderedWaypoints.splice(startPointIndex, 1)[0];
const end = orderedWaypoints.splice(endPointIndex > startPointIndex ? endPointIndex - 1 : endPointIndex, 1)[0];
orderedWaypoints.unshift(start);
orderedWaypoints.push(end);
}
return orderedWaypoints;
}
function calculateRoute() {
document.getElementById('error-message').innerText = '';
if (startPointIndex === null || endPointIndex === null) {
document.getElementById('error-message').innerText = 'スタート地点とゴール地点を選択してください。';
return;
}
let orderedWaypoints;
switch (selectedAlgorithm) {
case 'Google':
orderedWaypoints = calculateGoogleRoute();
break;
case 'greedy':
orderedWaypoints = calculateGreedyRoute();
break;
case 'branchAndBound':
orderedWaypoints = calculateBranchAndBoundRoute();
break;
default:
orderedWaypoints = calculateGoogleRoute();
break;
}
displayRoute(orderedWaypoints);
}
function calculateGreedyRoute() {
const origin = waypts[startPointIndex].location;
const end = waypts[endPointIndex].location;
const remainingWaypoints = waypts.filter((_, index) => index !== startPointIndex && index !== endPointIndex);
const orderedWaypoints = [origin];
let currentPoint = origin;
while (remainingWaypoints.length > 0) {
let nearestIndex = -1;
let nearestDistance = Infinity;
remainingWaypoints.forEach((waypoint, index) => {
const distance = google.maps.geometry.spherical.computeDistanceBetween(
new google.maps.LatLng(currentPoint.lat, currentPoint.lng),
new google.maps.LatLng(waypoint.location.lat, waypoint.location.lng)
);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
orderedWaypoints.push(remainingWaypoints[nearestIndex].location);
currentPoint = remainingWaypoints[nearestIndex].location;
remainingWaypoints.splice(nearestIndex, 1);
}
// ゴール地点を最後に追加
orderedWaypoints.push(end);
return orderedWaypoints;
}
function calculateBranchAndBoundRoute() {
// 距離行列の計算
const distances = waypts.map((waypoint1) =>
waypts.map((waypoint2) =>
google.maps.geometry.spherical.computeDistanceBetween(
new google.maps.LatLng(waypoint1.location.lat, waypoint1.location.lng),
new google.maps.LatLng(waypoint2.location.lat, waypoint2.location.lng)
)
)
);
const n = distances.length;
let bestRoute = [];
let bestCost = Infinity;
function branchAndBound(currentRoute, currentCost, visited) {
if (currentRoute.length === n - 2) { // スタートとエンド以外の地点数
const lastIndex = currentRoute[currentRoute.length - 1];
const totalCost = currentCost + distances[lastIndex][endPointIndex];
if (totalCost < bestCost) {
bestCost = totalCost;
bestRoute = [startPointIndex, ...currentRoute, endPointIndex];
}
return;
}
for (let i = 0; i < n; i++) {
// スタートとエンドを除いた地点を訪問
if (!visited[i] && i !== startPointIndex && i !== endPointIndex) {
visited[i] = true;
currentRoute.push(i);
// 現在の地点から次の地点へのコスト計算
const prevIndex = currentRoute[currentRoute.length - 2] || startPointIndex;
const newCost = currentCost + distances[prevIndex][i];
// コストが現在のベストコストよりも小さい場合のみ再帰的に探索
if (newCost < bestCost) {
branchAndBound(currentRoute, newCost, visited);
}
visited[i] = false;
currentRoute.pop();
}
}
}
// スタート地点を訪問済みとして初期化
const visited = Array(n).fill(false);
visited[startPointIndex] = true;
branchAndBound([], 0, visited);
// ベストルートの確認
if (bestRoute.length > 0) {
console.log("Branch and Bound Route (Index):", bestRoute);
const alphabetRoute = bestRoute.map((index, i) => String.fromCharCode(65 + i)); // A, B, C, ...
console.log("Branch and Bound Route (Alphabet):", alphabetRoute);
} else {
console.error("No valid route found.");
return [];
}
return bestRoute.map(index => waypts[index]?.location);
}
function calculateElevation(orderedWaypoints) {
const elevationService = new google.maps.ElevationService();
const pathRequest = {
path: orderedWaypoints,
samples: orderedWaypoints.length
};
elevationService.getElevationAlongPath(pathRequest, function (results, status) {
if (status === 'OK') {
let elevationGain = 0;
let elevationLoss = 0;
for (let i = 1; i < results.length; i++) {
const elevationChange = results[i].elevation - results[i - 1].elevation;
if (elevationChange > 0) {
elevationGain += elevationChange;
} else {
elevationLoss += Math.abs(elevationChange);
}
}
const elevationGainInMeters = elevationGain.toFixed(2);
const elevationLossInMeters = elevationLoss.toFixed(2);
addElevationToList(elevationGainInMeters, elevationLossInMeters);
} else {
console.error('Elevation service failed due to: ' + status);
}
});
}
function displayRoute(orderedWaypoints) {
const origin = orderedWaypoints[0];
const destination = orderedWaypoints[orderedWaypoints.length - 1];
const waypointsForRoute = orderedWaypoints.slice(1, orderedWaypoints.length - 1).map(waypt => ({
location: waypt,
stopover: true
}));
directionsService.route({
origin: origin,
destination: destination,
waypoints: waypointsForRoute,
travelMode: 'WALKING'
}, async function(response, status) {
if (status === 'OK') {
directionsRenderer.setOptions({
polylineOptions: {
strokeColor: '#0000ff',
strokeWeight: 8
}
});
directionsRenderer.setDirections(response);
const route = response.routes[0];
let totalDistance = 0;
let totalDuration = 0;
let totalElevationGain = 0;
let totalElevationLoss = 0;
let detailedDirections = '';
// 経由地の詳細を表示するための処理
for (let i = 0; i < route.legs.length; i++) {
const leg = route.legs[i];
totalDistance += leg.distance.value;
totalDuration += leg.duration.value;
// 高度計算の結果を追加
const legWaypoints = [leg.start_location, leg.end_location];
const { elevationGain, elevationLoss } = await calculateLegElevation(legWaypoints);
totalElevationGain += parseFloat(elevationGain);
totalElevationLoss += parseFloat(elevationLoss);
detailedDirections += `<p>Leg ${i + 1}: ${leg.start_address} to ${leg.end_address}, Distance: ${leg.distance.text}, Duration: ${leg.duration.text}, Elevation Gain: ${elevationGain} meters, Elevation Loss: ${elevationLoss} meters</p>`;
}
const distanceInKm = (totalDistance / 1000).toFixed(2);
const durationInMinutes = Math.floor(totalDuration / 60);
const totalInfo = `Total Elevation Gain: ${totalElevationGain.toFixed(2)} meters, Total Elevation Loss: ${totalElevationLoss.toFixed(2)} meters, Total Distance: ${distanceInKm} km, Total Duration: ${durationInMinutes}分`;
document.getElementById('directions-panel').innerHTML = `<p>${totalInfo}</p>` + detailedDirections;
// 一覧表への情報追加
addDistanceToList(startPointIndex + 1, endPointIndex + 1, distanceInKm, selectedAlgorithm, durationInMinutes);
} else {
showCustomConfirm('Directions request failed due to ' + status).then(() => {});
}
});
}
function calculateLegElevation(legWaypoints) {
const elevationService = new google.maps.ElevationService();
return new Promise((resolve, reject) => {
const pathRequest = {
path: legWaypoints,
samples: legWaypoints.length
};
elevationService.getElevationAlongPath(pathRequest, function (results, status) {
if (status === 'OK') {
let elevationGain = 0;
let elevationLoss = 0;
for (let i = 1; i < results.length; i++) {
const elevationChange = results[i].elevation - results[i - 1].elevation;
if (elevationChange > 0) {
elevationGain += elevationChange;
} else {
elevationLoss += Math.abs(elevationChange);
}
}
resolve({
elevationGain: elevationGain.toFixed(2),
elevationLoss: elevationLoss.toFixed(2)
});
} else {
reject('Elevation service failed due to: ' + status);
}
});
});
}
function addDistanceToList(startPoint, endPoint, distance, algorithm, duration) {
const distanceListDiv = document.getElementById('distance-list');
const newEntry = document.createElement('div');
newEntry.textContent = `WAYPOINT ${startPoint} to WAYPOINT ${endPoint}: ${distance} km, Duration: ${duration}分 (Algorithm: ${algorithm})`;
distanceListDiv.appendChild(newEntry);
}
function addElevationToList(elevationGain, elevationLoss) {
const distanceListDiv = document.getElementById('distance-list');
const newEntry = document.createElement('div');
newEntry.textContent = `Elevation Gain: ${elevationGain} meters, Elevation Loss: ${elevationLoss} meters`;
distanceListDiv.appendChild(newEntry);
}
function clearWaypoints() {
waypts = [];
markers.forEach(marker => marker.setMap(null));
markers = [];
startPointIndex = null;
endPointIndex = null;
document.getElementById('waypoints').innerHTML = '';
document.getElementById('total-distance').innerText = '';
document.getElementById('directions-panel').innerHTML = '<p>ルート最適化後に経由地の詳細が表示されます。</p>';
document.getElementById('error-message').innerText = '';
directionsRenderer.set('directions', null);
}
function showCurrentLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
const pos = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
if (currentLocationMarker) {
currentLocationMarker.setMap(null);
}
currentLocationMarker = new google.maps.Marker({
position: pos,
map: map,
title: '現在地',
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: 'red',
fillOpacity: 1,
strokeWeight: 2,
strokeColor: 'black'
}
});
map.setCenter(pos);
}, function() {
handleLocationError(true, map.getCenter());
});
} else {
handleLocationError(false, map.getCenter());
}
}
function handleLocationError(browserHasGeolocation, pos) {
showCustomConfirm(browserHasGeolocation ?
'エラーメッセージ: 現在地の取得に失敗しました。' :
'エラーメッセージ: このブラウザはGeolocationをサポートしていません。').then(() => {});
}
function setAlgorithm(algorithm) {
selectedAlgorithm = algorithm;
document.getElementById('algorithm').innerText = "Selected Algorithm: " + algorithm;
}
function saveData() {
const data = {
pixelCoords: pixelCoords,
latLngCoords: latLngCoords,
waypts: waypts,
calibrationImageScale: calibrationImageScale,
overlayImageUrl: overlayImageUrl,
date: new Date().toLocaleString()
};
const json = JSON.stringify(data);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `data_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
function loadData() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (event) => {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
const data = JSON.parse(e.target.result);
pixelCoords = data.pixelCoords || [];
latLngCoords = data.latLngCoords || [];
waypts = data.waypts || [];
calibrationImageScale = data.calibrationImageScale || 1;
overlayImageUrl = data.overlayImageUrl || '';
document.getElementById('uploadedImage').src = overlayImageUrl;
document.getElementById('uploadedImageContainer').style.display = 'block';
updateCalibrationInfo(); // キャリブレーションポイント情報の更新
updateWaypointsList(); // ウェイポイント情報の更新
// オーバーレイを再適用
calculateBoundsAndOverlay();
// 必要な場合オーバーレイ適用ボタンを表示
if (pixelCoords.length === 4 && latLngCoords.length === 4) {
document.getElementById('applyOverlay').style.display = 'block';
}
};
reader.readAsText(file);
};
input.click();
}
window.onload = function() {
setAlgorithm('Google');
};
window.initMap = initMap;
</script>
<script async defer
src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap&libraries=places,geometry">
</script>
</body>
</html>
例えば、東京都中野区周辺の10ポイントを回らなければならないとする。ちなみに地図上には数字とアルファベットが表示されているが、数字は登録地点番号(Waypointナンバー)であり、回る順番ではないことに注意。回る順番はA,B,C,Dというアルファベット順になっている。
WAYPOINT 2 to WAYPOINT 5: 15.73 km, Duration: 220分 (Algorithm: Google)
実際にはこのルートが一番とは限らないのは、計算方法がいくつかあるからだが、それも、選択できるようになっている。通常はGoogle アルゴリズムで十分だが、貪欲法、BranchandBoundといった三種類で試すことができる。ちなみに同じポイントを違うアルゴリズムで検索するとどうたろうか?
WAYPOINT 2 to WAYPOINT 5: 11.64 km, Duration: 164分 (Algorithm: Greedy)
WAYPOINT 2 to WAYPOINT 5: 10.59 km, Duration: 149分 (Algorithm: branchAndBound)
3通りのルート計算方法を比較した結果も表示される。
一番速かったのはBranch and Bound アルゴリズムで10.59㎞/149分。一番遅かったのはGoogleアルゴリズムで15.73Km/222分だった。
なんと、距離にして、約5キロ、時間にすると70分以上の差がでている。
これはかなり大きい差である。ルート選択の重要性がよくわかる。70分あれば取引先2件は多く回れるのではないだろうか。
最適ルート計算上の注意
上記の10ポイントを三通りのアルゴリズムで比較した場合、Branch and Boundアルゴリズムが速かった一方で、Googleアルゴリズムが一番遅かったが、これはケースバイケースだということに注意しなければならない。
距離比較表にはWaypoint2 to Waypoint5と出ているが、これは、スタートとゴールをそこに設定しているという意味。だから、どこをスタートにしてどこをゴールにするのかによって、どのアルゴリズムが最速なのか結果は違ってくるということだ。
スタート地点は現在自分がいるところだから、選択の余地はあまりないし、ゴール地点も、10ポイントを回った後どこへ向かうのかによって違う。中野区だったら中央線の駅、例えば高円寺駅あたりを目指すのか、それとも都営地下鉄大江戸線の中野坂上駅あたりを目指すのかという、その時の自分のスケジュールによっておのずと決まってくるだろう。だから、スタートとゴールを設定したら、三通りのアルゴリズム(計算法)で調べればほぼ完ぺきに最適化したルートを選べることになる。
ルート最適化アルゴリズムは基本的には4種類だが,一番優れたアルゴリズムは?
ちなみにルート最適化のアルゴリズムには基本的に4種類あるが、一つは総当たり法と言って、すべてのルートを計算して一番速いルートを見つけるというものだが、訪問地点が多いと、計算量が莫大に増えるというデメリットがある。私のプログラムでは採用しなかった。
そして、貪欲法(Greedy /どんよくほう)だが、これは比較的計算が簡単である。今いる場所、現在地から一番近い場所に移動し、それを連続させていくのだが、最終地点がどこになるのかわからず、結果的にスタート地点にもどるためにはかなり遠く離れた地点に到達してしまうことがあるのがデメリットだ。ただし結果的に一番速いこともある。
またBranch and Boundアルゴリズムだが、日本語で言えば「分岐と枝刈り」アルゴリズムとなる、ルートは分岐の連続だが、多数のルートの中には明らかにぱっと見て非効率なルートもあるわけで、そういったものは計算せず、そこそこ短い可能性のあるルートのみを計算し、最短ルートをみつけるという点では総当たりとは異なっている。
そして、Googleアルゴリズムは、Google独自の計算で、その日の交通状態などを加味したうえでの最適なルートを選択する。いくら距離だけで比べても、交通渋滞にはまってしまったら意味がないので、非常に現実的なアルゴリズムだと言える。かならずしも距離だけで、最適ルートは決められるものではないからだ。
私の作ったルート最適化アプリは、アルゴリズムが、Google,Greedy(貪欲法),Branch and Bound(分岐、枝刈り)の3種のアルゴリズムから選べるようになっているが、その理由は、スタートやゴール地点の設定によっては、どのアルゴリズムでも一番速いルートをとれる可能性があるからだ。一概にどのアルゴリズムが最もルートの最適化にすぐれているとは言えないので、経験を積んでいくうちに、どのアルゴリズムで、どこをスタートに、どこをゴールにすればよいのかが分かってくる。距離は短いが登りが多いルートを選ぶのか、それとも、登りはゆるやかだが、距離は長いルートを選ぶのかは経験値によって決めるほかはない。
ただし一つ注意点がある。検索しまくっているといつの間にか課金!?
Google APIをつかうことで、通常ローカルで一人でプログラミングしても実現できないことができてしまう反面、月々のリクエストの無料枠があり、それを超えてしまうと課金されてしまう可能性もある。そんなことから、常にGoogle Cloud Platformにアクセスして、使用量を確認しておくことが大事だ。筆者の経験から話すと、個人利用をしている限り、無料枠を超えることは無いと言える。あくまで筆者の経験上だが。
API KEYにはセキュリティがかけられるようになっており、決められたサイトで使わない限り起動しない設定にもできるし、APIといっていも種類が膨大にあるため、必要なAPIのみを有効にしておくこともセキュリティ面では有効な対策である。とにかくAPI KEYは第三者に利用されないようにしっかり管理する必要がある。
APIの取得方法については別記事で書く予定だから、それを参考にしてもらってもいいし、インターネット上にもその取得方法は出ているので参考にしてほしい。
複数地点ルート検索アプリは自作しかないの?実は有料アプリがいろいろある
特に最近、ウーバーイーツよか、アマゾンの配達とか、出前館などなど、複数地点を効率よく回るためのアプリがひつような業種がふえているためか、有料アプリがたくさんでている。主に配送業者用に最適化されているから、また別種のアルゴリズムをつかっているのだろう。ただし、配達はバイクやトラックだから、徒歩用には最適化されていない。先ほど公開したGoogle Maps APIを使ったアプリは、WALKING MODEに設定してあるし、登坂の距離も表示されるので、徒歩で移動するには最適だと思う。
徒歩でなくては通れない道も都心部にはあるし、一方通行の道路もおおいため、なかなか、地図上の最短ルートが車やオートバイでの配達の最短ルートにはなるない。
市販の有料アプリで評判のいいものは?やはり有料なのか?
配達系アプリで評判の良いものをいくつか挙げておこう。
配達にはスマホアプリをつかうだろうから、スマホアプリに限定すると、
。
iPhone用ルートプランアプリ
・Circuit Route Planner このアプリは非常に人気がある。500件の配送先を登録できるうえに、車に積んだ荷物がたくさんあってもそれを検索する機能までついている。本格的な配送業務に従事している方々には無料で、もちろん会社が払っているのだろうが支給されている。下に画面のスクリーンショットも載せたが、休憩時間をスキップしましたとか書いてあり、いかにも業務用といった感じがする
・配達NAVITMEゼンリン住宅地図/荷物管理/カーナビ 住宅地図と言えばゼンリンである。そのゼンリンとNAVITIMEがタッグを組んだのだから当然ともいえるが、配達というのは指定時刻がある点が難しいのだが、その点も考量して最適ルートを検索できる点は評価できる。配達は、移動時間だけではなく、配達先での荷物の引き渡しにどのくらい時間がかかるかも、事前に見積もっておかないと効率的な配送はできないが、その点も考量した時間計算がされるように作りこまれている。
Android用ルートプランアプリ
・やはりiphone同様「配達NAVITMEゼンリン住宅地図/荷物管理/カーナビ」が優れているし、機能的にもiPhoneと同様だが、iPhoneに慣れている人はそちらの方が使いやすいといった違いがあるぐらいである。
・Circuit Route Planner やはりiPhone同様、上位にランキングされるアプリである。ウーバーイーツの配達員などはスマホを二台バイクに取り付けている人が多いが、片方はナビゲーション用、もう片方は連絡用として利用していることが多い。その一方はこういったナビゲーションアプリが常に稼働していることが多い。とにかく、速く、安全に商品を届けないといけない仕事だから、有料版を使うのは至極当然のことに思える。無料版では制限が多くその分仕事にも影響がでる。
結局一番いいルート最適化アプリはこれだ!
何が一番いいのか?それは自分で何種類か使いこなしてみないとわからないものである。いちばんよくないのは少し使ってわからないからと言って、ほかのアプリに乗り換えることである。どんなアプリにも丁寧なマニュアルがついているのだから、徹底的に読み込んで、使いこなせるようになってから、他のアプリを使ってみなければ、なにが一番いいのかについては答えがないのである。ウェブ上のランキングなどは参考程度にとどめて、こんな使い方があったとは知らなかったと人に教えられるようなレベルになってほしい。
たいていのアプリには試用期間が設けられており、その期間を有効活用し度のアプリが自分のニーズに合っていることを見極めることが肝心だ。
たとえばiphone専用アプリではあるが「ルートメーカー」というアプリは有料でも300円買い切りだし、配送系アプリにはない徒歩モードも選択できるようになっている。スクリーンショットも載せたが、シンプルだが、使いやすい。有料版だと30ポイントのルート最適化ができる。ふつうに利用するなら十分な数だ。ちょっとした街歩きや散策にはもってこいのアプリだと言える。個人使用の有料アプリではこれが一番使いやすいが、iPhone版しかないのが残念である。もちろんGoogle Mapsアプリが入っていれば、そちらと連携して使うことも可能である。
私が作ったルート最適化アプリはAPI KEYを取得すればある一定回数までは無料で利用できるし、個人での利用ならば無料の範囲に収まることがほとんどであるということと、いちいちアプリをインストールしなくてよいということ、そして3通りのアルゴリズムで比較できるという点が特徴である。ROUTE OPTIMIZER(ルート最適化)と名前を付けているが、これが今のところ私にとってはベストなルート最適化アプリということになる。ファイルをサーバーにおいておけばスマホからも、パソコンからもアクセスできる。詳細な使用法は別の記事に載せる。
コメント