読み飛ばして良いまえがき
ファイル書き出し編 → https://ticketnote.dev/ticket/gF3m4LWN1ZHOcIsVKbdU
完成予定のCodePen → https://codepen.io/kaitou1192/pen/gORyBGX
①では、ローカルファイルの内容を文字列で読み込み、②では、読み込んだ文字列をDOMに変換したのち、書き出しのためにDOMを文字列に再変換、文字列→Blob→ファイル出力というのをやりました。
①、②だけだと元々のファイルを単に読み込んで、新たな出力しただけなので、OregonやeTrexのようなGPSで読み込む形式には変換されていないので、今回はその処理をするという内容です。
前提のお話
今回は、僕が昔使っていたGarmin Oregon 450という登山用のGPSを妻が引っ張り出してきて、Ride With GPSというサービスからダウンロードしてきた.pgxというルートデータ(これは中身はXML)を入れて、使えるようにしたいというのがお題です。
基本的には、XMLの操作がわかっている方であれば、書き出したいファイルに向けてDOMなり、なんなりを書き換えれば問題になるのは①と②かなと思い、ファイルの入出力を優先して書いたので、肝は実はここで終わっています。
ただ今回の③は実務ではよくありがちな問題を扱う内容なので、あまりこの手のものを作る経験が無い方は参考になるかもという点を重点的に書いていきます。
妻が使っているGarmin Oregon 450でおそらく問題になる点は下記になります。
- おそらく2バイト文字が扱えない(いろいろすれば使えるかもしれないけれど、僕はしていなかった)
- ルートのポイント数の上限が1万付近
- XMLにタブや空白が入っていると上手く読み込めない?(改行は大丈夫そう)
- 余計なタグがはいったときの挙動がわからない
これらは機材の仕様を調べたり、ググったりするだけでなく、.gpx自体をテキストエディタで開いて、要素や属性を消してみたり書き換えてみたりして調査した結果です。言葉で書くと凄く簡単なんですが、書き換えてみて、実機で確認して、本当にそうなのか?何が問題なのか?を分析するというところなので、実は凄く時間がかかります。
きっとこの手のものをオリジナルで作ったことがある方は同じような経験をされていると思います。もしポートフォリオに載せるような何かを作ろうとしたときに、オリジナルの何かを作っているというのは、そういうことができる人なんだなということが伝わるので、おすすめです。😎
本日の本題に入る前に
大本のサンプルとは違ってきてしまいますが、コードをわかりやすくするために整理します。
②の時点では inputFile.addEventListener('change', (event) => {}) の中に全部書いていましたが、まずはファイルを選択した際の処理と、ファイルの読み込みが完了した際の処理に分離します。
この時点でのCodePen → https://codepen.io/kaitou1192/pen/jOwRYdz
//「ファイルを選択」でローカルファイルが設定された際の処理
const setFile = (event) => {
const fileObject = event.target.files[0];
const rwgGpx = new FileReader();
rwgGpx.addEventListener('load', (event) => loadedFile(event));
rwgGpx.readAsText(fileObject);
}
inputFile.addEventListener('change', (event) => setFile(event));
//ファイルの読み込みが完了した際の処理
const loadedFile = (event) => {
const loadedGpx = event.target.result;
//DOM準備
const parser = new DOMParser();
const temporaryGpx = parser.parseFromString(loadedGpx, 'application/xml');
//出力用GPX用意
const outputGpx = new XMLSerializer();
let outputGpxText = outputGpx.serializeToString(temporaryGpx);
const blob = new Blob([outputGpxText], {type: 'application/xml'});
//ダウンロード用のエリアを表示
download.setAttribute('style','display: block;');
//ダウンロード用のエリア内に要素がある場合は削除
while(download.firstElementChild) {
download.firstElementChild.remove();
}
//ファイルをダウンロードさせる
const a = document.createElement('a');
a.href = window.URL.createObjectURL(blob);
a.textContent = 'ダウンロード';
a.download = 'convert.gpx';
download.appendChild(a);
}
もっと細かく機能ごとに関数を用意してわけてもよいのですが、一旦はこの程度にとどめておきます。
本日の本題
さて、やっと本日の本題です。
まずは読み込むファイルの情報を吸い出すためのエリアを作っていきます。div.settingというのを追加しました。こんな感じ。(あわせてCSSも追加してあります。)
<div class="file">
<label>RWGのGPX: <input type="file" accept=".gpx"></label>
</div>
<div class="setting">
<button>出力する</button>
</div>
<div class="download"></div>
JS側ではファイルを読み込んだら、即ダウンロード用のファイルのためのエリアdiv.downloadを表示しろと書いていましたが、ファイルを読み込んだらまずはdiv.settingを表示し、「出力する」ボタンを押したら必要な準備をしてdiv.downloadを表示するという処理に変更します。
const setting = document.querySelector('.setting');
const download = document.querySelector('.download');
const inputFile = document.querySelector('input[type=file]');
const button = document.querySelector('button');
//「ファイルを選択」でローカルファイルが設定された際の処理
const setFile = (event) => {
//エリア表示のリセット
setting.setAttribute('style','display: none;');
download.setAttribute('style','display: none;');
【略】
}
inputFile.addEventListener('change', (event) => setFile(event));
//ファイルの読み込みが完了した際の処理
const loadedFile = (event) => {
【略】
//設定用のエリアを表示(&ダウンロード用のエリアの非表示)
setting.setAttribute('style','display: block;');
download.setAttribute('style','display: block;');
//「出力する」ボタンのトリガー
button.addEventListener('click', (event) => generateNewFile(event, temporaryGpx));
}
//ファイルを出力する際の処理
const generateNewFile = (event, temporaryGpx) => {
//出力用GPX用意
const outputGpx = new XMLSerializer();
let outputGpxText = outputGpx.serializeToString(temporaryGpx);
const blob = new Blob([outputGpxText], {type: 'application/xml'});
//ダウンロード用のエリア内に要素がある場合は削除
while(download.firstElementChild) {
download.firstElementChild.remove();
}
//ファイルをダウンロードさせる
const a = document.createElement('a');
a.href = window.URL.createObjectURL(blob);
a.textContent = 'ダウンロード';
a.download = 'convert.gpx';
download.appendChild(a);
//ダウンロード用のエリアを表示
download.setAttribute('style','display: block;');
}
補足でいじってしまっているところは、誤操作防止のためにローカルのファイルを読み込み直すたびに設定や出力のエリアを非表示ですね。
この時点でのCodePen → https://codepen.io/kaitou1192/pen/XWgQZrQ
次に読み込んだローカルファイルで、出力する際に調整が必要になりそうな箇所を抽出します。具体的には下記の3点です。
- GPSのポイント数
- ファイル名
- トラック名
GPSのポイント数は、このGarminのOregonはルート表示に点と点をつないでそれを線にしてルートとして表示しています。そのポイント数が約1万を越した時点でその先を表示してくれないくなるので、1万よりも小さな数でとどめておきたいという意図です。また機種によってはおそらくこの約1万という基準も増減するので、デフォルトではOregon 450用の数字にとどめておいて、必要に応じてユーザーがポイント数をいじれるようにしようと思います。
ファイル名とトラック名は日本語が表示できたり、日本語ファイル名が許容される機種であれば気にする必要はないかもしれませんが、僕の持っているのは海外版のモデルのため、両方許容されていません。したがって、2点は2バイトではなく1バイトの文字である必要があります。ただ今回元の.gpxファイルをダウンロードしてくるRide With GPSでは両方とも2バイト文字が許容されてしまっているため、もしその場合はここで直してねという意図でユーザーが変更できるようにします。
ということで、JS経由で情報を取得して、HTMLのinputに格納します。
const inputPoints = document.querySelector('input[type=number]');
const inputFileName = document.querySelector('.fileName');
const inputTruckName = document.querySelector('.truckName');
ファイル名の取得はこんな感じです。inputFileName.value に _converted をつけているのは、同じファイル名で置き換えが発生しないようにという意図です。
const setFile = (event) => {
【略】
//ファイル名の取得と仮設定
const fileObject = event.target.files[0];
const temporaryFullName = fileObject.name;
const temporaryName = temporaryFullName.split('.')[0];
inputFileName.value = temporaryName + '_converted';
【略】
}
ファイルを読み込んだ際に名前を取得したのが fileObject.name 。そこから拡張子を外したものが欲しいので temporaryFullName.split('.')[0] をしています。
.split('.') は、文字列に含まれる「.」で区切った配列にしろという処理で、[0]をつけているのは、その区切った配列の中で先頭のものという指定です。つまり、ファイル名に複数の「.」が出てきた場合は、下記のようになり、想定外みたいになるのですが、今回は商用ではなくプライベートのツールなので、このあたりは目をつぶっています。
この時点でのCodePen → https://codepen.io/kaitou1192/pen/VwWNQMp
const fileName_1 = 'index.html';
console.log(fileName_1.split('.')[0]);//これはindex
const fileName_2 = 'index.final.html';
console.log(fileName_2.split('.')[0]);//これはindex.finalではなくindex
トラックの名前やポイント数は実際の.gpxの中身を見ていかないといけないので、準備をします。中のタグを扱いやすいようにしています。
const loadedFile = (event) => {
【略】
//前処理
const baseGpx = temporaryGpx.documentElement;
const baseTrk = baseGpx.querySelector('trk');
const baseTrkName = baseTrk.querySelector('name');
const baseTrkseg = baseTrk.querySelector('trkseg');
const baseTrkpt = baseTrkseg.querySelectorAll('trkpt');
【略】
}
トラック名の取得はここまでくれば簡単で、こんな感じに textContent で引っ張ってきて input.value に格納してあげればOKです。ちなみにファイル名もトラック名も1バイト文字なのか?のバリデートをつけてないのは、プライベートなツールなので目をつぶっている範囲になります。
//トラック名の取得と仮設定
inputTruckName.value = baseTrkName.textContent;
ポイント数も同じようにやってあげればよいのですが…… ①のタイミングでRide with GPSからダウンロードしてきたファイルのポイント数は15405なのでこれを約1万以下とのことなので…… 適当に9900以下にします。
//ポイント数の取得と仮設定
inputPoints.value = baseTrkpt.length;
if(inputPoints.value > 9900) {
inputPoints.value = 9900;
}
おそらくOregon 450には不要なmetadataの箇所も削除しておきます。
//Oregonには不要なデータを削除
baseGpx.querySelector('metadata').remove();
さて…… かなり長くなってきて、これ以上進めるとテーマが分散しそうなので、今回はここで区切りです。
次回こそ加工して読み込めるファイルにしようと思います。
この時点でのCodePen → https://codepen.io/kaitou1192/pen/ExXJQdK
Ride with GPSからダウンロードした .gpx を読み込めるようにJSで変換する
Twitterもやっているので、よかったらフォローしてください。🙏🏻
https://twitter.com/Kaitou1192