# 株価チャート chartjs-chart-financial を試行錯誤で使ってみた　 2

## csv ファイルを読み込んでチャート表示

カンマ区切りの csv ファイルを利用してチャートを表示してみました。

SBI 証券の HyperSbi2 では 20 年程度の時系列データをエクスポートできますのでこれを利用します。
そもそも HyperSbi2 でそのままチャートを表示すればいいだけですが、ここではデータの整形手順を説明するためにこのデータを利用します。

### csv の構造

![csv_4751.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182396/328868a9-72cd-3d57-6c50-4f321cb01b36.png)

HyperSbi2 の csv データの特徴として、3 桁までの数値はそのままですが 4 桁以上の数値はダブルクォーテーションで囲まれたカンマを含む 3 桁区切りで格納されているのが特徴で、単純に`.split(',')`や`.split('","')`等では正確な価格を取得することができません。

そこで、各行の模試列を先頭から区切り文字ごとに分割して、残りの先頭文字がダブルクォーテーションかどうかで次の区切り文字を「,」ないし「"」として文字列を取得することにしました。

また、ここでは利用しませんが、MA 等のデータが存在しない列は「--」が記録されています。

本当は csv ファイルを同期的に読み込みたかったのですが、await 等は C#のものとは比べ物にならないくらい難しく[^1][^2]、気軽に入門はできないとあきらめて、以下のページを参考にして[^3]、onload イベント内からチャート描画の function を呼ぶ方法にしました。
加えて、Mac と Windows でエクスポートしたファイルのエンコードが異なるようなので、encoding-japanese[^4]を利用して自動判別できるようにしてみました[^5]。

```HTML:SelectCsv.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title></title>
<script src="https://cdn.jsdelivr.net/npm/luxon@3.4.4"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.0.0"></script>
<script src="https://www.chartjs.org/chartjs-chart-financial/chartjs-chart-financial.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/encoding-japanese/2.0.0/encoding.min.js"></script>
<style>
    .info{font-size: small;}
    .json{width: 460px; height: 320px;}
</style>
</head>
<body>

    <div><input type="file" id="selectfile"><label class="info" id="info"></label></div>

    <div> <canvas id="chart"></canvas> </div>

    <!-- json出力エリア　手書き修正でチャートに反映することは未対応 -->
    <div><textarea class="json" id="json"></textarea></div>


    <script src="chart-viewer1.js"></script>
    <script src="chart-csv-reader.js"></script>

</body></html>
```

```Javascript:chart-csv-reader.js
document.getElementById("selectfile").addEventListener("change", function (event) {
    const file = event.target.files[0];
    const reader = new FileReader();
    // reader.readAsText(file);
    reader.readAsArrayBuffer(file);
    reader.onload = () => {
      // 8ビット符号なし整数値配列と見なす
      const array = new Uint8Array(reader.result);
      const strEncoded = autoEncoding(array);
      const prices = csvToArray(strEncoded);
      if (prices.error === undefined) {
        changeBrand(prices);
        document.getElementById("json").value = JSON.stringify(prices).replace( /},/g, "},\n" );
      } else {
        document.getElementById("json").value = `${prices.error} の列番号が認識できません\n中断しました`;
      }
    };
  });

// refer to https://setchi.hatenablog.com/entry/2014/07/25/231318
function autoEncoding(array) {
  // 文字コードを取得
  switch (Encoding.detect(array)) {
    case "UTF16":
      // 16ビット符号なし整数値配列と見なす
      array = new Uint16Array(e.target.result);
      break;
    case "UTF32":
      // 32ビット符号なし整数値配列と見なす
      array = new Uint32Array(e.target.result);
      break;
  }

  // Unicodeの数値配列に変換
  // Unicodeの数値配列を文字列に変換
  return Encoding.codeToString(Encoding.convert(array, "UNICODE"));
}

function csvToArray(content) {
  const lines = content.split("\n");
  // 1行目はフィールドであること 1行目から列番号取得
  const fields = lines[0].split(",");
  const fieldIndexes = [6];
  const label_eng = ["date", "open", "high", "low", "close", "vol"];
  const label_jp = ["日付", "始値", "高値", "安値", "終値", "出来高"];
  for (let j = 0; j < label_eng.length; j++) {
    for (let i = 0; i < fields.length; i++) {
      if ( fields[i].toLowerCase().indexOf(label_eng[j]) > -1 || fields[i].toLowerCase().indexOf(label_jp[j]) > -1 ) {
        fieldIndexes[j] = i;
        break;
      }
    }
    if (j < 5 && fieldIndexes[j] === undefined) {
      return { error: label_jp[j] };
    }
  }
  // 汎用性を高めるためcsvフィールド名を解析する仕様にしたら複雑になりすぎ　勘違いがあるかも
  const prices = [];
  for (let i = 1; i < lines.length; i++) {
    if (lines[i] === "") continue;
    const cols = [];
    let line = lines[i];
    // 3桁は数値、４桁以上は３桁区切り ""で囲まれている
    for (let j = 0; j < fields.length; j++) {
      const delimiter = line.substr(0, 1) == '"' ? '"' : ",";
      if (delimiter === '"') line = line.substr(1);
      if (fieldIndexes.includes(j)) {
        cols[fieldIndexes.indexOf(j)] = line.substr(0, line.indexOf(delimiter)).replace(/,/g, "");
      }
      line = line.substr(line.indexOf(delimiter) + (delimiter === '"' ? 2 : 1));
    }
    // luxonは「/」区切りの日付は認識出来ないようだ
    // adjust priceがある場合には未対応でもう一工夫が必要（各priceに * adjust / close）
    const date = cols[0].replace(/\//g, "-").replace("年", "-").replace("月", "-").replace("日", "");
    const data = {
      d: date, // console.log等で確認用に
      x: luxon.DateTime.fromSQL(date).valueOf(),
      o: Number(cols[1]),
      h: Number(cols[2]),
      l: Number(cols[3]),
      c: Number(cols[4]),
    };
    if (cols[5] !== undefined && !isNaN(cols[5])) {
      data.v = Number(cols[5]);
    }
    prices.push(data);
  }
  // csvは逆順のため日付で昇順に constもsortできるようだ
  return prices.sort((a, b) => a.x - b.x);
}
```
時系列データは逆順のままでもチャートは昇順に表示されるようですが、マウスポインタの移動で表示されるツールチップの挙動がおかしくなるようなので、昇順にソートをしています。

取得した時系列データ配列を chart-viewer1.jsの changeBrand に渡すと同時にTextAreaにJsonデータとして出力します。
```Javascript:chart-viewer1.js
const colors = {
    candle: { up: "orangered", down: "limegreen", unchanged: "dimgray" },
    volume: "blue",
};

const ctx = document.getElementById("chart").getContext("2d");
const chart = new Chart(ctx, {
    type: "candlestick",
    data: { datasets: [] },
});

// チャートに表示するデータを格納したグローバルデータ変数
let chartData = {};

// 銘柄（ファイル）変更
function changeBrand(dailyPrices) {
    chartData = {
        candle: dailyPrices,
        volume: dailyPrices.find(d => d.v) ? dailyPrices.map(d => { return { x: d.x, y: d.v } }) : undefined,
    };
    document.getElementById("info").textContent = 
        `${dailyPrices[0].d} ${dailyPrices[dailyPrices.length - 1].d} ～ ${dailyPrices.length.toLocaleString()}本`;
    changeDatasource();
}

function changeDatasource(barCount = 0) {
    if (chartData.candle === undefined) return;
    // barCount 0 の場合は全て表示する　データ期間のフィルター用
    if (barCount === 0) 
        barCount = chartData.candle.length;
    
    chartData.from = {
        index: chartData.candle.length - barCount,
        d: chartData.candle[chartData.candle.length - barCount].d,
        x: chartData.candle[chartData.candle.length - barCount].x,
    };

    // chartDataからchartのデータセットを設定していく
    // ここで各データに関して表示本数でフィルターをかける
    const datasets = [];
    const data_candles = {
        data: chartData.candle.filter(d => d.x >= chartData.from.x),
        color: colors.candle,
        yAxisID: "yAxisMain",
    };
    datasets.push(data_candles);
    if (chartData.volume !== undefined) {
        const ds_volume = {
        type: "bar",
        label: "volume",
        data: chartData.volume.filter(d => d.x >= chartData.from.x),
        borderColor: colors.volume,
        backgroundColor: colors.volume,
        borderWidth: 1,
        pointRadius: 0,
        yAxisID: "yAxisSub",
        };
        // 出来高データを追加
        datasets.push(ds_volume);
    }
    chartData.datasets = datasets;
    refreshChart(chartData);
}

// チャートにデータセットの設定とオプション設定
function refreshChart(chartData) {
    chart.config.data.datasets = chartData.datasets;
    chart.config.options = {
        plugins: {
        title: { display: false },
        legend: {
            position: "top",
            align: "center",
            labels: {
                filter: function (item) {
                    // console.log(item);
                    //ローソク足とvolumeの凡例を非表示
                    if (item.datasetIndex === 0 || item.text === "volume") return false;
                    else return true;
                },
                boxWidth: 6,
                boxHeight: 2,
            },
        },
        },
        //再度アクセスする場合に初回定義しなければ undefined となる
        scales: {},
    };

    if ( chart.config.data.datasets.length > 1 && chart.config.data.datasets[1].label == "volume") {
        //データセットから出来高max取得
        const volumes = chart.config.data.datasets[1].data.map(d => { return d.y });
        const volume_max = volumes.reduce(function (a, b) { return Math.max(a, b) });
        chart.config.options.scales.yAxisSub = {
            suggestedMax: volume_max * 3,
            position: "right",
            ticks: {
                callback: function (value, index) {
                if (value > volume_max || value < (volume_max * 2) / 4 || value == 0)
                    return "";
                else 
                    return `${(value / 10000).toLocaleString()}万`;
                },
                mirror: true,
            },
            grid: {
                drawOnChartArea: false,
            },
        };
    }

    chart.update();
}
```

もっとシンプルな方法もあると思います。

その他参考にしたページ[^6][^7][^8][^9][^10]。

![lasertec.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182396/ce6fb125-dae3-e584-2ba4-ae673892a445.png)

## 参考ページ

[^1]: [async/await 入門（JavaScript）](https://qiita.com/soarflat/items/1a9613e023200bbebcb3)
[^2]: [JavaScript の「コールバック関数」とは一体なんなのか](https://sbfl.net/blog/2019/02/08/javascript-callback-func/)
[^3]: [JavaScript ファイル読み込み完全ガイド！10 の使い方とサンプルコード](https://jp-seemore.com/web/3987/)
[^4]: [encoding.js](https://github.com/polygonplanet/encoding.js)
[^5]: [【FileAPI, readAsText】JavaScript で文字コードを判別して文字化けを倒す](https://setchi.hatenablog.com/entry/2014/07/25/231318)
[^6]: [replace の文字列置換・正規表現の使い方まとめ](https://qiita.com/conkon326/items/ed14a69e860ab4d65d5c)
[^7]: [【JavaScript】オブジェクト(連想配列)を昇順・降順に並び替える方法](https://web-engineer-wiki.com/javascript/object-sort/)
[^8]: [JavaScript における JSON データの読み込みや値取得の方法を解説](https://lorem-co-ltd.com/json-basic/)
[^9]: [【JavaScript】配列の最大値（最小値）を取得するには reduce を使うのがいいらしい](https://qiita.com/ndj/items/82e9c5a4518fe16e539f)
[^10]: [配列を征する者は JS を制す。JavaScript のスマートな配列操作テクニック 2023](https://ics.media/entry/200825/)