服部平次絶体絶命の暗号を作成するツール

名探偵コナンの第323~324話「服部平次 絶体絶命」に登場する暗号を自動で作れるサイトを作ってた。期末テストの息抜き程度に作る予定だったのに、いつの間にか春休みも半ばになってた(゜_゜)

hattori.cipher.jp

アニメ内では、服部平次は監禁された状態で提示された暗号を解いた上で、犯人を表す暗号に脳内で作り替えてコナンに送るというすごいことをやってのける。どういう思考回路してるのか全く理解できないんだけど、暗号を作成するプログラムを作ったら何かわかるかもしれないと思ったので、これを作った。

※ 暗号の解き方も書いちゃっているので、思いっきりネタバレです!

暗号の解き方

まず、楠川さん作の暗号を使って解読方法を復習する。

暗号は、以下のように7×7=49個の英数字からできている。前提知識としては、この暗号が7桁の数字を表しているということと、暗号の作成者が博打好きだということがある。

Q52A252
K332Q44
4KQQKJ6
AQJ96J3
355533Q
6246426
2KK53KA

1. アルファベットを数字に直す

暗号内のアルファベットがA, J, Q, Kであることから、これはトランプを表していると考え、それぞれ数字に直す。A→1, J→11, Q→12, K→13。

 12521252
 133321244
413121213116
 1121196113
  35553312
  6246426
2131353131

このようにアルファベットを数字に直す作業を「展開」と呼ぶことにする。また、その逆の操作を「短縮」とする。

展開の結果、暗号は64個の数字となった。

2. 仲間外れの9を除き、その9で残りの個数を割ると、答えの桁数である7になる

1~6の数字は多くあるのに、一つだけ9があることに注目する。仲間外れの9を除くと数字は64-1=63個となり、これを9で割ると答えの桁数である7となる。

125212521
333212444
131212131
161121161
133555331
262464262
131353131

↑9を除いた数字を9個ずつで改行したところ。数字が7行できていることがわかる。

この一行ずつが、答えの一桁ずつを表しているのではないか、と考える。

3. サイコロに当てはめる

1~6の数字があることと、暗号の作成者が博打好きであることから、サイコロを連想する。サイコロは正方形なので、9つの数字の組を一つのサイコロに見立てて、3×3の正方形に並べる。

125
212
521

333
212
444

131
212
131

(略)

4. それぞれの数字をサイコロに置き換える

f:id:YaaMaa:20170315175512p:plain

(4文字目以降は省略)

すると、赤い丸を持つ1だけが浮かび上がるので、3×3の枠全体を一つのサイコロと見ると、3156204(語呂合わせで「サイコロ振れよ」)の数字が浮かび上がる。これが暗号の答え。

※ コナンが作った暗号では、3×3の枠全体をサイコロと見なすのではなく、ただ枠に現れる模様を見る。すると、カタカナで「ソトニサソエ」と浮かび上がり、これが答えとなる。

解き方を知った上で暗号を解くと、

展開 → 9を除いた残りを3×3の枠に流し込む → 1が形成する模様を読み取る

という割とシンプルな方法で解ける。

暗号作成

こういう暗号を作るにあたって、二つの記号を定義しておく。

  • N: 2, 3, 4, 5, 6
  • R: Q, K

暗号では結局1以外の数字は「1でない」ことに意味があり、実際の数字なんて何でもいいので、2~6はNと抽象化しといて後で振り分けた方が楽。同様に、QとKも二桁目が2であるか3であるかの違いしかないので、「1以外は何でもOK」という考えの下ではそれほど違いがないため、まとめてRと置く。

ユーザーからの入力

ユーザーから暗号の答えを入力してもらう。

入力する方式は下の二つを用意しなければならない。

  • 模様: コナン作の暗号のように、3×3のマスの中の模様が表すものがそのまま暗号の答えになる場合

  • 数字: 楠川作の暗号のように、3×3のマス全体を一つのサイコロと見立てて数字を導き出す場合

入力されたら、1になるところをA、1以外になるところをNとしておく。

例えば、数字の入力欄に「810」と入力されたら

AAA ANA AAA  NNN NAN NNN  NNN NNN NNN

となるし(「8」はサイコロにない数字なので、勝手に模様作った)、模様の入力欄に

■□□
■□□
■■□

□■□
□□□
□■□

■□□
■■□
■■□

と入力されたら

ANN ANN AAN  NAN NNN NAN  ANN AAN AAN

となる。

短縮

数字をアルファベットに直す操作。短縮には二種類ある。

  • AA-J短縮: AAは「11」の意味なので、Jと短縮できる。

  • AN-R短縮: ANは「1, 1以外の数字」が並んだものなので、Nを2or3に限定することでRと短縮できる。どうせNは後から2~6のどれかの数字に決まることになるので、ここで限定しちゃってもOK

最低短縮数を設定する

J, Q, Kが揃ってないとトランプだとわかってもらうのは難しいので、それぞれ最低一つずつほしい。なので、

  • AA-J短縮の最低短縮数は1

  • AN-R短縮の最低短縮数は2

とする。これに従えない場合(以下で求める短縮可能数が十分でない場合とか)は妥協する。

短縮可能数を調べる

AA-J短縮とAN-R短縮がそれぞれ何回行えるかを調べる。

  • AA-J短縮: Aが連続して二つあり、且つ一つ目のAがそのAの連続内で奇数個目にあるときに行える

  • AN-R短縮: AとNがこの順で連続していて、且つAがそのAの連続内で奇数個目にあるときに行える

合計短縮数を決める

短縮を一度行うごとに、暗号は一文字ずつ短くなる。

つまり、合計短縮数は、展開後の数字の個数 - 展開前の英数字の個数である。作中に出てきた暗号の場合、82-72=15回。

展開後の数字の個数はユーザーからの入力によって自動で決まるけど、展開前の英数字の個数は特に決まっていない。とはいえ、以下のような制限がある。

  1. n2でなければならない

    展開前の英数字の並びが正方形であるというのには、服部曰く「サイコロを表す」という意味があるので、正方形にできるように自然数の二乗個でなければならない。

  2. n2であるとき、nは奇数でなければならない

    9を中央に配置するというのには、服部曰く「サイコロの1の目を表す」という意味があるので、9を中央に配置できるように一辺の個数は奇数でなければならない。

これらの条件を考慮して、また短縮可能数に収まる範囲内で、(そしてできれば最低短縮数よりは多く、)短縮合計数を決める。

ただし、上記の二つの条件には従えない場合がある。例えば暗号の答えが「0」だったとき、暗号は「NNN NNN NNN」のどこかに9を挿入したものとなる。すると数字が10個となり、短縮可能数も0なので、n2には絶対にできない。こういうときは妥協する。

短縮数を分配する

合計短縮数を、AA-J短縮とAN-R短縮に分配する。楠川さんたちが作った暗号での配分は以下の通り。

J + R J R
楠川作 15 3 12
服部作 15 4 11
コナン作 15 5 10

だいたいJの個数:Rの個数=1:2~4くらいが妥当なのかなと思う。

なので、配分の仕方は以下のようにした。

  • 合計短縮数 < 最低短縮数の合計になってしまうときは、それぞれの最低短縮数に比例するように短縮合計数を分配する

  • 最低短縮数の合計 < 合計短縮数の場合は、最低短縮数を満たせば、あとはなるべく上記の比率に従うようにランダムで分配する

置換/反転

ただ短縮しただけでは、文字の配置はワンパターンになってしまう。例えば、「AAAAAN」という並びは、「JJAN」「AJAR」などいろいろな方法で短縮できるのに、上記で行った短縮だけだと毎回同じパターンになってしまう。それを防ぐために、置換や反転を行う。

  • JNとARの置換: JNは、NをRに限定することでARと置き換えられる。特にRの数が足りないときに積極的に置換する

  • AとJの反転: AとJが隣接しているとき、それらはひっくり返しても大丈夫

具体化

抽象化してあったRとNを具体化する。

Rの具体化

RをQとKに具体化する。楠川さんたちが作った暗号では平等に割り振られているので、それに従う。

R Q K
楠川作 12 6 6
服部作 11 6 5
コナン作 10 5 5

Rが奇数個で余ってしまったら、服部作のに倣ってQに回せばいいかな。

Nの具体化

楠川さんたちの暗号での2~6の出現回数は以下の通り。

  • 楠川作
展開前 展開後
2の出現回数 7 13
3の出現回数 7 13
4の出現回数 5 5
5の出現回数 6 6
6の出現回数 5 5
  • 服部作
展開前 展開後
2の出現回数 5 11
3の出現回数 5 10
4の出現回数 6 6
5の出現回数 4 4
6の出現回数 6 6
  • コナン作
展開前 展開後
2の出現回数 3 8
3の出現回数 3 8
4の出現回数 4 4
5の出現回数 3 3
6の出現回数 4 4

こう見ると、展開後ではなく展開前の時点で大体均一に分配すればいいことがわかる。

これくらいのばらつきを保ちつつ、ほどよく均一に分配する方法を調べてたら、以下の記事がすごい参考になった。

Box-Muller法を用いて正規分布に従う分配を行う - Qiita

この方は、出てきた二数を$readyを使うことで効率的に使ってるけど、めんどくさいし、片方しか使ってない例(書いた方は「大丈夫なのか…」と不安げにしてはるけど、結果のグラフはいい感じに見える)もあるので、まあいっかと思って片方しか使わなかった。

標準偏差はどうするのかということだけど、こういう場合平均が上がるにつれて標準偏差も大きくなっていくのが自然だと思うので、標準偏差を平均で割ってみたらどんな値が出るか見てみた。

平均 標準偏差 標準偏差/平均
楠川さん作の暗号 6 0.89443 0.14907
服部作の暗号 5.2 0.74833 0.14391
コナン作の暗号 3.4 0.4899 0.14409

かなり一定になってる! なので、この平均をとって、標準偏差=平均*0.146にした。

(後から知ったけど、標準偏差/平均は「変動係数」と呼ばれるもので、ちゃんと意味のある数値らしい)

また暗号を解く際に服部が「1から6までは仰山あんのに、9は一個だけしかない」と言っていたことから、9を際立たせるために2~6は複数個、最低2個ずつないといけない。 更に、Nが極端に少ない場合、6を出現させるのを最優先する。「サイコロ」という発想を浮かばせるためには、「6までの数字がある」と認識させるのが大事だから。それに、2や3はRの展開で出てくる可能性もあるから、Nで無理に作る必要性はあんまりない。

なので、2~6までに2個ずつ配分できるほどNが多くない場合、つまりNの個数<(6-2+1)*2のときには、6→5→4→3→2→6→5→…の順で配分していく。

9の挿入

最後に、真ん中に「9」を挿入する。

これで暗号が完成!

できたもの

hattori.cipher.jp

↑のページで公開していますので、よかったら遊んでみてください。結構な頻度で「この暗号には問題があります」って出るので鬱陶しいと思います(*‘▽’)

ミニバードで無料ドメイン見てたら、「cipher.jp」っていうのがあったから、ぴったりな名前やなって思ってすぐ取得した。

サイト内で一番力を入れたのは、暗号解読画面の解説。なんとかアニメ放送された本家の画像をcanvasで再現しようと頑張った。

f:id:YaaMaa:20170315133906p:plain

f:id:YaaMaa:20170315133928p:plain

f:id:YaaMaa:20170315133940p:plain

f:id:YaaMaa:20170315180929p:plain

上が本家、下がcanvasで作ったやつ。よく見るとグラデーションの色合い・角度とか服部のツノ(一枚目の右上から生えてるやつ)のとんがり具合とかいろいろと違う(・_・`)

あと、楠川さんの暗号って3×3の枠に流し込んだときに2~6がすごい対称性のある綺麗な並びになるようにできてんねんかな。これのおかげで解読方法により確信が持てるようになるんだろうけど、私には真似できなかった…。服部とコナンも、(切羽詰まった状況で暗号作ったから当たり前だけど)そんなに対称性は持たせられてないので、まあしゃあないかと思って妥協してしまった。

服部が暗号を組み立てた手順

では、作中で服部がどんな感じであの暗号を組み立てたのか考えてみる。

まず、下3行を血で汚した理由だけど、あれは「3文字分で弁護士のことを表せるから、およそ3文字分に相当する3行分を汚そう」と思ったのか、「血の量的に汚すのは3行が限界だから3行分汚そう」となったのかどっちかはわからない。というか、手を使えない状況でどうやって暗号の紙を血で汚したんだろう。顎から一滴一滴したたらせてたんだったら死ぬほど時間かかるし、楠川さんに手伝ってもらったんかな。めっちゃ大変そう。

いずれにせよ、下3行分を見えなくした時点で、展開前の(7×3=)21文字が隠されたことになる。これは展開後の25文字分に相当する。3×3の枠で3文字を表そうと思ったら27個(=3×3×3)の数字が必要だが、最初の二つ(暗号全体の展開後でいうと後ろから26個目と27個目)を楠川さんのと共通にしとけばOK

そこを共通にするためには、犯人を示唆する文字列の一文字目はANから始まることになる。「Lib」ならその条件を満たしている。

Libを3×3の枠×3で表そうとすると、

(AN)N
ANN
AAN

NAN
NNN
NAN

ANN
AAN
AAN

上のように配置することになる。これを全部繋げると、下の通り。

(AN)NANNAANNANNNNNANANNAANAAN

※ 初めのANは既に固定されているので、()付きで表した。

このうち初めのANを除いた「NANNAANNANNNNNANANNAANAAN(25文字)」を短縮して21文字(7文字×3行)にすればいい。なので、短縮は25-21で4回行う。

血で汚れてないところにもJ, Q, Kはそれぞれトランプを連想するのに十分な回数出てるから、今からの短縮では特に文字の出現回数は考慮しなくて大丈夫。

短縮できるのは、0始まりで数えると、

  • 1-2個目: AN-R

  • 4-5個目: AA-J or 5-6個目: AN-R

  • 8-9個目: AN-R

  • 14-15個目: AN-R

  • 16-17個目: AN-R

  • 19-20個目: AA-J or 20-21個目: AN-R

  • 22-23個目: AA-J or 23-24個目: AN-R

これだけある。このうち、服部は4-5, 8-9, 20-21, 23-24個目を短縮した。

(私の方法でいったら、AANをARにするためにはまずAA-J短縮をしてからJN-AR置換をするけど、あれはプログラム書くときに楽な方を取っただけで、人間だったらAANからそのままAN-R短縮すると思う)

短縮が終わったら、下のようになった。

NANNJNNKNNNNANANNAQAK

最後に、Nに2~6までの適当な数字を分配する。

3A55J64K6243A6A64AQAK

これを上の4行と合わせたら、工藤に送る暗号の完成。

殺されそうな瀬戸際で、殴られまくって頭ガンガンしてるだろうに、血で汚れてない上4行を記憶しながらこんだけ考えられるなんて、服部ってすごい…(゜-゜)


USJでやってるコナンアトラクションの服部がめっちゃかっこいいらしい。何としてでも行かなければ(>_<)

コナンの「月と星と太陽の暗号」のフォントを作る

名探偵コナンの「月と星と太陽の秘密」のネタバレになってます!


名探偵コナンのアニメ163~164話で出てきた暗号をフォントにしたいと思った。

これがコナンくんが作成した解読表↓

InkscapeにSVG Font Editorっていうのが入ってたので、それを使って作る。

svgで一文字ずつ作らんとあかんねんけど、めっちゃ面倒。こんなに規則性がある文字なのに普通に一文字ずつ作るのは非効率極まりないと思ったので、パスの数値とかはスクリプトから出力することにした。

今JavaScriptの書き方しか覚えてないので、それで作る。URLのパラメータでひらがなを指定したらパスのd属性の中身が出てくるようにする。

スクリプト作成

月、星、太陽の大きさをどれくらいにすればいいのかよくわからないので、とりあえず定数として定義しておく。

// 全体
var SIZE = 1000;
var CIRCLE_HANDLE_RATIO = 0.55;

// 文字本体
var MOON_DIAMETER = 760;
var STAR_DIAMETER = 800;
var SUN_DIAMETER  = 530;
var SUN_MARGIN    = 50;
var RADIATION_LENGTH = 110;

// 文字についてくる丸(濁点, 半濁点, 右下の丸)
var ATTACHED_CIRCLE_DISTANCE_FROM_CENTER = 380;
var ATTACHED_CIRCLE_DIAMETER = 150;

後から思ったけど、SUN_MARGINが小さすぎたせいで太陽の丸と放射線がすごく近くなり、太陽が工場の地図記号みたいになってしまった。

CIRCLE_HANDLE_RATIOっていうのは、円を描くためのハンドルの長さ。円の半径に対して0.55くらいにしたらいいって下のサイトに書いてくれてあったので、それに従った。

9.ベジェ曲線の方向線についての補足

それから、URLにくっつけるパラメータは、「char」と「small」の二つにする。charは"a"とか"ni"とかローマ字で受け取る。smallは、「ゅ」「っ」みたいな小文字にするかどうかをtrue/falseで指定。

つまり、次のようなURLが渡ってくることになる。

.../index.html?char=wa&small=true

それをJavaScriptのwindow.location.searchで受け取って、母音と子音で場合分けしながらパスの文字列を作っていく。

SVGの書き方は、私にとってはこのページが一番わかりやすい↓

svg要素の基本的な使い方まとめ

出力は、パスのd属性の中身だけでなく、それをsvgで実際に描画したものも出したい。

なのでindex.htmlには、描画したのを見せるためのsvgとd属性の中身出力用のpタグ、両方を置いておく。

実際にパラメータを入力してみると、以下のように出てきた。

char=gi&small=falseのとき

char=yu&small=trueのとき

これを見ながらさっきの定数を調節して大きさをいい感じにしたら、JavaScriptでやることは完了!

Inkscapeに移す

それでは、JavaScriptで出力されるd属性を使って、実際にInkscapeでパスを作っていく。

Inkscapeのxmlエディタを使って一文字ずつ作成していく。そして、ストロークをパスに変換して、塗りつぶす必要のあるやつ(え段、お段とか)は適宜塗りつぶす。この作業がめっちゃくちゃめんどくさかった。もしかしたら初めからInkscapeだけで描画してた方が早かったんかも…。

それから、xmlエディタの入力欄は、すぐ無効(disabledの状態)になって鬱陶しい。 普通に立ち上げると既に無効(たまにまぐれで入力できるときもある)。何か一つパスを選択した状態で立ち上げると、入力できる状態になる。 エディタ内でノードを新規作成すると、また無効になる。エディタウィンドウの外側に触れてしまっても無効になる。一旦無効になったら、また開き直すしかない。

これって私だけなんかな。検索しても同じような状況の人は全然出てこない。それとも検索力が低いだけ?

とにかく、ひらがなのパス全て作成し終えたら、一番めんどいところは終了。

SVG Font Editorを使う

作りたいフォントの全般設定(フォントファミリー名とか大きさとかの設定)をしたら、個々のグリフを作成する。

http://www.sturnus.net/inkscape/menu/svg_font.html

↑このページのやり方を参考にした。(「1文字ごとにレイヤーにしてください」っていうのには従わなかったけど多分支障はなかった)

グリフを十分な数追加してマッチング文字列も入力したら、xmlエディタとフォントエディタを突き合わせて一つずつ登録していく。登録するたびにプレビューが一文字ずつ出来上がっていくのが快感。

全部登録し終えたらInkscapeでの作業は終了!

ttfに変換

ttfに変換する前に、svgファイルをテキストエディタで開いて、fontタグのidをちゃんとした名前に変えておく。じゃないと後々後悔する( ;∀;)

ttfに変換してくれるフリーのWebサービスはいくつかあるけど、Online Font Converterを使わせていただいた。サイトの見た目がめっちゃかわいいから。

ここでもらえたzipファイルの中にttfファイルがあるので、インストールする。

使ってみる

メモ帳で使った↓

ちゃんと暗号になってる!

メモ帳やとアンチエイリアスかかってなくて汚いけど、Inkscapeで使ったらきれいなフォントだったので安心。

ただ、二つ問題点がある。

1. Office系のソフトでは使えない

さっきのページによると、

なぜかオフィスでは英数字用のフォントになります。この状態では全角文字をこのフォントで表示することができません。調査中です。

とのこと。なのでWordでも使えず残念。

2. 他のフォントと並べると縦方向の位置がずれてる

作ったフォントに登録されてない文字(つまりひらがな以外全部)を入力すると、別のフォントで補ってくれる。ただ、そうやって並ぶと、下の画像のように縦位置がすごいずれてるのがわかる。

何が原因なのかはよくわからん。

配布

ここにttfファイル置いてあるので、よかったらどうぞ(*'▽')

https://drive.google.com/file/d/0B7anNAF08ZIbLTdEaEp2WTRxekU/view?usp=share_link&resourcekey=0-T02-brVMztN9-tX5YQLEnA

「せやかて」を遷移させる

2014年2月、つまり3年前からあった「"せやから"って文字を滑らかに遷移させたい!」という欲求を、今日やっと叶えることができた。といっても、内容は「せやかて」に変えたけど。

2014年2月

一番初め、私はモーフィングでそれを達成しようとした。

けど、いくらマークをたくさんつけても上手くいかなかった。遷移の途中が、下のようにすごく気持ち悪い感じになる。

f:id:YaaMaa:20170205045658j:plain

それで飽きたので、しばらく放置していた。

2014年10月

久しぶりに存在を思い出したので、もう一度チャレンジすることにした。このときにはペンツールを使えるようになっていたので、ペンツールを使って作ったsvgをアニメーションさせようとした。

しかし、「せ」から「や」に移行するところで、一画目の右側の丸まるところがうまくいかない。ノードがどういう動きをしているのかわからなかったので、ノードを赤・ハンドルを緑で表示してみたら、以下のようになった。

f:id:YaaMaa:20170205050641p:plain

ハンドルが逆行(?)してるっぽい。この辺でまた飽きて、考えるのをやめた。

2017年2月

また存在を思い出して、今度こそちゃんと作ろうと思った。去年やっとHTMLを覚えたので、ちょっとマークアップのやり方がわかってきてて、前回よりはやりやすかった。

今回は、「や」の右側の丸めをなだらかにすることで、ハンドルが逆行しないように気をつけた。根本的な解決にはなってないけど。

<svg width="600px" height="600px" viewBox="0 0 200 200"
     xmlns="http://www.w3.org/2000/svg" version="1.1">
  
  <g fill="none" stroke-width="16">
    <path d="M 25,97 C 82,82 138,79 176,80" stroke="#F44336">
      <animate
        attributeName="d" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="M 25,97 C 82,82 138,79 176,80;
          M 31,104 C 191,12 183,109 139,116;
          M 29,84 C 144,49 125,98 100,165;
          M 168,41 C 69,88 62,147 148,158;
          M 25,97 C 82,82 138,79 176,80"/>
      <animate
        attributeName="stroke" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="#F44336; #FFC107; #4CAF50; #3F51B5; #F44336"/>
    </path>
    <path d="M 134,33 C 135,64 133,102 128,122" stroke="#F44336">
      <animate
        attributeName="d" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="M 134,33 C 135,64 133,102 128,122;
          M 101,29 C 108,33 117,41 122,48;
          M 139,64 C 152,75 167,102 172,127;
          M 35,71 C 61,57 141,45 168,41;
          M 134,33 C 135,64 133,102 128,122"/>
      <animate
        attributeName="stroke" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="#F44336; #FFC107; #4CAF50; #3F51B5; #F44336"/>
    </path>
    <path d="M 70,48 C 61,159 71,171 154,161" stroke="#F44336">
      <animate
        attributeName="d" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="M 70,48 C 61,159 71,171 154,161;
          M 57,42 C 72,81 94,139 103,172;
          M 86,33 C 76,78 59,129 41,159;
          M 168,41 C 141,45 61,57 35,71;
          M 154,161 C 71,171 61,159 70,48"/>
      <animate
        attributeName="stroke" dur="5s" keyTimes="0.00; 0.25; 0.50; 0.75; 1.00" repeatCount="indefinite"
        values="#F44336; #FFC107; #4CAF50; #3F51B5; #F44336"/>
    </path>
  </g>
</svg>

今回はわりと上手くいったので、gif動画にした。

f:id:YaaMaa:20170205051410g:plain

3MBもある!

サポートの問題

こうやってsvgをアニメーションさせるのはSMILというものを使ってるんだけど、ブラウザがもうすぐそれをサポートしてくれなくなるみたい。今もう既に非推奨とのこと。

代替としては、CSSアニメーションかJavaScriptがある。CSSアニメーションで複雑なことするのは大変なので、JavaScriptを使うことが多くなるかもしれない。

JavaScriptが嫌いなわけではないけど、svg動かすのに使うのは何か納得できん笑

RailsでjQueryからajax

jQueryを使ってajax通信を行いたいんだけど、Railsの環境だとどうしたらいいのか全くわからなかった。

rails ajax」で検索したら「remote: trueにしたらいい」っていう記事ばっかり出て来るけど、それはフォームでの話らしい。私の場合、画像をクリックしたらajax通信したいので、jQueryでイベント検知するしかないなって感じだった。

qiita.com

この記事がすごい参考になった。

それから、ajaxが終わったらその結果を使ってpartialを更新したかったので、以下の記事も読んだ。

Ajaxによるページ部分更新 [ Ruby on Rails ]

https://www.prime-architect.co.jp/myblog/ruby-on-rails-1559

やることは以下の通り。

1. jQueryでのイベント定義

$(canvas).on 'click', (event) ->
  $.get('upload/show_similar_hattoris', {rgb: rgbAtPixel})

キャンバスをクリックしたら、rgbっていうデータを持ってupload/show_similar_hattorisへ行ってもらう。

2. routesの設定

get 'upload/show_similar_hattoris'

1のjQueryから来たのをちゃんと受け止めてあげられるように。

3. controllerのアクションの定義

def show_similar_hattoris
  @similar_color_hattoris = similar_color_hattoris(nil, rgb)
  @similar_dark_level_hattoris = similar_dark_level_hattoris(nil, dark_level(rgb))
end

こうやってインスタンス変数を持たせてあげると、ちゃんとpartialに渡せる。渡す処理は次に書く。

4. viewの定義

controllerでアクションを作ったら、普通ならそれに対応するviewを「アクション名.html.erb」で作るものだけど、今回は「アクション名.js.erb」で作っておいて、その中に「htmlを差し替える」っていう処理をjsで書いておけばよい。

$("#similar_hattoris_container")
  .html("<%= escape_javascript(
    render 'partials/image_detail_similar_hattoris',
    similar_color_hattoris: @similar_color_hattoris,
    similar_dark_level_hattoris: @similar_dark_level_hattoris
  ) %>");

ここで、さっきcontrollerでもらった@similar_color_hattorisと@similar_dark_level_hattorisをpartials/image_detail_similar_hattorisに渡せている。

そして、そのpartialが#similar_hattoris_containerの中に反映されるということになる。

これで完了!!

すごい連携やね。めっちゃ混乱する。

色差を求める

ある色を与えられたときに、それに近似した色の服部をデータベースから探して5つほど表示したい。

近似色のデータを探すには、色同士の差を計算しなければならないので、やり方を調べて丸写ししてきた。

サーバー側での処理なので、Rubyで書いてる。

1. linear RGBに直す

与えられたRGBをlinear RGBに変換する。

普通のRGBとlinear RGBの違いは下の質問のベストアンサーがわかりやすかった。

stackoverflow.com

普通のRGBは人の目に合わせてできてるから、そのまま単純に計算するわけにはいかないみたい。

直し方は、まず範囲が0~255だったら255で割って0~1にスケーリングしてから、以下のようにする。

def to_linear(rgb)
  rgb.map do |element|
    if element <= 0.04045
      return element / 12.92
    else
      return ((element + 0.055) / 1.055) ** 2.4
    end
  end
end

2. XYZに直す

XYZは、RGBとL*a*b*に限らず、いろんな色空間の橋渡し的な存在みたい。

def rgb2xyz(rgb)
  m = [
    [0.4124, 0.3576, 0.1805],
    [0.2126, 0.7152, 0.0722],
    [0.0193, 0.1192, 0.9505]
  ]
    
  m.map do |m_row|
    m_row.zip(rgb).inject(0) {|sum, (m_element, rgb_element)| sum + m_element * rgb_element * 100}
  end
end

行列とか意味わからんかったけど、とりあえず掛け算のやり方だけ何とか覚えた。

初めてブロックパラメータに括弧使ったけど、普通に使えて感動した。

3. XYZのスケーリング

def scale_xyz(xyz)
  xyz.zip([95.047, 100.000, 108.883]).map {|xyz_element, n| xyz_element / n}
end

この係数は、L*a*b*の英語版Wikipediaの記事から取ってきた。なぜか日本語版には書いてくれてなかったので。

4. L*a*b*に直す

def xyz2lab(xyz)
  def f(t)
    if t > 0.0089
      t ** (1.0 / 3)
    else
      ((29.0 / 3) ** 3 * t + 16) / 116
    end
  end
  
  [116 * f(xyz[1]) - 16, 500 * (f(xyz[0]) - f(xyz[1])), 200 * (f(xyz[1]) - f(xyz[2]))]
end

Wikipediaのとはちょっとやり方が違うけど、もちろんどちらも答えは同じ。こっちの方がdeltaとか定義してなくて楽そうだったのでこっちにした。

5. L*a*b*同士で距離を求める

普通にユークリッド距離を計算したらいいらしい。

参考

色空間の変換

↑このページの(2)と(3)をめっちゃ読んだ。9割くらい意味わからんかった笑

qiita.com

↑このQiitaの記事にめっちゃまとめてくれてあった。この方は、L*a*b*では飽き足らず、更にレベルの高いことをやってはるみたい(よくわからん)。

できたもの

f:id:YaaMaa:20170205044359p:plain

画像中の色を指定したら、近い色の服部を返してくれる!

今はさくさく動いてくれるけど、これからデータを増やしたらどうなるのかちょっとどきどきしてる。

AndroidでDatePickerDialogのまがい物を作る

2日かけて、AndroidのDatePickerDialogのまがい物を作った。名前被らないように、DateSelectDialogって名前にした。

何で作ろうと思ったかというと、標準のDatePickerDialogでは「年だけ選択させる」とか「年と月だけ選択させる」っていうのができないから。それを実現しようと思うと、

yukiasu.blogspot.jp

この方のような方法をとることになる。昔のUIだったらこれでいけたんだけど、最近のDatePickerDialogは下の画像のようにすごい複雑化してるので、ただ単に「日付選択のViewだけ非表示にする」っていうのではうまくいかなさそう。

f:id:YaaMaa:20170220120210p:plain

今では非推奨となってしまったHOLOテーマを指定する手もあるけど、マテリアルデザインが好きなので戻りたくない…。そこで、もう自分でまがい物を作ればいいんちゃうかなって思った。

現在の年選択ダイアログが

f:id:YaaMaa:20170220120702p:plain

こんなん。この上半分を消して、年選択の右側に月選択と日選択のスピナーを表示させたらいいんかな?

年選択はListViewで実装されてるっぽいので、それに倣ってやってみる。

完成図は下のような感じ。

f:id:YaaMaa:20170220121501p:plain

f:id:YaaMaa:20170221015054p:plain


ダイアログの中身

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="@dimen/dialog_padding_top"
    android:paddingBottom="@dimen/dialog_padding_bottom"
    android:paddingStart="@dimen/dialog_horizontal_padding"
    android:paddingEnd="@dimen/dialog_horizontal_padding"
    android:baselineAligned="false">

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/year"/>

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/month"/>

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/date"/>

</LinearLayout>

weightを1にして、均等に並べる。

ListViewの中身

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/date_select_dialog_item_height">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_dateselect_circle"
        android:layout_centerInParent="true"
        android:tint="@color/colorAccent"
        android:alpha=".38"
        android:id="@+id/image"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:gravity="center"
        android:textSize="13sp"
        android:id="@+id/text"/>

</RelativeLayout>

ImageViewは背景の丸で、TextViewは「2016」とかの文字。

Dialogのコード

このダイアログを実際に使うときには

DateSelectDialog dialog = new DateSelectDialog();
Bundle args = new Bundle();
args.putInt("mode", Calendar.DATE);
args.putInt("default", CalendarManager.toEpochtime(date, Calendar.DATE));
dialog.setArguments(args);
dialog.show(getActivity().getSupportFragmentManager(), "dialog");

このように、"mode"にCalendar.DATE or Calendar.MONTH or Calendar.YEARを指定することでどこまで選択させるかを決め、"default"で初期値を入れることにする。なので、そういう情報はgetArguments()から取ってくる。

ダイアログクラスのonCreateDialogは下のようになった。

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
  inflater = LayoutInflater.from(getContext());
  final View contentView = inflater.inflate(R.layout.date_select_dialog, null);

  mode = getArguments().getInt("mode");
  selected = CalendarManager.fromEpochtime(getArguments().getInt("default"), mode);

  ListView yearList = (ListView) contentView.findViewById(R.id.year);
  // monthとdateも同様

  // ダイアログ作る
  Dialog dialog = new AlertDialog.Builder(getContext())
    .setView(contentView)
    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
      @Override
      public void onClick(DialogInterface dialog, int which) {
        // あとで書く
      }
    })
    .setNegativeButton("Cancel", null)
    .create();

  // modeに応じてListViewを消す
  if (mode == Calendar.MONTH || mode == Calendar.YEAR) {
      dateList.setVisibility(View.GONE);
      if (mode == Calendar.YEAR) {
          monthList.setVisibility(View.GONE);
      }
  }

  // リストの中身を作成(数を+2してるのは、端っこに空白を作るため)
  List<Integer> yearObjects = new ArrayList<>();
  for (int i = 0; i < YEAR_LAST-YEAR_FIRST + 1 + 2; i++) {
      yearObjects.add(i);
  }
  List<Integer> monthObjects = new ArrayList<>();
  for (int i = 0; i < MONTH_COUNT + 2; i++) {
      monthObjects.add(i);
  }
  List<Integer> dateObjects = new ArrayList<>();
  for (int i = 0; i < CalendarManager.getEndOfMonth(selected) + 2; i++) {
      dateObjects.add(i);
  }

  // アダプタをセット
  yearList.setAdapter(new DateSelectDialogListAdapter(getContext(), 0, yearObjects, Calendar.YEAR));
  // monthとdateも同様

  // 端をフェードさせる
  yearList.setVerticalFadingEdgeEnabled(true);
  // monthとdateも同様

  updateTitle(dialog); // 現在選択されてる日時をタイトルにセットする
  return dialog;
}

Dialogの中身のListViewのアダプタ

まず、日付を変換する必要があったので、いろいろメソッドを用意した。この辺で使う数字には3タイプある。

  • リストでのポジション(0インデックス)

  • 普通に1から始まる(年は1970からだけど)表示用の数字

  • カレンダーにセットするとき用の数字(日付・年は表示用のままでいいのに、月だけは0インデックス)

それらを互いに変換しようと思うと、下のようになった。もうちょっと効率のいい方法あったかも…。

// positionから表示用の数字へ
static int pos2Displayed(int mode, int position) {
  switch (mode) {
    case Calendar.DATE:
      return position;
    case Calendar.MONTH:
      return position;
    case Calendar.YEAR:
    default:
      return position + YEAR_FIRST - 1;
  }
}

// positionからCalendar用の数字へ
static int pos2Calendar(int mode, int position) {
  switch (mode) {
    case Calendar.DATE:
      return position;
    case Calendar.MONTH:
      return position - 1;
    case Calendar.YEAR:
    default:
      return position + YEAR_FIRST - 1;
  }
}

// Calendar用の数字からpositionへ
static int calendar2Pos(int mode, Calendar calendar) {
  switch (mode) {
    case Calendar.DATE:
      return calendar.get(mode);
    case Calendar.MONTH:
      return calendar.get(mode) + 1;
    case Calendar.YEAR:
    default:
      return calendar.get(mode) - YEAR_FIRST + 1;
  }
}

ここからが一番ややこしかった。

特に、リストの一番上と一番下に空白を追加するところが厄介だった。

空白を入れないと、

f:id:YaaMaa:20170220131718p:plain

こんな感じで、フォーカスされてる日付を真ん中に持ってこようとしても限界がある。だから、空白を加えて下のようにする。

f:id:YaaMaa:20170220131805p:plain

その空白の高さ(ここではoffsetって名前にしてる)は「(ListView全体の高さ - 一行の高さ) / 2」なんだけど、ListView全体の高さっていうのがgetViewに来てからでないと求まらんかった。DialogFragmentの方のonCreateDialogとかonCreateViewとかでは、0が返ってきたりListView自体がnullだったりする。しかもよくわからないことに、ログ出力してみたらgetViewがすごい回数呼ばれてて、初めのうちはListViewの高さは0なのに、途中からちゃんとした数字になっていくみたいだった。

class DateSelectDialogListAdapter extends ArrayAdapter<Integer> {
  int mode;
  List<Integer> objects;
  int itemHeight;
  int offset = 0;

  public DateSelectDialogListAdapter(Context context, int resource, List<Integer> objects, int mode) {
    super(context, resource, objects);
    this.mode = mode;
    this.objects = objects;

    itemHeight = getResources().getDimensionPixelSize(R.dimen.date_select_dialog_item_height);
  }

  @Override
  public View getView(int position, View convertView, ViewGroup parent) {
    // 空白の分のViewかどうか
    boolean end = position == 0 || position == objects.size() - 1;

    // dateListの中身を月に合わせる
    if (mode == Calendar.DATE && objects.size() - 2 != CalendarManager.getEndOfMonth(selected)) {
      int excess = objects.size() - 2 - CalendarManager.getEndOfMonth(selected);
      for (int i = 0; i < Math.abs(excess); i++) {
        if (excess > 0) {
          objects.remove(objects.size() - 2);
        } else {
          objects.add(objects.size() - 1, objects.size() - 1);
        }
      }
      notifyDataSetChanged();
    }

    if (convertView == null) {
      convertView = inflater.inflate(R.layout.date_select_dialog_list_item, parent, false);
    }

    if (parent.getHeight() != 0 && offset == 0) {
      offset = (parent.getHeight() - itemHeight) / 2;

      // 選択中のアイテムが真ん中に来るようスクロール
      ((ListView) parent).setSelectionFromTop(calendar2Pos(selected), offset);

      convertView.getLayoutParams().height = end ? offset : itemHeight;
    }

    // TextViewの設定
    // end == trueならVisibilityをView.GONEにしたり、そうじゃなかったら数字を表示したり色を変えたり

    // 選択されている日であれば背景を表示
    convertView.findViewById(R.id.image).setVisibility(
      selected.get(mode) == pos2Calendar(position)
        ? View.VISIBLE
        : View.GONE
    );

    // クリックリスナー
    final int finalPosition = position;
    convertView.setOnClickListener(end ? null : new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);

        // 日付の変更
        selected.set(mode, pos2Calendar(finalPosition));
        updateTitle(getDialog());

        DateSelectDialogListAdapter.this.notifyDataSetChanged();
      }
    });

    return convertView;
  }
}

setSelectionFromTopやりすぎって思ったけど、一回セットしただけでは元に戻ってしまったので何回もやってる。

クリックリスナーでは、標準のDatePickerDialogと同じように、クリックされた際に短くバイブさせる。それがperformHapticFeedbackっていうメソッドなんだけど、ここでは引数であるfeedbackConstantにHapticFeedbackConstants.LONG_PRESSを指定してる。本当はCLOCK_TICKがよかったんだけど、APIレベルが足りないので仕方なく…。CLOCK_TICKが「ビッ」って感じだとすると、LONG_PRESSは「ズォンッ」って勢いでバイブレートしよる。

クリックが遅れて反応する問題

たまに、リストの中身をクリックしたら反応が遅れることがあった。

stackoverflow.com

ここの、LayoutParamsについて語ってる人の回答を見て解決!

convertViewに毎回新しく作ったLayoutParamsをセットしてたのが原因だった。

convertView.getLayoutParams().height = ○;

こうやって設定すればよかったみたい。

ダイアログの選択が完了したときのリスナー

インターフェースの中にメソッド入れといて、

public interface OnDateSelectedListener {
  void onSelect(Calendar calendar);
}

それをフィールドとしておいて、

OnDateSelectedListener onDateSelectedListener;

setOnDateSelectedListenerが呼び出されたときに登録する。

public void setOnDateSelectedListener(OnDateSelectedListener l) {
  this.onDateSelectedListener = l;
}

そしたら、さっき「あとで書く」って書いたOKボタンのリスナーには下のように書ける。

.setPositiveButton("OK", new DialogInterface.OnClickListener() {
  @Override
  public void onClick(DialogInterface dialog, int which) {
    if (onDateSelectedListener != null) {
      onDateSelectedListener.onSelect(今選択されてる日付のCalendarオブジェクト);
    }
  }
})

ダイアログを呼び出す側はこうなる↓

dialog.setOnDateSelectedListener(new DateSelectDialog.OnDateSelectedListener() {
  @Override
  public void onSelect(Calendar calendar) {
    // calendarを使って好きなことをする
  }
});

いろいろと効率悪いところもあるけど、これで上に貼った完成図のようなダイアログができた(*‘▽’)

横向けてもちゃんと表示されてる。

f:id:YaaMaa:20170220134937p:plain

AndroidのCanvasでドーナツチャートを描く

ドーナツ型の円グラフを描くのに結構試行錯誤したので、メモしておく。

完成形

f:id:YaaMaa:20170216032855p:plain

f:id:YaaMaa:20170216032906p:plain

こういう二種類のグラフを作りたい。

clipPathを使う

最初は、グラフの色付きの部分を扇形で描き、その後にclipPathで円の中身をくり抜いていた。アンチエイリアスがかからんかったのは気に入らないけど、それ以外はいい感じだと思っていた。

けど、その円グラフをActivityの上のFragment上のViewPagerの1ページのFragmentの上…という複雑な境遇のCanvasで描くと、clipPathした部分が反転して見えてしまった。

f:id:YaaMaa:20170216033238p:plain

なので、別の方法を探した。

くり抜く

sukohi.blogspot.jp

↑こちらのブログに、よく似たことをやってはる方がいたので、真似させていただいた。

Pathを初めにadd○○を使って塗り、その上から反時計回りでくり抜きたい形をaddしたらできるらしい。

私の場合なら、初めにaddArcしてからaddCircle(CCW)でくり抜いたPathをdrawPathすればいいっていうこと。

これで解決したと思ったんだけど…

f:id:YaaMaa:20170216034549p:plain

こっちのグラフが大惨事になっていた。何事かと思ったら、addArcする際に中心を指定するのを忘れていた。

そして指定しようと思ったら…まず中心を指定するための引数がなかった。drawArcにはuseCenterっていう引数がちゃんと用意されてんのに、addArcにはない笑

なので、この方法も使えない。

他の解決法

stackoverflowで調べてみると、いろんなアドバイスがあった。

「初めにドーナツ型の画像用意しといて、それ切り取ったらいいよ」、「drawArcで代用しーよ」、「PorterDuffXfermode使えばいいよ」、「いっそのことAChartEngine使わせてもらいさ」など。

けど、画像用意するのは気に入らんし、drawArcではくり抜けへんし(上から背景色で塗りつぶすのはできるけど気持ち悪い)、ポーターダフは過去何度も使おうと思ってその度に挫折してきたし、ライブラリ使うのは何か悔しい(わがまま)。

自分で扇形をPathに追加するメソッドを作る

addArcでuseCenterが使えないなら、自分で扇形をPathに追加できるようにすればいいのでは。と思って、作ってみた。

public Path addSector(Path path, float x, float y, float radius, float startAngle, float sweepAngle) {
  path.reset();
  path.moveTo(x, y);
  path.lineTo(
    x + (float) (radius*Math.cos(Math.toRadians(startAngle))),
    y + (float) (radius*Math.sin(Math.toRadians(startAngle)))
  );
  path.arcTo(new RectF(x-radius, y-radius, x+radius, y+radius), startAngle, sweepAngle);
  path.close();
  return path;
}

扇形は英語でsectorっていうらしいので、addSectorって名前にした。まず中心に点を打って、それをstartAngleまで持って行って、そこからsweepAngle分arcToし、閉じる。

ラジアンとかいう言葉久しぶりに聞いて頭痛がした。

これでできたかな?と思ったが、

f:id:YaaMaa:20170216040423p:plain

刺さってる笑

そういえば、敵の敵は味方みたいな感じで、もともと描画してないところをさらにくり抜こうとしたら逆に描画されてしまうんか。

ドーナツ型のパスを自分で作る

よく考えれば、真ん中から円をくり抜くことに固執する必要はなかった。初めから真ん中が抜けているドーナツ形のパスを追加すればいいんやん!

public Path makeDonutPath(float x, float y, float radius, float startAngle, float sweepAngle, float holeProportion) {
  Path donutPath = new Path();
  donutPath.moveTo(
    x + (float) (radius*Math.cos(Math.toRadians(startAngle))),
    y + (float) (radius*Math.sin(Math.toRadians(startAngle)))
  );
  donutPath.arcTo(
    new RectF(x-radius, y-radius, x+radius, y+radius),
    startAngle,
    sweepAngle
  );
  donutPath.lineTo(
    x + (float) (radius*holeProportion*Math.cos(Math.toRadians(startAngle+sweepAngle))),
    y + (float) (radius*holeProportion*Math.sin(Math.toRadians(startAngle+sweepAngle)))
  );
  donutPath.arcTo(
    new RectF(x-radius*holeProportion, y-radius*holeProportion, x+radius*holeProportion, y+radius*holeProportion),
    startAngle+sweepAngle,
    -sweepAngle
  );
  donutPath.close();
  return donutPath;
}

やっとできた…!!( ;∀;)

ネーミングセンスがあれやけど、holeProportionっていうのはドーナツ全体の大きさに対するドーナツの穴の大きさの割合。

ちょっと修正

sweepAngleが0だと、弧の始まりと終わりが同じなので円全体が描画されてしまった。なので、new Path()作った直後に、sweepAngle==0なら即座にその空っぽのPathを持って帰ってもらうようにした。

また、sweepAngle >= 360fのときには、完全な輪っかを作って返すようにした。