├── .gitignore ├── google-location-history-to-gpx ├── .gitignore ├── package.json ├── enum-devices.js ├── package-lock.json └── index.js ├── README.org └── my-location.el /.gitignore: -------------------------------------------------------------------------------- 1 | ~ 2 | *.elc 3 | -------------------------------------------------------------------------------- /google-location-history-to-gpx/.gitignore: -------------------------------------------------------------------------------- 1 | .log 2 | node_modules 3 | output 4 | devices.txt 5 | -------------------------------------------------------------------------------- /google-location-history-to-gpx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "JSONStream": "^1.3.5" 5 | }, 6 | "name": "google-location-history-to-gpx", 7 | "version": "1.0.0", 8 | "main": "index.js", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "description": "" 15 | } 16 | -------------------------------------------------------------------------------- /google-location-history-to-gpx/enum-devices.js: -------------------------------------------------------------------------------- 1 | // 履歴の中に登場するデバイスの一覧を作成するスクリプト 2 | 3 | // deviceTag(数値)と現れる時間の範囲や緯度経度の範囲を求める。 4 | 5 | // node enum-devices.js > devices.txt 6 | 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import JSONStream from 'JSONStream'; 10 | 11 | const inputFile = "./takeout-20241212T072446Z-001/Takeout/ロケーション履歴/ロケーション履歴(タイムライン)/Records.json"; //[修正してください] 12 | 13 | const devices = []; 14 | 15 | function recordDevice(data){ 16 | const device = devices.find((device)=>device.tag == data.deviceTag); 17 | 18 | // 以前は整数値で入っていたが、今回はISO文字列になっていた 19 | // const time = (new Date(parseInt(data.timestampMs))).toISOString(); 20 | const time = (new Date(Date.parse(data.timestamp))).toISOString(); 21 | 22 | if(device){ 23 | device.lastTime = time; 24 | device.count++; 25 | if(data.latitudeE7 < device.latMin){ 26 | device.latMin = data.latitudeE7; 27 | } 28 | if(data.latitudeE7 > device.latMax){ 29 | device.latMax = data.latitudeE7; 30 | } 31 | if(data.longitudeE7 < device.lngMin){ 32 | device.lngMin = data.longitudeE7; 33 | } 34 | if(data.longitudeE7 > device.lngMax){ 35 | device.lngMax = data.longitudeE7; 36 | } 37 | } 38 | else{ 39 | devices.push({ 40 | tag:data.deviceTag, 41 | firstTime: time, 42 | lastTime: time, 43 | latMin: data.latitudeE7, 44 | latMax: data.latitudeE7, 45 | lngMin: data.longitudeE7, 46 | lngMax: data.longitudeE7, 47 | count: 1 48 | }); 49 | } 50 | } 51 | function showDevices(){ 52 | console.log(devices); 53 | } 54 | 55 | const inputStream = fs.createReadStream(inputFile) 56 | .pipe(JSONStream.parse('locations.*')); 57 | 58 | inputStream.on('data', (data)=>{ 59 | recordDevice(data); 60 | }).on('end', ()=>{ 61 | showDevices(); 62 | }); 63 | -------------------------------------------------------------------------------- /google-location-history-to-gpx/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-location-history-to-gpx", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "1.0.0", 9 | "license": "ISC", 10 | "dependencies": { 11 | "JSONStream": "^1.3.5" 12 | } 13 | }, 14 | "node_modules/jsonparse": { 15 | "version": "1.3.1", 16 | "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", 17 | "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", 18 | "engines": [ 19 | "node >= 0.2.0" 20 | ] 21 | }, 22 | "node_modules/JSONStream": { 23 | "version": "1.3.5", 24 | "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", 25 | "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", 26 | "dependencies": { 27 | "jsonparse": "^1.2.0", 28 | "through": ">=2.2.7 <3" 29 | }, 30 | "bin": { 31 | "JSONStream": "bin.js" 32 | }, 33 | "engines": { 34 | "node": "*" 35 | } 36 | }, 37 | "node_modules/through": { 38 | "version": "2.3.8", 39 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 40 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 41 | } 42 | }, 43 | "dependencies": { 44 | "jsonparse": { 45 | "version": "1.3.1", 46 | "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", 47 | "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" 48 | }, 49 | "JSONStream": { 50 | "version": "1.3.5", 51 | "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", 52 | "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", 53 | "requires": { 54 | "jsonparse": "^1.2.0", 55 | "through": ">=2.2.7 <3" 56 | } 57 | }, 58 | "through": { 59 | "version": "2.3.8", 60 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 61 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /google-location-history-to-gpx/index.js: -------------------------------------------------------------------------------- 1 | // ロケーション履歴のjsonを日付毎にGPXへ変換するスクリプト 2 | 3 | // node index.js 4 | 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | import JSONStream from 'JSONStream'; 8 | 9 | const inputFile = "./takeout-20241212T072446Z-001/Takeout/ロケーション履歴/ロケーション履歴(タイムライン)/Records.json"; //[修正してください] 10 | const outputDir = "output"; 11 | 12 | // 無視するデバイスの deviceTag を指定する。 13 | // 自宅に置きっぱなしのデバイスがあるとその位置も出力されてしまうので。 14 | // 先に enum-devices.js で調べておく。 15 | const ignoreDevices = [ 16 | //[修正してください] 17 | // 例: 18 | // 12345678, //端末A 19 | // -23232323, //端末B 20 | // 35353535 //端末C 21 | ]; 22 | 23 | let lastDate = null; 24 | const dayPoints = []; 25 | 26 | function addPoint(timeMs, lat, lng){ 27 | const date = new Date(timeMs); 28 | 29 | // date change? 30 | if(!lastDate || 31 | // ↓ローカル時間での日付区切りになる(UTC区切りにしたければ要変更) 32 | lastDate.getFullYear() != date.getFullYear() || 33 | lastDate.getMonth() != date.getMonth() || 34 | lastDate.getDate() != date.getDate()){ 35 | 36 | const lastPoint = dayPoints[dayPoints.length-1];//point or undefined 37 | // Include the first point of the next day. 38 | if(lastDate){ 39 | dayPoints.push({date, lat, lng}); 40 | } 41 | 42 | flushGPX(); 43 | 44 | // Include the last point of the previous day. 45 | if(lastPoint){ 46 | dayPoints.push(lastPoint); 47 | } 48 | } 49 | 50 | lastDate = date; 51 | dayPoints.push({date, lat, lng}); 52 | } 53 | 54 | function flushGPX(){ 55 | if(lastDate){ 56 | if(dayPoints.length > 0){ 57 | writeGPX(lastDate, dayPoints); 58 | } 59 | lastDate = null; 60 | dayPoints.splice(0); 61 | } 62 | } 63 | 64 | function writeGPX(date, points){ 65 | const yyyymm = "" + date.getFullYear() + 66 | ("0" + (date.getMonth() + 1)).substr(-2); 67 | const directory = path.join(outputDir, yyyymm); 68 | const dd = ("0" + (date.getDate())).substr(-2); 69 | const filename = "" + yyyymm + dd + ".gpx"; 70 | fs.mkdirSync(directory, {recursive:true}); 71 | const stream = fs.createWriteStream(path.join(directory, filename)); 72 | 73 | const gpx = ` 74 | 75 | 76 | ${date.toISOString().split("T")[0]} 77 | 78 | ${ 79 | points.map((point)=> 80 | ` 81 | 82 | 83 | `).join("")}`; 84 | stream.end(gpx); 85 | } 86 | 87 | const inputStream = fs.createReadStream(inputFile) 88 | .pipe(JSONStream.parse('locations.*')); 89 | 90 | inputStream.on('data', (data)=>{ 91 | if(!ignoreDevices.includes(data.deviceTag)){ 92 | addPoint( 93 | Date.parse(data.timestamp), // parseInt(data.timestampMs), 以前は整数値だった 94 | parseInt(data.latitudeE7) / 10000000, 95 | parseInt(data.longitudeE7) / 10000000); 96 | } 97 | }).on('end', ()=>{ 98 | flushGPX(); 99 | }); 100 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: 過去に自分がいた位置をGPXファイルから求めるEmacs Lisp 2 | 3 | * 何が出来る 4 | 5 | ~M-x my-location-at-time~ の後に日時を入力するとその時刻にいた場所の緯度・経度を表示し、マップサービスのURLを生成して開きます。また、Emacs Lispから ~(my-location-latlng-at-time time)~ を評価すると緯度と経度が入ったconsセルが返ってきます。 6 | 7 | 写真の撮影日時情報と組み合わせてGPSを搭載していないカメラでも撮影場所を取得するために作りましたが、他にもアイデア次第で様々な活用方法があると思います。 8 | 9 | * 事前の準備 10 | ** GPXファイルの用意 11 | 12 | 過去の位置を記録したGPXファイル(GPSログ)が必要です。 13 | 14 | *** すでにGPXファイルを沢山持っている場合 15 | 16 | もしハイキングやサイクリングなど何らかの活動を趣味にしているならすでに沢山のGPXファイルを持っているかもしれません。もしそうならカスタマイズ変数 ~my-location-sources~ にそのパスを指定してください。 17 | 18 | ~my-location-sources~ のデフォルト値は次のようになっています。 19 | 20 | #+begin_src elisp 21 | (setq my-location-sources 22 | '((:dir "~/my-location/%Y%m" :file-pattern "\\`%Y%m%d.*\\.gpx"))) 23 | #+end_src 24 | 25 | ~:dir~ や ~:file-pattern~ の文字列は ~format-time-string~ 関数を通した後使われます。%Yや%mの部分は求めたい日時の年や月等に置き換わります。 26 | 27 | ~:file-pattern~ はファイルを探すための正規表現です。 ~:dir~ で指定したディレクトリ下にあるファイルの内、正規表現にマッチするファイルを全て読み込みます。例えば "\\`%Y%m%d.*\\.gpx" であれば、 "20211218_稲荷山.gpx" のようなファイルにマッチします。 28 | 29 | *** Googleロケーション履歴のデータからGPXファイルを生成する 30 | 31 | Android端末を持ち歩いていてロケーション履歴を有効にしているなら、既に沢山の位置情報がGoogle社のサーバに記録されているかもしれません。[[https://timeline.google.com/][タイムライン]]にアクセスして確認してみましょう。もし過去の沢山の地点が表示されたなら、そのデータからGPXファイルを生成することができるかもしれません。 32 | 33 | まずはロケーション履歴に関する全てのデータをjson形式でダウンロードする必要があります。指定した日のkmlでは時刻とポイントの正確な対応関係が分からないので面倒でも全てのデータをダウンロードする必要があります。タイムラインから歯車マークをクリックし、「全てのデータのコピーをダウンロード」を選択するとGoogleの個人情報を一括してダウンロードできるページにジャンプします。そこから他のサービスの選択を解除して、ロケーション履歴のデータだけをダウンロードしてください。やり方は今後変わるかもしれないのでそのときは調べてみてください。また、ダウンロードできるようになるまでにしばらくかかる可能性もあります。私の場合、夜寝る前にリクエストして起きたときにはダウンロードできるようになっていました。1時間程度で用意できたようです。 34 | 35 | 私の場合、 ~takeout-20211217T164337Z-001.zip~ というファイル名で80MB程度のzipファイルがダウンロードできました。 36 | 37 | 展開すると ~takeout-20211217T164337Z-001/Takeout/ロケーション履歴/ロケーション履歴.json~ というファイルができました。これがお目当てのファイルでAndroid端末から送られた全ての位置情報、とりわけ日時と緯度経度が入っています。 38 | 39 | ただ、このファイルは 1.2GB というとんでもないサイズなのでEmacsで開くのもためらわれます。 40 | 41 | そこでNode.jsで変換するスクリプトを作成しました。本リポジトリの ~google-location-history-to-gpx/~ ディレクトリに入っているので、Node.jsを使えるようにして、そのディレクトリ下で ~npm install~ を実行して依存するパッケージをインストールしてください。 ~JSONStream~ というパッケージを使用します。 42 | 43 | まずは ~enum-devices.js~ を使って除外する端末のデバイスタグを求めます。複数のAndroid端末を使用している場合、同時に別々の場所の位置情報が記録されている場合があります。私の場合自宅にタブレットを置きっぱなしにしているのでその位置情報も一緒に記録されています。それを除外しないと正しい位置が求められません。 44 | 45 | 1. ~enum-devices.js~ 内の const inputFile = "..." の部分を書き替えて =ロケーション履歴.json= を指すようにする。 46 | 2. コマンドラインから ~node enum-devices.js>devices.txt~ を実行してデバイス一覧をテキストファイルに保存する。 47 | 3. devices.txtの内容を見て除外するタグ番号を集める。 48 | 49 | タグ番号(tag:)の他に最初(firstTime:)や最後(lastTime:)に現れる日時、緯度経度の範囲(latMin:, latMax:, lngMin:, lngMax:)、記録回数(count:)も出力しているので、それを元にどのタグ番号がどの端末なのかを特定し除外するタグ番号を決めてください。 50 | 51 | 次に実際の変換作業に入ります。 52 | 53 | 1. ~index.js~ 内の const inputFile = "..." の部分を書き替えて =ロケーション履歴.json= を指すようにする。 54 | 2. ~index.js~ 内の ~const ignoreDevices = [ ... ]~ の部分に除外したい端末のタグ番号(整数値)をカンマ区切りで書く。 55 | 3. コマンドラインから ~node index.js~ を実行する。 56 | 57 | ~output/YYYYMM/YYYYMMDD.gpx~ という形式のファイル名で日毎のGPXファイルが出力されます。 58 | 59 | GPXファイルが用意できたら、カスタマイズ変数 ~my-location-sources~ がGPXファイルを参照できるように修正してください。 ~output~ の中身をデフォルトの場所 =~/my-location/= へ移動するのでも構いません。 60 | 61 | ** マップサービスのURLを設定する 62 | 63 | 必要ならカスタマイズ変数 ~my-location-map-url~ に使いたいマップサービスへのURLを設定してください。デフォルトはOpen Street Mapsを使うようになっています。 64 | 65 | #+begin_src elisp 66 | (setq my-location-map-url "https://www.openstreetmap.org/#map=17/{{{lat:%.6f}}}/{{{lng:%.6f}}}") 67 | #+end_src 68 | 69 | ** Emacs Lispコードを読み込む 70 | 71 | ~my-location.el~ をEmacsが読み込める場所に置いてください。 ~(require 'my-location)~ で読み込みます。 72 | 73 | * 使ってみる 74 | ** インタラクティブなコマンド 75 | ~M-x my-location-at-time~ と打つと過去の日時を聞いてきます。例えば ~8/7 12:35~ と入力すると、過去の一番近い 8月7日 12:35 の場所を探します。該当するGPXファイルをロードし、その中にその時刻の位置情報があるなら結果をエコーラインに表示します。同時にマップサービスのURLをブラウザで開きます。コマンドプレフィックスを付けると緯度経度をバッファに挿入します(マップは開きません)。 76 | 77 | ** キャッシュのクリア方法 78 | 一度読み込んだGPXファイル内の情報はEmacs内の変数にキャッシュされます。クリアして再度読み込みたい場合は ~M-x my-location-clear~ を実行してください。 79 | 80 | ** Emacs Lispからの利用 81 | 82 | #+begin_src elisp 83 | (my-location-latlng-at-time (encode-time (parse-time-string "2021-01-02 12:34:56"))) ;;=> (xx.xxxxxxxxxx . xxx.xxxxxxxx) 84 | #+end_src 85 | 86 | ** 利用例 87 | 88 | 次のコードは写真(JPEG画像)の撮影場所を緯度経度で表示し、ブラウザでその場所を開きます。JPEG画像のExif情報から撮影日時とGPS情報を取得します。GPS情報が取得できなかった場合は ~my-location-latlng-at-time~ を使用して撮影日時から位置を割り出します。 89 | 90 | #+begin_src elisp 91 | (require 'my-location) 92 | ;; require code at end of http://misohena.jp/blog/2021-12-16-how-to-get-shooting-date-and-location-of-jpg-with-elisp.html 93 | ;; and exif.el 94 | (defun my-photo-location (file) 95 | (interactive "fJPEG File: ") 96 | (let* ((exif (or (my-exif-parse-file file) 97 | (error "No Exif Data"))) 98 | (time (or (my-exif-date-time-original exif) 99 | (error "No Exif.DateTimeOriginal"))) 100 | (latlng (or (my-exif-latlng exif) ;;From GPS Info 101 | (my-location-latlng-at-time time)))) ;From GPX File 102 | 103 | (when latlng 104 | (my-location-browse-map latlng) 105 | (message "%.6f,%.6f" (car latlng) (cdr latlng))))) 106 | #+end_src 107 | 108 | Emacs27以降に標準で入っているexif.elを使用していますが、それだけでは機能が十分ではないので[[http://misohena.jp/blog/2021-12-16-how-to-get-shooting-date-and-location-of-jpg-with-elisp.html][jpgファイルの撮影日時と撮影場所をEmacs Lispで取得する方法(exif.el) | Misohena Blog]]の最後に書かれているコード(my-exif-*)も使用しています。将来的にexif.elが改善されると良いのですが……。 109 | -------------------------------------------------------------------------------- /my-location.el: -------------------------------------------------------------------------------- 1 | ;;; my-location.el --- My Location DB -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2021 AKIYAMA Kouhei 4 | 5 | ;; Author: AKIYAMA Kouhei 6 | ;; Keywords: 7 | 8 | ;; This program is free software; you can redistribute it and/or modify 9 | ;; it under the terms of the GNU General Public License as published by 10 | ;; the Free Software Foundation, either version 3 of the License, or 11 | ;; (at your option) any later version. 12 | 13 | ;; This program is distributed in the hope that it will be useful, 14 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | ;; GNU General Public License for more details. 17 | 18 | ;; You should have received a copy of the GNU General Public License 19 | ;; along with this program. If not, see . 20 | 21 | ;;; Commentary: 22 | 23 | ;; Find out where you were at the specified time. 24 | 25 | ;;; Code: 26 | 27 | (require 'dom) 28 | (require 'cl-lib) 29 | (require 'subr-x) 30 | (require 'parse-time) 31 | 32 | (defgroup my-location nil 33 | "Find out where you were at the specified time" 34 | :prefix "my-location-" 35 | :group 'data) 36 | 37 | ;;;; Math 38 | 39 | (defun my-location-latlng-to-vec3 (ll) 40 | (let* ((lat (degrees-to-radians (car ll))) 41 | (lng (degrees-to-radians (cdr ll))) 42 | (cos-lat (cos lat))) 43 | (vector (* (cos lng) cos-lat) ;;x=lng0 44 | (* (sin lng) cos-lat) ;;y=lng90E 45 | (sin lat)))) ;;z=North 46 | 47 | (defun my-location-vec3-to-latlng (v) 48 | (cons 49 | (radians-to-degrees 50 | (atan (aref v 2) 51 | (sqrt (+ (* (aref v 0) (aref v 0)) 52 | (* (aref v 1) (aref v 1)))))) 53 | (radians-to-degrees 54 | (atan (aref v 1) 55 | (aref v 0))))) 56 | 57 | (defun my-location-vec3-cross (a b) 58 | (vector (- (* (aref a 1) (aref b 2)) (* (aref a 2) (aref b 1))) 59 | (- (* (aref a 2) (aref b 0)) (* (aref a 0) (aref b 2))) 60 | (- (* (aref a 0) (aref b 1)) (* (aref a 1) (aref b 0))))) 61 | 62 | (defun my-location-vec3-dot (a b) 63 | (+ (* (aref a 0) (aref b 0)) 64 | (* (aref a 1) (aref b 1)) 65 | (* (aref a 2) (aref b 2)))) 66 | 67 | (defun my-location-vec3-length-sq (v) 68 | (let ((x (aref v 0)) 69 | (y (aref v 1)) 70 | (z (aref v 2))) 71 | (+ (* x x) (* y y) (* z z)))) 72 | 73 | (defun my-location-vec3-length (v) 74 | (sqrt (my-location-vec3-length-sq v))) 75 | 76 | (defun my-location-vec3-angle (a b) 77 | (atan (my-location-vec3-length (my-location-vec3-cross a b)) 78 | (my-location-vec3-dot a b))) 79 | 80 | (defun my-location-vec3-mul-scalar (v s) 81 | (vector (* (aref v 0) s) 82 | (* (aref v 1) s) 83 | (* (aref v 2) s))) 84 | 85 | (defun my-location-vec3-add (v1 v2) 86 | (vector (+ (aref v1 0) (aref v2 0)) 87 | (+ (aref v1 1) (aref v2 1)) 88 | (+ (aref v1 2) (aref v2 2)))) 89 | 90 | (defun my-location-vec3-normalize (v) 91 | (my-location-vec3-mul-scalar 92 | v 93 | (/ 1.0 (my-location-vec3-length v)))) 94 | 95 | (defun my-location-vec3-rotate-z-90 (v) 96 | (vector (- (aref v 1)) 97 | (aref v 0) 98 | (aref v 2))) 99 | 100 | (defun my-location-vec3-slerp (v1 v2 T) 101 | (let* ((angle (* T (my-location-vec3-angle v1 v2))) 102 | (axis (my-location-vec3-cross v1 v2)) 103 | (err 10e-16) 104 | (axis (if (< (my-location-vec3-length-sq axis) (* err err)) 105 | (my-location-vec3-rotate-z-90 v1) 106 | axis)) 107 | (dir (my-location-vec3-normalize (my-location-vec3-cross axis v1)))) 108 | (my-location-vec3-add 109 | (my-location-vec3-mul-scalar v1 (cos angle)) 110 | (my-location-vec3-mul-scalar dir (sin angle))))) 111 | 112 | (defun my-location-interpolate (ll1 ll2 T) 113 | (my-location-vec3-to-latlng 114 | (my-location-vec3-slerp 115 | (my-location-latlng-to-vec3 ll1) 116 | (my-location-latlng-to-vec3 ll2) 117 | T))) 118 | 119 | ;;;; Date/Time 120 | 121 | (defun my-location-string-to-number (str) 122 | (when str 123 | (string-to-number str))) 124 | 125 | (defun my-location-string-to-time (str &optional curr-time) 126 | (unless (string-match "\\(?:\\(?:\\(?:\\([0-9]+\\)[/-]\\)?\\([0-9]+\\)[/-]\\)?\\([0-9]+\\) +\\)?\\([0-9]+\\):\\([0-9]+\\)\\(?::\\([0-9]+\\)\\)?" str) 127 | (error "Invalid date/time format")) 128 | (let* ((curr-time (or curr-time (decode-time (current-time)))) 129 | (curr-second (decoded-time-second curr-time)) 130 | (curr-minute (decoded-time-minute curr-time)) 131 | (curr-hour (decoded-time-hour curr-time)) 132 | (curr-day (decoded-time-day curr-time)) 133 | (curr-month (decoded-time-month curr-time)) 134 | (curr-year (decoded-time-year curr-time)) 135 | (input-second (or (my-location-string-to-number (match-string 6 str)) 136 | 0)) 137 | (input-minute (my-location-string-to-number (match-string 5 str))) 138 | (input-hour (my-location-string-to-number (match-string 4 str))) 139 | (input-day (or (my-location-string-to-number (match-string 3 str)) 140 | (if (<= (+ (* 3600 input-hour) 141 | (* 60 input-minute) 142 | input-second) 143 | (+ (* 3600 curr-hour) 144 | (* 60 curr-minute) 145 | curr-second)) 146 | curr-day 147 | (1- curr-day)))) 148 | (input-month (or (my-location-string-to-number (match-string 2 str)) 149 | (if (<= input-day curr-day) 150 | curr-month 151 | (1- curr-month)))) 152 | (input-year (or (my-location-string-to-number (match-string 1 str)) 153 | (if (or (and (= input-month curr-month) 154 | (<= input-day curr-day)) 155 | (< input-month curr-month)) 156 | curr-year 157 | (1- curr-year))))) 158 | (encode-time 159 | (make-decoded-time :second input-second 160 | :minute input-minute 161 | :hour input-hour 162 | :day input-day 163 | :month input-month 164 | :year input-year)))) 165 | 166 | (defun my-location-guess-time-from-file-name (file) 167 | (when (and (stringp file) 168 | (string-match "\\(20[0-9][0-9]\\|19[0-9][0-9]\\)-?\\(0[1-9]\\|1[0-2]\\)-?\\([0-3][0-9]\\)[ _]?\\([01][0-9]\\|2[0-3]\\)\\([0-5][0-9]\\)\\([0-5][0-9]\\)?" file)) 169 | (encode-time 170 | (make-decoded-time 171 | :year (string-to-number (match-string 1 file)) 172 | :month (string-to-number (match-string 2 file)) 173 | :day (string-to-number (match-string 3 file)) 174 | :hour (string-to-number (match-string 4 file)) 175 | :minute (string-to-number (match-string 5 file)) 176 | :second (string-to-number (or (match-string 6 file) "0")))))) 177 | 178 | (defun my-location-read-date-time (prompt) 179 | (let* ((str (read-string prompt)) 180 | (time (or (ignore-errors (my-location-string-to-time str)) 181 | (my-location-guess-time-from-file-name str)))) 182 | (unless time 183 | (error "Invalid date/time format")) 184 | time)) 185 | 186 | ;;;; Track Point 187 | 188 | (cl-defstruct (my-location-point 189 | (:constructor my-location-point-create (lat lng time))) 190 | lat 191 | lng 192 | time) 193 | 194 | (defun my-location-point-latlng (point) 195 | (when point 196 | (cons 197 | (my-location-point-lat point) 198 | (my-location-point-lng point)))) 199 | 200 | ;;;; Track Segment 201 | 202 | (cl-defstruct (my-location-segment 203 | (:constructor my-location-segment-create (points track))) 204 | points 205 | track) 206 | 207 | (defun my-location-segment-min-time (segment) 208 | "SEGMENTに記録されている点の最も早い時刻を返します。" 209 | (when segment 210 | (my-location-point-time (car (my-location-segment-points segment))))) 211 | 212 | (defun my-location-segment-max-time (segment) 213 | "SEGMENTに記録されている点の最も遅い時刻を返します。" 214 | (when segment 215 | (my-location-point-time (car (last (my-location-segment-points segment)))))) 216 | 217 | (defun my-location-segment-find-point-by-time (segment time) 218 | "SEGMENTに記録されている点のリストのTIMEで指定されている時刻以降を返します。 219 | 220 | 返すリストの要素が一つだけの場合、SEGMENTが持つ一番最後の点とTIME 221 | の時刻は一致しています。 222 | 223 | 返すリストの要素が二つ以上の場合、先頭と二番目の要素の間に時刻 224 | TIMEがあります。二つ目の要素がある場合、二つ目の時刻は必ずTIMEよ 225 | り大きくなります(等しいということはありません)。一つ目の要素は 226 | TIMEと同じか小さい時刻になります。 227 | 228 | TIMEがSEGMENTが持つ範囲の外である場合、nilを返します。 229 | " 230 | (let ((points (my-location-segment-points segment))) 231 | 232 | (unless (time-less-p time (my-location-point-time (car points))) 233 | (while (and (cdr points) 234 | (not 235 | (time-less-p time (my-location-point-time (cadr points))))) 236 | (setq points (cdr points))) 237 | (if (or (cdr points) 238 | (time-equal-p (my-location-point-time (car points)) time)) 239 | points)))) 240 | 241 | (defun my-location-segment-latlng-at-time (segment time) 242 | "SEGMENTに記録されている情報を使って、TIMEで指定した時刻の位置を推定し緯度経度で返します。 243 | 244 | 記録されていない時刻の場合、前後の記録から補間して求めます。 245 | 246 | TIMEがSEGMENTが持つ範囲の外である場合、nilを返します。" 247 | (let ((points (my-location-segment-find-point-by-time segment time))) 248 | (cond 249 | ((null points) 250 | nil) 251 | ((null (cdr points)) 252 | (my-location-point-latlng (car points))) 253 | (t 254 | (let* ((p0 (car points)) 255 | (p1 (cadr points)) 256 | (time0 (my-location-point-time p0)) 257 | (time1 (my-location-point-time p1)) 258 | (dt01 (float-time (time-subtract time1 time0))) 259 | (dt0t (float-time (time-subtract time time0)))) 260 | (if (= dt01 0) 261 | (my-location-point-latlng p1) 262 | (my-location-interpolate 263 | (my-location-point-latlng p0) 264 | (my-location-point-latlng p1) 265 | (/ dt0t dt01)))))))) 266 | 267 | ;;;; Track 268 | 269 | (cl-defstruct (my-location-track 270 | (:constructor my-location-track-create (name))) 271 | name 272 | segments) 273 | 274 | ;;;; Global Location Data 275 | 276 | (defun my-location-clear () 277 | (interactive) 278 | (my-location-loaded-files-clear) 279 | (my-location-tracks-clear) 280 | (my-location-segments-clear)) 281 | 282 | ;;;;; Global Segment List 283 | 284 | (defvar my-location-segments nil) ;; a list of segment 285 | 286 | (defun my-location-segments-clear () 287 | (setq my-location-segments nil)) 288 | 289 | (defun my-location-segment-on-time (time-lower &optional time-upper) 290 | (unless time-upper 291 | (setq time-upper time-lower)) 292 | (seq-find 293 | (lambda (segment) 294 | (and 295 | (not (time-less-p time-upper (my-location-segment-min-time segment))) 296 | (not (time-less-p (my-location-segment-max-time segment) time-lower)))) 297 | my-location-segments)) 298 | 299 | (defun my-location-add-segment (segment) 300 | ;; @todo Prohibit duplication of ranges? 301 | ;; (unless (my-location-segment-on-time 302 | ;; (my-location-segment-min-time segment) 303 | ;; (my-location-segment-max-time segment)) ) 304 | (push segment my-location-segments)) 305 | 306 | (defun my-location-points-on-time (time &optional no-auto-load-p) 307 | (unless no-auto-load-p 308 | (my-location-load-files-on-time time)) 309 | (when-let ((segment (my-location-segment-on-time time))) 310 | (my-location-segment-find-point-by-time segment time))) 311 | 312 | (defun my-location-latlng-at-time (time &optional no-auto-load-p) 313 | (unless no-auto-load-p 314 | (my-location-load-files-on-time time)) 315 | (when-let ((segment (my-location-segment-on-time time))) 316 | (my-location-segment-latlng-at-time segment time))) 317 | 318 | ;;;;; Global Track List 319 | 320 | (defvar my-location-tracks nil) ;;a list of track 321 | 322 | (defun my-location-tracks-clear () 323 | (setq my-location-tracks nil)) 324 | 325 | (defun my-location-add-track (track) 326 | (push track my-location-tracks) 327 | (mapc 328 | #'my-location-add-segment 329 | (my-location-track-segments track))) 330 | 331 | (defun my-location-add-tracks (tracks) 332 | (mapc 333 | #'my-location-add-track 334 | tracks)) 335 | 336 | ;;;;; Global File Management 337 | 338 | (defvar my-location-loaded-files nil) 339 | 340 | (defun my-location-loaded-files-clear () 341 | (setq my-location-loaded-files nil)) 342 | 343 | (defcustom my-location-sources 344 | '((:dir "~/my-location/%Y%m" :file-pattern "\\`%Y%m%d.*\\.gpx")) 345 | "gpx file locations." 346 | :group 'my-location 347 | :type '(repeat 348 | (list 349 | (const :format "" :dir) 350 | (string :format "Directory: %v" "~/my-location/%Y%m") 351 | (const :format "" :file-pattern) 352 | (string :format "File Regexp: %v" "\\`%Y%m%d.*\\.gpx")))) 353 | 354 | (defun my-location-source-files-on-time (time) 355 | (apply 356 | #'nconc 357 | (mapcar 358 | (lambda (source) 359 | (when-let ((dir-format (plist-get source :dir)) 360 | (file-pattern-format (plist-get source :file-pattern))) 361 | (let ((dir (format-time-string dir-format time)) 362 | (file-pattern (format-time-string file-pattern-format time))) 363 | (when (and (file-exists-p dir) 364 | (directory-name-p dir)) 365 | (directory-files dir t file-pattern))))) 366 | my-location-sources))) 367 | 368 | (defun my-location-load-files-on-time (time) 369 | (mapc #'my-location-load-file (my-location-source-files-on-time time))) 370 | 371 | (defun my-location-load-file (file) 372 | (let ((abs-file (expand-file-name file))) 373 | (unless (assoc abs-file my-location-loaded-files #'string=) 374 | (message "loading %s..." abs-file) 375 | (let ((tracks (my-location-load-gpx abs-file))) 376 | (push (cons abs-file tracks) my-location-loaded-files))))) 377 | 378 | (defun my-location-unload-file (file) 379 | (let* ((abs-file (expand-file-name file)) 380 | (file-tracks (assoc abs-file my-location-loaded-files #'string=))) 381 | (when file-tracks 382 | ;; Remove track & segments 383 | ;;@todo 384 | 385 | ;; Remove the entry 386 | (setf 387 | (alist-get abs-file my-location-loaded-files nil 'remove #'string=) 388 | nil)))) 389 | 390 | ;;;; GPX File 391 | 392 | ;; References: 393 | ;; - https://www.topografix.com/gpx.asp 394 | 395 | (defun my-location-load-gpx (file) 396 | "GPXファイルを読み取ります。読み取ったデータはグローバルなトラックリストやセグメントリストに格納して問い合わせに備えます。" 397 | (let* ((gpx (with-temp-buffer 398 | (insert-file-contents file) 399 | (libxml-parse-xml-region (point-min) (point-max)))) 400 | (tracks 401 | (cl-loop 402 | for trk in (dom-children gpx) 403 | when (eq (dom-tag trk) 'trk) 404 | collect 405 | (let* ((track (my-location-track-create 406 | (dom-text (car (dom-by-tag trk 'name))))) 407 | (segments (cl-loop 408 | for trkseg in (dom-children trk) 409 | when (eq (dom-tag trkseg) 'trkseg) 410 | collect 411 | (my-location-segment-create 412 | (cl-loop 413 | for trkpt in (dom-children trkseg) 414 | when (eq (dom-tag trkpt) 'trkpt) 415 | collect 416 | (when-let ((lat-str (dom-attr trkpt 'lat)) 417 | (lng-str (dom-attr trkpt 'lon)) 418 | (time-elem (car (dom-by-tag trkpt 'time)))) 419 | (my-location-point-create 420 | (string-to-number lat-str) 421 | (string-to-number lng-str) 422 | (parse-iso8601-time-string 423 | (dom-text time-elem))))) 424 | track)))) 425 | (setf (my-location-track-segments track) segments) 426 | track)))) 427 | (my-location-add-tracks tracks) 428 | tracks)) 429 | 430 | 431 | ;;;; Map 432 | 433 | (defun my-location-expand-template (template alist) 434 | (let ((regexp "{{{\\([^:}]+\\)\\(?::\\([^:}]+\\)\\)?}}}")) 435 | (replace-regexp-in-string 436 | regexp 437 | (lambda (str) 438 | (save-match-data 439 | (if (string-match regexp str) 440 | (let ((key (match-string 1 str)) 441 | (fmt (or (match-string 2 str) "%s"))) 442 | (format fmt (alist-get (intern key) alist ""))) 443 | ""))) 444 | template 445 | t))) 446 | 447 | (defcustom my-location-map-url 448 | "https://www.openstreetmap.org/#map=17/{{{lat:%.6f}}}/{{{lng:%.6f}}}" 449 | "Map Service URL." 450 | :group 'my-location 451 | :type '(choice 452 | (const nil) 453 | (const :tag "Open Street Maps" "https://www.openstreetmap.org/#map=17/{{{lat:%.6f}}}/{{{lng:%.6f}}}") 454 | (const :tag "Apple Maps" "http://maps.apple.com/?ll={{{lat:%.6f}}},{{{lng:%.6f}}}&z=17") 455 | (const :tag "Google Maps" "https://www.google.com/maps?ll={{{lat:%.6f}}},{{{lng:%.6f}}}&z=17") 456 | (const :tag "Japan GSI" "https://maps.gsi.go.jp/#17/{{{lat:%.6f}}}/{{{lng:%.6f}}}/") 457 | string)) 458 | 459 | (defun my-location-browse-map (ll) 460 | (message "url=%s" (my-location-expand-template 461 | my-location-map-url 462 | (list (cons 'lat (car ll)) 463 | (cons 'lng (cdr ll))))) 464 | (browse-url (my-location-expand-template 465 | my-location-map-url 466 | (list (cons 'lat (car ll)) 467 | (cons 'lng (cdr ll)))))) 468 | 469 | (defun my-location-at-time (time &optional arg) 470 | "TIMEで指定した時刻の位置を表示します。 471 | 472 | ARGを指定した場合結果をバッファに挿入します。指定しなかった場合マップを開きます。 473 | " 474 | (interactive 475 | (list 476 | (my-location-read-date-time 477 | "Past date and time ([[[YYYY-]MM-]DD ]HH:MM[:SS]): ") 478 | current-prefix-arg)) 479 | (let ((ll (my-location-latlng-at-time time))) 480 | (if ll 481 | (let ((str (format "%.6f,%.6f" (car ll) (cdr ll)))) 482 | (if arg 483 | (insert str) 484 | (when my-location-map-url 485 | (my-location-browse-map ll))) 486 | (message "%s" str) 487 | ll) 488 | (message "No data") 489 | nil))) 490 | 491 | 492 | (provide 'my-location) 493 | ;;; my-location.el ends here 494 | --------------------------------------------------------------------------------