Pixel Pedals of Tomakomai

北海道苫小牧市出身の初老の日常

SQLite で日付を扱う

SQLite には日付型がない。と、我々はいつから錯覚していたのか。

日付型はないのだが、なぜか日付を操作する関数群がある。これが超絶便利。

www.sqlite.org

先に注意点だが、これらの関数は文字列を日付として扱うのだが、 T ではなく空白文字を区切り文字として利用しており、 ISO-8601 に準拠していない 。ドキュメントに記載がある通り 1 YYYY-MM-DD HH:MM:SS というフォーマットである。ミリ秒まで含める場合は、この後ろに .SSS と続く。 DB にはこの形式で入れておいたほうが、 index を効かせられて便利である。また、タイムゾーンが入っていないことにも注意が必要だ。基本的に SQLite3 は UTC と localtime を扱えるのだが、複数のタイムゾーンから値を読み書きをすることを考えれば UTC に揃えておくのが無難だろう。その辺は、以下の記事にも書かれている。

qiita.com

さて、道具があることはわかったが、これだけで本当に日付を扱えるのか。 SQLite3 の日付関数には、非常に強力な modifier という機構がある。これは、 date や datetime の文字列を返す関数に文字列の引数の形で指定をするもので、 UNIX コマンドを | でつなげて実行するような感覚で利用できる。

例えば、入力された文字列を整形する場合に、ミリ秒まで含めたい場合には以下のように subsecond modifier を噛ませる。これで出力に .SSS が付与される。ミリ秒まである形式の日付を正規化して INSERT する場合に用いることができる。

datetime(?, 'subsecond')

localtime を UTC にしたい場合には、 utc modifier を通す。日付の足し算も modifier として +1 days のように書くといい。テーブル内に UTC でデータが入っているものとして、 localtime で日付を指定して検索を行う場合には以下のようになる。正規化された日付文字列の辞書順は時系列での sort と等しいので、きちんと index も効かせることができる。

         WHERE datetime(?, 'utc') <= timestamp_column
           AND timestamp_column < datetime(?, 'utc', '+1 days')",

なんともうまくできた仕組みである。


  1. ドキュメントには ISO-8601 であると書いてあるので注意。

Google Mapsの不具合の傾向と対策

Google Maps の調子がよくない。

まず、数ヶ月ほど前からタイムラインがバグっている。具体的に言うと、移動を正しく検出できない傾向にある。 GPS データは正しく蓄積されているため、各場所の滞在時間を正しく設定し直すことで、隠されてしまっている移動経路を修繕することは可能だが、この作業が非常に手間がかかって仕方がない。

そしてさらに、4月に入ってから androidGoogle Maps の自転車ナビが壊れた。ナビ中にスリープにしておくと、本来は、スリープ中でも音声ナビは有効のままだし、スリープ解除するとロック解除不要でナビに戻れるという大変親切な挙動であった 1 。しかし、4月になってからスリープをするとナビが異常終了するようになってしまった。異常終了してしまうので、スリープ中は音声ナビができないし、ロックを解除して再びナビ開始ボタンを押さなければナビに戻ることができない。この挙動は自転車ナビだけであり、徒歩や自動車の方は壊れていないようである。

これを直す方法は、元も子もないが、アプリをダウングレードすることである。アイコン長押しで App info に進み、右上の ... から Uninstall updates してしまえば端末にプリインストールされたバージョンまで戻ることができる。これで問題は解決した。

Play Store の Google Maps の項目で、右上の ... から Auto update を無効化するのも忘れずに。


  1. なお、この挙動は iPhone 版にはない。

Windows 上の Rust で SQLite

Rust でちょっとしたツールを書いているのだが、読み込む JSON が巨大過ぎてデータの一部分だけを使うにしても毎回 1 分程度読み込みに時間がかかってしまうので、パース結果だけを使いやすい形で保存しておきたくなった。もちろん、 JSON を分割して保存しておくだけでも効果はあるのだが、整形し直すのであれば JSON に拘る必要もない。

現在 Windows で開発しているので、 Windows でも動くことを要件とする。もちろん、 WSL2 で動かせばいいのだけど、せっかく cross platform な Rust で開発しているのだから、それだけのために linux の世界に閉じこもるのもナンセンスに思える 1 。それと、外部ライブラリが必要となる crate も避けたい。 Windows PC は複数あるので、このツールを動かすためだけに各 PC で1台1台ライブラリを揃えるのは億劫だ。

ChatGPT に相談したところ、初めは surrealdb を勧められた。面白そうではあるのだけど、 JSON を整形して保存するだけにしてはちょっと大げさ過ぎやしないか。そう思いながらもビルドしてみたのだが、 Windows 上ではエラーとなってサンプルが動かなかったので早々に諦めた。もちろん、インストール手順に従って最初に surrealdb をインストールすれば動くのだろうが、それは今回の要件に反する。

次に勧められたのは sled で、 pure-rust だし今回の要件としては NoSQL で十分なのでコイツを使おうと思っていたのだけど、そんな矢先に Qiita でこんな記事を見つけた。

kawasin73.hatenablog.com

SQLite が使えるのであれば、 sqlite3 コマンドラインでクエリできるし、それに越したことはない。試しにビルドをしてみたが、 Windows ではエラーでビルドできないようだ。原因はここ。 std::os::unix::fs::FileExt を使っている。

github.com

幸い、 fs::FileExt には windows 用の類似のインタフェースもあるので、そちらでお茶を濁すとビルドできた。

diff --git a/src/lib.rs b/src/lib.rs
index c7c0461..6ce5991 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -32,7 +32,7 @@ use std::cell::Cell;
 use std::cell::RefCell;
 use std::fmt::Display;
 use std::fs::OpenOptions;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::path::Path;
 
 use anyhow::bail;
@@ -171,7 +171,7 @@ impl Connection {
             .open(filename)
             .with_context(|| format!("failed to open file: {:?}", filename))?;
         let mut buf = [0; DATABASE_HEADER_SIZE];
-        file.read_exact_at(&mut buf, 0)?;
+        file.seek_read(&mut buf, 0)?;
         let header = DatabaseHeader::from(&buf);
         header
             .validate()
diff --git a/src/pager.rs b/src/pager.rs
index 0d7c565..fce71a0 100644
--- a/src/pager.rs
+++ b/src/pager.rs
@@ -26,7 +26,7 @@ use std::io;
 use std::num::NonZeroU32;
 use std::ops::Deref;
 use std::ops::DerefMut;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::rc::Rc;
 
 use crate::header::DatabaseHeader;
@@ -224,7 +224,7 @@ impl Pager {
             let (page1, is_new) = self.cache.get_page(PAGE_ID_1);
             let mut page1 = if is_new {
                 let mut page1 = page1.borrow_mut();
-                self.file.read_exact_at(&mut page1.buf, 0)?;
+                self.file.seek_read(&mut page1.buf, 0)?;
                 page1
             } else {
                 page1.try_borrow_mut()?
@@ -238,7 +238,7 @@ impl Pager {
             let mut trunk_page = if is_new {
                 let mut trunk_page = trunk_page.borrow_mut();
                 self.file
-                    .read_exact_at(&mut trunk_page.buf, self.page_offset(first_page_id))?;
+                    .seek_read(&mut trunk_page.buf, self.page_offset(first_page_id))?;
                 trunk_page
             } else {
                 trunk_page.try_borrow_mut()?
@@ -293,7 +293,7 @@ impl Pager {
         if is_new {
             let mut raw_page = page.borrow_mut();
             self.file
-                .read_exact_at(&mut raw_page.buf, self.page_offset(page_id))?;
+                .seek_read(&mut raw_page.buf, self.page_offset(page_id))?;
         }
         let header_offset = if page_id == PAGE_ID_1 {
             DATABASE_HEADER_SIZE
@@ -327,7 +327,7 @@ impl Pager {
         let (page1, is_new) = self.cache.get_page(PAGE_ID_1);
         let mut page1 = if is_new {
             let mut page1 = page1.borrow_mut();
-            self.file.read_exact_at(&mut page1.buf, 0)?;
+            self.file.seek_read(&mut page1.buf, 0)?;
             page1
         } else {
             page1.try_borrow_mut()?
@@ -348,7 +348,7 @@ impl Pager {
             let mut trunk_page = if is_new {
                 let mut trunk_page = trunk_page.borrow_mut();
                 self.file
-                    .read_exact_at(&mut trunk_page.buf, self.page_offset(first_page_id))?;
+                    .seek_read(&mut trunk_page.buf, self.page_offset(first_page_id))?;
                 trunk_page
             } else {
                 trunk_page.try_borrow_mut()?
@@ -407,7 +407,7 @@ impl Pager {
             let raw_page = page.try_borrow()?;
             if raw_page.is_dirty {
                 self.file
-                    .write_all_at(&raw_page.buf, self.page_offset(*page_id))?;
+                    .seek_write(&raw_page.buf, self.page_offset(*page_id))?;
                 drop(raw_page);
                 page.try_borrow_mut()?.is_dirty = false;
             }
@@ -431,7 +431,7 @@ impl Pager {
             if is_new {
                 let mut page1 = page1.borrow_mut();
                 self.file
-                    .read_exact_at(&mut page1.buf, 0)
+                    .seek_read(&mut page1.buf, 0)
                     .expect("read page 1 must succeed");
             }
             let buffer = &page1.borrow().buf;
@@ -562,12 +562,12 @@ mod tests {
     #[test]
     fn test_new() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[0_u8; 4096], 0).unwrap();
+        file.seek_write(&[0_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), 1);
 
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[0_u8; 4096 * 2], 0).unwrap();
+        file.seek_write(&[0_u8; 4096 * 2], 0).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), 2);
 
@@ -583,8 +583,8 @@ mod tests {
     #[test]
     fn test_get_page() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -606,8 +606,8 @@ mod tests {
     #[test]
     fn test_make_page_mut() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -637,7 +637,7 @@ mod tests {
     #[test]
     fn test_make_page_mut_failure() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -660,8 +660,8 @@ mod tests {
     #[test]
     fn test_swap_page_buffer() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file, 2, 4096, 4096, None, 0).unwrap();
         let page_id_2 = PageId::new(2).unwrap();
 
@@ -694,8 +694,8 @@ mod tests {
     #[test]
     fn test_commit() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -712,7 +712,7 @@ mod tests {
         pager.commit().unwrap();
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [3_u8; 4096]);
         assert_eq!(buf[4096..], [4_u8; 4096]);
     }
@@ -720,7 +720,7 @@ mod tests {
     #[test]
     fn test_commit_failure() {
         let file = tempfile::tempfile().unwrap();
-        file.write_all_at(&[1_u8; 4096], 0).unwrap();
+        file.seek_write(&[1_u8; 4096], 0).unwrap();
         let pager = Pager::new(file, 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -742,7 +742,7 @@ mod tests {
             DatabaseHeaderMut::from((&mut original[..DATABASE_HEADER_SIZE]).try_into().unwrap());
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(1);
-        file.as_file().write_all_at(&original, 0).unwrap();
+        file.as_file().seek_write(&original, 0).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 1, 4096, 4096, None, 0).unwrap();
 
         let page = pager.get_page(PAGE_ID_1).unwrap();
@@ -759,7 +759,7 @@ mod tests {
             original.as_slice()
         );
         let mut buf = [0; 4096];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf, original);
 
         assert_eq!(pager.first_freelist_trunk_page_id.get(), PageId::new(2));
@@ -808,7 +808,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 2);
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [10_u8; 4096]);
         assert_eq!(buf[4096..], [11_u8; 4096]);
     }
@@ -816,8 +816,8 @@ mod tests {
     #[test]
     fn test_allocate_page_non_empty_file() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let (page_id, page) = pager.allocate_page().unwrap();
@@ -834,7 +834,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 3);
 
         let mut buf = [0; 4096 * 3];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [1_u8; 4096]);
         assert_eq!(buf[4096..4096 * 2], [2_u8; 4096]);
         assert_eq!(buf[4096 * 2..], [3_u8; 4096]);
@@ -844,7 +844,7 @@ mod tests {
     fn test_allocate_page_failure() {
         let file = tempfile::NamedTempFile::new().unwrap();
         file.as_file()
-            .write_all_at(&[1; 4096], (MAX_PAGE_ID as u64 - 1) * 4096)
+            .seek_write(&[1; 4096], (MAX_PAGE_ID as u64 - 1) * 4096)
             .unwrap();
         let pager = Pager::new(file.reopen().unwrap(), MAX_PAGE_ID, 4096, 4096, None, 0).unwrap();
         assert_eq!(pager.num_pages(), MAX_PAGE_ID);
@@ -858,8 +858,8 @@ mod tests {
     #[test]
     fn test_allocate_page_abort() {
         let file = tempfile::NamedTempFile::new().unwrap();
-        file.as_file().write_all_at(&[1_u8; 4096], 0).unwrap();
-        file.as_file().write_all_at(&[2_u8; 4096], 4096).unwrap();
+        file.as_file().seek_write(&[1_u8; 4096], 0).unwrap();
+        file.as_file().seek_write(&[2_u8; 4096], 4096).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 2, 4096, 4096, None, 0).unwrap();
 
         let (page_id, page) = pager.allocate_page().unwrap();
@@ -881,7 +881,7 @@ mod tests {
         assert_eq!(file.as_file().metadata().unwrap().len(), 4096 * 2);
 
         let mut buf = [0; 4096 * 2];
-        file.as_file().read_exact_at(&mut buf, 0).unwrap();
+        file.as_file().seek_read(&mut buf, 0).unwrap();
         assert_eq!(buf[..4096], [1_u8; 4096]);
         assert_eq!(buf[4096..4096 * 2], [2_u8; 4096]);
     }
@@ -905,14 +905,14 @@ mod tests {
         );
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(5);
-        file.as_file().write_all_at(&header_buf, 0).unwrap();
+        file.as_file().seek_write(&header_buf, 0).unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 3, 0, 0, 0, 2, 0, 0, 0, 4, 0, 0, 0, 5], 4096)
+            .seek_write(&[0, 0, 0, 3, 0, 0, 0, 2, 0, 0, 0, 4, 0, 0, 0, 5], 4096)
             .unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 6], 4096 * 2)
+            .seek_write(&[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 6], 4096 * 2)
             .unwrap();
-        file.as_file().write_all_at(&[2; 4096], 4096 * 6).unwrap();
+        file.as_file().seek_write(&[2; 4096], 4096 * 6).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 7, 4096, 4096, PageId::new(2), 5).unwrap();
 
         let (page_id, _) = pager.allocate_page().unwrap();
@@ -948,12 +948,12 @@ mod tests {
         );
         header.set_first_freelist_trunk_page_id(PageId::new(2));
         header.set_n_freelist_pages(127);
-        file.as_file().write_all_at(&header_buf, 0).unwrap();
+        file.as_file().seek_write(&header_buf, 0).unwrap();
         file.as_file()
-            .write_all_at(&[0, 0, 0, 0, 0, 0, 0, 126], 512)
+            .seek_write(&[0, 0, 0, 0, 0, 0, 0, 126], 512)
             .unwrap();
         file.as_file()
-            .write_all_at(
+            .seek_write(
                 &[
                     0, 0, 0, 10, 0, 0, 0, 11, 0, 0, 0, 12, 0, 0, 0, 13, 0, 0, 0, 14, 0, 0, 0, 15,
                     0, 0, 0, 16, 0, 0, 0, 17, 0, 0, 0, 18,
@@ -961,7 +961,7 @@ mod tests {
                 1024 - 36,
             )
             .unwrap();
-        file.as_file().write_all_at(&[2; 512], 512 * 17).unwrap();
+        file.as_file().seek_write(&[2; 512], 512 * 17).unwrap();
         let pager = Pager::new(file.reopen().unwrap(), 18, 512, 512, PageId::new(2), 127).unwrap();
 
         let (page_id, _) = pager.allocate_page().unwrap();
diff --git a/src/test_utils.rs b/src/test_utils.rs
index c37d3b4..fb9e0de 100644
--- a/src/test_utils.rs
+++ b/src/test_utils.rs
@@ -14,7 +14,7 @@
 
 use std::fmt::Write;
 use std::fs::File;
-use std::os::unix::fs::FileExt;
+use std::os::windows::fs::FileExt;
 use std::path::Path;
 
 use tempfile::NamedTempFile;
@@ -47,7 +47,7 @@ pub fn create_sqlite_database(queries: &[&str]) -> NamedTempFile {
 
 pub fn create_pager(file: File) -> anyhow::Result<Pager> {
     let mut header_buf = [0_u8; DATABASE_HEADER_SIZE];
-    file.read_exact_at(&mut header_buf, 0)?;
+    file.seek_read(&mut header_buf, 0)?;
     let header = DatabaseHeader::from(&header_buf);
     Ok(Pager::new(
         file,
@@ -61,7 +61,7 @@ pub fn create_pager(file: File) -> anyhow::Result<Pager> {
 
 pub fn create_empty_pager(file_content: &[u8], pagesize: u32, usable_size: u32) -> Pager {
     let file = NamedTempFile::new().unwrap();
-    file.as_file().write_all_at(file_content, 0).unwrap();
+    file.as_file().seek_write(file_content, 0).unwrap();
     Pager::new(
         file.as_file().try_clone().unwrap(),
         file_content.len() as u32 / pagesize,
@@ -75,7 +75,7 @@ pub fn create_empty_pager(file_content: &[u8], pagesize: u32, usable_size: u32)
 
 pub fn load_btree_context(file: &File) -> anyhow::Result<BtreeContext> {
     let mut header_buf = [0_u8; DATABASE_HEADER_SIZE];
-    file.read_exact_at(&mut header_buf, 0)?;
+    file.seek_read(&mut header_buf, 0)?;
     let header = DatabaseHeader::from(&header_buf);
     Ok(BtreeContext::new(
         header.pagesize() - header.reserved() as u32,

よし、使おう、と思ったのだがこの crate 、実は DB の初期化がまだ実装されていない。単体テストが通らなくてなぜかと思っていたのだが、よく読むと rusqlite という別のクレートを使っており、こちらは sqlite3 がインストールされていないとコンパイルが通らない。

github.com

ところで、この rusqlite というのは一体どんな crate なのだろう。 crates.io を見てみると、こんな記述がある。

features = ["bundled"]

これは、 crate にバンドルされた SQLite3 を使ってコンパイルするもので、今回の要件に完全に合致する。試してみると Windows 上でもそのままビルドできた。当たり前ではあるが、 rusqlite で生成した DB ファイルは WSL2 上の sqlite3 コマンドで読み書きできる。素晴らしい。

余談が 2 つある。まず、 rusqlite だが、この crate は libsqlite3-sys を使っている( github の同一リポジトリに存在する)のだが、 rusqlite のダウンロード数が 15M なのに対し、 libsqlite3-sys が 25M と大きく数がかけ離れている。これは libsqlite3-sys が他のクレートからも直接依存されているからで、 SQLxdiesel からの依存がある。それだけ信頼性のある crate ということだろう。

2 つ目の余談。知らなかったのだが、 SQLite3 はデフォルトでは LIKE の前方一致検索で index を使わない。これはデフォルトでは case insensitive なマッチを行うためで、

PRAGMA case_sensitive_like=ON;

とするだけで index が使われるようになる。一見、全然関連性のない設定なので、裏事情まで知らないと意味がわからない挙動の変化に思える。が、この話は 2006年のblog で見られるような内容なので、お前は今さら何を言っているんだ的な話なのであろう。


  1. 一方で、例えば HaskellPerl の主戦場は linux なわけで、そういうときは WSL2 に籠もれば良い。

詳解: staticmap

最近、 Rust の staticmap クレートにいくつか PR を送ったので、忘れないように staticmap についてメモっておく。

docs.rs

staticmap とは

事前知識なしに、簡単に地図画像を生成するためのライブラリである。例えば以下は、与えられた緯度経度の列を plot した画像を生成するためのプログラムである。

use std::{env::args, error::Error};

use staticmap::{tools::{CircleBuilder, Color}, StaticMapBuilder};

fn main() -> Result<(), Box<dyn Error>> {
    let mut args = args().skip(1);
    let mut map = StaticMapBuilder::new()
        .width(720)
        .height(480)
        .build()?;

    while let (Some(lat), Some(lon)) = (args.next(), args.next()) {
        let circle = CircleBuilder::new()
        .lat_coordinate(lat.parse::<f64>()?)
        .lon_coordinate(lon.parse::<f64>()?)
        .color(Color::new(true, 255, 0, 0, 255))
        .radius(5.)
        .build()?;
        map.add_tool(circle);
    }

    map.save_png("examples/results/plot_points.png")?;
    Ok(())
}

以下が実行結果である 1 。画像の大きさだけ指定し、生成した StaticMap 型の値 map に緯度経度をひたすらプロットするだけで、いい感じの縮尺で地図を作ってくれる。非常に便利だ。

> cargo run --example plot_points 43.07860134016368 141.34119085145716 33.59907205202905 130.22409863072042 35.71622219061979 139.76355588070456 35.129339438616675 136.09212718953398 35.154769245737306 136.97056270511507 38.27242398344753 141.000820278268 34.82219993583771 135.52507877778308

plot_points.png

OpenStreetMap

staticmapOpenStreetMap の地図データを使っている。地図は四角形の区域ごとに URL で指定される。以下が詳しい。

wiki.openstreetmap.org

結論だけ言ってしまえば、 https://tile.openstreetmap.org/zoom/x/y.png という形式の URL にアクセスすれば、 256×256 のサイズの地図の画像が手に入る。 zoom0 から 18 で、 0 の場合は一枚で世界地図となる。

https://tile.openstreetmap.org/0/0/0.png

xywiki に書かれている方法で計算可能で、メルカトル図法となっている。 0/0 は緯度経度で言えば 85.051100, -180.000000 の地図画像ということになる。地図で言えば、緯度経度の原点 Null Island を真ん中に書いた場合、左上を Slippy Map の (x, y) の原点 (0, 0) としてとることになる。

地図の取得は attohttpcrayon を使って並列に回している。 rayon は並行処理用のライブラリのはずなので、 http client に使うのはいかがなものかと思うのだけど、残念ながら現状の実装では密結合されているため、差し替えることはできない。

tiny-skia

画像の生成には tiny-skia というライブラリを使っている。こちらも密結合されており、差し替えられない。このライブラリは SKIA という Chrome で使われている画像ライブラリの移植らしいが、クラス名などは割と違うので、別途使い方を覚えたほうが良い。

tools

地図上への書き込みは、 tools::* 型を使って行う。 Circle Line などの型があり、これを StaticMapadd_tool 経由で追加することで円や線を書くことができる。 tools とはなっているが、実際は shape に近い概念となっている。

Builder pattern

Rust や Go では常套手段ではあるが、 staticmapBuilder Pattern が使われている。 StaticMaptools::Circle の値が欲しい場合は、対応する Builderbuild メソッドを経由して生成することになる。

座標系

ここから、内部実装の話となる。 staticmap は内部的には 3 つの座標系があり、 staticmap 本体の開発者はこれらを意識する必要がある。

  • lat , lon : 緯度経度
    • lat-90 から 90lon-180 から 180
    • 地図の右方向、 方向が正の方向
    • staticmap の利用者が意識するのはこれだけ
  • x , y : Slippy Map の x y
    • lat , lon だけでは決まらず、 zoom によって変わる
    • 地図の右方向、 方向が正の方向
    • メルカトル図法なので、 y 方向については lat線形ではない
    • f64 の任意の値で良い
      • 地図を得るときに i32 に変換 2 して使う
    • xy0 以上の値を想定するが、最大値も zoom によって決まる
  • px
    • tiny_skia の座標系
    • 左上が原点 (0, 0) で、値はピクセル単位(ただし、型は便利のため f64
    • 地図の右方向、 方向が正の方向
    • 最後の png 画像の描画処理でしか登場しないため、あまり意識しなくて良い

staticmap 開発者は lat, lonx, y の両方を使用するため、これらの違いをきちんと把握しておくことが必要となる。特に Y 座標については上下が逆転するので、例えば min を取る場合に lat では地図の下方側を取ればいいのに対して y については上方側を取らなければならない。

Bounds

bounds.rs に定義されており、このクレートの肝と言える部分である。地図の描画範囲を表す型であり、以下のフィールドで描画範囲は決定される。

  • height width : 生成される png 画像の高さと幅(ピクセル
  • x_center y_center : x, y 座標系における描画の中心点
  • tile_size : Slippy Map における 1 枚のタイルのサイズで、前述した通りほとんどの場合は 256 ピクセル

height widthtile_size で割ってやれば縦横何枚分のタイルが必要かがわかり、 x_center y_center からその枚数分までの範囲が描画されることになる。

Boundszoom も保持しており、これによって tools::* の各図形が持つ lat, lon の座標系を x y に変換することができる。 Slippy Map の tile を得るための URL にも必要である。

一方で、 x_min x_max y_min y_max は Slippy Map のどの範囲を get してくるべきかを表すフィールドだが、他の情報から簡単に計算可能なので、実は不要なフィールドであると言える。

Tool trait

tools/mod.rs に定義されている。 draw に関しては解説不要だろう。察しの通り、 tiny_skia を使って自身地図上に描画する。

問題は extent の方だ。このメソッドは、自身を完全に描画するための範囲を lat, lon の座標系で返す。この値は、地図の zoom や描画範囲を自動で決めるために必要となる。登録された tools::* がすべて画像内に収まるように、 Bounds を生成すればいいのである。

extent の呼び出しは zoom が引数となっていることには注意が必要だ。例えば、 tools.Circleradius は円の半径だが、この値はピクセル単位で指定をする。画像上の 3 pixel が lat lon でどの範囲になるのかは、 zoom によって大きく変わってしまうのだ。

Bounds の生成

save_png が呼び出されたタイミングで、すべての Tool が収まるような範囲 Bounds を生成する。

中心座標 x_center y_center を算出するのは簡単で、 add_tool によって追加されたすべての Toolextent を呼び出し、すべてを収めるための四角形の矩形を lat lon で算出した後に、 x y 座標系に変換してからその四角形の中心を求めれば良い。 lat は最終的に生成される画像に対して線形ではないため、 lat lon のまま中心を求めてはいけないということには注意が必要である 3

zoom を算出するには、総当りする必要がある。 zoom を大きい側 17 から 0 まで順に変化をさせていくと、すべての Tool について extent の呼び出しで求められる範囲を x y 座標で表した場合の四角形の一辺の長さは、逆に小さくなっていく 4 。これと画像の大きさ width height を比較して、四角形が画像内にすべて収まったところでズームアウトをやめると、最適な zoom を決定することができる。具体的には、以下の実装である。

github.com

この方法がうまくいくのは、 lat lon の座標が 2 個以上 add_tool で追加されている場合だけである。 lat lon の座標が 1 個の場合は、 zoom を変化させてもなんら状況は変化しない。極端な例として、半径 radius が 100 pixel の円を縦横 100x100 pixel の画像に描画しようとした場合は、 zoom をどんな値にしても入らないものは入らない。すべての試行に失敗して zoom = 0 を返す。逆に 10 pixel の場合は zoom に関係なく入るので、zoom = 17 だ。

やっかいなのが radius50 にした場合で、理論上はギリギリ画像内に書けるのだが、小数点以下の誤差によって画像内に入ると判定されたり入らないと判定されたり判定がランダムにブレる。その結果、決定される zoom は適当な任意の値となる。


  1. これがなんの plot かは読者への宿題とする。ドラゴンボールの位置ではない。
  2. Slippy Map では 0 ~ zoom によって決まる整数値の間の値しか取れないが、地球は丸いので、任意の実数の x y について、剰余を取るなどして所属する Slippy Map の tile を整数値として算出可能である。
  3. Fix center by hiratara · Pull Request #18 · danielalvsaaker/staticmap · GitHub
  4. Google Maps で北海道を表示してからズームアウトしていけば、ズームアウトすればするほど描画される北海道の大きさは小さくなるだろう。

ChatGPTとプログラミング2

hiratara.hatenadiary.jp

その後も継続して ChatGPT をサブスクで使っているのだが、今のところサブスクを解除する予定はない。サブスクした直後は Google 検索の代わりに日常の疑問を尋ねることが多かったのだが、最近はもっぱらプログラミングに ChatGPT を使っている。

ChatGPT の強みは、詳細なコンテキストを与えられることだと考えている。しかも、チャット形式のお陰でそれを反復的に行うことができる。「 tiny_skia のサンプル作って」などと短文の一度の指示で課題を解決しようとすると真価は発揮できず、自分の持っている課題について要点を的確に伝えるほど生成される回答の精度は増す。

以下は、オリジナルの Skiatiny_skiaAPI の違いを知るためにそれぞれのサンプルコードを生成させた例である。知りたい情報がきちんと生成されている 1

#include "include/core/SkCanvas.h"
#include "include/core/SkPaint.h"
#include "include/core/SkShader.h"
#include "include/effects/SkGradientShader.h"

void draw(SkCanvas* canvas) {
    SkPoint points[2] = {SkPoint::Make(0, 0), SkPoint::Make(200, 200)}; // グラデーションの開始点と終了点
    SkColor colors[2] = {SK_ColorRED, SK_ColorBLUE}; // グラデーションの色
    auto shader = SkGradientShader::MakeLinear(points, colors, nullptr, 2, SkTileMode::kClamp);

    SkPaint paint;
    paint.setShader(shader);
    paint.setAntiAlias(true);

    canvas->drawRect(SkRect::MakeWH(200, 200), paint);
}
use tiny_skia::*;

fn main() {
    let mut pixmap = Pixmap::new(200, 200).unwrap();
    let gradient = Gradient::new_linear(
        Point::from_xy(0.0, 0.0),
        Point::from_xy(200.0, 200.0),
        vec![
            GradientStop::new(0.0, Color::from_rgba8(255, 0, 0, 255)), // 赤
            GradientStop::new(1.0, Color::from_rgba8(0, 0, 255, 255)), // 青
        ],
        SpreadMode::Pad,
        Transform::identity(),
    ).unwrap();

    let paint = Paint {
        shader: gradient.into(),
        anti_alias: true,
        ..Paint::default()
    };

    pixmap.fill_rect(Rect::from_xywh(0.0, 0.0, 200.0, 200.0).unwrap(), &paint, Transform::identity(), None);
}

他にも知りたいことはあったので、「画像を描画するための skia ライブラリについて質問です。 SkPixelMap 上に四角形を描画したいときは、どうしますか。サンプルコードを作ってください。」というプロンプトから初めて、その後 tiny_skia との違いを教えてもらいつつ、最後に知りたかったこととしてこの違いを生成させた。 Google 検索と決定的に違うのは、この一連の質問の過程で ChatGPT がこちらが話している内容のコンテキストをぐんぐんと取り込んでいくことである。後半に行けば行くほど、こちらが明確な指示を出さなくても的確な結果を生成するようになる。

ChatGPT がもう一点強いのは、英語の生成である。例えば、ブランチ名やメソッド名、コード内のコメント、 git の commit メッセージや github の PR の解説文など、 OSS を意識した場合に英語が望ましいシーンがプログラミングでは大量に発生する。 ChatGPT はこれらの書式をよく知っているので、プロンプトさえ的確に書けば生成して提案してくれる。その際に、どんな言語でどのような開発をしているのかもコンテキストとして与えれば、(我々日本人の感覚では分からないが)英語圏の開発者が違和感を感じないレベルの命名、英文が生成される。

最近購入した UMPC との相性も良い。 UMPC では大量の文字を打つのは大変だが、そこを ChatGPT にやらせて、自分は生成されたコードをレビューすれば良い。 この PR はすべて UMPC (と外付けのモバイルキーボード)上で生成したが、 ChatGPT の力を大いに借りている。太古の昔と違って、現在のプログラミングはほぼすべてのロジックはすでに誰かが生成しており、我々はそれを切り貼りして新しいコードを作っている。ライブラリには必ずサンプルコードが付いているし、 linter がコードの記述のブレをなるべく無くして均一なコードになるようにしてくれる。創作的だと錯覚しているプログラミングだが、実はそこまで創作的な行為ではないのだ。ほとんどの場合は独創性は必要とされず、生成 AI である ChatGPT が得意とするところである。

もちろん、生成されたコードの正しさは必ずしも担保されない。難しいタスクであれば失敗する。しかし、それは人間でも同じである。ちょっと出来の悪い部下に依頼して、コードを書かせたり調査をしてもらったと思えばいいのである。月額 3,000 円以上は少し効果だが、人間を雇うよりは遥かに安い。しかも、「ちょっと出来の悪い」は、そんじょそこらのエンジニアの平均レベルよりは、出来が良い可能性もある。


  1. そのまま使うことが目的ではないので、コンパイルできるか、動くかは見ていない。

ライフタイム制約と所有権の移動

以下の Rust のコードはコンパイルできる。

fn f<T: 'static> (_: T) {}

fn main() {
    let s1: &'static str = "static";
    f(s1);

    let s2: String = String::from("string");
    f(s2);
}

fs1 を渡せるのは、よくわかる。 &'static なのだから、 T: 'static を満たすのは当然と言えよう。

しかし、 s2 はどうだろうか? 'static とは思えない。 lifetime が 'static というのは、 ChatGPT4 さんによれば、「参照されるデータ自体がプログラムの実行期間全体で生存する」ことらしい。 s2 は関数内で定義したローカル変数なので、スコープを外れれば削除されてしまうように思える。この例ではたまたま main 関数で定義しているので、プログラムの実行期間全体で生存すると言えばそう言えるのかもしれないのだが、別に main 関数ではなくてもこの結果は変わらない。

この件について ChatGPT4 さんと小一時間議論したのだが、スッキリした回答は得られなかった。そこで Rust のドキュメントにあたったところ、単純明快な回答が書かれていた。

doc.rust-lang.org

T: 'a means that all lifetime parameters of T outlive 'a.

つまり、これは逆を言えば、 lifetime parameters を持たない型は任意の 'a 制約を満たすし、当然 'static 制約も満たすのだ。なるほど。

GPD win miniを買った

久しぶりにパソコンを購入した。と言っても、UMPCである。

gpd-direct.jp

ぶっちゃけ、小さすぎて普通のPCとしては使えない。デスクトップのWindowsが動くスマホ、という程度の使い勝手であることは想像できた。ので、買うべきかは非常に迷った。

とは言え、自転車移動をするようになって小さなPCは探していて、重さが500g程度なのも魅力だった。清水の舞台から飛び降りる覚悟で購入した。

まず、性能に関しては素晴らしいの一言。 VSCode も WSL も普通に動くし、 rust のプログラムもさくさくコンパイルすることができる。キーボードやディスプレイを外付けしてしまえば、当たり前ではあるが普通のデスクトップ機となる。

では、外付けせずに単体で使うとどうかと言えば、思っていたよりは使えるなあという印象を持った。キーボードの親指入力はスマホでやるよりは遥かに打ちやすいし、CtrlやEsc、Altも備えた普通のキーボードなので、bashなどの普段のキー操作が普通に使える。ただ、Emacsやtmuxのキーバインドは手癖で覚えているもののようで、親指入力で再現しようとすると、やはり全く手が動かなくなる(キー操作を思い出せれば入力できる)。

ゲームパッドによるマウス操作も、まあ、普通に使える。そういう意味では、きちんと使いやすさを考慮した入力インタフェースになっているなあと思う。キーボードにバックライトがあるのも地味に嬉しい。このブログも、暗い寝床で親指入力で書いている。

とは言え、この端末で文を打つのは、通常キーボードより遥かに疲れる。画面が小さいので当然だが、目の負担もハンパじゃない。その点においては、最初から小さいことを念頭に作られているスマホの方が上手であると言える。

最初にも書いたが、性能的には普通に開発ができる端末である。出先で簡単なコードを書いたり、コードリーディングしたりくらいは試しておきたい。AtCoderの練習をするくらいには使えるだろうか。ああ、そう言えば、Dockerをまだ入れていなかった。