JavaScript+Hyperappで心理学実験プログラムを作った

これです(github)。

僕の学科では学生が実験に参加しつつその実験についてレポートを書くという授業が多く行われている。そこで行う実験は古典的なものが多いのだが、大昔に書かれた実験プログラムがメンテナンスされずに使われ続けているということがある。

今回書いた記憶実験のプログラムもそうで、誰がいつどうやって作ったかわからないexeファイルが受け継がれ使われ続けてきた。しかし内部の処理がわからないので細かい仕様がわからず、いろいろ試してみて推測することしかできない状態だった。さらにいつ動かなくなるかもわからない。

そこでこのプログラムを移植(ソースコードがないので移植と言うかは微妙だが)しようという話になった。当初はPythonが候補に上がっていたが、多数の学生に授業中に同時に行わせるにあたり、Pythonをexeに固めたものは容量が大きくなりすぎダウンロードが長引く懸念があった(経験的には数十MB)。そこでJavaScriptでの制作を提案した。サンプルを作って披露したところゴーサインが出たので制作した。

動作は単純で

  1. x桁のランダム数列を3秒間表示
  2. 数列を消し5秒間待つ
  3. 参加者が数列を思い出して回答する
  4. 正誤を3秒間表示
  5. 桁数を変更し、1に戻る

というものだ。5の桁数変更のアルゴリズムには上下法や恒常法などがある。

上下法は正解すれば次は1段階難しく、不正解なら次は1段階簡単にするというアルゴリズムだ。これによって参加者の正解できる限界の桁数に収束する。メリットは試行数が少なく済むこと。

恒常法は一定の範囲の桁数をランダムな順番で課し、桁数を横軸に、正解率を縦軸にプロットすることで正解率を桁数を結びつける関数を得る。そして正解率50%(任意の値)に相当する桁数をその人の限界とする。メリットは関数そのものが得られるため多様な分析が可能であること。

心理学の実験は基本的にはwebではやらない。それは参加者の環境が統一できないからだ。MATLAB+Psychtoolboxが主流で、Python+Psychopy(pygame)もある。しかしポータビリティは低い。GUIを使うプログラムはDockerで動かすのは不便で、できてもパフォーマンスに不安がある。

そこまで環境にこだわらない実験や、今回のように同じ部屋で多くのPCで実験を行うときにはポータビリティの高いwebプログラミングによる実験は便利だ。そもそもGUIを扱うノウハウやライブラリという点ではJavaScript、というよりブラウザという環境が優れている。

寛容

※この記事は『Asahi クリアセブン』を飲みながら書かれた。

↑まずい。

今日はクソ酷いミスをして人に迷惑をかけてしまったのだが、さらりと許してもらえた。僕もそういう人間でありたいと思う。食堂の給茶機の順番待ちに割り込まれても笑顔でいられるようになりたい。

これまでaptで入れていたChromeを公式debから入れ直したことでバージョンが70になった。タブのデザインが変わり当たり判定が増えたのもそうだが、何より素晴らしいのは拡張機能のSpeedDialが正常に機能するようになったことだ。これまでは4列に設定するとなぜか右にスペースができてしまってとても気持ち悪かったのだが、それがなくなりきちんと中央に配置されるようになった。

Hyperappのプログラムをいじっていたのだが、actionは引数を1つしかとれないということを学んだ。そういうプログラムになっているのはそうとして、そうしている理由はなんなのだろう。

Actions

The only way to change the state is via actions. An action is a unary function (accepts a single argument) expecting a payload. The payload can be anything you want to pass into the action.

カフェインとMastodon2.5.1リレー機能

カフェイン

授業とミーティングを終えてコーヒーを飲んだら、飲んでいる最中は何もなかったのに立ち上がってから酷いめまいに襲われた。低血圧なので立ちくらみはよくあるが、立ちくらみのときは視界が白くなる。今回は純粋に平衡感覚だけが狂うというはじめての経験だった。友人に肩を借りてなんとか研究室まで戻り、やらなきゃならない仕事を片付けてからすぐに帰宅した。おそらく季節による体調変動とカフェインの刺激が重なったのだろう。

カフェインと言えば、緑茶にも多く含まれる。私の出身地は茶の産地であり、小学校の総合学習では茶について学ぶ。学んだことを発表するまで含めて総合学習なのだが、茶の効能を歌にするグループがあったような気がする。「カッテキン\カフェイン/カッテキン\カフェイン/」というリズムを今でも覚えている。

僕は24歳だ。(なぜか)まだ学生だし世間的には若者だろうが、一方で身体機能は既にピークを超えたものもある。たとえば大食いは20歳ごろがピークだった。という話は以前もしたような気がする。

Mastodonを立て直した

自分の変更点を残しつつ最新版に追従しようとしたらgitの闇に飲まれてわけがわからなくなったのでリセットボタンポチーした。何回ぶっ壊してもいいVPSとかいうおもちゃ最高。そうまでしてv2.5.1にバージョンアップした理由はリレー機能だ。

マストドン、「連合リレー」対応の2.5.0公開

Activity Pubを受け取って登録済みのインスタンスにそのまま流すリレーサーバーが開発されており、Mastodon側からも対応したとのことだ。管理画面でリレーのURLを入力するだけで接続できる。これを使うと何もしなくても連合TLに同じリレーに属するインスタンスの投稿が流れてきて、賑やかになる。

自分のインスタンスを立てて痛感したのは初速の重要さだ。小規模インスタンスの場合登録しても結局他のインスタンスの知り合いや有名人をフォローしに行くことになり、Mastodonの特徴であるごった煮高速TLを楽しむことができない。リレー機能はここに一石を投じるものだと思う。

今日のUI心理学

5.6は視覚探索の話。視野のたくさんの図形から1つの目標図形を探すとする。このとき目標図形をターゲット、それ以外をディストラクタと呼ぶ。素朴に考えれば私達は図形を一つ一つ見て確認していくはずだ。ターゲットを何番目に見るかの期待値はディストラクタの数の半分なので、見つけるまでにかかる時間はディストラクタ数が増えるに従って線形に増加するはずだ。以下のような画像から「右上を向いた赤」を探すときは実際にそうなり、右のほうが長くかかる。

しかしこれには例外がある。以下の画像から「右上を向いたもの」を探すときや

以下の画像から「緑」を探すとき。

このようなときはディストラクタの数に関係なく一瞬で見つかる。この現象を「ポップアウト」と呼ぶ。脳内の情報処理で「色」や「方位」それぞれのマップを作って探すときは全体を一瞬で処理できるが、複数種類の特徴を統合して判断する処理は一箇所ずつしかできないと考えられている。詳しく知りたい人は「特徴統合理論」でググって。

参考文献:『視覚科学』横澤一彦

Hyperappでhtml要素の真偽属性を操作する

※この記事は『金麦 RICH MALT』を飲みながら書かれた。

hyperappのviewはh関数で作る。これは公式サイトのサンプルだ。

const view = (state, actions) =>
  h("div", {}, [
    h("h1", {}, state.count),
    h("button", { onclick: () => actions.down(1) }, "-"),
    h("button", { onclick: () => actions.up(1) }, "+")
  ])

h関数は3つの引数をとる。1つ目はタグ名、2つ目は属性を列挙したハッシュ、3つ目は子要素だ。ここで属性はハッシュなので、キーとバリューのペアが必要だ。しかし全ての属性がキーとバリューのセットで用いられるわけではない。たとえばreadonlyは値をセットする必要がなく、存在することによって機能する。

ここでHTML5の真偽属性の仕様を確認する。

checkedおよびdisabledとなるチェックボックスの例を示す。checkedおよびdisabled属性は真偽属性である。

<label><input type=checkbox checked name=cheese disabled> Cheese</label>

これは次に書かれるものと等価であるべきである:

<label><input type=checkbox checked=checked name=cheese disabled=disabled> Cheese</label>

スタイルを混在させることもできる。以下は依然として等価である:

<label><input type='checkbox' checked name=cheese disabled=""> Cheese</label>

どういうことかというと、以下の3つはどれでもいい(参考)。

  • disabled
  • disabled=disabled
  • disabled=""

つまり真偽属性を変化させたいときは以下のように書きわけることができる。

h("input", {readonly=""}, "")
h("input", {not-readonly=""}, "")

これをhyperappの枠組みで考えるとこうなる。

const state = {
  readonly: "readonly"
}

const actions = {
  writable: () => state => ({ readonly: "not-readonly" })
}

const view = state => h("input", { [state.readonly]: "" }, "")

viewのh関数の第2引数のハッシュで、ブラケット記法を用いてキーの方を変更する。バリューはなんでもいい。

Mastodonに自動ハッシュタグ機能をつけた

アニメ実況時は同じハッシュタグで継続的にツイートする。よってハッシュタグを保持し自動的に付加し続けてくれる機能は便利だ。作った。

やたらとトゥートの反映が遅いのはサーバーがイマイチだからかな…

この機能追加はReact+Reduxの処理階層を知っていれば全然難しくない。まずハッシュタグ入力欄と、そこへの入力を処理するaction, reducer, stateを作る(全部compose-formのコピー)。さらにCtrl+Enterによるトゥートを処理するhandleKeyDownにShift+Enterによって発火する別ルートのトゥート投稿処理を追加する。これもトゥートボタンによって発火する処理をほとんどコピー。そのルートの最後にあるのがこれ。

const status = getState().getIn(['compose', 'text'], '') + ' ' + getState().getIn(['compose', 'preservedHashtag']);

これだけが実質的に意味のあるコードだ。興味のある方はコミットログをどうぞ。

1年前のドワンゴインターンではJavaScriptが全然わからず(わからなかったのはJavaScriptだけではないが)チームメンバーに教えてもらっていたが、今では自力で触れるようになった。

Mastodonでハッシュタグ補完すると1文字目が勝手に確定する問題

Mastodon(v2.5.0rc1)でトゥート入力時にペーストでハッシュタグを入力すると、ハッシュタグ補完機能でハッシュタグの候補が表示される。それをEnterやクリックで確定して投稿すると、次のトゥートを入力するときに日本語入力を有効にしていても1文字目が勝手に確定される。

1文字目が勝手に確定されるという現象は割とよくあるやつで、slackでもTwitter公式でも発生した事例はある。しかし今回はハッシュタグ補完時のみという条件があるので、もしかしたらハッシュタグ補完が発生したときにはトゥート投稿時の投稿欄の情報リセット忘れがあるのかもしれない。現在ソースコードを確認中だ。

なおfriends.nicoでも同様の現象が起きることを確認済み。OSはUbuntu18.04で日本語入力はmozcだ。書いてて思ったが変換の方で何か悪さをしている可能性は十分ある。あるいはデスクトップ環境かも知れないし、ブラウザかも知れない。なかなか手強そうだ。

スプラップアンドビルド

※この記事は『極搾り ピーチ』を飲んで書かれた。

マストドンにelasticsearchをつけようと思ったらなんか壊れてしまったので作り直した。

なんか壊れてしまったというのは、マウント関連だ。リバースプロキシであるnginx-proxyは設定ファイルを外部から読み込むために、ホストの設定ファイル(./my_custom_proxy_settings.conf)をマウントしている。この状態で更にvolumes_fromでmastodon-nginxからnginx-proxyをマウントするとなんだかよくわからないがマウント関連っぽいエラーが出る。read onlyとかなんとか書いてあった。

この2重マウントは僕もわけがわからずやっているが、そんなことだからエラーが出るのは当然だ。面倒なので一回全部消してマストドンを立て直した。前回のデプロイから1週間ちょっとしか経っていないが、github上のデプロイガイドが書き換わっていて驚いた。mastodonの進化はまことに速い。

新しいdocker-compose.ymlはこうだ。今回は自分で改造することも視野に入れ、自前でビルドすることにした。なおmastodonのバージョンはv2.5.0rc1だ。

version: '3'
services:

  mstdn-nginx:
    image: nginx:1.11.10-alpine
    expose:
      - "9090"
    restart: always
    tty: false
    env_file: .env.production
    links:
      - web
      - streaming
    volumes:
      - ./setting/nginx/conf.d:/etc/nginx/conf.d:ro
      - ./setting/nginx/conf:/etc/nginx/conf/:ro
      - /var/www/certs:/etc/nginx/certs:ro
    networks:
      - common_link
      - back-mastodon
  db:
    restart: always
    image: postgres:9.6-alpine
    networks:
      - back-mastodon
### Uncomment to enable DB persistance
    volumes:
      - ./postgres:/var/lib/postgresql/data

  redis:
    restart: always
    image: redis:4.0-alpine
    networks:
      - back-mastodon
### Uncomment to enable REDIS persistance
    volumes:
      - ./redis:/data

  es:
    restart: always
    image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.1.3
    environment:
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    networks:
      - back-mastodon
### Uncomment to enable ES persistance
    volumes:
      - ./elasticsearch:/usr/share/elasticsearch/data

  web:
    build: .
    image: tootsuite/mastodon
    restart: always
    env_file: .env.production
    command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000 -b '0.0.0.0'"
    networks:
      - back-mastodon
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      - db
      - redis
#      - es
    volumes:
      - ./public/assets:/mastodon/public/assets
      - ./public/packs:/mastodon/public/packs
      - ./public/system:/mastodon/public/system

  streaming:
    build: .
    image: tootsuite/mastodon
    restart: always
    env_file: .env.production
    command: yarn start
    networks:
      - back-mastodon
    ports:
      - "127.0.0.1:4000:4000"
    depends_on:
      - db
      - redis

  sidekiq:
    build: .
    image: tootsuite/mastodon
    restart: always
    env_file: .env.production
    command: bundle exec sidekiq -q default -q mailers -q pull -q push
    depends_on:
      - db
      - redis
    networks:
      - back-mastodon
    volumes:
      - ./public/packs:/mastodon/public/packs
      - ./public/system:/mastodon/public/system
## Uncomment to enable federation with tor instances along with adding the following ENV variables
## http_proxy=http://privoxy:8118
## ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
#  tor:
#    build: https://github.com/usbsnowcrash/docker-tor.git
#    networks:
#      - common_link
#      - back-mastodon
#
#  privoxy:
#    build: https://github.com/usbsnowcrash/docker-privoxy.git
#    command: /opt/sbin/privoxy --no-daemon --user privoxy.privoxy /opt/config
#    volumes:
#      - ./priv-config:/opt/config
#    networks:
#      - common_link
#      - back-mastodon

networks:
  common_link:
    external: true
  back-mastodon:
    external: true

注目してほしいのはmstdn-nginxのvolumesでホストマシンの/var/www/certsをマウントしていることだ。nginx-proxyでもホストマシンの同じディレクトリをマウントしている。2重マウントを解消し同じディレクトリをマウントするようにした。

elasticsearchはデフォルトで日本語対応してません。日本語対応プラグインを入れるのはちょっとしんどそうなのでまた今度やります。

ぶっ壊してまたイチから作ってというのを繰り返せるのは楽しい。無限にやりたい。

Ubuntu18.04はlibinput

※この記事は『本搾り オレンジ』を飲みながら書かれた。

昨日の記事の続きだ。新しいデバイス名を指定することで変換行列を指定することには成功した。しかし奇妙な現象が発生した。Kritaで絵を描くとき、細い線が引けないのだ。よく調べてみると、筆圧が5%を超えるまでクリック判定が発生していなかった。これをつぶさに調べる過程でデバイス名が変わっていた理由も判明した。

なぜUbuntu18.04にアップグレードするとデバイス名が変化していたのか。なぜ筆圧とクリックの挙動が変わってしまったのか。それはlibinputのせいだ。libinputはデバイス入力ドライバで、xinputの奥で動いているものらしい。16.04では液晶タブレットはevdevドライバで動いていた。18.04ではlibinputドライバが導入され、液晶タブレットの管理もlibinputに引き継がれたようだ。

考えるのが面倒だったのでlibinputを消したら解決した。

sudo apt purge xserver-xorg-input-libinput

libinputを消したらマウスの加速プロファイルが変わってしまって違和感があった。GNOME Tweaksから設定をFlatに戻したら直った。

今日のUI心理学

やろうと思ったんですが、ゲシュタルトの法則を一つずつ紹介していくだけだとクソつまんないのでもうちょっと面白いところまで読み進むのを待っててください。

Ubuntu18.04へのアップグレードに伴うデバイス名の変化

Ubuntu16.04で液晶タブレット(UGEE HK1560)をデュアルディスプレイで使うとき、ペンタブレットの入力を片方のディスプレイのみにマッピングするためにはxinputの"Coordinate Transformation Matrix"プロパティを使う。

私の場合は左のディスプレイ(液晶タブレット)が1920x1080で右のディスプレイが2560x1440だ。つまり液晶タブレットの入力はそのままだと2つのディスプレイをつなげた4480x1440の長方形にマッピングされる。これを左のモニターのみにマッピングするためには、x座標0~4480を0~1920に、y座標0~1440を0~1080に写像するような変換行列を用いる(3行3列の1はたぶん筆圧は無変換っていう意味だったと思う)。

さて、これを実行するコマンドは
xinput set-prop "UC-Logic TABLET MONITOR Pen" --type=float "Coordinate Transformation Matrix" 0.428571 0 0 0 0.75 0 0 0 1
だっ。Ubuntu16.04では。

Ubuntu18.04にしてからこのままのコマンドでは以下のようなエラーを吐くようになった。

X Error of failed request:  BadMatch (invalid parameter attributes)
  Major opcode of failed request:  131 (XInputExtension)
  Minor opcode of failed request:  57 ()
  Serial number of failed request:  19
  Current serial number in output stream:  2

なんだこれは?ググっても全然わからない。そもそもLinuxでペンタブレットを使おうという変態の絶対数が少ない。

xinputでデバイス名を確認したらこうなっていた

⎡ Virtual core pointer                    	id=2	[master pointer  (3)]
⎜   ↳ Virtual core XTEST pointer              	id=4	[slave  pointer  (2)]
⎜   ↳ UC-Logic TABLET MONITOR Mouse           	id=10	[slave  pointer  (2)]
⎜   ↳ E-Signal COUGAR Minos X3                	id=11	[slave  pointer  (2)]
⎜   ↳ E-Signal COUGAR Minos X3                	id=12	[slave  pointer  (2)]
⎜   ↳ LingYao ShangHai Thumb Keyboard         	id=16	[slave  pointer  (2)]
⎜   ↳ UC-Logic TABLET MONITOR Pen Pen (0)     	id=18	[slave  pointer  (2)]
⎣ Virtual core keyboard                   	id=3	[master keyboard (2)]
    ↳ Virtual core XTEST keyboard             	id=5	[slave  keyboard (3)]
    ↳ Power Button                            	id=6	[slave  keyboard (3)]
    ↳ Power Button                            	id=7	[slave  keyboard (3)]
    ↳ Sleep Button                            	id=8	[slave  keyboard (3)]
    ↳ UC-Logic TABLET MONITOR Pen             	id=9	[slave  keyboard (3)]
    ↳ Mistel MD600                            	id=13	[slave  keyboard (3)]
    ↳ Mistel MD600                            	id=14	[slave  keyboard (3)]
    ↳ LingYao ShangHai Thumb Keyboard         	id=15	[slave  keyboard (3)]
    ↳ E-Signal COUGAR Minos X3                	id=17	[slave  keyboard (3)]

理屈はわからないが、なぜかこれまで使っていたデバイス名"UC-Logic TABLET MONITOR Pen"はキーボード扱いになっていた。その代わりに"UC-Logic TABLET MONITOR Pen Pen (0)"という怪しげなデバイスが出現している。先ほどのコマンドのデバイス名をこちらに入れ替えたところ、成功した。

LinuxでHK1560は普通に使える。筆圧も取れる。ワコムの同サイズ製品は18万円するのに対してHK1560は現在4万円だ(こちらはFHDであちらは4Kだが)。ガラスの厚みによる視差は液タブの宿命として当然あるが、それはワコムだろうとゼロにはできない以上程度の問題でしかない。発色が悪いと言われている(おそらく1677万色ではない)が、僕はそもそもきちんとキャリブレーションされたモニターを一つも所有していないのでこれを問うのは無意味だ。ペン先の沈み込みもよく不安がられる要素だが、ワコムの飛び出したり引っ込んだり不安定なペン先よりは弾力のあるこちらのペンの方が描きやすく、筆圧も自然につけられる。ワコムはドライバが不安定だと言われるが、こちらはLinuxで幅広く使われているxinputで作業中のトラブルは一度もない。

描画ソフトにはKritaを使っている。非常に大きな特徴として消しゴムツールが存在しない(全てのブラシツールに消しゴムモードがあり、オンオフして使う)ことが挙げられるが、慣れればどうということはない。

実はメインマシンをUbuntuに変えるときにこの辺りの用途がきちんとこなせるか不安だったのだが、マッピングを解決するだけですぐに使えたことで僕の中でのUbuntuへの信頼が高まった。

今日のUI心理学

2.2 類同

見た目が似ているものはグループとして見える。当たり前だね。小学生がやるみたいにメニューの項目で1つずつ色を変えたりしてはいけないということだ。

ぐぎぎぎぎぎ

使い慣れたものの感覚を捨てて新しいものに慣れようとしているときの僕が発する音。

2017年春からVirtualboxでUbuntuを使っており、2018年1月からはメインPCもUbuntuにした。ずっとUnityデスクトップを使ってきた。2018年春にUbuntu18.04がリリースされ、7月末には16.04からのアップグレード機能が提供された。

16.04は2021年までサポートされるが、僕は新しいものが好きだし、飛び移るときの高低差が小さいうちにどんどん新しいものに移っていくことが時代に取り残されないために大切なことだと思うので18.04にアップグレードした。

OSの機能としては特に変化は感じない。性能やセキュリティが若干向上しているらしい。大きく変化したのはデスクトップ環境だ。UnityからGNOME3に変わった。タイトルバーやらトップバーが無意味に空間を占拠していることに憤りは感じているが、空間の効率的な利用は絶対的な善なのでGNOMEにもやがてLIMが導入されるだろう。

GNOMEには多彩な拡張機能があるが、ちゃんと動かないものや組み合わせると動かないものなどがあり、気持ちが悪い。「拡張機能で自由自在」というのはよくない。ユーザーが好き勝手に拡張機能を作るということは、それらの間で爆発的に増えていく組み合わせのパターンを管理する人間がいないということだ。バニラが一番不愉快なエラーに遭遇しにくいし、だからこそバニラの使い心地こそが重視されるべきだ。

これはChrome拡張でOperaのスピードダイヤルを再現したものだ。見ればわかるが、右端に隙間がある。設定上は「4列」にしてあるにもかかわらずなぜか3列+空き列になる。実はさっきまでこの現象は直っていたのだが、GNOMEのbuggyな拡張を全部消したら戻ってしまった。ままならない。この左右非対称性にはいつまで経っても慣れない。気持ちが悪い。

今日は実験プログラムの最終調整(いつまで最終調整してるんだ)をした。同期が学部生向けの授業で使う実験プログラムをつくるらしいのだが、どうやらいつ誰が作ったかもわからない、ソースコードも残されていないプログラムを作り直す案件らしい。悲しい話だ。メンテナンスする人間がいなくなり、する方法も失われたプログラムの末路は死しかない。

明日は内定者懇親会ということで、内定先の金で銀座のうまい飯が食える。どうせなら映画もなにか見に行きたいところだ。