20190209のJavaScriptに関する記事は12件です。

Jupyterで使える自作のプロットライブラリを作ってみたい。

Jupyter上で使えるプロットを自分で作って遊べないか試してみました。
ゴミでもなんでもアウトプットしていくべきとのことで、大したことのないものですが進めていきます。
plotlyとか既に色々選択肢がありますが、勉強目的と、自分でカスタマイズなどもできるように、車輪の再発明をしていきます。

使うjsライブラリ

インタラクティブ且つ色々アニメーションさせたりも考えて、D3.jsをPython側から扱っていく方向で進めます。後は作業中は個別のモジュール単位などで実施し、定期的に全体のテストを流していく等すればテスト時間はそこまで気にならなくなりそうです。

(D3.js自体は以前記事にしているのでそちらも良ければ : SVGとD3.jsの入門まとめ

jsライブラリの読み込みはどうやるの?

べたでjsやHTMLを書いたりする分にはIPython.displayパッケージ内の関数やクラスを使っていけばJupyter上で色々できますが、外部のjsファイルなどを対象としたい場合はどうすればいいのでしょう。
Jupyter上で、HTMLやdisplayメソッドを組み合わせてscriptタグでsrc指定したくらいだと、エラーで怒られるようです。
他の方のライブラリでマジックコマンドでJupyter上でD3.jsを扱えるようにするものがありましたが、今回はJupyter上で直接D3.jsを扱うのではなく、.pyファイルを経由する形でライブラリで色々やりたいところです。

調べたところ、requirejsを使う形でJupyterで読み込めるとの記事があったため、そちらを利用する形にしました。

%%html
<script>
    requirejs.config({
        paths: {
            'd3': ['https://d3js.org/d3.v4.min'],
        },
    });
</script>

※minの後に.jsといった拡張子は付けない形で指定します。
※v4のバージョンの個所は他のものを使う際には調整してください。

後は、使いたいタイミングでrequire(['d3'], function(d3) {...といった記述をすることで、D3.jsがJupyter上で使えます。

%%html
<svg id="test-svg" width="100" height="100">
</svg>
<script>
    require(['d3'], function(d3) {
        d3.select("#test-svg")
            .append("rect")
            .attr("width", "100")
            .attr("height", "100")
            .attr("fill", "#ff0000");
    });
</script>

20190119_2.png

※htmlのマジックコマンドで記述したましたが、IPython.display.display関数とIPython.display.HTMLを組み合わせて、.py上から実行してもちゃんとJupyter上で表示されます。

テストどうしようか問題

フロントのテストに近いような印象ですが、Jupyter上で動作することを目的とするため、普通のバックエンド側の単体テストなどと比べると少し厄介です。
手動でのテストに頼る形でもいいのかもしれませんが、50とか100とかにプロットの種類がなってくると少ししんどい気配があります。
細かいアニメーションなどは目に頼る必要がありますが、それ以外のあまりテストを書く負担が重くなく、且つ書いてあると安心感が出てくる費用対効果が高そうなところはJupyterなどが絡む個所でもテストを書く形で進めます。

Jupyterの起動

まずは、Jupyterを起動させないといけないため、noseのテストランナーのラッパー的なモジュールを用意しました。

そのモジュール内で、別のプロセスでJupyterを起動するようにしました。
起動コマンドの--no-browserオプションで、ブラウザを起動せずにJupyterを起動できます。後で触れますが、selenium側で別途ブラウザを立ち上げるので、ここではブラウザの起動はしない形で設定しています。
また、--portでテスト用のJupyterのポートを指定しています。通常は8888が使われ、8888が使用済みであれば8889...といった具合にポート番号が割り振られていくので、それらとテスト用のJupyterで番号が被らないように設定しています。

import os
import multiprocessing as mp
import subprocess as sp
import time

...

def run_jupyter_process():
    """
    Start Jupyter process for testing.
    """
    os.system(
        'jupyter notebook --no-browser --port={jupyter_test_port} &'.format(
            jupyter_test_port=JUPYTER_TEST_PORT
        ))


...

    jupyter_process = mp.Process(target=run_jupyter_process)
    jupyter_process.start()

また、別のプロセスにしないとJupyter起動のコマンドで処理が止まってしまう(Ctrl + CなどでJupyterを止めないと次に進まない)のでmultiprocessingモジュールを利用しています。
ただ、いつ起動が終わるのかが見えないので、起動が終わったかどうかをチェックする必要があります。以下のようなコマンドで、起動が終わって動いているJupyterの一覧が表示できるので、そのリストの中に指定したテスト用のポートのJupyterが存在するかどうかをチェックし、存在する状態になったタイミングでnoseのテストに移るようにwhile文で制御します。

$ jupyter notebook list

以下のようにポートやトークンなどを含めたリストが表示されます。

Currently running servers:
http://localhost:8888/?token=27fc5d92e60184655a145a6ef723ff5f6349571b3cd0cb1e :: C:\Users\
def is_jupyter_started():
    """
    Get the boolean value as to whether Jupyter for testing has
    been started or not.

    Returns
    -------
    result : bool
        If it is started this function will returns True.
    """
    out = sp.check_output(
        ['jupyter', 'notebook', 'list'])
    out = str(out)
    is_in = str(JUPYTER_TEST_PORT) in out
    if is_in:
        return True
    return False

...

    while not is_jupyter_started():
        time.sleep(1)

また、テストが終わった後に、起動したJupyterを止めないと、ポート番号がどんどんずれていったり、メモリを無駄に消費したりと好ましくありません。
アクセス時にもポート番号がずれずに固定のものだと制御が楽なので、テスト前と終わったタイミングでテスト用のポートのJupyterが起動していれば止めるようにしておきます。
以下のようなフォーマットのコマンドで任意のJupyterを止めることができます。

$ jupyter notebook stop {ポート番号}
def stop_jupyter():
    """
    Stop Jupyter of the port number used in the test.
    """
    os.system('jupyter notebook stop {jupyter_test_port}'.format(
        jupyter_test_port=JUPYTER_TEST_PORT
    ))

...

    stop_jupyter()
    jupyter_process.terminate()

テスト長くない?問題

テスト時にJupyterを起動させる都合、ちょっとテストが終わるまで長くなります。
一部のモジュールだけテストしたい、といったケースでも1分かかったりします。
仕事だと部分的なテストは3秒くらいあれば起動から終わりまで通るのでそれらと比べると少し辛いところです。
基本的にテストを流している間もぼーっとしているのは非効率なので、作業しつつ終わったら通知が来るようにしておきます。Win10環境で作業しているので、Windows 10 Toast NotificationsというPythonライブラリを使わせていただきました。
これで、テストが終わった際に画面右下に通知が表示されます。

20190123_1.png

インストール :

$ pip install win10toast==0.9

また、テストにはnoseライブラリを使っていますが、noseで引数に--with-xunitと--xunit-fileを指定することで、指定のパスにテストの実行結果をXMLで保存してくれるようになるようです。XML内に、テスト全体の実行件数や失敗件数、各テストの処理時間が保存されます。
XMLのパース用に、Pythonのxmlモジュールを使って値を取っていきます。

import xml.etree.ElementTree as ET
...
from win10toast import ToastNotifier
import nose
...
def run_nose_command(module_name):
    """
    Execute the test command with the Nose library, and
    obtain the number of execution tests and the number of
    failure tests.

    Parameters
    ----------
    module_name : str
        Name of the module to be tested. Specify in a form
        including a path. Omit extension specification.
        If an empty character is specified, all tests are
        targeted.

    Returns
    -------
    test_num : int
        Number of tests executed.
    error_num : int
        Number of errors.
    failures_num : int
        Number of tests failed.
    """
    xml_path = 'log_test.xml'
    nose_command = 'nosetests'
    if module_name != '':
        nose_command += ' %s' % module_name
    nose_command += ' --with-xunit --xunit-file={xml_path} -s -v'.format(
        xml_path=xml_path
    )
    os.system(nose_command)
    with open(xml_path, 'r') as f:
        test_xml = f.read()
        xml_root_elem = ET.fromstring(text=test_xml)
    test_num = int(xml_root_elem.attrib['tests'])
    error_num = int(xml_root_elem.attrib['errors'])
    failures_num = int(xml_root_elem.attrib['failures'])
    return test_num, error_num, failures_num
...
    test_num, error_num, failures_num = run_nose_command(
        module_name=module_name)
...
    toast_msg = '----------------------------'
    toast_msg += '\ntest num: %s' % test_num
    toast_msg += '\nerror num: %s' % error_num
    toast_msg += '\nfailures num: %s' % failures_num
    toast_notifier = ToastNotifier()
    toast_notifier.show_toast(
        title='The test is completed.',
        msg=toast_msg,
        duration=5)

これでテスト終了時に通知が飛んでくるようになります。音も鳴るのでよそ見していても安心。
お好みでSlackなどに調整するといいと思われます。というか、なんとなくお試しでこれ使ってみたけれど、普通にSlackでいいよね。
後は作業中は個別のモジュール単位などで実施し、定期的に全体のテストを流していく等すればテスト時間はそこまで気にならなくなりそうです。
また、テストのコマンドの引数で、Jupyterの起動をスキップするかどうかの指定を受け入れるようにも調整しました(1つの関数のみのテストなどで、Jupyterを使わない場合など)。

遊びなので本格的なCI的なところまでは対応しませんが、ひとまずは個人で進めるにはこの程度で良さそうです。
(本当はPython用のlintを入れたり、Jupyterのプロセスを一度起動したら使いまわしたりした方がテストが早く終わったりで快適かと思われますが、それらは後日機会があれば少しずつ・・)

seleniumでChromeのWebDriverを使う

テスト用のJupyterへのアクセスはseleniumとChromeのWebDriverを使わせていただきました。
過去、PhantomJSやらFireFoxは使ったことがありましたが、今回初のChromeです。前者二つよりも考えるべきことが少なく済んでなんだか快適です。

参考にさせていただきました : Python + Selenium で Chrome の自動操作を一通り

import chromedriver_binaryとするだけでパスが通るのもシンプルでいいですね。

seleniumからJupyterのセルにスクリプトを入力できない問題

テスト時にJupyterやら起動させる点は問題がありませんでしたが、その後selenium経由でJupyterのセル内にスクリプトを入力していこうとしたところ、input周りの構造が大分複雑でうまくいきませんでした。クリックした後にseleniumのsend_keyなどだといまいちうまくいきません。どうやらtextarea関係がJupyter上では実は非表示になっているそうで・・

対策として、開く前にipynbファイルのセルの設定に直接値を設定して、それからノートのページを開くように調整しました。(テスト用であればこれだけでも十分かなと)

ただし、よく調べてみるとselenim経由でDOMを色々操作してしまえばいけそう、という情報が見つかりました。こちらの方がスマートな気がしないでもないので、後日気が向いたら調整しようと思います。

NOTE I have been successful when changing the DOM with javascript execution by making the textarea visible and then sending keys to it. (This was done by removing hidden; from the style attribute in the parent div element that it inherited from. However, I am looking for a solution which does not require altering the DOM.
python Selenium send_keys to a Jupyter notebook

後は、seleniumからRUN ALLメニューなどを操作するスクリプトを書いて、アウトプットの内容を取得してテストするスクリプトを組んでひとまずはテストができそうな気配が出てきました。

アウトプットの要素のスクショがうまく取れない問題

How to take partial screenshot with Selenium WebDriver in python?

テストのためのJupyterやらseleniumなどの準備が整ったので、いざJupyter上でのD3.js経由のアウトプットの表示結果のスクショを取ってみよう・・と上記のstackoverflowの投稿を参考に進めてみたところ、なんだかスクショ領域がずれます。

なぜだろう・・と色々悩んだり調べていたところ、上記のstackoverflow内で以下のコメントを見つけました。

On MacOS (retina) there is problem that web element position in pixels dont match element position in screenshot, due to resize/ratio –

画面の比率の問題・・そういえば、タブレットで作業をしているので、150%(推奨値)に設定していたのを思い出しました。
ちょっと文字が小さい感が無きにしもですが、100%にしてみたところ正常に動作しました・・どうやら、selenium側での座標などの取得値が画面の解像度が100%ではない場合はずれてしまう模様。
てっきりJupyterのヘッダー部分などがある都合、座標がずれるのかとか考えて、非表示にする処理を追加したりしてしまいましたがそうではなかったようで・・。
環境変数的なもので、画面解像度設定のファイルを設置してもいいのですが、少し手間なので一旦100%で進めます。

引数に渡したWebElement要素のスクショを取るコード(基本的にJupyter上のSVG要素を指定):

def save_target_elem_screenshot(
        target_elem, img_path=DEFAULT_TEST_IMG_PATH):
    """
    Save screenshot of target element.

    Parameters
    ----------
    target_elem : selenium.webdriver.remote.webelement.WebElement
        The WebElement for which screen shots are to be taken.
    img_path : str, default DEFAULT_TEST_IMG_PATH
        The destination path.
    """

    driver.find_element_by_tag_name('body').send_keys(
        Keys.CONTROL + Keys.HOME)
    location_dict = target_elem.location
    size_dict = target_elem.size
    elem_x = location_dict['x']
    left = location_dict['x']
    top = location_dict['y']
    right = location_dict['x'] + size_dict['width']
    bottom = location_dict['y'] + size_dict['height']
    screenshot_png = driver.get_screenshot_as_png()
    img = Image.open(BytesIO(screenshot_png))
    img = img.crop((left, top, right, bottom))
    img.save(img_path)
    img.close()

※Jupyterの起動、seleniumの起動、Jupyterの入力のセルにテスト用のスクリプトを設定する処理、テスト用のノートを開く処理、Jupyter上のスクリプトの実行、ヘッダーと入力のセルを非表示にする処理(スクショが途切れないように)、アウトプットのSVG領域のスクショを保存する処理の流れのGIFアニメ:

20190126_1.gif

一部以前使っていたJupyterの拡張機能の都合、通知の許可云々が出てきていますが、害は無いので放置します:sweat:

スクショの保存結果:

tmp_test_img.png

ひとまずはD3.jsを経由しての四角を追加するだけのシンプルなものではありますが、このテストの流れでいけそうな気配があります。

OpenCVでの画像の比較

ヒストグラム比較

参考にさせていただきました。

スクショで取った画像のRGBのヒストグラムを比較して、ほぼ想定した通りの画像になっているのかをチェックするための処理を用意します。(将来、フォントなどの表示が少し変わっても分布の差はそこまで変わらずにテストが通る、といった状況を想定)

OpenCVのcalcHistでヒストグラムの計算、compareHistでヒストグラムの比較をします。
RGBの各チャンネルに対して実施し、それぞれの類似度の平均を取得するようにします。

def compare_img_hist(img_path_1, img_path_2):
    """
    Get the comparison result of the similarity by the histogram of the
    two images. This is suitable for checking whether the image is close
    in color. Conversely, it is not suitable for checking whether shapes
    are similar.

    Parameters
    ----------
    img_path_1 : str
        The path of the first image for comparison.
    img_path_2 : str
        The path of the second image for comparison.

    Returns
    -------
    similarity : float
        Similarity between two images. The maximum is set to 1.0, and the
        closer to 1.0, the higher the similarity. It is set by the mean
        value of the histogram of RGB channels.
    """
    assert_img_exists(img_path=img_path_1)
    assert_img_exists(img_path=img_path_2)
    img_1 = cv2.imread(img_path_1)
    img_2 = cv2.imread(img_path_2)
    channels_list = [[0], [1], [2]]
    similarity_list = []

    for channels in channels_list:
        img_1_hist = cv2.calcHist(
            images=[img_1],
            channels=channels,
            mask=None,
            histSize=[256],
            ranges=[0, 256]
        )
        img_2_hist = cv2.calcHist(
            images=[img_2],
            channels=channels,
            mask=None,
            histSize=[256],
            ranges=[0, 256]
        )
        similarity_unit = cv2.compareHist(
            H1=img_1_hist, H2=img_2_hist, method=cv2.HISTCMP_CORREL)
        similarity_list.append(similarity_unit)
    similarity = np.mean(similarity_list)
    return similarity

試しにテストで真っ赤な画像2枚を指定して、類似度が1.0(最大)になっていることや、赤と緑の画像を比較して類似度が下がっていることを確認しました。
※注 赤と緑の画像の比較でも、青は両方とも0でそこは類似しているという判定になるので、類似度が0にはならない点に注意します。

from nose.tools import assert_equal, assert_true, assert_raises, \
    assert_less_equal
from PIL import Image

...

    img = Image.new(mode='RGB', size=(50, 50), color='#ff0000')
    img.save(TEST_IMG_PATH_1)
    img.save(TEST_IMG_PATH_2)
    img.close()
    similarity = img_helper.compare_img_hist(
        img_path_1=TEST_IMG_PATH_1,
        img_path_2=TEST_IMG_PATH_2)
    assert_equal(similarity, 1.0)

    img = Image.new(mode='RGB', size=(50, 50), color='#00ff00')
    img.save(TEST_IMG_PATH_2)
    img.close()
    similarity = img_helper.compare_img_hist(
        img_path_1=TEST_IMG_PATH_1,
        img_path_2=TEST_IMG_PATH_2)
    assert_less_equal(similarity, 0.5)

テストでのしきい値はそのうち様子を見て調整していくとして、これでとりあえずはエラーなんかで画像が表示されていない!とかデグレして全然違うように表示されてしまっている!といったことがチェックできます。
色ではなく形を重視して比較する方法(例えば、カラーセットの変更を加味して実装した場合のテストなど)もありますが、そちらは必要性を感じてきたら追加するようにします。

実際にプロットの機能の実装を考える

ここまでが結構長かった感じですが、やっと本格的なプロットの機能を考えていきます。
最初にどういったものを作るか・・という点ですが、シンプルなプロットであればPythonで様々な選択肢があり、それを作るのでは面白みがありません。そのため、まだ他の方で作られてなさそうな、Storytelling with Data: A Data Visualization Guide for Business Professionalsの書籍に出てくるようなプロットのPythonでの実装を考えてみます。

どういったプロットかというと、「極力シンプルに」「何を伝えたいのかを極力明確に」「なるべく短時間で使えたい内容を伝える」「より効果的にするためにデザインの知見を活かす」「色弱の方でも伝わる配色」といったような、スパゲッティコードならぬスパゲッティグラフを回避するためのプロットです。
プレゼンで聞いている方への説明で短時間で伝えないといけない際など、ビジネスで役立ちます。
詳細は著者の方のサイトのhow I storyboardなどもご確認ください。

まずは一つのラインのみ目立たせる折れ線グラフから

ベーシックなものを作っていきます。
伝えたい内容が一つの数値だけで、他の要素はあまり重要ではないようなケースで使うようなプロットを考えます。
折れ線グラフで、目立たせるものを青色、他をグレーの配色にします。(色弱の方でも区別が付きやすく、青の部分があなたが伝えたいことということが瞬時に分かるプロット)

その他、以下のような点を対応します。

  • 凡例を端の方でまとめる形ではなく、折れ線グラフの右端に配置するのを想定します。
  • タイトル・説明文をオプションとして設定できるようにします。
  • X軸の値は日付(時系列のデータ)を想定します。
  • 年の表記は毎回設定する必要がないのと、X軸の表示を回転させると可読性が下がるそうなので、回転させずに年と月日で2段の表示とします。
  • Y軸のラベルをオプションで設定できるようにします。(何の値なのかのラベル)
  • Y軸のラベルで、前後に文字列をオプションで設定できるようにします。(例 : $や円や%記号など) 

Storytelling with data for grants managersの記事に、良くない例(Before)と修正後の良い例(After)が載っています。

モックを作る

最初からJupyterへの組み込みをしながらレイアウトなどの調整をするのはしんどいため、最初はHTML単体で書いてみます。こうすることで、色々D3.jsで試行錯誤がしやすかったり、最終的にPython側から渡さないといけないパラメーターの洗い出しなどを目的とします。

こんな感じになりました。

20190203_1.png

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <script src="https://d3js.org/d3.v4.js"></script>
        <style>
            #test-svg {
                border: 1px solid #999999;
            }

            #test-svg .font {
                font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", YuGothic, "ヒラギノ角ゴ ProN W3", Hiragino Kaku Gothic ProN, Arial, "メイリオ", Meiryo, sans-serif;
            }

            #test-svg .title {
                fill: #6bb2f8;
                font-size: 25px;
            }

            #test-svg .description {
                font-size: 14px;
                fill: #999999;
            }

            #test-svg .legend {
                font-size: 14px;
                fill: #999999;
            }

            #test-svg .stands-out-legend {
                font-size: 14px;
                fill: #6bb2f8;
                font-weight: bold;
            }

            #test-svg .x-axis path,
            #test-svg .y-axis path,
            #test-svg .x-axis line,
            #test-svg .y-axis line {
                stroke: #999999;
                shape-rendering: crispEdges;
            }

            #test-svg .x-axis text,
            #test-svg .x-axis-year,
            #test-svg .y-axis text,
            #test-svg .y-axis-label {
                font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", YuGothic, "ヒラギノ角ゴ ProN W3", Hiragino Kaku Gothic ProN, Arial, "メイリオ", Meiryo, sans-serif;
                fill: #999999;
                font-size: 14px;
            }

            #test-svg .line {
                fill: none;
                stroke: #cccccc;
                stroke-width: 2.5;
            }

            #test-svg .stands-out-line {
                fill: none;
                stroke: #acd5ff;
                stroke-width: 4.0;
            }
        </style>
    </head>
    <body>

        <script>

            const SVG_ID = "test-svg";
            const SVG_WIDTH = 600;
            const SVG_HEIGHT = 372;
            const OUTER_MARGIN = 20;
            const X_TICKS = 5;
            const Y_TICKS = 5;
            const Y_AXIS_PREFIX = "";
            const Y_AXIS_SUFFIX = "";
            const PLOT_TITLE_TXT = "Time series of fruit prices.";
            const PLOT_DESCRIPTION_TXT = "Orange price keeps stable value in the long term.";

            const DATASET = [{
                date: new Date(2018, 0, 1),
                Apple: 100,
                Orange: 120,
                Melon: 250
            }, {
                date: new Date(2018, 3, 12),
                Apple: 120,
                Orange: 150,
                Melon: 220
            }, {
                date: new Date(2018, 10, 3),
                Apple: 110,
                Orange: 100,
                Melon: 330
            }, {
                date: new Date(2019, 1, 10),
                Apple: 130,
                Orange: 160,
                Melon: 310
            }]
            const COLUMN_LIST = ["Apple", "Melon"];
            const STANDS_OUT_COLUMN_LIST = ["Orange"];
            var MERGED_COLUMN_LIST = COLUMN_LIST.concat(STANDS_OUT_COLUMN_LIST);
            const LEGEND_DATASET = [
                {key: "Apple", value: 130},
                {key: "Orange", value: 160},
                {key: "Melon", value: 310}
            ];
            const LEGEND_KEY = function(d) {
                return d.key;
            }
            const YEAR_DATASET = [
                new Date(2018, 0, 1),
                new Date(2019, 0, 1)
            ];
            const Y_AXIS_MIN = 0;
            const Y_AXIS_MAX = 310 * 1.1;
            const Y_AXIS_LABEL = "Price of each fruit";
            const X_AXIS_MIN = new Date(2018, 0, 1);
            const X_AXIS_MAX = new Date(2019, 1, 10);

            var svg = d3.select("body")
                .append("svg")
                .attr("width", SVG_WIDTH)
                .attr("height", SVG_HEIGHT)
                .attr("id", SVG_ID)

            var plotBaseLineY = 0;
            if (PLOT_TITLE_TXT !== "") {
                var plotTitle = svg.append("text")
                    .attr("x", OUTER_MARGIN)
                    .attr("y", OUTER_MARGIN)
                    .attr("dominant-baseline", "hanging")
                    .text(PLOT_TITLE_TXT)
                    .classed("title font", true);
                var plotTitleBBox = plotTitle.node().getBBox();
                plotBaseLineY += plotTitleBBox.y + plotTitleBBox.height;
            }

            if (PLOT_DESCRIPTION_TXT !== "") {
                var plotDescription = svg.append("text")
                    .attr("x", OUTER_MARGIN)
                    .attr("y", plotBaseLineY + 10)
                    .attr("dominant-baseline", "hanging")
                    .text(PLOT_DESCRIPTION_TXT)
                    .classed("description font", true);
                var plotDesciptionBBox = plotDescription.node().getBBox();
                plotBaseLineY += plotDesciptionBBox.height + 10;
            }

            var legend = svg.selectAll("legend")
                .data(LEGEND_DATASET, LEGEND_KEY)
                .enter()
                .append("text")
                .text(function(d) {
                    return d.key;
                })
                .attr("dominant-baseline", "central");
            legend.each(function(d) {
                var className;
                if (STANDS_OUT_COLUMN_LIST.indexOf(d.key) >= 0) {
                    className = "legend stands-out-legend font";
                }else {
                    className = "legend font";
                }
                d3.select(this)
                    .classed(className, true);
            })

            var yLabelMarginAdjust = 0;
            if (Y_AXIS_LABEL !== "") {
                var yAxisLabel = svg.append("text")
                    .text(Y_AXIS_LABEL)
                    .attr("transform", "rotate(270)")
                    .attr("text-anchor", "end")
                    .attr("dominant-baseline", "text-before-edge")
                    .classed("font y-axis-label", true);
                yAxisLabel.attr("x", -plotBaseLineY - OUTER_MARGIN + 1)
                    .attr("y", OUTER_MARGIN - 3);
                var yAxisLabelBBox = yAxisLabel.node()
                    .getBBox();
                yLabelMarginAdjust = yAxisLabelBBox.height + 2;
            }
            var yAxisScale = d3.scaleLinear()
                .domain([Y_AXIS_MIN, Y_AXIS_MAX])
                .range([SVG_HEIGHT - OUTER_MARGIN, plotBaseLineY + OUTER_MARGIN]);
            var yAxis = d3.axisLeft()
                .scale(yAxisScale)
                .ticks(Y_TICKS)
                .tickFormat(function (d) {
                    var tickFormat = d;
                    if (Y_AXIS_PREFIX !== "") {
                        tickFormat = Y_AXIS_PREFIX + tickFormat;
                    }
                    if (Y_AXIS_SUFFIX !== "") {
                        tickFormat += Y_AXIS_SUFFIX;
                    }
                    return tickFormat;
                });
            var yAxisGroup = svg.append("g")
                .classed("y-axis font", true)
                .call(yAxis);
            var yAxisBBox = yAxisGroup
                .node()
                .getBBox();
            var yAxisPositionX = OUTER_MARGIN + yAxisBBox.width + yLabelMarginAdjust;
            yAxisGroup.attr("transform", "translate(" + yAxisPositionX + ", 0)");

            var xAxisScale = d3.scaleTime()
                .domain([X_AXIS_MIN, X_AXIS_MAX])
                .range([yAxisPositionX, SVG_WIDTH - OUTER_MARGIN]);

            var yearFormat = d3.timeFormat("%Y");
            var year = svg.selectAll("year")
                .data(YEAR_DATASET)
                .enter()
                .append("text")
                .text(function(d) {
                    return yearFormat(d);
                })
                .attr("text-anchor", "middle")
                .attr("x", function(d) {
                    return xAxisScale(d);
                })
                .attr("y", SVG_HEIGHT - OUTER_MARGIN)
                .classed("font x-axis-year", true);
            var yearBBox = year.node()
                .getBBox()

            var xAxis = d3.axisBottom()
                .scale(xAxisScale)
                .ticks(X_TICKS)
                .tickFormat(d3.timeFormat("%m/%d"));
            var xAxisGroup = svg.append("g")
                .classed("x-axis font", true)
                .call(xAxis)
            var xAxisBBox = xAxisGroup
                .node()
                .getBBox();
            xAxisPositionY = SVG_HEIGHT - OUTER_MARGIN - xAxisBBox.height - yearBBox.height;
            xAxisGroup.attr(
                "transform",
                "translate(0, " + xAxisPositionY + ")");

            yAxisScale.range([xAxisPositionY, plotBaseLineY + OUTER_MARGIN]);
            yAxis.scale(yAxisScale);
            yAxisGroup.call(yAxis);

            var legendMaxWidth = 0;
            svg.selectAll(".legend").each(function(d) {
                var width = d3.select(this)
                    .node()
                    .getBBox()["width"];
                legendMaxWidth = Math.max(legendMaxWidth, width);
            });
            svg.selectAll(".legend")
                .attr("x", function(d) {
                    return SVG_WIDTH - OUTER_MARGIN - legendMaxWidth;
                })
                .attr("y", function(d) {
                    return yAxisScale(d.value);
                });
            xAxisScale.range(
                [yAxisPositionX, SVG_WIDTH - OUTER_MARGIN - legendMaxWidth - 10]);
            xAxis.scale(xAxisScale);
            xAxisGroup.call(xAxis);
            year.attr("x", function(d) {
                return xAxisScale(d);
            });

            for (var i = 0; i < MERGED_COLUMN_LIST.length; i++) {
                var columnName = MERGED_COLUMN_LIST[i];
                var line = d3.line()
                    .x(function (d) {
                        return xAxisScale(d.date);
                    })
                    .y(function (d) {
                        return yAxisScale(d[columnName]);
                    });
                var line_class;
                if (STANDS_OUT_COLUMN_LIST.indexOf(columnName) >= 0) {
                    className = "stands-out-line";
                }else {
                    className = "line";
                }
                svg.append("path")
                    .datum(DATASET)
                    .classed(className, true)
                    .attr("d", line);
            }
        </script>
    </body>
</html>

若干、複数回scaleを調整しているところとかはもうちょっとシンプルにできそうな気配かありますが、D3.jsのコードをもっとたくさん書いていれば段々洗練されていくのでしょう・・。

作業中、結構アンカーポイントやバウンディングボックス関係の設定で、合っているのか結構不安だったため、Chrome拡張のRuler関係のツールを多用していました。

image.png

Page Ruler Redux

また、色弱の方に対する表示も確認しておきます(水色と灰色の組み合わせは大丈夫だよ、と書籍に書かれていたものの一応)。

Chromatic Vision Simulatorというツールを利用させていただきました。

image.png

ちゃんと目立たせている場所とそうではない場所の区別が付くようで、大丈夫そうですね。

組み込んでJupyter上で表示できるようにする

作ったモックを組み込んでいきます。テンプレートとしてのjsとCSSのファイルを用意してそれらを文字列としてPythonで読み込み、パラメーターを置換して、最後にIPython.displayモジュール内のdisplay関数でJupyter上で表示します。

jsのテンプレートでは、以下のようにPythonの文字列のformat関数やDjangoのテンプレートのPython変数に近い感覚で、{変数名}のという形で記述しました。

...
const SVG_WIDTH = {svg_width};
const SVG_HEIGHT = {svg_height};
const SVG_BACKGROUND_COLOR = "{svg_background_color}";
const SVG_MARGIN_LEFT = {svg_margin_left};
const OUTER_MARGIN = {outer_margin};
...

CSS側は、{}の括弧で書いてしまうとVS Code上でエラーになり、入力補完が効かなくなって辛い感じなので、--変数名--という形式で置換対象のパラメーターを設定しました。

...

    fill: --axis_text_color--;
    font-size: --axis_font_size--px;
...

テンプレートのjsとCSS読み込み処理は以下のように対応してみました。

def read_template_str(template_file_path):
    """
    Read string of template file.

    Parameters
    ----------
    template_file_path : str
        The path of the template file under the template directory.
        e.g., storytelling/simple_line_date_series_plot.css

    Returns
    -------
    template_str
        Loaded template string. The repr function is set.

    Raises
    ------
    Exception
        If the file can not be found.
    """
    file_path = os.path.join(
        settings.ROOT_DIR, 'plot_playground', 'template',
        template_file_path)
    if not os.path.exists(file_path):
        err_msg = 'Template file not found : %s' % file_path
        raise Exception(err_msg)
    with open(file_path, 'r') as f:
        template_str = f.read()
    template_str = re.sub(re.compile('/\*.*?\*/', re.DOTALL) , '', template_str)
    template_str = repr(template_str)[1:-1]
    template_str = template_str.replace('\\n', '\n')
    return template_str

ここで注意が必要な点として、Pythonの通常の文字列は{}の括弧や%などの記号が意味を持ちます。(%sなどと文字列内で記述したりなど)
{}内の記述がうまく表示されない一方で、jsなどだとこの括弧の記号が多用されます。
そのため、Python上で生の文字列として扱う必要があります。
Python上で定義する文字列であれば以下のようにクォーテーションの前にrを付けることで生(raw)の文字列として扱われます。

sample_str = r'{your_variable}'

一方で、今回はテンプレートのファイルを読み込んで生の文字列にする必要があります。
そういった場合にはrepr関数を使います。ただ、r記号を付けた場合と若干挙動が異なり、最初と最後に余分なクォーテーションが付与されてしまうのと、改行までエスケープされます。そのため、以下の記述で最初と最後の文字を取り除き、且つ改行を普通に改行させるために置換をしています。

    template_str = repr(template_str)[1:-1]
    template_str = template_str.replace('\\n', '\n')

その他、正規表現でテンプレートの上部などに設けていたコメント部分を取り除いています。

template_str = re.sub(re.compile('/\*.*?\*/', re.DOTALL) , '', template_str)

置換処理として以下のような関数を用意しました。
辞書のキーにテンプレート上のパラメーター名、値に置換するPythonのパラメーターを設定して、ループで回して置換しています。

def apply_css_param_to_template(css_template_str, css_param):
    """
    Apply the parameters to the CSS template.

    Parameters
    ----------
    css_template_str : str
        String of CSS template.
    css_param : dict
        A dictionary that stores parameter name in key and parameter
        in value. Parameter name corresponds to string excluding hyphens
        in template.

    Returns
    -------
    css_template_str : str
        Template string after parameters are reflected.
    """
    for key, value in css_param.items():
        key = '--%s--' % key
        css_template_str = css_template_str.replace(key, str(value))
    return css_template_str


def apply_js_param_to_template(js_template_str, js_param):
    """
    Apply the parameters to the js template.

    Parameters
    ----------
    js_template_str : str
        String of js template.
    js_param : dict
        A dictionary that stores parameter name in key and parameter
        in value. If the parameter is a list or dictionary, it is
        converted to Json format.

    Returns
    -------
    js_template_str : str
        Template string after parameters are reflected.
    """
    for key, value in js_param.items():
        if isinstance(value, (dict, list)):
            value = data_helper.convert_dict_or_list_numpy_val_to_python_val(
                target_obj=value)
            value = json.dumps(value)
        key = r'{' + key + r'}'
        value = str(value)
        js_template_str = js_template_str.replace(key, value)
    return js_template_str

一部、配列や辞書などもパラメーターで渡しているのですが、Pandasなどを経由している都合、NumPyの型の数値など(np.int64など)が紛れ込んでいるとjsonモジュールでJSON形式に変換できないので、NumPyの型の数値部分を置換する処理を挟みました。

def convert_numpy_val_to_python_val(value):
    """
    Convert NumPy type value to Python type value.

    Parameters
    ----------
    value : *
        The value to be converted.

    Returns
    -------
    value : *
        The converted value.
    """
    np_int_types = (
        np.int,
        np.int8,
        np.int16,
        np.int32,
        np.int64,
        np.uint,
        np.uint8,
        np.uint16,
        np.uint32,
        np.uint64,
    )
    if isinstance(value, np_int_types):
        return int(value)
    np_float_types = (
        np.float,
        np.float16,
        np.float32,
        np.float64,
    )
    if isinstance(value, np_float_types):
        return float(value)
    return value


def convert_dict_or_list_numpy_val_to_python_val(target_obj):
    """
    Converts the value of NumPy type in dictionary or list into
    Python type value.

    Parameters
    ----------
    target_obj : dict or list
        Dictionary or list to be converted.

    Returns
    -------
    target_obj : dict or list
        Dictionary or list after conversion.

    Raises
    ------
    ValueError
        If dictionaries and lists are specified.
    """
    if isinstance(target_obj, dict):
        for key, value in target_obj.items():
            if isinstance(value, (dict, list)):
                target_obj[key] = convert_dict_or_list_numpy_val_to_python_val(
                    target_obj=value
                )
                continue
            target_obj[key] = convert_numpy_val_to_python_val(
                value=value)
            continue
        return target_obj
    if isinstance(target_obj, list):
        for i, value in enumerate(target_obj):
            if isinstance(value, (dict, list)):
                target_obj[i] = convert_dict_or_list_numpy_val_to_python_val(
                    target_obj=value
                )
                continue
            target_obj[i] = convert_numpy_val_to_python_val(value=value)
            continue
        return target_obj
    err_msg = 'A type that is not a dictionary or list is specified: %s' \
        % type(target_obj)
    raise ValueError(err_msg)

※若干、NumPy側で探せば関数が用意されている気配がしないでもないです・・。

表示してみる

実際にJupyter上で確認してみます。すごい適当なデータフレームを用意しました。

import pandas as pd
from plot_playground.storytelling import simple_line_date_series_plot

df = pd.DataFrame(data=[{
    'date': '2017-11-03',
    'apple': 100,
    'orange': 140,
}, {
    'date': '2017-12-03',
    'apple': 90,
    'orange': 85,
}, {
    'date': '2018-04-03',
    'apple': 120,
    'orange': 170,
}, {
    'date': '2018-09-03',
    'apple': 110,
    'orange': 180,
}, {
    'date': '2019-02-01',
    'apple': 90,
    'orange': 150,
}])

以下の関数内で、テンプレートの読み込みやら置換やらdisplay関数の呼び出しなどをしています。

simple_line_date_series_plot.display_plot(
    df=df,
    date_column='date',
    normal_columns=['apple'],
    stands_out_columns=['orange'],
    title='Time series of fruit prices.',
    description='Orange price keeps stable value in the long term.')

20190209_1.png

無事Jupyter上で表示できました!大体完成ですが、scikit-learnなどみたくメタデータ的なものを返却したり、このプロットのテストを追加したり細かいところを対応しておきます。

PyPI登録

pipでインストールできるように、PyPI登録を進めます。
他の方が色々記事を書かれているのでここでは基本的なところは触れずに、躓いたところを中心に触れておきます。

1. 同じバージョンのものは削除してももうアップできない

当たり前かもしれませんが、一度テストでTestのPyPI環境(ステージング的な環境)にアップして、後日web画面からそのバージョンを消して再度アップしようとしたら弾かれました。テスト環境でも、バージョンの番号を上げるしかなさそう?な印象です。(特に困ったりはしませんが・・)

2. ビルドの前に、過去のビルドで生成されたディレクトリを削除しておいた方が良さそう

前のものが含まれているとアップのときに弾かれたり、事故の元になりそうです。
この辺りのビルドのスクリプトは大した量ではないので後で書いておきたいところ・・。

3. cssやjsをパッケージ内に含んでくれなかった

最初、.pyのモジュールしかビルド内に含んでくれませんでした。
調べたところ、setup.pyなどのビルド用のモジュールと同じフォルダにMANIFEST.inというファイルを設置して、含めるファイルを指定しないといけなかった模様です。
今回は以下のようにjsとCSSを含むように指定しました。

recursive-include plot_playground *.js
recursive-include plot_playground *.css

さらに、MANIFEST.inに書いただけでは含んでくれず、setup.pyのsetup関数内で以下の記述をしないと含:womans_hat:んでくれませんでした。他の記事でMANIFEST.inだけ触れられていたものを参考にしていたのでしばらく悩むことに・・

    include_package_data=True,

以下の記事に上記の点が書かれていました。助かりました:bow:
Pythonのパッケージングのベストプラクティスについて考える2018

ここまでできたら、ステージング環境的なTestのPyPIにアップして動かしてみます。
Windows環境で作業していたので、ついでにLinuxでも念のため確認・・ということで、Azure Notebooksのクラウドのノートを利用しました。

20190209_2.png

ちょっと普通のpipコマンドと比べると、テスト環境なのでURLを指定したりで少し長いですが、無事インストールできました!

20190209_3.png

jsやCSSのテンプレートファイルも、問題なく読み込めているようです。
後は本番のPyPI環境にアップするだけですので、本番にアップします。
この段階で、お馴染みのpipコマンドでインストールできるようになります。
再び、Azure Notebooks上で試してみます。
既にテスト用のものがインストールされているので一旦アンインストールしてから実施します(ノートのカーネルも再起動しつつ)。

20190209_4.png

無事インストールできました。
長かったですがこれでやっと完了です!ちょっとドキュメントを書いたりは明日以降進めます。
また、まだプロットが1つだけで寂しいので、少しずつ作っていきたいところです。今回はテスト用のコードを書いたり、D3.jsとJupyterを繋いだり・・のところなども対応や検証が必要だったので、結構時間がかかりましたが、次回からは本質的なプロットの追加作業に注力できる・・はず。

おまけ

今回のコードのGithubリポジトリ

※まだドキュメントなど全然書いていません。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavascriptでJSON

はじめに

 JavascriptでJSONを扱う機会が多いのでまとめてみます。mapとかreduceとかそろそろ使いこなせるようになりたいです。

前提

 扱うJSONは下のような感じにしました。気分です。最近iPod卒業して以下の3つを品定め中だからです。ランキングは一部抜粋、今Apple Musicに傾きつつあるので、他のやつのランキングはスキップで。まあだいたい同じですし。

var sample = {
    Music_Services:[
        {
            Service: "Apple Music",
            Company: "Apple",
            Top_Song_Ranking:{
                observation_date: "2019-02-09",
                ranking:[
                    {
                        artist: "あいみょん",
                        song: "マリーゴールド",
                        rank: 1
                    },
                    {
                        artist: "ONE OK ROCK",
                        song: "Stand Out Fit In",
                        rank: 2
                    },
                    {
                        artist: "あいみょん",
                        song: "今夜このまま",
                        rank: 3
                    },
                    {
                        artist: "エド・シーラン",
                        song: "Shape of You",
                        rank: 7
                    },
                    {
                        artist: "あいみょん",
                        song: "愛を伝えたいだとか",
                        rank: 9
                    }
                ]
            }
        },
        {
            Service: "Google Play Music",
            Company: "Google",
            Top_Song_Ranking: null
        },
        {
            Service: "Spotify",
            Company: "Spotify AB",
            Top_Song_Ranking: null
        }
    ]
}

* データ構造は適当に作ったものでApple MusicのAPI定義とかに合わせたりしてません。

参照

レコード参照

「音楽サービスのどんなデータ入ってるのかザーッと見たい」

sample["Music_Services"].forEach((item) => {
    console.log(item)
})

{ Service: 'Apple Music',
  Company: 'Apple',
  Top_Song_Ranking: 
   { observation_date: '2019-02-09',
     ranking: [ [Object], [Object], [Object], [Object], [Object] ] } }
{ Service: 'Google Play Music',
  Company: 'Google',
  Top_Song_Ranking: null }
{ Service: 'Spotify',
  Company: 'Spotify AB',
  Top_Song_Ranking: null }

[Object]の中身表示したかったらconsole.log(JSON.stringify(item)) にすれば見れますね。

プロパティ参照

「一覧だと見づらいからどんな属性があるかだけ見たい」

var firstService = sample["Music_Services"][0]
Object.keys(firstService).forEach((data) => {
  console.log(data);
})

Service
Company
Top_Song_Ranking

「もっと深い属性が見たい」

Object.keys(firstService["Top_Song_Ranking"]).forEach((data) => {
  console.log(data);
})
console.log("====")

var firstSong = firstService["Top_Song_Ranking"]["ranking"][0]
Object.keys(firstSong).forEach((data) => {
  console.log(data);
})

observation_date
ranking
====
artist
song
rank

 基本的に1つ目のレコードだけサンプル的にとってきて中を覗く感じです。レコードによってプロパティに過不足ある場合とかは対象外で。

検索

単一検索

「Apple Musicの情報だけ欲しい」

const AppleMusic = sample["Music_Services"].find((item, index) => {
    return (item.Service === "Apple Music")
})
/*こっちでもおなじ
const AppleMusic = sample["Music_Services"].find((item, index) => {
    if(item.Service === "Apple Music") return true
})
*/

console.log(AppleMusic)

{ Service: 'Apple Music',
  Company: 'Apple',
  Top_Song_Ranking: 
   { observation_date: '2019-02-09',
     ranking: [ [Object], [Object], [Object], [Object], [Object] ] } }

ここからはApple Musicしか眼中に入れません。
なので、変数 "AppleMusic"を使いまわします。

複数検索

「あいみょんの曲ってどんぐらいランキング入ってるんだろ」

const Aimyon_Songs = AppleMusic["Top_Song_Ranking"]["ranking"].filter((item, index) => {
    return (item.artist === "あいみょん")
})

console.log(Aimyon_Songs)

[ { artist: 'あいみょん', song: 'マリーゴールド', rank: 1 },
  { artist: 'あいみょん', song: '今夜このまま', rank: 3 },
  { artist: 'あいみょん', song: '愛を伝えたいだとか', rank: 9 } ]

*本当は他にももっと入ってますよ

「英語タイトルの曲どんくらいランキング入ってるんだろ」

const English_Titles = AppleMusic["Top_Song_Ranking"]["ranking"].filter((item, index) => {
    return (item.song.match(/[a-zA-Z]/))
})

console.log(English_Titles)

[ { artist: 'ONE OK ROCK', song: 'Stand Out Fit In', rank: 2 },
  { artist: 'エド・シーラン', song: 'Shape of You', rank: 7 } ]

*おまけ

filterは複数検索の時ですね。findも兼ねますが、なんとなく使い分けたいなと。

制限

「曲名だけ一覧でバーっと見たい」

const song_names = AppleMusic["Top_Song_Ranking"]["ranking"].map(x => x["song"])
console.log(song_names)

[ 'マリーゴールド',
  'Stand Out Fit In',
  '今夜このまま',
  'Shape of You',
  '愛を伝えたいだとか' ]

 mapって主にこういうときに使うイメージですね。他にもあるかな。

集約

「結局ランキングにどういうアーティストが何曲ずつくらい入ってるんだろ」

const summary = AppleMusic["Top_Song_Ranking"]["ranking"].reduce((accum, current)=> {
    //同じアーティスト名がaccumの中にあるか検索
    const element = accum.find((item) => {return item.artist === current.artist});
    //あったらカウントだけする
    if(element){ element.count ++}
    //なかったらaccumに追加してあげる
    else{
        accum.push({
            artist: current.artist,
            count: 1
        });
    }
    return accum
}, []);

console.log(summary)

[ { artist: 'あいみょん', count: 3 },
  { artist: 'ONE OK ROCK', count: 1 },
  { artist: 'エド・シーラン', count: 1 } ]

 reduceは集約するときに使う感じですね。forEachで足していくほうがわかりやすいんですけどreduceが使えたらかっこいいなと。まだまだ慣れません。

おわりに

 reduceはやはり小難しい。あいみょんすごい。もう少し追記していきたいと思っているところです。


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MTG WikiのWisdom GuildへのリンクをWiki内部にするユーザースクリプト

MTG Wikiの水色背景で書かれるカード説明。
そのカード名リンクはカード単体へのリンクではなくWisdom Guildへのリンクになっている。
デッキ集などのキーカード紹介では直感的にそのカードのリンクへ飛べなく、DCG(アリーナ)プレイヤーには正直Wisdomは不要なのでリンクを書き換える。

// ==UserScript==
// @name         MtG Wiki wisdom to wiki link
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  try to take over the world!
// @author       You
// @match        http://mtgwiki.com/wiki/*
// @grant        none
// ==/UserScript==

(function() {
  'use strict';
  const URL = 'http://mtgwiki.com/wiki/'
  document.querySelectorAll('a.external[href^="http://whisper.wisdom-guild.net/card/"]').forEach(a => {
    let text = a.textContent
    let names = text.split('/').map(name => {
      return name.trim().replace(' ', '_')
    })
    a.href = URL + names[1] + '/' + names[0]
    //console.log(names)
  })
})();

カード名をURLに変換しているので、分割カードなど特殊には未対応

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsのCoffeeScriptで共通の処理を別のファイルへ切り出したい

タイトルの通りのことをやろうとしたときの、解決方法を見つけるまでに
調べたことを自分用のまとめも兼ねて書いておきます。Rails初学者向けです。
解決策は1番下にあります。

CoffeeScriptとは?

前提知識として。
CoffeeScriptとはすごく簡単に言うと、JavaScriptをより書きやすくするためのものです。
Rubyっぽくかけます。Railsで採用されています。CoffeeScriptはCoffeeScriptのままでは
使えません。CoffeeScriptで書かれたものを普通のJavaScriptに変換して使用します。
そのあたりの変換はRailsが勝手にやってくれます。
なお、RailsはCoffeeScriptもJavaScriptも併用できます
ぐぐるとすぐどんなものかわかると思いますが、書きやすさの片鱗を1ミリだけ見せると以下です。

javascript
alert("Hello");
coffeescript
# 引数をとる関数を呼び出すときの括弧いらない。
# 行末のセミコロンいらない。
alert "Hello"

CoffeeScriptで書くと、他にも色々書きやすくなる点があります。
調べてみてください。

以下本題です。

やりたいこと

やりたいことは単純です。

test1.coffee
sayHello = ->
  alert "Hello"
test2.coffee
sayHello = ->
  alert "Hello"
(なお、上記をJavaScriptで書くとこう)
function sayHello() {
  alert("Hello");
}

test1.coffeeとtest2.coffeeには同じ関数が定義されています。
同じことを2回書いているので、下記のように共通処理をまとめた
common.coffeeを作り、そこから呼び出せるようにしたい。

common.coffee
sayHello = ->
  alert "Hello"
test1.coffee
# common.coffeeを先に読み込んでいる前提
sayHello() # common.coffeeのsayHello関数を呼び出したい(できない)
test2.coffee
# common.coffeeを先に読み込んでいる前提
sayHello() # common.coffeeのsayHello関数を呼び出したい(できない)

しかし、コメントにも書きましたが、実際にはcommon.coffeeからsayHello関数を
test1.coffeeやtest2.cofeeから呼び出すことはできません。
しかしJavaScriptならこれができます。

common.js
function sayHello() {
  alert("Hello");
}
test1.js
// common.jsを先に読み込んでいる前提
sayHello(); // common.coffeeのsayHello関数を呼び出せる
test2.js
// common.jsを先に読み込んでいる前提
sayHello(); // common.coffeeのsayHello関数を呼び出せる

CoffeeScriptだとなぜできないのか

これはCoffeeScriptの仕様です。

CoffeeScriptファイルは、そのファイルで定義したものを即時・無名関数で丸ごと包むことにより、
ローカルスコープに閉じ込めるため、他のファイルからは使用できません。

何言ってるかわかりませんね。あとで説明します。

JavaScriptだとなぜできるのか

その前に、なぜJavaScriptだとできるのかを抑えておきます。
これを理解するには、以下のキーワードを知る必要があります。

  • スコープ(一般的な概念)
  • グローバルスコープ(プログラミングの一般的な概念)
  • ローカルスコープ(プログラミングの一般的な概念)

スコープとは

スコープとは「範囲」です。イメージしやすいように、ここでは「エリア」と言い換えます。

グローバルスコープとは

グローバルスコープとは、「そこに置いたものは、どこからでも呼び出せるようになる」エリアです。
関数の外側で定義された変数や関数が、このエリアに所属することになります。
このエリアで定義された変数や関数は、どこからでも上書きしたり、呼び出すことができます。

test1.js(関数funcの外側がグローバルスコープのエリア)
var hoge = "global"

function func() {
  alert(hoge); // → "global"と表示される。変数hogeはグローバルなのでfunc関数内で使える。
}

alert(hoge); // → "global"と表示される。変数hogeはグローバルなのでfunc関数外でも使える。
test2.js
// 事前にtest1.jsが読み込まれている前提
alert(hoge); // → "global"と表示される。変数hogeはグローバルなので、ここでも使える。

// test1.jsの変数hogeは書き換えることができる。
func(); // → "global"と表示される。
var hoge = "replace!";
func(); // → "replace!"と表示される。(test1.jsの変数hogeを書き換えできたことがわかる)

ローカルスコープとは

ローカルスコープとは、「そこに置いたものは、その場所でしか呼び出せない」エリアです。
関数の内側で定義された変数や関数が、このエリアに所属することになります。
このエリアで定義された変数や関数は、関数の内側以外では、上書きしたり、呼び出すことはできません。

test1.js(関数funcの内側がローカルスコープのエリア)
function func() {
  var hoge = "local"
  alert(hoge); // → "local"と表示される。変数hogeはローカルなのでfunc関数内で使える。
}

alert(hoge); // → エラーになる。変数hogeはローカルなのでfunc関数外では使えない。
test2.js
// 事前にtest1.jsが読み込まれている前提
alert(hoge); // → エラーになる。変数hogeはローカルなのでここでも使えない。

// test1.jsの変数hogeは書き換えることができない。
func(); // → "local"と表示される。
var hoge = "replace!";
func(); // → "local"と表示される。(test1.jsの変数hogeを書き換えできなかったことがわかる)

※但し、var hogevarを取ると、グローバルな変数とすることもできます。
ただ、varの有る無しでローカススコープのエリアで定義しているのに、
グローバルとローカルが切り替わってしまうため、基本的にはvarは付けたほうがいい。
なお、CoffeeScriptにはvarはもともと存在せず、グローバル変数にはできません。

JavaScriptだとなぜできるのか(再掲)

ではもう一度、JavaScriptだとなぜできるのか。
再度、JavaScriptのファイルを見てみます。

common.js
function sayHello() {
  alert("Hello");
}
test1.js
// common.jsを先に読み込んでいる前提
sayHello(); // common.coffeeのsayHello関数を呼び出せる
test2.js
// common.jsを先に読み込んでいる前提
sayHello(); // common.coffeeのsayHello関数を呼び出せる

common.jsのsayHello()はグローバルスコープのエリアで定義されたものです。
よって、test1.jsからもtest2.jsからも呼び出せます。

これがJavaScriptだとできる理由です。

CoffeeScriptだとなぜできないのか(再掲)

CoffeeScriptでできない理由も再度見てみましょう。

CoffeeScriptファイルは、そのファイルで定義したものを即時・無名関数で丸ごと包むことにより、
ローカルスコープに閉じ込めるため、他のファイルからは使用できません。

以下のキーワードを知る必要があります。

  • 即時関数(JavaScriptの機能)
  • 無名関数(JavaScriptの機能)

即時関数とは

即時関数とは、定義した関数が即実行される関数です。よくわかりませんね。

普通の関数はfunctionで定義して、その後、明示的にfunctionの名前で呼び出して実行します。

普通の関数
function sayHello() {
  alert("Hello");
}

sayHello(); // → ここで初めて実行される。

即時関数は下記のように書きます。

即時関数
(function sayHello() { // → ここで定義された時点で即実行される。
  alert("Hello");
}());

何かの初期化処理など1回だけ実行されればいい時など、その関数を再利用する
必要がない時に使います。これが即時関数です。

無名関数とは

一方、無名関数とは名前の無い関数です。よくわかりませんね。

普通の関数はfunctionのあとに関数名を書きますが、無名関数はその関数名を省略した関数です。
変数に関数を入れる例で書くと以下のようになります。
(JavaScript初学者の方には、え?って思うかもしれませんが、変数は値だけでなく関数も入れられます。)

普通の関数
var func = function sayHello() { alert("Hello") };
func()
無名関数
var func = function () { alert("Hello") }; // → sayHelloという関数名を省略できます。
func()

無名関数は、関数名が必要ないときに使います。

即時関数と無名関数は組み合わせることができます。
ここではこれを即時・無名関数と呼んでおきます。

即時・無名関数
(function () {
  alert("Hello");
}());

CoffeeScriptからJavaScriptへの変換

CoffeeScriptからJavaScriptへの変換処理は、
変換するときに、中身を丸ごと即時・無名関数として包みます。

変換前CoffeeScript
sayHello = ->
  alert "Hello"
変換後JavaScript
(function () {
  sayHello = function() {
    return alert("Hello");
  };
}());

関数の内側に自分が書いたコードが包まれるので、変換後は全ての変数や関数は
ローカルスコープに閉じ込められるということです。なぜこんなことをするのか。
それは、前述のローカルスコープのところで説明した効果を得たいためです。

ローカルスコープとは、「そこに置いたものは、その場所でしか呼び出せない」エリアです。
関数の内側で定義された変数や関数が、このエリアに所属することになります。
このエリアで定義された変数や関数は、関数の内側以外では、上書きしたり、呼び出すことはできません。

もしたくさんのCoffeeScriptファイルを取り扱うようになった時、あるファイルでせっかく
定義した関数や変数を、別のファイルの中で同じ名前の関数や変数を使って全く違う定義を
誤ってしてしまうかもしれません。それを防ぐ効果があります。

この効果のために、共通処理を切り出せなくなっています。

解決策

いくつか方法があるようです。

方法1

JavaScriptにはwindowオブジェクトという特殊なオブジェクトがあります。
先程から良く使っているalert関数も実はwindowオブジェクトが持つ関数の一つです。
つまりこう書けます。このwindowは省略できるため、alertだけで普段使えています。

window.alert("Hello");

このwindowオブジェクトはグローバルスコープに属しています。
グローバルスコープに属しているものはどこからでも上書き・呼び出しができます。
上書きしてしまうとalertもなにも使えなくなってしまうので、このオブジェクトに
関数を追加します。

common.coffee
window.sayHello = ->
  alert "Hello"
test1.coffee
sayHello()
test2.coffee
sayHello()

方法2

@を使います。@はCoffeeScriptに用意されたJavaScriptのthisの別名です。
JavaScriptのthisは、グローバルスコープのエリアでは、windowオブジェクトを指します。

よって方法1の下記は・・・

common.coffee
window.sayHello = ->
  alert "Hello"

下記のように書けます。

common.coffee
@sayHello = ->
  alert "Hello"
test1.coffee
sayHello()
test2.coffee
sayHello()

終わり

以上で終わりです。
新しいバージョンのJavaScriptだとちゃんと関数の外に定義した変数や関数も
グローバルにならないように宣言する方法があるみたいです。
ただ、どこまで今存在するWebブラウザが、そのバージョンに対応しているかは
調べられていません。「ECMAScript 6」とかでぐぐると出てきます。

なにかこう・・・JavaScriptって、昔から、こう、、、アレなんですよね。。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RainyDay.jsを使ったので使用方法メモ

基本的にjavascriptほぼわからないしGithubの見方もわからない、英語もほぼ読めない初心者が、今後自分がもう一度やるときに役立つようにという目的でまとめているため、非常に初歩的なことから書いています。

やりたいこと

サイトに雨の降るエフェクトを付けたかった

結論

プラグインRainyDay.jsを入れる。
コードは下記。

html
<body id="body" onload="run();">
    <img id="background" alt="" src="" />
css
#main{
    z-index:1000;
}

img#background{
    position: fixed;
    width: 100%;
    height: 100vh;
    top: 0;
}

canvas{
    width: 100%;
    height: auto;
    z-index: 1;
}
javascript
function run() {
    var image = document.getElementById('background');
    image.onload = function () {
        var engine = new RainyDay({
            image: this,
            gravityAngle:Math.PI/10
        });
            engine.rain([ [1, 2, 8000] ]);
            engine.rain([ [3, 3, 0.88], [5, 5, 0.9], [6, 2, 1] ], 100);
    image.crossOrigin = 'anonymous';
    image.src = '/rain.jpg';
}

試行錯誤

サイトを探す

雨のようなエフェクトを自分のサイトに使いたいと思い、使えるプラグインを探す。
ググったところrainyday.jsが目的と合う。
プラグインの名前で検索したところ、下記の通りのサイトが引っかかる。

rainyday.jsを使ってみる | cly7796.net
窓に雨を降らせる事ができるJSライブラリ「rainyday.js」! | Design Magazine
ブラウザに雨を降らすことができるrainyday.jsを使ってみたよ! | ザ・サイベース
[JS]リアルさが想像以上!ブラウザのウインドウを窓に喩えて、雨を楽しむ癒やし系スクリプト -rainyday.js | コリス

しかし、上記のサイトがリンクを貼っているGithubは既に404。
代替サイトはないかと更に検索してたら、普通にGithubじゃないサイトが引っかかる。

RainyDay.js : rendering raindrops with javascript

組み込む

上記公式サイト内の、
2019-02-09_14h17_37.jpg

Githubに移動して、
2019-02-09_14h17_46.jpg

2019-02-09_14h17_53.jpg

これでファイルがDLできる(昔はわからなくて迷った)。

zipのrainydays.js-master/docs/js/rainyday.min.jsをサイトの</body>の前(自分のjsがあればそれよりは上)に組み込む。

その上で、窓に雨を降らせる事ができるJSライブラリ「rainyday.js」! | Design Magazineを参考にして、

html
 <body onload="run();">
        <img id="background" src="img/rain.jpg" alt="background">
javascript
    function run() {
        var image = document.getElementById('background');
        image.onload = function() {
            var engine = new RainyDay({
                image: this
            });
            engine.rain([ [1, 2, 8000] ]);
            engine.rain([ [3, 3, 0.88], [5, 5, 0.9], [6, 2, 1] ], 100);
        };
        image.crossOrigin = 'anonymous';
        image.src = '/rain.jpg';
    }

とする。やりたかったこととは違ったけれど、雨の降る画像が表示された。
ちなみに、<img id="background" alt="" src="" />のsrcが空で大丈夫なの?と思ったけれども上記javascriptのimage.src = '/rain.jpg';で指定したものが当然表示された。

やりたかったことがうまくいかない

やりたかったこと
やりたかったこと.jpg

現状
現実.jpg
文字の後ろに雨が降るんじゃなく、どうやっても雨が前に出る。

そのため、最初は画面の全体に透過pngをz-index:9999ぐらいでおいて、それに雨を降らせようかと思っていた(それだと下の要素がクリック出来ないのを忘れてた)。

javascript
    function run() {
        var image = document.getElementById('background');
        image.onload = function() {
            var engine = new RainyDay({
                image: this
            });
            engine.rain([ [1, 2, 8000] ]);
            engine.rain([ [3, 3, 0.88], [5, 5, 0.9], [6, 2, 1] ], 100);
        };
        image.crossOrigin = 'anonymous';
        image.src = '/rain.jpg'; //ここが画像のアドレス
    }

image.src = '/rain.jpg';image.src = '/rain.png';に変更。
しかし、どういったものに変更しても画像が表示されない。
これはもしかしてpng画像不可?と判断し、諦めて画像の上に文字をいれることにする。

下にある文字列の入っているdiv#mainにCSSを適用。

css
#main{
    z-index: 100; /* 99だと雨のほうが上に表示された */
}

それからブラウザに雨を降らすことができるrainyday.jsを使ってみたよ! | ザ・サイベースを見ながら

css
img#background{
    position: fixed;
    width: 100%;
    height: 100%;
    top: 0;
}

canvas{
    width: 100%;
    height: auto;
    z-index: 1;
}

これで文字列が入っていレイヤーが上になり、なおかつ背景画像が後ろ全体に広がる。
やりたかったこと.jpg
これの表示になる。

ちなみに公式サイトのデモ6がちょうどやりたかったものだったのだけれど、何故かうまくいかなかった。

サイズを変更したら雨が潰れる

ところで、これを使いたいサイトは長い文章などをおいているサイトとなる。
縦に長いページで確認したところ、雨の降る頻度が非常に少ない。
開発者ツールで確認したところ、
<canvas style="position: absolute; top: 0px; left: 0px; z-index: 99;" width="1338" height="11285"></canvas>
という表示があった。つまり、縦全体に対して、縦幅が狭いときとかわらない頻度で雨をふらせているため、結果的にあまり雨が降らないように感じられるということ?っぽい。

なので、

css
img#background{
    position: fixed;
    width: 100%;
    height: 100vh;
    top: 0;
}

canvas{
    width: 100%;
    height: 100vh;
    z-index: 1;
}

に変更した。
結果、雨粒が縦につぶれて横に広がるようになった。
開発者ツールで確認したところ、
<canvas style="position: absolute; top: 0px; left: 0px; z-index: 99;" width="1338" height="11285"></canvas>
から変わっていない。
たぶんこのheight="11285"を変更するにはrainday.min.jsを読めなきゃだめなんだろうなということまでは理解し、読めなかったので諦める。

最終的に、縦幅の短いページのみで使用することとした。
ちなみに上記の表記にしても背景画像/rain.jpgもすごく伸びたり縮んだりする。

微調整

下記、いじってみてなんとなくわかったもの。
何度も書くけれどもjavascriptがわからない人がやっているので、多分間違いも多い。

javascript
    //bodyタグにつける onload=""と同じ名称にする
    function run() {
         //bodyタグ直下の<img id="background" alt="" src="" />のIDと同じものにする
         var image = document.getElementById('background'); 
        image.onload = function() {
            var engine = new RainyDay({
                //このあとメモする
                image: this
            });
            //雨の降る頻度。雨粒の大きさが[]内部、それが100ミリ秒の間にこれがどこかに降るということ
            engine.rain([ [1, 2, 8000] ]);
            engine.rain([ [3, 3, 0.88], [5, 5, 0.9], [6, 2, 1] ], 100);
        };
        image.crossOrigin = 'anonymous';
        //画像アドレス
        image.src = '/rain.jpg';
    }

RainyDay.js : rendering raindrops with javascriptのオプションその他をgoogle翻訳につっこんで見てみたやつ。()内部は私が勝手につけた説明。

option

オプション デフォルト 説明
image none 画像
parentElement Optimal 効果を生成する親要素のID(上記のだとdocument.getElementById('background'); で指定してるから未記入でOK?)
sound none 音のパス
blur 10 ぼかし強度を画像に適用(背景画像のボケ具合)
crop none 画像の一部を切り取りたい場合は、オブジェクトを切り取ります。
enableCollisions true 雨滴の衝突を有効にする(よくわからず。falseにしても雨粒はぶつかってくる)
enableSizeChange true 画面サイズが変わるとキャンバスが更新される(trueにしててもよくわからないが更新されないことが何度かあった)
fps 24 キャンバス更新のfpsのレンダリング
gravityAngle Math.PI / 2 重力角(ぶつかった雨粒が垂れる)
gravityAngleVariance 0 風の効果をシミュレートするために重力に分散を追加します(100ぐらいにすると暴風雨っぽく雨粒が左右に飛んでく)
gravityThreshold 3 重力の強さ(よくわからず)

methodsはrainだけなんとなく指定できれば大丈夫じゃないかと。
雨だれが落ちるほうが面白かったので、私はgravityAngleのみ指定して出来上がり。

デメリット

  • 重い
  • (設置場所のせいだろうけれども)画像が読み込まれるのに時間がかかる
  • 縦が長いと雨粒があまり落ちてこない(javascript書けたら変わると思う)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

全俺が泣いた!感動のconsole.xxx

JavaScriptにはconsole.log()以外にも便利なlog出力があります。
最近知った便利なconsole.xxxを紹介します。

※ この投稿のタイトルは最近話題になっていたコピーメカ を使いました。他意しかないです。

console.log()

誰もが叩くconsole.log()。devtoolsのコンソールに引数の出力結果が表示されます。

console.log()
スクリーンショット 2019-02-09 13.14.39.png

console.error(), console.warn(), console.info()

表示はconsole.logと同じ。でも文字色、背景色が代わり、目立たせる事ができます。
(infoがlogと変わらないのはなぜだろう?)

console.info()
スクリーンショット 2019-02-09 13.08.15.png
console.warn()
スクリーンショット 2019-02-09 13.09.14.png
console.error()
スクリーンショット 2019-02-09 13.08.51.png

console.table()

配列、オブジェクトのデータを表形式で表示します。
console.logで、いちいち展開するより超見やすいです。
最近無駄にconsole.table使ってます。

console.log() console.table()
log.gif table.gif

console.dir()

ディレクトリ形式で出力結果のメンバを表示できます。arrayやobjectなどの場合はconsole.logとあまり変わらないですが、DOMツリーなどを表示するときなど表示が異なります。

console.log() console.dir()
Feb-09-2019 12-41-44.gif Feb-09-2019 12-40-15.gif

console.time(), console.timeLog()

処理時間を計測するのに便利です。
console.time("タイマー名")でタイマーを設定して、
console.timeLog("タイマー名")でそのタイマーを設定しからの経過時間を出力できます。

let timeDemo = () => {
    // タイマー"demo"のセット
    console.time("demo")
    setInterval(()=>{
      // 1000msごとに今のタイマー"demo"の経過時間を表示
      console.timeLog("demo")
    }, 1000)}

実行結果

console.time(), console.timeLog()
timer.gif

console.trace()

console.trace()が呼び出されるまでの経路を出力することができます。
この関数はどこでどのように呼び出されるのか調べる時など便利です。

function trace() {
    console.trace()
}

function c() {
    trace()
}

function b() {
    c()
}

function a() {
    b()
}

実行結果

console.trace()
Feb-09-2019 14-37-00.gif

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

便利なconsole.xxx

JavaScriptにはconsole.log()以外にも便利なlog出力があります。
最近知ったconsole.xxxを紹介します。

console.log()

誰もが叩くconsole.log()。devtoolsのコンソールに引数の出力結果が表示されます。

console.log()
スクリーンショット 2019-02-09 13.14.39.png

console.error(), console.warn(), console.info()

表示はconsole.logと同じ。でも文字色、背景色が代わり、目立たせる事ができます。
(infoがlogと変わらないのはなぜだろう?)

console.info()
スクリーンショット 2019-02-09 13.08.15.png
console.warn()
スクリーンショット 2019-02-09 13.09.14.png
console.error()
スクリーンショット 2019-02-09 13.08.51.png

console.table()

配列、オブジェクトのデータを表形式で表示します。
console.logで、いちいち展開するより超見やすいです。
最近無駄にconsole.table使ってます。

console.log() console.table()
log.gif table.gif

console.dir()

ディレクトリ形式で出力結果のメンバを表示できます。arrayやobjectなどの場合はconsole.logとあまり変わらないですが、DOMツリーなどを表示するときなど表示が異なります。

console.log() console.dir()
Feb-09-2019 12-41-44.gif Feb-09-2019 12-40-15.gif

console.time(), console.timeLog()

処理時間を計測するのに便利です。
console.time("タイマー名")でタイマーを設定して、
console.timeLog("タイマー名")でそのタイマーを設定しからの経過時間を出力できます。

let timeDemo = () => {
    // タイマー"demo"のセット
    console.time("demo")
    setInterval(()=>{
      // 1000msごとに今のタイマー"demo"の経過時間を表示
      console.timeLog("demo")
    }, 1000)}

実行結果

console.time(), console.timeLog()
timer.gif

console.trace()

console.trace()が呼び出されるまでの経路を出力することができます。
この関数はどこでどのように呼び出されるのか調べる時など便利です。

function trace() {
    console.trace()
}

function c() {
    trace()
}

function b() {
    c()
}

function a() {
    b()
}

実行結果

console.trace()
Feb-09-2019 14-37-00.gif

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Vue】eval電卓

概要

Vue.jsの入門として、何か作ってみたいという人向けです。
まず、通常のjsでeval電卓を作成し、Vue.jsを使って抽象化していきます。

実装

以下のような電卓を、最小構成で実装する。
index.htmlonClickイベントにより、calc関数が呼ばれ、
クリックされたボタンに応じた処理をします。

image.png

通常のjsの場合

buttonタグの羅列が冗長であることが確認できる。

とりあえず、動かしたい人は、cloneして下さい。

clone
git clone https://github.com/Naoto92X82V99/calc.git
index.html
<html>
    <head>
        <meta charset ="utf-8">
        <script src="script.js"></script>
    </head>
    <body>
        <table id="calcTable">
            <tr>
                <td colspan="3"><input type="text" id="output" value="0"></td>
                <td><button value="C" onClick="calc('C')">C</button></td>
            </tr>
            <tr>
                <td><button onClick="calc('7')">7</button></td>
                <td><button onClick="calc('8')">8</button></td>
                <td><button onClick="calc('9')">9</button></td>
                <td><button onClick="calc('/')">/</button></td>
            </tr>
            <tr>
                <td><button onClick="calc('4')">4</button></td>
                <td><button onClick="calc('5')">5</button></td>
                <td><button onClick="calc('6')">6</button></td>
                <td><button onClick="calc('*')">*</button></td>
            </tr>
            <tr>
                <td><button onClick="calc('1')">1</button></td>
                <td><button onClick="calc('2')">2</button></td>
                <td><button onClick="calc('3')">3</button></td>
                <td><button onClick="calc('-')">-</button></td>
            </tr>
            <tr>
                <td><button onClick="calc('0')">0</button></td>
                <td><button onClick="calc('.')">.</button></td>
                <td><button onClick="calc('+')">+</button></td>
                <td><button onClick="calc('=')">=</button></td>
            </tr>
        </table>
    </body>
</html>
script.js
function calc(cmd){
    const element = document.getElementById('output')
    const value = element.value

    if(cmd === '='){
        element.value  = eval(value)
    }else if(cmd === 'C'){
        element.value = '0'
    }else if(value === '0') {
        element.value = cmd
    }else{
        element.value += cmd
    }
}

Vue.jsの場合

上記実装において、buttonタグの羅列が冗長であるため、Vue.jsで書き直してみる。
v-forを使うことで、繰り返し処理を記述できる。

とりあえず、動かしたい人は、cloneして下さい。

clone
git clone https://github.com/Naoto92X82V99/vue-calc.git
index.html
<html>
    <head>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    </head>
    <body>
        <table id="app">
            <tr>
                <td colspan="3"><input type="text" v-model="output"></td>
                <td><button value="C" v-on:click="calc('C')">C</button></td>
            </tr>
            <tr v-for="row in items">
                <td v-for="item in row">
                    <button v-on:click="calc(item)">{{ item }}</button>
                </td>
            </tr>
        </table>

        <script src="script.js"></script>
    </body>
</html>
script.js
var app = new Vue({ 
    el: '#app',
    data: {
        output: '0',
        items: [
            ['7', '8', '9', '/'],
            ['4', '5', '6', '*'],
            ['1', '2', '3', '-'],
            ['0', '.', '+', '=']
        ]
    },
    methods: {
        calc: function (cmd) {
            if(cmd === '='){
                this.output = eval(this.output)
            }else if(cmd === 'C'){
                this.output = '0'
            }else if(this.output === '0') {
                this.output = cmd
            }else{
                this.output += cmd
            }
        }
    }
})

まとめ

Vue.jsを用いて、最小構成でeval電卓を実装しました。
間違い・指摘等があればコメントお願いします。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SlickGrid で遊んでみる

SlickGridとは

Excelみたいなのを作るのに便利なJavaScriptライブラリ
https://github.com/mleibman/SlickGrid/wiki

詳細はこの辺
https://qiita.com/icoxfog417/items/98e34c0555991033afec

何をしたかったのか

Excelで設定データを入力していたら1000超えたあたりで重い上に面倒になったのでいっそのこと自作してしまおうと思った。
GUIベースだとMacとWindowsを行き来する今の環境だと面倒なのでブラウザベースにした。

仕様

  • データの更新を検出したらデータベースを更新(データをJSONにしてAPIを叩く)
  • データの更新は編集行が変わった時だけに行う(更新頻度が高いと負荷がかかるため)
  • データの更新はセルを選択すれば可能
  • UpdateかInsertかはサーバー側で処理する(update APIで行う)
  • データベースはsqlite3を選択(検索APIを考慮するとcsvよりデータベースを使った方が良いがこの程度でサーバーを起動するのが面倒)
  • エラーメッセージはコンソールログに出し、アラートではださない
  • 強制更新ボタンは今回は付けていない(関数呼び出すだけなので実装自体は楽)
  • 絞り込みが可能になっている(search APIで行う)
  • サーバーにはPythonの簡易サーバー(CGIHTTPServer)を使用(クライアントPC1台でも完結させたいため)
  • 見栄えはcssで後からどうにかする
  • データもコードもクラウドストレージと同期させる(データの管理を楽にするため)

実装

SAMPLEを利用する

一から書くと大変なのであちこちからサンプルを参考(コピペしてカスタマイズ)して使っていますが、どこから持ってきたのか覚えて居ない……。

完成したのがこんなコード(cssはフォームの表示を整える為にあります)

※元のライセンスがMITライセンスなのでMITライセンスになると思います。

HTML

wordedit.html
<!DOCTYPE HTML>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
  <title>編集</title>
  <link rel="stylesheet" href="./lib/slickgird/slick.grid.css" type="text/css"/>
  <link rel="stylesheet" href="./lib/slickgird/css/smoothness/jquery-ui-1.8.16.custom.css" type="text/css"/>
  <link rel="stylesheet" href="./serch.css" type="text/css">
<!---
  <link rel="stylesheet" href="examples.css" type="text/css"/>
-->
  <!--  from SlickGrid example Spread Sheet -->
  <style>
    .slick-cell.copied {
      background: blue;
      background: rgba(0, 0, 255, 0.2);
    }
  </style>
</head>
<body>
<div id = "header">
<div class = "header_text">絞り込み:<input type="text" id="word" class="textbox" size="10" onkeydown="CreateSpreadSheet(document.getElementById('word').value);"></div>
</div>
<div style="position:relative 1;top: 200px;">
  <div style="width:100%;">
    <div id="myGrid" style="height:800px;"></div>
  </div>

<script src="./lib/slickgird/lib/firebugx.js"></script>

<script src="./lib/slickgird/lib/jquery-1.7.min.js"></script>
<script src="./lib/slickgird/lib/jquery-ui-1.8.16.custom.min.js"></script>
<script src="./lib/slickgird/lib/jquery.event.drag-2.2.js"></script>

<script src="./lib/slickgird/slick.core.js"></script>
<script src="./lib/slickgird/plugins/slick.autotooltips.js"></script>
<script src="./lib/slickgird/plugins/slick.cellrangedecorator.js"></script>
<script src="./lib/slickgird/plugins/slick.cellrangeselector.js"></script>
<script src="./lib/slickgird/plugins/slick.cellcopymanager.js"></script>
<script src="./lib/slickgird/plugins/slick.cellselectionmodel.js"></script>
<script src="./lib/slickgird/slick.editors.js"></script>
<script src="./lib/slickgird/slick.grid.js"></script>

<script>
//検索APIの指定
  const searchapi = './api/v1/search';
//更新APIの指定
  const updateapi = './api/v1/update';
  var grid;
  var data = [];
  var options = {
    editable: true,
    enableAddRow: true,
    enableCellNavigation: true,
    asyncEditorLoading: true,
    autoEdit: true
  };
  var columns = [
    {
      id: "selector",
      name: "",
      field: "num",
      width: 60
    }
  ];

  fieldnames = ['単語','読み','コメント']; //フィールド名を指定
  fieldids = ['word','read','comment']; //フィールドIDを指定

//上二つの数は一致すること

  for (var i = 0; i < fieldnames.length; i++) {
    columns.push({
      id: fieldids[i],
      name: fieldnames[i],
      field: fieldids[i],
      width: 120,
      editor: FormulaEditor
    });
  }
  /***
   * A proof-of-concept cell editor with Excel-like range selection and insertion.
   */
  function FormulaEditor(args) {
    var _self = this;
    var _editor = new Slick.Editors.Text(args);
    var _selector;
    $.extend(this, _editor);
    function init() {
      // register a plugin to select a range and append it to the textbox
      // since events are fired in reverse order (most recently added are executed first),
      // this will override other plugins like moverows or selection model and will
      // not require the grid to not be in the edit mode
      _selector = new Slick.CellRangeSelector();
      _selector.onCellRangeSelected.subscribe(_self.handleCellRangeSelected);
      args.grid.registerPlugin(_selector);
    }
    this.destroy = function () {
      _selector.onCellRangeSelected.unsubscribe(_self.handleCellRangeSelected);
      grid.unregisterPlugin(_selector);
      _editor.destroy();
    };
    this.handleCellRangeSelected = function (e, args) {
      _editor.setValue(
          _editor.getValue() +
              grid.getColumns()[args.range.fromCell].name +
              args.range.fromRow +
              ":" +
              grid.getColumns()[args.range.toCell].name +
              args.range.toRow
      );
    };
    init();
  }
  $(function () {
    CreateSpreadSheet('all');
  });

function CreateSpreadSheet(opt){
    let datacount = 0;
    if(opt == 'all' || opt == '') {
        opt = 'all=1';
    } else {
        opt = 'word=' + opt;
    }
    $.getJSON(searchapi,opt,function(data, status, xhr) {
        datacount = data.length;
        for( let i = 1 ;i <= data.length; i++){
            let d = (data[i-1]);
            d["num"] = i;
        }

        grid = new Slick.Grid("#myGrid", data, columns, options);

        grid.setSelectionModel(new Slick.CellSelectionModel());
        grid.registerPlugin(new Slick.AutoTooltips());
    // set keyboard focus on the grid
        grid.getCanvasNode().focus();
        var copyManager = new Slick.CellCopyManager();
        grid.registerPlugin(copyManager);

      copyManager.onPasteCells.subscribe(function (e, args) {
        if (args.from.length !== 1 || args.to.length !== 1) {
            throw "This implementation only supports single range copy and paste operations";
        }
        var from = args.from[0];
        var to = args.to[0];
        var val;
        for (var i = 0; i <= from.toRow - from.fromRow; i++) {
            for (var j = 0; j <= from.toCell - from.fromCell; j++) {
                if (i <= to.toRow - to.fromRow && j <= to.toCell - to.fromCell) {
                    val = data[from.fromRow + i][columns[from.fromCell + j].field];
                    data[to.fromRow + i][columns[to.fromCell + j].field] = val;
                    grid.invalidateRow(to.fromRow + i);
                }
            }
        }
        grid.render();
     });

     grid.onAddNewRow.subscribe(function (e, args) {
      var item = args.item;
      var column = args.column;
      grid.invalidateRow(data.length);
      data.push(item);
      grid.updateRowCount();
      grid.render();
     });

     let beforeRow = -1;
     let beforeRowdata = {};


     grid.onSelectedRowsChanged.subscribe(function (e, args) {
        rownum = args.rows[0];
        if(beforeRow >= 0 && beforeRow != rownum){   //編集行が変わったチェック
            console.log(beforeRow);
            rowdata = grid.getDataItem(beforeRow);
            let a = JSON.stringify(beforeRowdata);  // 編集前データ
            let b = JSON.stringify(rowdata);    // 編集後データ
            if(a !== b ){                       // 変更があるかチェック
                console.log('edited!');
                // 更新APIを呼び出す

//サンプルではJOINEDキーを使っているので更新前のデータを二つ送っている                
                rowdata["oldword"] = beforeRowdata["word"];
                rowdata["oldread"] = beforeRowdata["read"];
                let params = JSON.stringify(rowdata); 
                console.log(params);
//Application/JSONで送りたかったのですがPythonのCGIServerだと上手く取得できる方法が分からなかったのでのでx-www-form-urlencodedのdata='jsondata'でPOSTしています。
                $.ajax({
                    type: 'POST',        // method = "POST"
                    url: updateapi,      // POST送信先のURL
                    data: "data=" + params,        // JSONデータ本体
                    contentType: 'application/x-www-form-urlencoded', // リクエストの Content-Type
                    dataType: 'text',
                    success: function(json_data) {   // 200 OK時
                        console.log(json_data);
                    },
                    error: function(json_data) {
                        console.log(json_data);
                        console.log('server error');
                    },
                    complete: function() {      // 成功・失敗に関わらず通信が終了した際の処理
                    }
                });
            }
            $.extend(true,beforeRowdata,grid.getDataItem(rownum));
        } else if(beforeRow < 0){
            $.extend(true,beforeRowdata,grid.getDataItem(rownum));
        }
        beforeRow = rownum;
     });
    });
}

</script>
</body>
</html>

Update APIとSearch APIを作成

 コードがあまりに汚い。

テーブルスキーマ

drop table WordDictionary;
create table WordDictionary(
    WORD text not null,
    READ text not null,
    comment text,
    UNIQUE (WORD,READ)
);

Search API(JSONで検索結果を返す)

search
#!/usr/local/bin/python3
import urllib
import cgitb
import sqlite3
import json
from contextlib import closing

import sys
import configparser
import os
import os.path as path

#config.iniにはデータベースファイルの位置が入っている
#実行環境によって相対パスでは取得できないのでsearch APIのフォルダの場所を自動取得
#config.ini
#[setting]
#database=/path/too
inipath = path.join(path.dirname(sys.argv[0]),'config.ini')
inifile = configparser.ConfigParser()
inifile.read( inipath , 'UTF-8')
dbname = inifile.get('settings', 'database')

#cgitb.enable()

def query4Yomi(conn,yomi):
    cur = conn.cursor()
    yomi = '%' + yomi + '%'
    return cur.execute('select * from WordDictionary where READ like ?', (yomi,))

def query4Word(conn,word):
    cur = conn.cursor()
    return cur.execute('select * from WordDictionary where WORD = ?', (word,))

def queryall(conn):
    cur = conn.cursor()
    return cur.execute('select * from WordDictionary order by READ')

def query4Any(conn,word):
    cur = conn.cursor()
    word = '%' + word + '%'
    return cur.execute('select * from WordDictionary where READ like ? or WORD like ? or comment like ? order by READ', (word,word,word,))


with closing(sqlite3.connect(dbname)) as conn:
    if 'QUERY_STRING' in os.environ:
        query = urllib.parse.parse_qs(os.environ['QUERY_STRING'])
    else:
        query = {}

    if ( 'word' in query ):
#        results = query4Yomi(conn,query['word'][0])
        results = query4Any(conn,query['word'][0])
    elif ( 'all' in query ):
        results = queryall(conn)
    else:
        results = {}

    print ("Content-Type: application/json")
    print ("")
    print ('[')
    line = 0
    for row in results:
        if (line !=0):
             print (',')
        print ('{')
        print ('"word": "' + row[0] + '"')
        print (',"read": "' + row[1] + '"')
        print (',"comment": "' + row[2] + '"')
        print ('}')
        line += 1

    print (']')

Update API(data='JSONDATA'でJSONDATAの内容をデータベースにアップ

update
#!/usr/local/bin/python3

import cgi
import cgitb
import sqlite3
import sys
import json

import configparser
import os
import os.path as path

#config.iniにはデータベースファイルの位置が入っている
#実行環境によって相対パスでは取得できないのでupdate APIのフォルダの場所を自動取得
#config.ini
#[setting]
#database=/path/too

inipath = path.join(path.dirname(sys.argv[0]),'config.ini')
inifile = configparser.ConfigParser()
inifile.read( inipath , 'UTF-8')
dbname = inifile.get('settings', 'database')

from contextlib import closing

def update(conn,param):
    cur = conn.cursor()

    cur.execute('update WordDictionary ' + 
            'SET WORD = ?, READ = ?, comment = ? WHERE WORD = ? and READ = ?',
         (param['word'],param['read'],param['comment'],param['oldword'],param['oldread']))
    conn.commit()



def query(conn,word,read):
    cur = conn.cursor()
    cur.execute('select WORD,READ from WordDictionary where WORD = ? and READ = ?', (word,read))
    s = cur.fetchone()
    try:
        if (str(s[0]) == word and str(s[1]) == read):
            return (1,s)
        else:
            return (0,None)
    except:
        pass
    return (0,None)


def insert(conn,param):
    cur = conn.cursor()

    p ={'comment': ''}

    for key in p.keys():
        if(key in param):
            p[key] = param[key]

    cur.execute('insert INTO WordDictionary ' +
        'VALUES (?, ?, ?)',
        (param['word'],param['read'],p['comment'])
    conn.commit()

with closing(sqlite3.connect(dbname)) as conn:
    print ("Content-Type: application/json")
    print ("")


#    if (os.environ['REQUEST_METHOD'] != "POST"):
#       print (str(os.environ['REQUEST_METHOD']))

    form = cgi.FieldStorage()
    q = json.loads(form['data'].value)

    try:
        (r,s0) = query(conn,q['oldword'],q['oldread'])
        if(r != 1) :
            insert(conn,q)
            m = 'insert'
        else:
            update(conn,q)
            m = 'update'

        (r,s1) = query(conn,q['word'],q['read'])

        results = {"result" : "200" , "method" : m}
        print (results)
    except:
        import traceback as tr
        s = tr.format_exc()
        results = {"result" : "403" ,"trace" : s}
        print (results)

簡易サーバー

cgiserver
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
import http.server
import threading
import webbrowser
import time

handler = http.server.CGIHTTPRequestHandler
handler.cgi_directories = ['/cgi-bin','/api']
http.server.test(HandlerClass=handler)

フォームも起動させようとするやつ

cgiserver.py
#!/usr/local/bin/python3
# -*- coding: utf-8 -*-
import http.server
import threading
import webbrowser
import time


url = 'http://localhost:8000/wordedit.html'

def openBrowser(url):
   time.sleep(1)
   print ("OPEN URL {}".format(url))
   webbrowser.open(url)

#1秒待ってからブラウザを起動

try:
   if __name__ == "__main__":
      thread = threading.Thread(target=openBrowser,args=(url,))
      thread.start()

except:
   print ("Error: unable to start thread")

handler = http.server.CGIHTTPRequestHandler
handler.cgi_directories = ['/cgi-bin','/api']
http.server.test(HandlerClass=handler)

補足:Windowsでは起動前に以下の環境変数を設定しておかないと挙動不審になるので注意

set PYTHONIOENCODING=utf-8

結果

Excelより軽くて便利

課題

  • 必要なJSがどれか全く分かっていない
  • テーブルのメタデータからフィールド名を自動生成&自動処理可能にする(汎用化)
  • 動作環境が変わった時に設定ファイルをいじらなくても動くようにする
  • JavaScriptのコードをHTMLから分離
  • 新規追加に少しトリッキーな操作を要求する(データベースでエラーが出た場合の処理が甘い)
  • 一括更新
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Element から学ぶ Vue.js の component の作り方 その2 (button)

button

前提

Elemnt 2.52
公式ページ
https://element.eleme.io/#/en-US

ボタン

image.png

用意されている機能

  • サイズの指定 3種
  • タイプの指定によるデザインテーマの指定
  • ボタンの形式(プレーン、角丸、円形)
  • ローディングアクション
  • 非活性
  • アイコンの利用
  • オートフォーカス
  • ネイティブタイプ(button / submit / reset)

構成

  <button
    class="el-button"
    @click="handleClick"
    :disabled="buttonDisabled || loading"
    :autofocus="autofocus"
    :type="nativeType"
    :class="[
      type ? 'el-button--' + type : '',
      buttonSize ? 'el-button--' + buttonSize : '',
      {
        'is-disabled': buttonDisabled,
        'is-loading': loading,
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle
      }
    ]"
  >

disable は buttonDisabled と loadng のいずれか。
buttonDisabled は下記の computed で定義。
親から渡した prop か、elForm を inject しているのでフォームの中においてるとフォームと連動する。

buttonDisabled() {
  return this.disabled || (this.elForm || {}).disabled;
}

他は prop の値を素直に受け取っている。

<i class="el-icon-loading" v-if="loading"></i>
<i :class="icon" v-if="icon && !loading"></i>
<span v-if="$slots.default"><slot></slot></span>

loading が true の場合は loading icon が表示される。
icon が指定されていて、loading 中でなければ icon が表示される。
slot の指定が可能になっている。

その他の computed

computed: {
  _elFormItemSize() {
    return (this.elFormItem || {}).elFormItemSize;
  },
  buttonSize() {
    return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size;
  },

buttonSize は prop か _elFormItemSize の帰り値の inject している elForm か elFormItems のサイズ、
あと、this.$ELEMENT は何だろう。グローバルな規定があるのかな。※ 要調査

所感

第二回目だが、一回目と比べてあまり成長がないな。。
次こそ頑張ろう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[chrome拡張機能] chrome.cookies APIの使い方おぼえがき

クッキー関係のプラグインを作りました

SUGOI!Cookies: gclid tester for Google Ads
https://chrome.google.com/webstore/detail/sugoicookies-gclid-tester/oidgodfancakeifokbiocnnlfoocmbpd?hl=ja

こんな拡張機能作ってみました。

幸せになる人:
・今までgclidテストを手打ちでやってた人
・今までわざわざF12 => Application => Cookiesで
 Goolge広告やアナリティクスのクッキーを確認していた人

超ニッチです(笑)。せっかくなので、chrome.cookies APIに関してメモ。

前提

①: 公式のドキュメントはここです。

Chrome API => https://developer.chrome.com/extensions/cookies
MDN => https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/cookies

②: このAPIは、background.jsでしか呼べない。

なので、content.jsからは、
chrome.runtime.sendMessage で「このAPI呼んで!」と、
叫ぶ必要があります。例えば、下のような感じ。

content.js
/** 
 * to background.js
 */
const getCookies_ = () =>{
 chrome.runtime.sendMessage({message:'getCookies', domain:document.domain}, function callback); 
};
background.js
/**
 * eventListener 
 * chrome.cookies should be called in this file
 */
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  const domain = request.domain;
  const msg = request.message;
  if(msg=='getCookies'){
    chrome.cookies.get...
  }
});

③: manifest.jsonで「このURLのクッキー触るよ」の宣言が必要

manifest.json
 {
  "permissions": [
     "cookies",
     "<all_urls>"
  ]
}

API

onChanged : クッキーに変更が加えられてた時に呼ばれます

onChanged.addEventListener

/**
 * listen to change events to cookies
 */   
chrome.cookies.onChanged.addListener((e)=>{   
  /** @type{string} 'expired', 'explicit' or 'override' */
  console.log(e.cause));   

  /** @type{Object} */
  console.log(e.cookie)); 
  const cookie = e.cookie;
    cookie.url  /** @type string*/
      // manifest.jsonで該当URLのpermissionが必要です。
    cookie.name /** @type string*/
      // クッキーは"name=val"形式。name部分です。
    cookie.value /** @type string*/
      // "name=val" のval
    cookie.domain /** @type string*/
    cookie.path /** @type string*/  
    cookie.secure /** @type boolean*/
    cookie.httpOnly /** @type boolean*/
    cookie.SameSiteStatus /** @type Enum "no_restriction", "lax", or "strict" */
    cookie.expirationDate /** @type double*/
    cookie.storeId /** @type string*/

  /** @type{boolean} if the change event has got triggered by "removing a cookie" */
  console.log(e.removed)); 
});

onChanged.removeEventListener: イベントリスナーの削除

/**
 * eventListener
 */
chrome.cookies.onChanged.addListener(doSomething_);

/**
 * remove the event added by above
 */
chrome.cookies.onChanged.removeListener(doSomething_);

/**
 * @private
 * @param {Object} event - you can access to cookie obj by "event.cookie" 
 */
function doSomething_(event){
  // do something
};

getAll : 空オブジェクトを渡せば本当に全部のクッキーが。ドメイン渡せば、そのドメインのクッキーがリターン。

chrome.cookies.getAll({},((allCookies)=>{
    doSomething_(allCookies);
}));

chrome.cookies.getAll({domain:'qiita.com'},((qiitaCookies)=>{
    doSomething_(qiitaCookies);
}));

get: url, nameを指定して、クッキーを1つゲット

chrome.cookies.get({url:'aUrl', name:'aCookieName'}, ((aCookie)=>{
    doSomething_(aCookie);
}));

remove: url, nameを指定して、クッキーを1つ削除

chrome.cookies.remove({url:'aUrl', name:'aCookieName'}, function callback);

set: クッキーを生成します。

/**
 * Objectの中にurlプロパティは必須です。
 */
chrome.cookies.set(object details, function callback);

番外編

background.jsではなく、content.jsでやりたいならdocument.cookieでやりましょう
https://developer.mozilla.org/ja/docs/Web/API/Document/cookie

//そのドメインのクッキーが、stringで入る
let cookies = document.cookie;

// Array.<string> - 'name=value'の配列 
cookies = cookies.split(';');

// 新しいクッキーを生成
document.cookie = 'aNewName=aNewVal';

以上です。最後までありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

配列を指定サイズで分割

ライブラリ使わずにやる

  • Array.reduce()、spread operator, Array.splice() を組み合わせる
const arr = [...Array(100).keys()];
// => [0, 1, 2, 3, 4, 5, 6, 7, 8,...,99];

const size = 8;

const result = arr.reduce(
  (prev, curr, index) =>
    index !== 0 && index % size !== 0
      ? [...prev.splice(0, prev.length - 1), [...prev[prev.length - 1], curr]]
      : [...prev, [curr]],
  []
);
//=> [
//   [0, 1, 2, 3, 4, 5, 6, 7],
//   [8, 9, 10, 11, 12, 13, 14, 15],
//   [16, 17, 18, 19, 20, 21, 22, 23],
//   [24, 25, 26, 27, 28, 29, 30, 31],
//   [32, 33, 34, 35, 36, 37, 38, 39],
//   [40, 41, 42, 43, 44, 45, 46, 47],
//   [48, 49, 50, 51, 52, 53, 54, 55],
//   [56, 57, 58, 59, 60, 61, 62, 63],
//   [64, 65, 66, 67, 68, 69, 70, 71],
//   [72, 73, 74, 75, 76, 77, 78, 79],
//   [80, 81, 82, 83, 84, 85, 86, 87],
//   [88, 89, 90, 91, 92, 93, 94, 95],
//   [96, 97, 98, 99]
// ];

lodash使える環境

const arr = [...Array(100).keys()];
const size = 8
const result = _(arr).chunk(size).value()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む