はじめに
HEREでエンジニアをしておりますkekishidaと申します。
先日投稿した記事にて、最新のHERE Maps API for JavaScriptにてHEREの日本地図が一新されたことについて掲載しました。
もうひとつ大きなアップグレードとしてHERE Waypoints Sequence APIがHERE Maps API for Javascriptから利用することができるようになりました。 こちらを利用することで、物流DXの代表的なユースケースである配送ルートの計算アプリをReactなどのJavasctiptベースプラットフォームから容易に実装することが可能になります。
今回、こちらの機能を利用して、Reactベースで簡易的な配送ルート計算アプリにトライしてみたいと思います。(以下のイメージは、あらかじめ配送リストを定義したJSONファイルをドラッグアンドドロップすることで、自動的に配送ルートと到着予定時刻を表示するイメージになります。)
HERE Waypoints Sequence APIとは?
HERE Waypoints Sequence APIとは、複数の経由地を指定して、最適な順番を算出することができるAPIになります。以前拙記事において、Pythonを使用してHERE Waypoints Sequence APIの記事を投稿致しました。
このAPIの特徴は、HEREが保有する日本の道路ネットワーク情報、交通渋滞情報、トラック規制情報を加味した上で最適な順番を算出することにあります。また、各経由地に制約(到着時間の制限)を加えることも可能なため、配達時間指定などのユースケースにも対応することが可能です。
https://www.here.com/docs/bundle/waypoints-sequence-api-developer-guide-v8/page/README.html
実装方針
入力パラメータのUIをこだわらない
HERE Waypoints sequence APIの機能をフル活用するために、入力パラメータを全てJSONファイルで定義することにしました。
{"start": "京都駅:京都府京都市下京区東塩小路釜殿町;34.98515,135.75709",
"destination1": "東寺:京都府京都市南区九条町1;34.98054,135.74664;;st:300",
"destination2": "清水寺:京都府京都市東山区清水1丁目294;34.99587,135.78305;acc:mo12:00:00+09:00|fr18:00:00+09:00;st:600",
"destination3": "金閣寺:京都府京都市北区金閣寺町1;35.03909,135.72928;acc:mo12:00:00+09:00|fr18:00:00+09:00;st:300",
"destination4": "銀閣寺:京都府京都市左京区銀閣寺町2;35.02686,135.79827;;st:300",
"destination5": "渡月橋:京都府京都市右京区嵯峨天龍寺芒ノ馬場町1-5;35.01374,135.67753;acc:mo12:00:00+09:00|fr18:00:00+09:00;st:600",
"destination6": "映画村:京都府京都市右京区太秦東蜂岡町10;35.01553,135.70837;acc:mo08:00:00+09:00|sa12:00:00+09:00;st:600",
"destination7": "京都御所:京都府京都市上京区京都御苑3;35.02658,135.75989;acc:mo18:00:00+09:00|sa20:00:00+09:00;st:600",
"destination8": "伏見稲荷:京都府京都市伏見区深草藪之内町68;34.96781,135.77273;acc:mo18:00:00+09:00|sa20:00:00+09:00;st:300",
"destination9": "南禅寺:京都府京都市左京区南禅寺福地町;35.01015,135.79144;acc:mo08:00:00+09:00|sa12:00:00+09:00;st:300",
"destination10": "二条城:京都府京都市中京区二条城町541;35.01482,135.74658;;st:600",
"destination11": "八坂神社:京都府京都市東山区祇園町北側625;35.00369,135.77861;acc:mo14:00:00+09:00|sa16:00:00+09:00;st:300",
"end": "京都駅(戻り):京都府京都市下京区東塩小路釜殿町;34.98515,135.75709",
"departure": "2024-11-13T12:19:33+09:00",
"improveFor": "time",
"mode": "fastest;truck;traffic:enabled;dirtRoad:-2"}
こちらは、以前の記事において、サンプルとして使用したものですが、既に配送ポイントにいくつかの制約を加えております。つまり、パラメータ指定に関しては、こちらの定義(APIのルール)に従ってもらうことにして、UI側で制限をかけません。
Reactエコシステムを活用する
JSONファイルをドラッグアンドドロップで入力し、Waypoints sequence API結果を表形式で表現するため、以下のプロジェクトを利用しました。
各Waypoints間の経路
HERE Waypoints Sequence APIは、あくまで最適な経由地の順序を算出するAPIで、実際の経路を示しません。従って、実際の経路はHERE Routing APIを使用して算出します。HERE Routing APIについては以下の拙記事で紹介しました。
今回紹介するサンプルでは、経由地間の経路は簡易的に”目安”として表示させるために、非常にシンプルな入力パラメータとしました。
let routingParameters = {
'transportMode': 'truck',
'return': 'polyline'
};
実装次第では、出発時間、道路条件、交通渋滞状況などのパラメータを入力することで、より正確な経路を算出することも可能です。
実装イメージ
必要なもの
ご紹介するサンプルコードは、HEREアカウントの取得が必要になります。
HEREアカウントの取得
作成手順 (step by step)
以下よりstep by stepでコードを紹介いたします。APIKEY以外は基本的にコピーペーストのみで実装可能なように配慮してあります。
React projectの作成
npx create-react-app <プロジェクトフォルダ名>
パッケージのインストール
プロジェクトフォルダに移動して、先に説明したサポートパッケージをインストールします。
npm install react-dropzone
npm install material-react-table
npm install export-to-csv
index.htmlの編集
index.htmlを以下のように書き換えてください。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>HERE Waypoints Sequence Demo</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<link rel="stylesheet" type="text/css" href="https://js.api.here.com/v3/3.1/mapsjs-ui.css" />
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-core.js"
></script>
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-service.js"
></script>
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-ui.js"
></script>
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-mapevents.js"
></script>
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-clustering.js"
></script>
<script
type="text/javascript"
src="https://js.api.here.com/v3/3.1/mapsjs-harp.js"
></script>
</head>
<body>
<div id="root" ></div>
</body>
</html>
index.jsの編集
index.jsを以下のように書き換えてください。
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<App/>
);
App.jsの編集
App.jsを以下のように書き換えてください。また、以下のAPIKEYは取得されたAPIKEYに置き換えてください。
import Map from './Map';
import Dropzone from './Dropzone';
import { useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
createMRTColumnHelper,
} from 'material-react-table';
import { Box, Button } from '@mui/material';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { mkConfig, generateCsv, download } from 'export-to-csv';
let apikey = APIKEY;
function App() {
const [ gps, setGps ] = useState({lat: "35.6814568602531", lng: "139.76799772026422"});
const [ wse, setWse ] = useState(null);
const [ data, setData ] = useState([]);
const columnHelper = createMRTColumnHelper();
const columns = [
columnHelper.accessor('id', {
header: 'ID',
size: 40,
}),
columnHelper.accessor('lat', {
header: 'Latitude',
size: 40,
}),
columnHelper.accessor('lng', {
header: 'Longitude',
size: 40,
}),
columnHelper.accessor('sequence', {
header: 'Sequence',
size: 40,
}),
columnHelper.accessor('estimatedArrival', {
header: 'Estimated Arrival',
size: 40,
}),
columnHelper.accessor('estimatedDeparture', {
header: 'Estimated Departure',
size: 40,
}),
];
const csvConfig = mkConfig({
fieldSeparator: ',',
decimalSeparator: '.',
useKeysAsHeaders: true,
filename: new Date().getFullYear()+"-"+(new Date().getMonth()+1)+"-"+new Date().getDate()+"-"+new Date().getHours()+"-"+new Date().getMinutes()+"-"+new Date().getSeconds()+"-",
});
const Table = () => {
const handleExportRows = (rows) => {
const rowData = rows.map((row) => row.original);
const csv = generateCsv(csvConfig)(rowData);
download(csvConfig)(csv);
};
const handleExportData = () => {
const csv = generateCsv(csvConfig)(data);
download(csvConfig)(csv);
};
const table = useMaterialReactTable({
columns,
data: data,
enableRowSelection: true,
columnFilterDisplayMode: 'popover',
paginationDisplayMode: 'pages',
positionToolbarAlertBanner: 'bottom',
renderTopToolbarCustomActions: ({ table }) => (
<Box
sx={{
display: 'flex',
gap: '16px',
padding: '8px',
flexWrap: 'wrap',
}}
>
<Button
//export all data that is currently in the table (ignore pagination, sorting, filtering, etc.)
onClick={handleExportData}
startIcon={<FileDownloadIcon />}
>
Export All Data
</Button>
<Button
disabled={table.getPrePaginationRowModel().rows.length === 0}
//export all rows, including from the next page, (still respects filtering and sorting)
onClick={() =>
handleExportRows(table.getPrePaginationRowModel().rows)
}
startIcon={<FileDownloadIcon />}
>
Export All Rows
</Button>
<Button
disabled={table.getRowModel().rows.length === 0}
//export all rows as seen on the screen (respects pagination, sorting, filtering, etc.)
onClick={() => handleExportRows(table.getRowModel().rows)}
startIcon={<FileDownloadIcon />}
>
Export Page Rows
</Button>
<Button
disabled={
!table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
//only export selected rows
onClick={() => handleExportRows(table.getSelectedRowModel().rows)}
startIcon={<FileDownloadIcon />}
>
Export Selected Rows
</Button>
</Box>
),
});
return <MaterialReactTable table={table} />;
};
return (
<div>
<div>
<h1>Waypoints Sequence Demo</h1>
</div>
<Map apikey={apikey} gps={gps} wse={wse} onWseRespResult={query=> setData(query)}/>
<Dropzone onWseResult={query=> setWse(query)}/>
{ data.length !== 0?
<div style={ { width: "1500px", height: "500px" } } >
<Table/>
</div>
:
<p/>
}
</div>
);
};
export default App;
Dropzone.jsの作成
以下のファイルを追加してください。
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
const Dropzone = (props) => {
const onDrop = useCallback((acceptedFiles) => {
const reader = new FileReader();
reader.onabort = () => console.log("File reading was aborted");
reader.onerror = () => console.log("File reading was failed");
reader.onload = () => {
try{
console.log(reader.result);
const requestObj = JSON.parse(reader.result);
props.onWseResult(requestObj);
console.log(requestObj);
} catch(e){
alert("Not right JSON format")
}
}
acceptedFiles.forEach(file=>reader.readAsText(file));
}, []);
const { getRootProps, getInputProps } = useDropzone({ onDrop });
return (
<div>
<div {...getRootProps()}>{/* (3) */}
<input {...getInputProps()} />{/* (3) */}
<p>
<b>Waypoints sequence JSONファイル</b>をここにドラッグアンドドロップするか、
クリックしてファイルを選択してください
</p>
</div>
</div>
);
}
export default Dropzone;
Map.jsの作成
以下のファイルを追加してください。
import * as React from 'react';
const Map = (props) => {
const apikey = props.apikey;
const gps = props.gps;
const [ depoLat, setDepoLat ] = React.useState(gps.lat);
const [ depoLng, setDepoLng ] = React.useState(gps.lng);
const mapRef = React.useRef(null);
React.useLayoutEffect(() => {
if (!mapRef.current) return;
const H = window.H;
const platform = new H.service.Platform({
apikey: apikey
});
const defaultLayers = platform.createDefaultLayers({
engineType: H.Map.EngineType.HARP
});
const engineType = H.Map.EngineType["HARP"];
const omvService = platform.getOMVService({
path: "v2/vectortiles/core/mc",
// Request the transit vector layer
queryParams: {
content: "default,transit"
},
});
const baseUrl = `https://js.api.here.com/v3/3.1/styles/harp/oslo`;
const style = new H.map.render.harp.Style(`${baseUrl}/tko.normal.day.json`);
const omvProvider = new H.service.omv.Provider(omvService, style, {
engineType,
lg: "ja",
});
var omvlayer = new H.map.layer.TileLayer(omvProvider, { max: 22 ,dark:true});
var map = new H.Map(mapRef.current, omvlayer, {
zoom: 14,
center: { lat: gps.lat, lng: gps.lng },
engineType,
});
const routingService = platform.getRoutingService(null, 8);
const routingFunction =(origin, destination, color, wseIcon1, wseIcon2) => {
let onError = (error) => {
alert(error.message);
}
let onResult = function(result) {
if (result.routes.length) {
result.routes[0].sections.forEach((section) => {
let linestring = H.geo.LineString.fromFlexiblePolyline(section.polyline);
let routeLine = new H.map.Polyline(linestring, {
style: { strokeColor: color, lineWidth: 5 }
});
const startMarker = new H.map.Marker(section.departure.place.location,{ icon: wseIcon1 });
const endMarker = new H.map.Marker(section.arrival.place.location,{ icon: wseIcon2 });
map.addObjects([routeLine, startMarker,endMarker]);
});
}
}
let routingParameters = {
'transportMode': 'truck',
'return': 'polyline'
};
let calculateRoute = () => {
if (!origin || !destination) return;
routingParameters.origin = origin.lat+","+origin.lng;
routingParameters.destination = destination.lat+","+destination.lng;
routingService.calculateRoute(routingParameters, onResult, onError);
}
calculateRoute();
}
const onWseResult = (result) => {
if(result.results != null){
const waypoints =[];
result.results[0].waypoints.map(waypoint=>{
delete waypoint.fulfilledConstraints;
waypoints.push(waypoint);
});
props.onWseRespResult(waypoints);
var pngIcon = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2c2.131 0 4 1.73 4 3.702 0 2.05-1.714 4.941-4 8.561-2.286-3.62-4-6.511-4-8.561 0-1.972 1.869-3.702 4-3.702zm0-2c-3.148 0-6 2.553-6 5.702 0 3.148 2.602 6.907 6 12.298 3.398-5.391 6-9.15 6-12.298 0-3.149-2.851-5.702-6-5.702zm0 8c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm10.881-2.501c0-1.492-.739-2.83-1.902-3.748l.741-.752c1.395 1.101 2.28 2.706 2.28 4.5s-.885 3.4-2.28 4.501l-.741-.753c1.163-.917 1.902-2.256 1.902-3.748zm-3.381 2.249l.74.751c.931-.733 1.521-1.804 1.521-3 0-1.195-.59-2.267-1.521-3l-.74.751c.697.551 1.141 1.354 1.141 2.249s-.444 1.699-1.141 2.249zm-16.479 1.499l-.741.753c-1.395-1.101-2.28-2.707-2.28-4.501s.885-3.399 2.28-4.5l.741.752c-1.163.918-1.902 2.256-1.902 3.748s.739 2.831 1.902 3.748zm.338-3.748c0-.896.443-1.698 1.141-2.249l-.74-.751c-.931.733-1.521 1.805-1.521 3 0 1.196.59 2.267 1.521 3l.74-.751c-.697-.55-1.141-1.353-1.141-2.249zm16.641 14.501c0 2.209-3.581 4-8 4s-8-1.791-8-4c0-1.602 1.888-2.98 4.608-3.619l1.154 1.824c-.401.068-.806.135-1.178.242-3.312.949-3.453 2.109-.021 3.102 2.088.603 4.777.605 6.874-.001 3.619-1.047 3.164-2.275-.268-3.167-.296-.077-.621-.118-.936-.171l1.156-1.828c2.723.638 4.611 2.016 4.611 3.618z"/></svg>', { size: { w: 56, h: 56 } });
var marker = new H.map.Marker(result.results[0].waypoints[0], { icon: pngIcon });
map.addObject(marker);
const colorList =["black","gray","blue","green","orange","red","brown","pink","purple"]
for(let i=0; i<result.results[0].waypoints.length; i++){
if( i != 0){
let num = Math.floor( Math.random() * colorList.length )
var wseIcon1 = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="9" cy="9" r="9" fill="'+colorList[num]+'"/><text x="50%" y="50%" font-family="FiraGO" font-size="10pt" text-anchor="middle" dominant-baseline="central" fill="white">'+i-1+'</text></svg>', { size: { w: 18, h: 18 } });
var wseIcon2 = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="9" cy="9" r="9" fill="'+colorList[num]+'"/><text x="50%" y="50%" font-family="FiraGO" font-size="10pt" text-anchor="middle" dominant-baseline="central" fill="white">'+i+'</text></svg>', { size: { w: 18, h: 18 } });
routingFunction(result.results[0].waypoints[i-1],result.results[0].waypoints[i], colorList[num], wseIcon1, wseIcon2);
}
}
setDepoLat(result.results[0].waypoints[0].lat);
setDepoLng(result.results[0].waypoints[0].lng);
}
}
if (props.wse != null) {
const waypointsSequenceService = platform.getWaypointsSequenceService();
waypointsSequenceService.findSequence(props.wse, onWseResult, console.error);
}
var behavior = new H.mapevents.Behavior(new H.mapevents.MapEvents(map));
var LocationOfMarker = { lat: depoLat, lng: depoLng };
map.getViewModel().setLookAtData({position: LocationOfMarker,zoom: 12},2);
return () => {
map.dispose();
};
}, [props.wse,depoLat,depoLng]); // This will run this hook every time this ref is updated
return <div style={ { width: "1500px", height: "500px" } } ref={mapRef} />;
};
export default Map;
create-react-app
で作成された不要なファイルを削除すると、プロジェクトファイルの構成は以下のようになります。
プロジェクトの実行
npm start
ちょっと解説
Map.js
の中のWaypoints Sequence APIに関連するコードは以下の部分のみになります。実際にAPIを呼び出しているのはwaypointsSequenceService.findSequence(props.wse, onWseResult, console.error)
で第一引数がAPIの入力パラメータ、その後はそれぞれのcallback関数です。
そして、callback関数のonWseResult
では、そのレスポンス経由地間毎にHERE Routing APIの関数を再起的に呼び出しております。
const onWseResult = (result) => {
if(result.results != null){
const waypoints =[];
result.results[0].waypoints.map(waypoint=>{
delete waypoint.fulfilledConstraints;
waypoints.push(waypoint);
});
props.onWseRespResult(waypoints);
var pngIcon = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2c2.131 0 4 1.73 4 3.702 0 2.05-1.714 4.941-4 8.561-2.286-3.62-4-6.511-4-8.561 0-1.972 1.869-3.702 4-3.702zm0-2c-3.148 0-6 2.553-6 5.702 0 3.148 2.602 6.907 6 12.298 3.398-5.391 6-9.15 6-12.298 0-3.149-2.851-5.702-6-5.702zm0 8c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm10.881-2.501c0-1.492-.739-2.83-1.902-3.748l.741-.752c1.395 1.101 2.28 2.706 2.28 4.5s-.885 3.4-2.28 4.501l-.741-.753c1.163-.917 1.902-2.256 1.902-3.748zm-3.381 2.249l.74.751c.931-.733 1.521-1.804 1.521-3 0-1.195-.59-2.267-1.521-3l-.74.751c.697.551 1.141 1.354 1.141 2.249s-.444 1.699-1.141 2.249zm-16.479 1.499l-.741.753c-1.395-1.101-2.28-2.707-2.28-4.501s.885-3.399 2.28-4.5l.741.752c-1.163.918-1.902 2.256-1.902 3.748s.739 2.831 1.902 3.748zm.338-3.748c0-.896.443-1.698 1.141-2.249l-.74-.751c-.931.733-1.521 1.805-1.521 3 0 1.196.59 2.267 1.521 3l.74-.751c-.697-.55-1.141-1.353-1.141-2.249zm16.641 14.501c0 2.209-3.581 4-8 4s-8-1.791-8-4c0-1.602 1.888-2.98 4.608-3.619l1.154 1.824c-.401.068-.806.135-1.178.242-3.312.949-3.453 2.109-.021 3.102 2.088.603 4.777.605 6.874-.001 3.619-1.047 3.164-2.275-.268-3.167-.296-.077-.621-.118-.936-.171l1.156-1.828c2.723.638 4.611 2.016 4.611 3.618z"/></svg>', { size: { w: 56, h: 56 } });
var marker = new H.map.Marker(result.results[0].waypoints[0], { icon: pngIcon });
map.addObject(marker);
const colorList =["black","gray","blue","green","orange","red","brown","pink","purple"]
for(let i=0; i<result.results[0].waypoints.length; i++){
if( i != 0){
let num = Math.floor( Math.random() * colorList.length )
var wseIcon1 = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="9" cy="9" r="9" fill="'+colorList[num]+'"/><text x="50%" y="50%" font-family="FiraGO" font-size="10pt" text-anchor="middle" dominant-baseline="central" fill="white">'+i-1+'</text></svg>', { size: { w: 18, h: 18 } });
var wseIcon2 = new H.map.Icon('<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="9" cy="9" r="9" fill="'+colorList[num]+'"/><text x="50%" y="50%" font-family="FiraGO" font-size="10pt" text-anchor="middle" dominant-baseline="central" fill="white">'+i+'</text></svg>', { size: { w: 18, h: 18 } });
routingFunction(result.results[0].waypoints[i-1],result.results[0].waypoints[i], colorList[num], wseIcon1, wseIcon2);
}
}
setDepoLat(result.results[0].waypoints[0].lat);
setDepoLng(result.results[0].waypoints[0].lng);
}
}
if (props.wse != null) {
const waypointsSequenceService = platform.getWaypointsSequenceService();
waypointsSequenceService.findSequence(props.wse, onWseResult, console.error);
}
おまけ
最後にこのアプリを使用して四国八十八ヶ所を効率的に回るルートのシミュレーションをしてみました。
このHERE Waypoints Sequence APIは、いわゆる巡回セールスマン問題を解いているようなものなので、経由地が増加すればするほどその計算時間も増大します。従って、先ほどの京都の例では結果が素早く返ってきますが、四国八十八ヶ所の場合、1−2分程度計算時間を要します。ですが本アプリにおいても、以下の様に計算結果が返ってきます。(特に結果を待ち合わせるUIは入れておりませんので、いきなり結果が表示されます。)
このシミュレーションでは、2024年11月9日9時30分に高松空港から出発して、トラック移動で、特に制約条件を入れずに回った場合、2024年11月10日18時36分に終了する結果となりました。
まず、高松空港を出発して、神毫山 一宮寺へ向かっています。
途中を飛ばしまして、最後に白牛山 国分寺を出発して、高松空港に戻ります。
このシミュレーションは当然、拝観可能な時間や巡礼者の休憩時間などを一切無視しております。(そもそもトラックで移動しておりますが。。。)しかし、HERE Waypoints Sequence APIには、様々な制約条件指定することが可能なので、アプリの実装次第では本ユースケースにおいても正確なシミュレーションを実現することも可能です。
おわりに
いかがでしたでしょうか?今回はReactベースで物流DXをトライするというテーマで、新しくHERE Maps API for JavaScriptに組み込まれましたHERE Waypoints Sequence APIを使用して、簡易的な配送シミュレーションアプリを作成してみました。少ないコード量で最低限の機能を実装できることをご確認頂けたのではないかと思います。今回の記事を参考に、ぜひ皆様のユースケースにあったアプリを作成頂ければ幸いです。ここまで読んでいただきありがとうございました。