plotlyの散布図(scattergl)で大きなデータセットを間引いて表示する

# はじめに
- plotlyで散布図を表示するには以下の手法がある。
    - scatter: svg(ベクトル方式)で描画、保存できる。
        - ただ、10k点以上の散布図に関しては動作速度の点からscatterglの使用が推奨されている
    - scattergl: webgl(ラスター方式)で描画、保存。
        - scatterよりは早い
        - ただ、100k点以上の散布図に関しては動作速度が遅くなる
            - 不思議なことに99999点まではまともに動く。[issue](https://github.com/plotly/plotly.py/issues/1271)に似た症状があるが、解決したと書かれている。
    - pointcloud: scatterglの簡易機能版。非常に軽い
        - 100k点でも問題なく表示できる
        - 書式などの機能が制限されており、思い通りのxy軸の書式を作れない可能性が高い
        - [廃止予定](https://community.plotly.com/t/announcing-plotly-js-2-0/53675)の機能であるため、scatterglを使用するように公式で推奨されている。
# 問題点
- 100k点以上の描画が必要
- pointcloudでは必要なy軸の書式を実現できなかった
    - automarginが変な動作をしており、y軸タイトルが見切れていた
    - y軸ラベルが中央ぞろえになっていたのを修正することができなかった
- pointcloudは今後のplotlyでは使えなくなると述べられていた

# 解決策
- データを間引いて表示する。
- ズームによって表示する点数を変える
    - 100%の時は10k点を表示
    - 100\*n%の時は10k\*n点を表示
        - min(10k\*n, num_points)によってデータの点数を超えないようにする。
        - 倍率は適当に調整

# 実装例
- 元データの保管用グローバル変数の用意
```
original_data = {}
```
- グラフ
```python
app = dash.Dash(__name__)
# 要素を動的に作る場合はその要素に対応するcallbackがエラーを吐くため対策を行った
app.config.suppress_callback_exceptions=True 
original_data = {"x": x.to_numpy(), "y": y.to_numpy()} # 元データの保存
app.layout = html.Div([
   dcc.Graph(
      id="graph", 
      figure=fig, 
      config={"scrollZoom": True}
   ),
])
```
- メモ化用の関数の準備
```python
from flask_caching import Cache
cache = Cache(app.server, config={ 
    'CACHE_TYPE': 'SimpleCache'
})
```
- 表示点数削減callback
    - https://stackoverflow.com/a/64891612 を参考にした
    - lttbを用いて密集しているデータから優先的に間引いた。
        - listのindexを用いて間引いたときは疎な場所がさらに疎になってしまう欠点があった。
```python
timeout = 0.1
@app.callback(Output("graph", "figure"),
            Input("graph", "relayoutData"), # 拡大されたときに発火
            State("graph", "figure"),
            prevent_initial_call=True)
# この関数の実行時間は0.4 msであるので余裕をもってtimeout秒に一回しか実行しないようにする。
# 実行しない場合は前回の結果をそのまま返す。
@cache.memoize(timeout=timeout)  
def downsample_data(relayout_data, figure): 
    global previous_callback
    if original_data is not None: # svgなどのように散布図形式でない場合はoriginal dataをNoneにして発火を防ぐ
        original_data_x = original_data["x"]
        original_data_len = len(original_data_x)
        original_data_x_max = max(original_data_x)
        original_data_x_min = min(original_data_x)
        original_data_y = original_data["y"]
        original_data_y_max = max(original_data_y)
        original_data_y_min = min(original_data_y)
        # 現在のxy軸の範囲とxyデータの範囲を比較して倍率を得る
        zoom_x = abs((original_data_x_max-original_data_x_min)/(relayout_data["xaxis.range[1]"]-relayout_data["xaxis.range[0]"]))
        zoom_y = abs((original_data_y_max-original_data_y_min)/(relayout_data["yaxis.range[1]"]-relayout_data["yaxis.range[0]"]))
        zoom = zoom_x*zoom_y
        figure["data"][0]["x"], figure["data"][0]["y"] = lttbc.downsample(original_data_x, original_data_y, min(original_data_len, ceil(1e4*zoom)))
        return figure
    else:
        return figure
```

# 描画の実装例
- 一度すべてのデータで画像を保存した後、間引いたデータで再描画。
- 再描画を行わないと初期段階ですべてのデータが描画されているため、動作が重い
- 描画せずにプロットを保存するにはkaleidoパッケージが必要である。(importは必要ない)
```python
import plotly.graph_objects as go
fig = go.Figure(data=go.Scattergl(x=x, y=y), layout=layout)
fig.write_image(path) # requires kaleido. print image with all the data
x, y = x.to_numpy(), y.to_numpy() # pandas dataframeの場合はnumpy ndarrayにする必要がある
nx, ny = lttbc.downsample(x, y, 10000) # データを10000点に間引く
fig = go.Figure(data=go.Scattergl(x=nx, y=ny) # 間引いたデータで再描画
return fig
```