MTGのために画像と名前をさっとサジェストで検索する

ニコニコ動画で、シチュエーション[^1]に即したカード名が
《日本語/英語》
でコメントされる流れが好きです。

しかし日本名はともかく英語までいちいち調べるのは面倒[^2]なので、情報をさっと取れないかなと調べました。
さすが[「もっともよく遊ばれているTCG」](http://mtgwiki.com/wiki/%E3%82%AE%E3%83%8D%E3%82%B9%E4%B8%96%E7%95%8C%E8%A8%98%E9%8C%B2)。
APIも充実していました。

あ、いまさらですがMTGはミーティングではなくMagic The Gatheringのことです。([お約束](https://qiita.com/Hirotaku/items/4a648652d347adaa4cac)

# 完成品

![mtg.gif](https://qiita-image-store.s3.amazonaws.com/0/62354/beb41220-ccd2-0eb5-d641-4c0651dcad8f.gif)


[MtG Card Suggest](https://khsk.github.io/MtG-card-suggest/)

[khsk/MtG-card-suggest: Search and Completion from your input](https://github.com/khsk/MtG-card-suggest)


# API

[MTG Developers](https://magicthegathering.io/)
を日本語で検索しています。

# サジェスト

[アニメの名言を簡単に引用できるChrome extension『Kotoha』作りました - Konifar's WIP](http://konifar.hatenablog.com/entry/2015/07/24/014613)
で使われている
[At.js](http://ichord.github.io/At.js/)
を使用しています。

# コード

```js:ライブラリ側
const API_URL = 'https://api.magicthegathering.io/v1/cards'


const mtg_callback = {
    remoteFilter: (() => {
        let oldQuery
        let oldCards
        let promise = null

        return (query, callback) => {
            // 気休めの接続低減
            // 変換開始時の空文字queryを排除している
            if (!query) {
                return
            }
            // 気休めの接続低減
            // カーソル移動でイベントが発生するので同値なら結果を使い回す
            if (query === oldQuery) {
                callback(oldCards)
                return
            }

            if (promise) {
                // 気休めの負荷緩和
                // 英字入力やカーソル移動で通信が多発するので、古いのはabortしてやる
                promise.abort()
                promise = null;
            }

            promise = $.getJSON(
                API_URL,
                {
                    name: query,
                    language: 'Japanese',
                },
                (data) => {
                    let cards = $.map(data.cards, (card) => {
                        const foreignData = $.grep(card.foreignNames, (foreignName) => {
                            return (foreignName.language == 'Japanese')
                        })[0]

                        return {
                            name: card.name,
                            jname: foreignData.name || card.name,
                            jimageUrl: foreignData.imageUrl || card.imageUrl,
                        }
                    })

                    // 外部通信なので、検索中と区別がつくように結果0用のリストを追加する
                    if (cards.length == 0) {
                        cards = [{
                            name: 'Not matched',
                            jname: 'Not matched',
                            jimageUrl: 'Not matched',
                        }];
                        cards[0].atwho_order = 0

                        console.log('cards2', cards)
                    }

                    oldQuery = query
                    oldCards = cards
                    promise = null
                    callback(cards)
                }
            )
        }
    })(),


    sorter: (query, items, searchKey) => {
        var _results, i, item, len;
        if (!query) {
            return items;
        }
        _results = [];
        for (i = 0, len = items.length; i < len; i++) {
            item = items[i];
            // remoteFilterで作ったNot matchedを活かすため、手動でordwerを決めた場合は判定しないようカスタマイズ
            if (typeof item.atwho_order === 'undefined') {
                item.atwho_order = item.atwho_order || new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase());
            }
            if (item.atwho_order > -1) {
                _results.push(item);
            }
        }
        return _results.sort(function (a, b) {
            return a.atwho_order - b.atwho_order;
        });
    },
}
```

```js:利用ページ側
      $('textarea, #nameInput').atwho({
          at: "\/mtg",
          displayTpl: "<li>${jname} / ${name}</li>",
          insertTpl: "《 ${jname} / ${name} 》",
          limit: 200,
          callbacks: mtg_callback,
          searchKey: 'jname', // ここnullで動かしたいのだが
      })
      $('#imageInput').atwho({
          at: "\/imtg",
          displayTpl: "<li>${jname} / ${name}</li>",
          insertTpl: "${jimageUrl}",
          searchKey: 'jname', // ここnullで動かしたいのだが
          callbacks: mtg_callback,
      })
      $('#imageInput').change(function() {
        $('#preview').attr('src', $('#imageInput').prop('value'))
      })
```

# At.jsの改変とコールバック実装

## IMEのバグ修正？の取り消し

[Return early for undefined events by markedmondson · Pull Request #534 · ichord/At.js](https://github.com/ichord/At.js/pull/534)
の変更を意図的に抜き、フィルター側で空文字のqueryを弾いて居ます。 これは検索開始時の空文字query検索を防ぐとともに、変換確定時にqueryが実行されるようにする処置です。
(逆に言えばこのプルリクが適用されていると、変換確定したときに検索されないです)

```js
const mtg_callback = {
    remoteFilter: (() => {
        let oldQuery
        let oldCards
        let promise = null

        return (query, callback) => {
            // 気休めの接続低減
            // 変換開始時の空文字queryを排除している
            if (!query) {
                return
            }
}
```
ここのifでプルリクの代替としています。

## 検索回数低減

先の空文字検索を防ぐガードに加え、もう少しガードを追加しています。

```js
            // 気休めの接続低減
            // カーソル移動でイベントが発生するので同値なら結果を使い回す
            if (query === oldQuery) {
                callback(oldCards)
                return
            }

            if (promise) {
                // 気休めの負荷緩和
                // 英字入力やカーソル移動で通信が多発するので、古いのはabortしてやる
                promise.abort()
                promise = null;
}
```
コメントそのままですが、検索の発火がキャレットの移動でも走る繊細さなので、結果を一つだけキャッシュしたり、ローマ字検索などで先にしてしまっている検索をabortして最後の完成queryだけで検索しようと画策しています。
効果のほどは知らん…

APIの制限は一時間に5000回と太っ腹ですが、触っていて気になるレベルでしたのでいろいろ試しました。

# 検索結果なしの表示

普通？は内部の候補を表示するので高速だと思いますが、今回は外部との通信なので少しばかり待ち時間が生じます。
そしてAt.jsは候補が0のときに何も出さない作りなので、
「検索待ちなのか検索結果が0なのか」
がわからない問題がありました。
そこで、

```js
                   // 外部通信なので、検索中と区別がつくように結果0用のリストを追加する
                    if (cards.length == 0) {
                        cards = [{
                            name: 'Not matched',
                            jname: 'Not matched',
                            jimageUrl: 'Not matched',
                        }];
                        cards[0].atwho_order = 0

                        console.log('cards2', cards)
}
```

と検索結果が0のときにダミーの候補を作ってやるようにしました。
`atwho_order`が検索キーワードと`indexOf`して出来たint値で、`!-1`なら一致したということです。

ただ、検索結果側のみで手動で`atwho_order`を偽装するだけではうまく動きませんでした。

というのも、`atwho_order`が作られるのは`sorter`のタイミングだったからでした。

[At.js/default.coffee at master · ichord/At.js](https://github.com/ichord/At.js/blob/master/src/default.coffee#L94-L102)
[At.js/jquery.atwho.js at 1b7a52011ec2571f73385d0c0d81a61003142050 · ichord/At.js](https://github.com/ichord/At.js/blob/1b7a52011ec2571f73385d0c0d81a61003142050/dist/js/jquery.atwho.js#L73-L89)

なので、デフォルトの`sorter`から手動で作った`atwho_order`を見逃す実装を追加しました。

```js
    sorter: (query, items, searchKey) => {
        var _results, i, item, len;
        if (!query) {
            return items;
        }
        _results = [];
        for (i = 0, len = items.length; i < len; i++) {
            item = items[i];
            // remoteFilterで作ったNot matchedを活かすため、手動でordwerを決めた場合は判定しないようカスタマイズ
            if (typeof item.atwho_order === 'undefined') {
                item.atwho_order = item.atwho_order || new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase());
            }
            if (item.atwho_order > -1) {
                _results.push(item);
            }
        }
        return _results.sort(function (a, b) {
            return a.atwho_order - b.atwho_order;
        });
},
```

# 問題点

## ダブる

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/b1e02468-4188-dea6-1f1e-3a711316b703.png)
(候補すべて取り消し)

APIは再録ごとに別カードとして扱うので、同じ候補がたくさんでます。
テキストやイラスト違いなどがあるので一概に悪いわけではないのですが…

オブジェクトのdistinctは
[JavaScriptのオブジェクト配列に対してdistinct的な事（重複を排除した結果を返す）をする - Qiita](https://qiita.com/NewGyu/items/eb52784e9860d6240765)
によると
O(n^2)らしいので、うーん…

## 日本語名がないカードがある

たとえば、有名な[Black Lotus](http://mtgwiki.com/wiki/Black_Lotus)は日本語版未発売？と考えられますが、
勝舞くんが使っていた[筋肉スリヴァー/Muscle Sliver](http://mtgwiki.com/wiki/%E7%AD%8B%E8%82%89%E3%82%B9%E3%83%AA%E3%83%B4%E3%82%A1%E3%83%BC/Muscle_Sliver)
などテンペストブロック周りの古いカードなどにも日本語名がありません。
https://api.magicthegathering.io/v1/cards?name=Muscle
画像も無い場合があります。
新しめのカードは最初から充実しているのですが。
なので、ダブっている場合でもリストの下の方が取れる可能性が高いです。

あと効果やフレーバーまで全言語対応してくれると…は高望みですね。

## 最大の問題点

画像URLを取得しようとすると、手が勝手に`imgt`と打ってしまうこと…

あとアドオンにして配布しないと特に便利ではないことですねー…。

***

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/8a9162cc-711e-56e3-2dea-b4ce50c00222.png)

それ([MTG](http://mtgwiki.com/wiki/%E5%91%AA%E8%A9%9B/Hex)じゃなくて)[HS](https://wiki.denfaminicogamer.jp/hearthstone/%E5%91%AA%E8%A1%93_Hex)だよ

# 他の

* [at.jsはinputにも使える(不正動作あり) - Qiita](https://qiita.com/khsk/items/a4928caaa472b7e0a59f)

At.jsを使うとき、依存物のCaret.jsを誤って
[caret - cdnjs.com - The best FOSS CDN for web related libraries to speed up your websites!](https://cdnjs.com/libraries/caret)
`accursoft/caret`
にしていてハマりました。
ちゃんと
`ichord/Caret.js`
使おうね。
[^1]: カードゲームにまったく関係ない動画の
[^2]: ~~Wikiに併記してある~~