トレラン用品の需要と入手性

先日、神田のスポーツ用品店を回ったときに感じたのですが、トレラン用品ってやっぱり入手しやすいとは言えないですね。自分の住んでいる大阪でも、梅田まで出かけてもお目当てのものが確実に入手できるとは限らないし、登山用品やランニング用品のかたわらにちょっと置いてあるという感じ。需要のあるところに供給ありと言いますが、まだまたマイナーなんですね。といってもあまり競技人口が増えても、レースの運営面で難しいところも出てくるので、今くらいが丁度よいくらいなのかもしれない。

まあ、入手しにくいといっても、オリエンテーリング用品に比べたら全然ましですが。オリエンテーリング用品で一般のお店で入手できるのはコンパスくらいかな。それも登山用品として売っていて、しかも初心者向けのものだし。自分が使っているのは全部個人輸入の形で海外から取り寄せたもの。ただ、この間の大会で見ていたらシューズは、イノヴェートやソロモンなどトレラン用のを使っている人が多かった。やっぱり一般のお店で買えるのが良いのだろう。選択肢も多いし、試し履きもできるし。

トレランでも、靴とザックは専用の方が良いが、ウェアとかは、ランニング用や登山用を使っててもよいし、そういう意味では専門店を構えてやっていくのはなかなか難しいんだろうと思う。自分の家から一番近い(といっても16kmくらいありますけど)、ソトアソさんには頑張ってほしいです。

クラブカップリレー(オリエンテーリング)に参加しました。

昨日は、3連休の中日、岐阜県で開催されたオリエンテーリングの大会、クラブカップリレーにベテランクラスで参加してきました。
走順は4人リレーの4人目。一番距離が短いコースでしたが、思ったよりも地形が読めなくて大苦戦してしてしまいました。
最近、オリエンテーリングの大会にはあまり出てないので、難しいコースだとレース感が鈍っていて、もっと慎重に走るべきでした。
それにしても、老眼のせいで、走りながらでは地図の細かいところが読めなくて、帰ってきてからじっくり眺めたら、もっとああすればよかったと思う点が多々ありました。
そのあたりの感どころがレースにあまり出てないと、狂ってしまいがちなところですね。
次回は、10月中旬の全日本大会になります。もうちょっとミスを減らしたいな。
ところで、オリエンテーリングの大会で初めてGPSで記録をとってみました。GPSのルートを地理院地図に重ねてみると、どこでどうミスしてうろうろしていたかがよくわかって面白いですね。

f:id:alasixOsaka:20190916155043j:plain
当日のルートとコースを地理院地図上に重ねてみた。

スタートでボタンを押し忘れたので、1番ポストに着いてからしか記録がないが、大きくミスっているのは2番と4番。
2番はまっすぐ行こうとして下のほうにずれて行き過ぎて現在地を見失っている。後でじっくり地図を読めば、もう少し慎重に、高いところから攻めるか、初めから低いところを目指していけば問題なくたどり着けたはず。
4番は、手前でウロウロしてしまっている。実際はもっと遠くにポイントがあった。なんとなくそれに気づいて奥の尾根に登ってからアタックしなおしてようやく見つけたが、初めから奥の尾根を目印にアタックしていればもっとロスタイムを減らすことができたはず。
次はもうちょっとまともなレースをしないと。

ダイアログリストからファイルを選択する(アンドロイド地図アプリの開発 その10)

前回、実機を使って端末の向きを検出して地図を回転することを行いました。
alasixosaka.hatenablog.com

ただ、地図は相変わらずのベルリンマップで、実際に使うには日本の地図にする必要があります。
しかし、デフォルトで日本の地図にするだけでは面白くないので、地図ファイルを選択できるようにしてみました。
地図ファイルの選択は以前にもやりましたが、その時はファイルマネージャーを起動してそこからファイルを選ぶという方法でした。
alasixosaka.hatenablog.com
それはそれで目的は達成できるので良いのですが、いろいろ調べていると、もっとスマートな方法があるようなので、今回試してみました。
それがダイアログを表示してその中のリストから選択するという方法です。

ダイアログとは

ダイアログは、警告や許可を求めるときなどに表示されるアレです。開発中のアプリでも、ファイルのアクセスやGPS位置情報のアクセスには許可が必要なので、初回のみですがダイアログが出てきて許可をするかしないかの選択をします。
ダイアログ表示にするメリットとしては、ファイルを拡張子でフィルタリングして必要な拡張子のみ表示させることが可能な点があります。マップ形式のファイルであれば拡張子はmapですし、GPX形式のファイルであれば拡張子はgpxです。なので、マップファイルを選択するときは拡張子がmapのファイルだけを選んで表示させることが可能になります。

まずは小さなアプリから

仕組みを理解するには初めから複雑なことをしていては理解が進まないので、メインアクティビティ一つの小さなアプリから始めます。

public class MainActivity extends AppCompatActivity {
    final String[] items = {"Data1", "Data2", "Data3", "Data4","Data5","Data6","Data7","Data8","Data9","Data10","Data11","Data12","Data13","Data14","Data15"};
    int selected;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        setContentView(R.layout.activity_main);

        Button button = findViewById(R.id.button);
        clickListener listener = new clickListener();
        button.setOnClickListener(listener);


    }

    private class clickListener implements View.OnClickListener{
        @Override
        public void onClick(View view){
            new AlertDialog.Builder(MainActivity.this).setTitle("データを選択してください").setItems(items, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int item) {
                    callToast(item);
                }
            })
            .show();
        }
    }
    // ダイアログで入力した値をtextViewに入れる - ダイアログから呼び出される
    public void callToast(int i){
        Toast.makeText(this,items[i]+"が選択されました",Toast.LENGTH_SHORT).show();
    }
}

ビューはボタンが一つだけのシンプルなものです。

f:id:alasixOsaka:20190907145715j:plain
起動時の画面。ボタンが一つ表示されるだけ。
ボタンをクリックするとダイアログが表示されてリストが出てきます。
f:id:alasixOsaka:20190907145755j:plain
ボタンをクリックするとダイアログが表示され、リストが出てきます。
ここで、なんでもいいから適当にクリックすると、「xxxが選択されました」とトースト表示されます。
f:id:alasixOsaka:20190907145912j:plain
選択したのがトースト表示される。
表示するデータは冒頭でString[] items = {.....}で定義している。ダイアログを表示する部分はpublic void onClick(View view){... の部分で、AlertDialog.Builderをnewして、setTitleでタイトル表示、setItemsでリスト表示を行っている。setItemsは第一引数が表示するリスト(ここでは文字列配列のitems)、第二引数がクリックリスナー。何番目をクリックしたかは、その後の@Override以下の部分でint itemに受け渡される。それをcallToastで処理して表示させている。

ダイアログ表示を別クラスにする

実際の地図アプリはいろいろなクラスが集まって一つのアプリになっている。メインアクティビティではボタンを配置していろいろなことを処理するようにしているので、そこに地図ファイル選択のボタンを配置してファイル選択用のアクティビティを起動できるようするのが最終目的になる。そこで、メインアクティビティとは別にファイル選択専用のアクティビティを作って動かしてみた。また、そうすることで、マップ形式のふぁいるだけでなく、gpx形式のファイルも同じプログラムを使って選択することが可能になる。
しかし、ちょっと複雑になってくると素人の悲しさで途端にあちこちにはままってしまった。
まずは、メインアクティビティから。

public class MainActivity extends AppCompatActivity implements FileListDialog.onFileListDialogListener {
    
    String sdPath = Environment.getExternalStorageDirectory().getPath();
    private final static int REQUEST_PERMISSION = 1002;
    String fillter = "map";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (Build.VERSION.SDK_INT >= 23) {
            // permissionの確認
            checkPermission();
        }
        else {
            setupSearch();
        }




    }

    private void setupSearch(){
        Button button = findViewById(R.id.button);
        clickListener listener = new clickListener();
        button.setOnClickListener(listener);
    }

    private void checkPermission() {
        if (ActivityCompat.checkSelfPermission(this,
                Manifest.permission.READ_EXTERNAL_STORAGE) ==
                PackageManager.PERMISSION_GRANTED){

            setupSearch();
        }
        // 拒否していた場合
        else{
            requestLocationPermission();
        }
    }

    private void requestLocationPermission() {
        if (ActivityCompat.shouldShowRequestPermissionRationale(this,
                Manifest.permission.READ_EXTERNAL_STORAGE)) {
            ActivityCompat.requestPermissions(MainActivity.this,
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
                    REQUEST_PERMISSION);

        } else {
            Toast toast = Toast.makeText(this,
                    "許可されないとアプリが実行できません", Toast.LENGTH_SHORT);
            toast.show();

            ActivityCompat.requestPermissions(this,
                    new String[]{Manifest.permission.READ_EXTERNAL_STORAGE,},
                    REQUEST_PERMISSION);

        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode,
                                           @NonNull String[] permissions,
                                           @NonNull int[] grantResults) {
        if (requestCode == REQUEST_PERMISSION) {
            // 使用が許可された
            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                setupSearch();

            } else {
                // それでも拒否された時の対応
                Toast toast = Toast.makeText(this,
                        "これ以上なにもできません", Toast.LENGTH_SHORT);
                toast.show();
            }
        }
    }

    private class clickListener implements View.OnClickListener{
        private FileListDialog.onFileListDialogListener listener;

        @Override
        public void onClick(View view){
            FileListDialog dlg = new FileListDialog(MainActivity.this);
            dlg.setOnFileListDialogListener(MainActivity.this);
            dlg.show(sdPath,sdPath,fillter);

        }

    }
    
    @Override
    public void onClickFileList(File file) {
        if (file == null) {
            Toast.makeText(this, "ファイルが取得できませんでした", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(this, file.getName(), Toast.LENGTH_SHORT).show();

        }
    }
}

メインアクティビティは大したことをしていない。はじめのシンプルなアクティビティと同様にメインアクティビティの画面はシンプルなボタン表示しかない。ボタンをクリックするとダイアログを表示する別アクティビティに飛ぶようになっている。

    private class clickListener implements View.OnClickListener{
        private FileListDialog.onFileListDialogListener listener;

        @Override
        public void onClick(View view){
            FileListDialog dlg = new FileListDialog(MainActivity.this);
            dlg.setOnFileListDialogListener(MainActivity.this);
            dlg.show(sdPath,sdPath,fillter);

        }

    }

の部分がそうだ。
あらかじめ、FileListDialog.javaとして別クラスを生成しておき、FileListDialog dlg = new FileListDialog(MainActivity.this)の部分でインスタンスを生成しています。
そして、dlg.setOnFileListDialogListener(MainActivity.this)でインターフェイスを実装しています。
また、dlg.show(sdPath, sdPath, fillter)でダイアログを表示しています。第一引数はディレクトリ、第二引数はタイトル、第三引数がフィルターです。
フィルターは冒頭の部分でfillter="map"として、拡張子がmapのファイルを抽出するようにしています。実はここがはまりどころの一つだった。詳細は後で書くことにする。

ダイアログで選択したファイル名は、

    @Override
    public void onClickFileList(File file) {
        if (file == null) {
            Toast.makeText(this, "ファイルが取得できませんでした", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(this, file.getName(), Toast.LENGTH_SHORT).show();

        }
    }

の部分でトースト表示している。fileには選択したファイルがファイル形式で格納されている。file.getName()でファイル名が得られるので、 Toast.makeText(this, file.getName(), Toast.LENGTH_SHORT).show();
でトースト表示してやっている。
メインアクティビティでは、FileListDialog.onFileListDialogListenerをimplemetしておき、onClickFileListをオーバーライドすることで、ダイアログで選択したファイルをfileに格納している。
この部分、参考サイトのやり方をまねて書いている。
alldaysyu-ya.blogspot.com

しかし、はじめは、implemetの記述をしておらず、ダイアログが出て選択できるが、そこからファイル名を得ることができずに困っていた。同じように書いてもエラーになるし、理由がわからなかった。
通常、オーバーライドは、継承した親クラスの処理を子クラスが上書きすると説明されている。しかし、メインアクティビティでは、継承しているのは、extends AppCompatActivity と書かれているだけで、onClickFileListとは関係ない。なので@Overrideとするとエラーになる。
ここで、FileListDialog.onFileListDialogListenerをimplementしてやることでオーバーライドができるようになり、FileListDialogクラスの方から選んだファイル名を持ってこれるようになった。こういうやり方もあるのだなと勉強になった。

一方、FileListDialog.javaの方は、ほぼ参考サイトの記述をそのまま使っている。ダイアログ表示の部分はpublic void show (String path, String title,String fillter) {... 以下の部分で、pathでディレクトリのパスをtitleでダイアログに表示するタイトルを、fillterで拡張子のフィルターを受け取っている。
参考サイトと少し変えているところが、拡張子にフィルターをかけるところで、拡張子が一致しているかの判断は、file.getName().endsWith(fill))で行っている。
また、ファイルリスト自体は、File(path).listFiles()で得られるが、フィルタリングしているのでフィルタリング後のファイルリストを作成する必要がある。はじめは、これをやらなかったので、クリックしても別のファイル名が選ばれていて、ありゃりゃとなった。フィルタリング後のファイルリストは、countをカウント変数として、ファイル名がディレクトリか拡張子が一致した場合のみ、finalFileList[count] = fileとして、配列に格納し、カウンタをインクリメントしている。ファイル名の方はアレイリストに加えていって、String [] array = arrayList.toArray(new String[arrayList.size()])で文字列配列に変換し、最後にAlertDialog.Builder()で表示している。

クリックしたときの処理は、public void onClick(DialogInterface dialog, int which) {... 以下の部分で、ディレクトリが選ばれていたらそのディレクトリ内のファイルリストを再度表示し、ファイルが選ばれていたらそのファイルを返す処理を行っている。

public class FileListDialog extends Activity implements View.OnClickListener, DialogInterface.OnClickListener {

    private Context _parent = null;
    private File[] _dialog_file_list;
    private onFileListDialogListener _listener = null;
    private int _select_count = -1;
    private boolean _is_directory_select = false;
    private String fill = "";
    private ArrayList<String> arrayList ;
    private File [] finalFileList;

    public void setDirectorySelect(boolean is){
        _is_directory_select = is;
    }

    public boolean isDirectorySelect(){
        return _is_directory_select;
    }

    public FileListDialog(Context context){
        _parent = context;
    }

    public void show (String path, String title,String fillter) {
        fill = fillter;
        arrayList = new ArrayList<>();
        try {
            _dialog_file_list = new File(path).listFiles();
            if(_dialog_file_list==null){
                if(_listener!=null){
                    //_listener.onClickFileList(null);
                }
            }else {
                finalFileList = new File[_dialog_file_list.length];
                int count = 0;
                String name = "";

                for (File file : _dialog_file_list){
                    //ディレクトリ名ないし拡張子がfillに一致していればリスト化する
                    if(file.isDirectory()||file.getName().endsWith(fill)) {
                        name = file.getName();
                        //ディレクトリ名なら名前の後ろに"/"をつける
                        if(file.isDirectory()){
                            name = name + "/";
                        }
                        //アレイリストにファイル名(ディレクトリ名)を追加
                        arrayList.add(name);
                        //フィルタリング後の最終リストを作成。
                        finalFileList[count] = file;
                        count++;
                    }
                }
                //作成したアレイリストを文字列配列に変換
                String [] array = arrayList.toArray(new String[arrayList.size()]);
                //ダイアログに表示
                new AlertDialog.Builder(_parent).setTitle(title).setItems(array,this).show();
            }
        }catch (SecurityException se){

        }catch (Exception e){

        }
    }

    @Override
    public void onClick(DialogInterface dialog, int which) {
        _select_count = which;
        if((_dialog_file_list==null)||(_listener==null)){

        }else {
            //File file = _dialog_file_list[which];
            File file = finalFileList[which];
            //もしディレクトリだったら選択されたディレクトリのファイルリストを再度表示する
            if(file.isDirectory() && !isDirectorySelect()){
                show(file.getAbsolutePath(), file.getPath(),fill);
            //ファイルが選択されていれば、fileを返す。
            }else {
                _listener.onClickFileList(file);
            }
        }

    }

    @Override
    public void onClick(View v) {

    }

    public void setOnFileListDialogListener(onFileListDialogListener listener) {
        _listener = listener;
    }

    public interface onFileListDialogListener{
        public void onClickFileList(File file);
    }
}

メインアクティビティでは、ボタンがクリックされると引数として、検索するディレクトリ、表示するタイトル(ディレクトリ名と同じ)、検索する拡張子を入れて、dlg.show(sdPath,sdPath,fillter)としてやってダイアログ表示させる。
クリックで選ばれたファイルは public void onClickFileList(File file) {...  のところでfileとして返される。

地図アプリに実装する

ファイル選択ができるようになったので、地図アプリの方に実装してみる。ここまでできれば実装自体はさして難しくない。しかし、アプリを起動するたびに毎度毎度地図ファイルを選択するのは芸がないので、いぜんやったのと同じように、SharedPreferenceを使って、マップファイルの初期値を入れておいて、前回選んだファイルが次回の起動時に反映されるようにしておく必要がある。また、もともとのプログラムはベルリンの地図を選ぶように書かれているので、そこの部分をSharedPreferenceから値を拾ってくるように書き換える必要がある。
メインアクティビティでは、ボタンを一つ追加し、”Map File Select"とした。

f:id:alasixOsaka:20190907183054j:plain
メイン画面にファイル選択のボタンを追加

ファイル選択ボタンをbtMapfileSelectとしてリスナ登録しておき、クリック処理のところをswitch(id){... で分岐処理して、ファイル選択ダイアログを出すようにしている。case R.id.btMapfileSelect:以下がその処理の部分で、
fillter="map"として拡張子を”map"として、 Path = sdPath で外部ファイルのルートディレクトリを指定している。

            switch (id) {

                case R.id.btRotationalView:
                    Intent RMV = new Intent(getApplicationContext(),RotateMapViewer.class);
                    startActivity(RMV);
                    break;

                case R.id.btMapfileSelect:
                    fillter="map";
                    SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
                    MAP_FILE = preferences.getString(fillter, "berlin.map");
                    Path = sdPath;
                    FileListDialog dlg = new FileListDialog(MainActivity.this);
                    dlg.setOnFileListDialogListener(MainActivity.this);
                    dlg.show(Path,Path,fillter);


                    break;
            }

ファイル選択後の処理は、SharedPreferenceの処理を追記して、ファイル名を書き換えている。

     public void onClickFileList(File file) {
        if (file == null) {
            Toast.makeText(this, "ファイルが取得できませんでした", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(this, file.getName(), Toast.LENGTH_SHORT).show();
            SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
            SharedPreferences.Editor editor = preferences.edit();
            editor.putString(fillter, file.getName());
            editor.putString("path", file.getPath());
            editor.apply();

        }
    }

選んだファイルを反映せせる部分は、検索したところ、SImplestMapViewer.javaとSamplesBaseActivity.javaに該当箇所があった。デバッガーを動かして確認したところ、SamplesBaseActivity.javaの方を使っているみたいだったが、念のため両方をSharedPreferenceからとってくるように書き換えた。
それぞれの該当箇所は下記の通り。retuen "berlin.map"となっているところをコメントアウトし、SharedPreferenceからファイル名を引っ張ってくるようにした。

     protected String getMapFileName() {
        SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);
        return preferences.getString("map", "berlin.map");
        //return "berlin.map";
    }
     protected String getMapFileName() {
        SharedPreferences preferences = getSharedPreferences("DATA", Context.MODE_PRIVATE);

        String mapfile = (MainActivity.launchUrl == null) ? null : MainActivity.launchUrl.getQueryParameter("mapfile");
        if (mapfile != null) {
            return mapfile;
        }
        return preferences.getString("map", "berlin.map");
        //return "berlin.map";
    }

ただ、このままでは、ファイルを別ファイルに変えても、マップの中心位置がそのままなので、GPSの最後の位置を覚えておいてそこを中心にするとか工夫が必要になる。そのあたりは、次回以降で行っていきたい。

実機ではうまくいかない

この方法、実機では内部のストレージにしかアクセスできないのでうまくいきませんでした。ファイルマネージャーを使った処理をした時に、散々調べてSDカードのディレクトリを調べる王道がないことをすっかり忘れていました。マップファイルの選択は今のところファイルマネージャーを使うしかなさそうです。したがって、この方法はGPXファイルやPOIのファイルを選択するときに使うことを検討したいと思います。

神田のスポーツショップ街に行ってみました

今日は東京に出張だったので、ついでに神田のスポーツショップ街に行ってみました。

仕事は午後からだったので、少し早めの新幹線で東京から神田へと向かった。

しかし、それにしても暑い、暑いだけでなくめちゃくちゃ蒸し暑い。関東には7年くらい住んでいたがここまで蒸し暑いのはあまりないじゃないかな。千葉の方ではまだ停電のところがあるそうで、この暑さでエアコンなしはかなり体にこたえるのではないかと思う。

さて、スポーツ街に来た目的は、トレラン用のロングパンツとポールを見るため。ポールはマウンテンキングのがみたいと思っていたが、さかいやスポーツにも石井スポーツにも無くて空振り。ロングパンツはノースフェイスのフライトエクスプロールタイツ、ファイントラックのフロウフラップあたりを狙っていたがこちらもどっちにもなくって空振りに終わった。ノースフェイスは神田に直営店があるとのことだったが行って見たら閉まっていた。

ファイントラックはさかいやスポーツにスカイトレイルというのはあって結構いい感じだったがやっぱりフロウフラップを見て見たいと思って、結局買わずじまい。

お昼は神保町の交差点にあるお蕎麦やさんへ。ザルが二枚とかき揚げがついてお値段何と450円。会社の社員食堂で食べるのと変わらない!!いつから東京の物価はこんなに安くなったの?いやあ、ビックリ。

でもトレラン用のロングパンツが買えなかったので、帰りに京橋のモンベルショップによって見た。始めビルの案内に2Fと表示があったので2Fに行って見たら、自転車用のウェアと子供用しか無い。ここのお店はこういうコンセプトのお店なのかとあきらめかけたら、下に降りる階段があって念のため行って見たら、一杯ありました。種類がめちゃくちゃあって目移りして選び切れないくらいある。

結局、ここでクロスランナーパンツを購入。

 

ついこの間、オンラインショップでソフトシェルのアウトレットを買った時に会員になっておいた甲斐があった。でもここのモンベルショップは品揃えが豊富でとっても良い。東京駅からも歩いて行けるし、出張の度に寄ってしまいそうだ。

とりあえず、これでウェアは何とか揃ったので、後はポールをどうするか?東海道浪漫歩行で使って見たいと思ってはいるのだが、レースではしばらくは出番街なさそうなので、しばらく悩むことになりそう。

突然の不調

関東では台風でかなりの被害が出た見たいですが、関西では厳しい残暑が続いています。

ちょっとバテぎみなのか、お疲れモードだったので、昨日は軽めのチャリトレ。ロードバイクで20km ほどのアップダウンのあるコースを走った。走り始めは体が重いと感じていたが、走り出すと快調に走れて、松ヶ丘の登りもいつもよりすいすい登れた。ところが、飛ばしすぎがたたったのか、摂津峡の奧の林道を走っているあたりから急に苦しくなって、萩谷に着いた頃には完全に息があがっていた。家に帰ってからもなんとなく息苦しい状態が続いて、今日もなんとなく息苦しい感じが残っている。喘息の苦しいのとは少し違って、呼吸は問題ないが、息をしても酸素が足りてない感じ。これが単なるオーバーワークだったらよいのだが。まあ、もう少し様子を見るしかなさそうだが。

ところで、この間蝉はもう鳴いてないと書いたばっかりだったが、林道を走っているといっぱい鳴いてました。家の辺りではすっかり聞かなくなった蝉の声を久しぶりに聞いて、季節感が一気に逆戻りしてしまった。

スマホのセンサーで方位を知る(アンドロイド地図アプリの開発 その9)

アンドロイド地図アプリの開発。今日は第9回目です。
今回はスマホに搭載されているセンサーで方位を測ってみようというものです。測った方位をもとに地図を回転してやれば、常に地図の方位と自分の向きが合うので地図が見やすくなります。
というか、グーグルマップでもカーナビ用のアプリでもみんな基本はそうなっているので、開発中のアプリにもその機能を搭載してやろうということです。
地図をある角度で回転させるということはすでにできているので、あとはセンサーの数値を取り出すだけです。
alasixosaka.hatenablog.com
スマホのセンサーを使うので、エミュレータでは無理なので今回は実機を使うことになります。

方位を知るためのセンサー

スマホには色々なセンサーが搭載されています。この中で、方位を知る方法は大まかに2通りの方法があるようです。

  1. GPSを使う方法
  2. 地磁気センサーと加速度センサーを使う方法

GPSを使う方法は、メリットとしては、どうせGPSを使って現在地を測るのだから、ついでに方位まで知ることができるので一石二鳥になる。デメリットは、あくまで移動してきた方向を知ることができるだけで、例えばじっとしているときに今どっちに向いているかはわからないということ。
地磁気センサーと加速度センサーを使う場合は、じっとしていても向いている方位がわかるのでリアルタイム性に優れている。ただ、余分にセンサーを使うことになるので消費電力が増えることになる。
どちらも、一長一短だが、今回は地磁気センサーと加速度センサーを使う方法を試してみた。消費電力の問題はモバイルバッテリーで何とかできると考えている。
ちなみに、GPSを使う方法では、location.getBearing()という関数を使うと方位が得られる。

なぜ 地磁気センサーと加速度センサーを使うのか

スマホには地磁気センサーが搭載されていて、方位がわかるようになっている。でも、端末が傾いていると傾きが反映されないので、方位がうまく測れないらしい。そこで、加速度センサーを使って端末自体の傾き、水平なのか少し立っているのかを調べて計算するらしい。あまり細かいことはわからないけど。詳しいことを書いてあるサイトは少ないけど、下記のサイトの解説は割とわかりやすく書いてあった。
方位角を計算したいけど全然分かんない – dalomo
論より証拠なのでとりあえず、ソースコードの載っているサイトを参考に、プログラムを書いてやってみる。ところが、問題が!!

値のふらつきが大きい

参考サイトのソースコードを使うと確かに、値を得ることができるのですが、値がふらついて安定しません。イベントリスナーを使って値をとってくるので、値が変わるたびにイベントリスナーが呼び出され、地図を回転しようとすると非常に細かい間隔で地図が微妙に動くことになります。(Mapsforgeの描画がそこまで追随できるかという問題もあるとは思いますが)
なので、もう少し値のふらつきを減らす必要があります。地図の向きを整えるだけなので、正直あまり精度は不要で、おおまかに10度刻みとか5度刻みでもよいと思っています。

ローパスフィルターは効果が弱い。

色々調べると、ローパスフィルターをかけると良いという記事があり、やっては見たもののあまり効果的でなく、ふらつきが若干ましになる程度。もっと効果的な方法が必要です。

センサーの取得間隔を長くする。

さらに調べると、どうやら地磁気センサーに関してはセンサーの取得間隔を調整できるらしい。加速度センサーはダメ見たい。
そこで簡単なプログラムを書いて実験してみた。ソースは下記の通り。

public class MainActivity extends AppCompatActivity implements SensorEventListener {
    private SensorManager m_sensorManager;
    private TextView m_val_x_TextView;
    private TextView m_val_y_TextView;
    private TextView m_val_z_TextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        m_sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);
        m_val_x_TextView = findViewById(R.id.val_x);
        m_val_y_TextView = findViewById(R.id.val_y);
        m_val_z_TextView = findViewById(R.id.val_z);
    }
    @Override protected void onResume() {
        super.onResume();
        // Event Listener登録
        Sensor accel = m_sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
        m_sensorManager.registerListener(this, accel, (int)1e6);
    }

    @Override protected void onPause() {
        super.onPause();
        // Event Listener登録解除
        m_sensorManager.unregisterListener(this);
    }

    @Override
    public void onSensorChanged(SensorEvent event) {
        if(event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD){
            m_val_x_TextView.setText(String.format("%.3f", event.values[0]));
            m_val_y_TextView.setText(String.format("%.3f", event.values[1]));
            m_val_z_TextView.setText(String.format("%.3f", event.values[2]));
        }
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy) {

    }
}

地磁気センサーを動かして、x,y,zそれぞれの値を画面に表示するだけのシンプルなプログラム。onRsumeとonPauseを使っているのは、作法に従っただけで、センサーの消費電力を抑えるための工夫で、画面が隠れて別アプリが起動するとセンサーをオフにするようにしている。
やっていることは、m_sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE); でセンサーマネージャーをインスタンス化して、
Sensor accel = m_sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
m_sensorManager.registerListener(this, accel, (int)1e6);
でリスナー登録をしていて、その時の引数で取得間隔を調整している。値はマイクロ秒単位で、(int)1e6の部分が相当する。今回は1秒間隔にしている。
ちなみに、加速度センサーでやってみたがやっぱりうまくいかないようだった。Sensor accel= ・・・のところはその名残で、地磁気センサーなので、mgfieldとかなんとかがふさわしいのだけれどそのままになっている。
値の取得は、

 @Override
 public void onSensorChanged(SensorEvent event) {
        if(event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD){
            m_val_x_TextView.setText(String.format("%.3f", event.values[0]));
            m_val_y_TextView.setText(String.format("%.3f", event.values[1]));
            m_val_z_TextView.setText(String.format("%.3f", event.values[2]));
        }
    }

の部分で、得られた値をテキストビューで表示するだけである。

f:id:alasixOsaka:20190907112557p:plain
地磁気センサーの値を1秒間隔で取得すると安定する。
1秒間隔にするとまあまあいい感じである。

1秒間隔で方位を取得する。

間隔を調整できたので、方位を計算して表示するアプリに適用してみる。

public class MainActivity extends AppCompatActivity {
    private SensorManager sensorManager = null;
    private SensorEventListener sensorEventListener = null;

    private float[] fAccell = null;
    private float[] fMagnetic = null;
    float[] saveAcceleVal  = new float[3];
    float[] saveMagneticVal = new float[3];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);

        sensorEventListener = new SensorEventListener() {
            @Override
            public void onSensorChanged(SensorEvent event) {
                switch (event.sensor.getType()){
                    case Sensor.TYPE_ACCELEROMETER:
                        fAccell = event.values.clone();
                        LowPassFilter(fAccell);
                        break;
                    case Sensor.TYPE_MAGNETIC_FIELD:
                        fMagnetic = event.values.clone();
                        LowPassFilter2(fMagnetic);
                        float[] inR = new float[9];
                        SensorManager.getRotationMatrix(inR, null, fAccell,fMagnetic);
                        float[] outR = new float[9];
                        SensorManager.remapCoordinateSystem(inR, SensorManager.AXIS_X, SensorManager.AXIS_Y, outR);
                        float[] fAttitude = new float[3];
                        SensorManager.getOrientation(outR, fAttitude);
                        String buf =
                                "---------- Orientation --------\n" +
                                        String.format( "方位角\n\t%d\n", (int)rad2deg( fAttitude[0] )) +
                                        String.format( "前後の傾斜\n\t%d\n", (int)rad2deg( fAttitude[1] )) +
                                        String.format( "左右の傾斜\n\t%d\n", (int)rad2deg( fAttitude[2] ));
                        TextView t = (TextView) findViewById( R.id.textView1 );
                        t.setText( buf );
                        break;
                }
                //if (fAccell != null && fMagnetic != null){

                //}
            }

            @Override
            public void onAccuracyChanged(Sensor sensor, int accuracy) {

            }
        };
    }
    private float rad2deg( float rad ) {
        return rad * (float) 180.0 / (float) Math.PI;
    }
    protected void onStart() { // ⇔ onStop
        super.onStart();

        sensorManager.registerListener(
                sensorEventListener,
                sensorManager.getDefaultSensor( Sensor.TYPE_ACCELEROMETER ),
                (int)1e6 );
        sensorManager.registerListener(
                sensorEventListener,
                sensorManager.getDefaultSensor( Sensor.TYPE_MAGNETIC_FIELD ),
                (int)1e6 );
    }

    protected void onStop() { // ⇔ onStart
        super.onStop();

        sensorManager.unregisterListener( sensorEventListener );
    }

    final float filterVal = 0.8f;

    public void LowPassFilter(float[] target ){
        float outVal[] = new float[3];
        outVal[0] = (float)(saveAcceleVal[0] * filterVal
                + target[0] * (1-filterVal));
        outVal[1] = (float)(saveAcceleVal[1] * filterVal
                + target[1] * (1-filterVal));
        outVal[2] = (float)(saveAcceleVal[2] * filterVal
                + target[2] * (1-filterVal));

        //現在の測定値を次の計算に使うため保存する
        saveAcceleVal = target.clone();

        //加速度センサーから得た値を書き換える
        fAccell = outVal.clone();
        return ;
    }
    public void LowPassFilter2(float[] target ){
        float outVal[] = new float[3];
        outVal[0] = (float)(saveMagneticVal[0] * filterVal
                + target[0] * (1-filterVal));
        outVal[1] = (float)(saveMagneticVal[1] * filterVal
                + target[1] * (1-filterVal));
        outVal[2] = (float)(saveMagneticVal[2] * filterVal
                + target[2] * (1-filterVal));

        //現在の測定値を次の計算に使うため保存する
        saveMagneticVal = target.clone();

        //加速度センサーから得た値を書き換える
        fMagnetic = outVal.clone();
        return ;
    }
}

色々試してみた名残が残っていて、一応ローパスフィルターもかけてあるし。また、センサー取得間隔も加速度センサーのリスナー登録の部分にも書いてあって不細工なプログラムになっているが、まあ動けばいいやということで。

f:id:alasixOsaka:20190907112653p:plain
1秒間隔で方位を取得。角度は小数点以下は四捨五入している。

いい感じなので、地図アプリの方に書き込んでみた。長くなるので詳細は書かないが、RotateMapViewerクラスを書き換えている。createControls()のところにRotate Buttonを登録する部分があるが、その部分をキャンセルしてセンサーマネジャーとイベントリスナーを登録し、onResume()とonPause()はそのままコピー、それ以外の処理の部分はクラスの直下に書き込む形にしてある。

f:id:alasixOsaka:20190907113200p:plain
相変わらずベルリンの地図だが、地図の回転はいいかんじになった。
これで、GPSでの位置取得と合わせれば、地図アプリとしては使えるものになりそうだ。そうすると、GPXファイルで作ったルートの表示やPOIの表示ができれば、やろうとしていることの9割がたはできたことになる。

参考にしたサイト
Androidデバイスの方位を調べる – Linux & Android Dialy
加速度センサーと地磁気センサーを使うには(AndroidMode編) | 自己啓発。人生について考える
SensorManager#registerListener()で値の取得間隔を設定できるらしいが… - fresh digitable
AndroidでSensorManagerを使って磁気センサー(コンパス)の方位を取得する - 酢ろぐ!
[Android 開発] 端末の傾きと方位角を求める - Open MagicVox.net
[Android 開発] GPS を使う - Open MagicVox.net
方位角を計算したいけど全然分かんない – dalomo

秋の訪れと冬の準備

まだまだ暑い日が続いていますが、気が付くとトンボが飛びま回っていたり、いつの間にかセミの鳴き声が聞こえなくなっていたり、季節は確実に秋に移っていっているんだなと思う今日この頃です。
さて、そろそろ、秋から冬に向かっての計画を立てないとと思っています。
イベントでは、とりあえす、決まっているのが、9/15がオリエンテーリング(クラブカップ)、10/6がウォーキング(東海道浪漫歩行 30km)。
その翌週(10/13)は社内の自転車イベントをするか、オリエンテーリングの全日本に出るか検討中。
ようやく、少し長めの距離でも何とか走れるようになってきたので、12月くらいには25㎞のトレランに出たいと考えているところ。箕面北摂の27㎞か、妙見山パワートレイルの25㎞あたりに参加できればと思っている。

冬用のトレランウェアを買わないと

ところで、冬のトレランを考えるとウェアが圧倒的に足りない。平地をたらたら走るなら何とかなるが、ある程度高いところに登って、更に長い距離、途中で休憩もありといろいろ考えると、あれもこれもと欲しいものが出てくる。
最近はレイヤリングが基本のようで、とりあえず、長そでのインナーとアウターを揃えようと思っています。
特に、アウターが全くなくて。平地走で使っている、ウィンドブレーカーは結構ピラピラして、トレランには向いてないし、それ以外には自転車用のウェアぐらいしかない状態。また、来月には消費税の値上げも控えているという状況で、高いものは9月中に買っておけみたいな感じで、ちょっとした散財状態に陥っています。
今日は、モンベルのライトシェルジャケットがアウトレットで安くなっていたので思わずポチってしまった。
webshop.montbell.jp

また、高いのでどうしようか結構悩んだが、ファイントラックのフロウラップジャケットも買ってしまった。

また、使ってみたらレビューを書いてみたい。